feat(web): add account workspaces and signed content flows

This commit is contained in:
justme committed 2026-10-14 04:06:03 +08:00
1 parent 7b8d61d1ba
commit b5cf3d5958
117 files changed
+2789 -1674

No files matched your search

+7 -3
View File
@@ -36,7 +36,7 @@ async (page) => {
await owner.getByRole('button', { name: '新建空间', exact: true }).waitFor();
if (await owner.getByLabel('个人名称', { exact: true }).inputValue() !== '多设备测试用户') throw new Error('Profile did not survive reload');
await owner.getByRole('img', { name: '个人头像', exact: true }).waitFor();
results.push('login, encrypted profile and avatar survive reload');
results.push('server-owned profile and avatar survive login and reload');
await owner.getByRole('button', { name: '新建空间', exact: true }).click();
await owner.getByLabel('空间名称(加入者和服务端可见)').fill('架构验收空间');
await owner.getByRole('button', { name: '创建', exact: true }).click();
@@ -52,9 +52,11 @@ async (page) => {
await owner.getByRole('button', { name: '频道设置 常规', exact: true }).click();
await owner.getByLabel('所属分组').selectOption({ label: '工作' });
await owner.getByRole('button', { name: '保存', exact: true }).click();
await owner.getByRole('button', { name: '保存', exact: true }).waitFor({ state: 'hidden' });
await owner.getByRole('button', { name: '重命名分组 工作', exact: true }).click();
await owner.getByLabel('分组名称', { exact: true }).fill('项目');
await owner.getByRole('button', { name: '保存', exact: true }).click();
await owner.getByRole('button', { name: '保存', exact: true }).waitFor({ state: 'hidden' });
await owner.getByRole('button', { name: '分组 项目', exact: true }).click();
if (await owner.getByRole('button', { name: '讨论', exact: true }).isVisible()) throw new Error('Group did not collapse');
await owner.getByRole('button', { name: '分组 项目', exact: true }).click();
@@ -65,12 +67,14 @@ async (page) => {
await owner.getByRole('button', { name: '频道设置 讨论', exact: true }).click();
await owner.getByLabel('所属分组').selectOption({ label: '未分组' });
await owner.getByRole('button', { name: '保存', exact: true }).click();
await owner.getByRole('button', { name: '保存', exact: true }).waitFor({ state: 'hidden' });
if (await owner.getByRole('region', { name: '频道分组 项目', exact: true }).getByRole('button', { name: '讨论', exact: true }).count()) throw new Error('Ungrouping did not remove group membership');
await owner.getByRole('button', { name: '频道设置 讨论', exact: true }).click();
await owner.getByLabel('所属分组').selectOption({ label: '项目' });
await owner.getByRole('button', { name: '保存', exact: true }).click();
await owner.getByRole('button', { name: '保存', exact: true }).waitFor({ state: 'hidden' });
await owner.getByRole('button', { name: '常规', exact: true }).click();
results.push('channel groups create, rename, collapse and survive encrypted replay');
results.push('backend channels and groups create, rename, collapse and survive reload');
const path = new URL(owner.url()).pathname;
if (owner.url().includes('#')) throw new Error('Account channel navigation retained a secret fragment');
await owner.locator('textarea').last().fill('常规 thread 聊天验证');
@@ -116,7 +120,7 @@ async (page) => {
await owner.getByLabel('移动所选文件到文件夹').selectOption({ label: '资料目录' });
await owner.getByRole('button', { name: '资料目录', exact: true }).dblclick();
await owner.getByRole('button', { name: 'architecture-file.txt', exact: true }).waitFor();
results.push('folder navigation and encrypted file moves work');
results.push('backend folders and file locations update without re-encrypting content');
await second.goto(origin);
const selfRow = second.getByRole('region', { name: '账号设备' }).locator('div').filter({ has: second.getByText('本机', { exact: true }) });
await selfRow.locator('input').fill('验收副设备');
+53
View File
@@ -0,0 +1,53 @@
// Works against either the development proxy or the embedded production frontend.
async (page) => {
const origin = new URL(page.url()).origin;
const context = await page.context().browser().newContext({ viewport: { width: 1440, height: 900 } });
const owner = await context.newPage(); const results = [];
try {
await owner.goto(origin);
await owner.getByLabel('用户名', { exact: true }).fill(`backend_${Date.now()}`);
await owner.getByLabel('密码', { exact: true }).fill('Backend-data-test-password');
await owner.getByRole('button', { name: '创建账号', exact: true }).click();
await owner.getByRole('button', { name: '新建空间', exact: true }).click();
await owner.getByLabel('空间名称(加入者和服务端可见)').fill('后端数据源测试');
await owner.getByRole('button', { name: '创建', exact: true }).click();
await owner.getByRole('button', { name: '常规', exact: true }).waitFor();
const spaceId = new URL(owner.url()).pathname.split('/').pop();
await owner.getByRole('button', { name: '新建频道分组', exact: true }).click();
await owner.getByLabel('分组名称', { exact: true }).fill('前端分组');
const requestPromise = owner.waitForRequest(request => request.method() === 'POST' && request.url().endsWith(`/spaces/${spaceId}/groups`));
await owner.getByRole('button', { name: '创建', exact: true }).click();
const request = await requestPromise;
const allHeaders = await request.allHeaders();
const headers = Object.fromEntries(['authorization', 'x-wd-role', 'x-wd-endpoint', 'x-wd-account', 'content-type'].filter(key => allHeaders[key]).map(key => [key, allHeaders[key]]));
await owner.getByRole('button', { name: '分组 前端分组', exact: true }).waitFor();
const initial = await context.request.get(`${origin}/api/v1/spaces/${spaceId}/structure`, { headers }).then(r => r.json());
const groupId = initial.structure.groups[0].id;
const renamed = await context.request.patch(`${origin}/api/v1/spaces/${spaceId}/groups/${groupId}`, { headers, data: { name: '后端分组' } });
if (!renamed.ok()) throw new Error(`Backend rename failed ${renamed.status()}`);
await owner.getByRole('button', { name: '分组 后端分组', exact: true }).waitFor();
const created = await context.request.post(`${origin}/api/v1/spaces/${spaceId}/channels`, { headers, data: { name: '后端频道', groupId } });
if (created.status() !== 201) throw new Error('Backend channel creation failed');
await owner.getByRole('button', { name: '后端频道', exact: true }).waitFor();
results.push('direct backend group and channel edits update the UI without client structure records');
const locationResponse = owner.waitForResponse(response => response.request().method() === 'PUT' && response.url().endsWith(`/spaces/${spaceId}/locations`));
await owner.getByRole('region', { name: '常规 聊天', exact: true }).locator('textarea').fill('后端作者归属测试');
await owner.getByRole('button', { name: '发送消息', exact: true }).click();
if (!(await locationResponse).ok()) throw new Error('Backend item association failed');
const profile = await context.request.patch(`${origin}/api/v1/accounts/profile`, { headers, data: { displayName: '后端昵称' } });
if (!profile.ok()) throw new Error('Backend profile update failed');
await owner.getByRole('article', { name: '后端昵称 的消息', exact: true }).getByText('后端作者归属测试', { exact: true }).waitFor();
results.push('backend account profile and stored authors determine message identity');
const spaces = await context.request.get(`${origin}/api/v1/accounts/spaces`, { headers }).then(r => r.json());
if (!spaces.some(space => space.id === spaceId) || spaces.some(space => 'read_hash' in space || 'ownerLink' in space || 'memberLink' in space)) throw new Error('Space metadata catalog is invalid');
for (const path of ['/accounts/profile/import', `/spaces/${spaceId}/structure/import`]) {
const response = await context.request.post(`${origin}/api/v1${path}`, { headers, data: {} });
if (response.status() !== 404 && response.status() !== 405) throw new Error('Unexpected legacy import route');
}
await owner.reload();
await owner.getByRole('button', { name: '后端频道', exact: true }).waitFor();
await owner.getByRole('article', { name: '后端昵称 的消息', exact: true }).waitFor();
results.push('server-owned state survives reload and there are no migration endpoints');
return { passed: results };
} finally { await context.close(); }
}
+50
View File
@@ -0,0 +1,50 @@
async (page) => {
const origin = page.url().startsWith('http') ? new URL(page.url()).origin : 'http://127.0.0.1:5179'; const browser = page.context().browser(); const contexts = []; const results = [];
const password = 'Channel-domains-test-password'; const suffix = Date.now(); const ownerName = `domains_owner_${suffix}`; const memberName = `domains_member_${suffix}`;
let owner, member; const failures = [];
async function account(username) {
const context = await browser.newContext({ viewport: { width: 1440, height: 900 } }); contexts.push(context); const target = await context.newPage();
await target.goto(origin); await target.getByLabel('用户名', { exact: true }).fill(username); await target.getByLabel('密码', { exact: true }).fill(password);
await target.getByRole('button', { name: '创建账号', exact: true }).click(); await target.getByRole('button', { name: '新建空间', exact: true }).waitFor(); return target;
}
async function send(target, text) { await target.getByRole('region', { name: '常规 聊天', exact: true }).locator('textarea').fill(text); await target.getByRole('button', { name: '发送消息', exact: true }).click(); await target.getByRole('article').getByText(text, { exact: true }).waitFor(); }
try {
owner = await account(ownerName); member = await account(memberName);
member.on('response', async response => { if (response.status() >= 400 && response.url().includes('/api/v1/')) failures.push({ status: response.status(), method: response.request().method(), path: new URL(response.url()).pathname, code: (await response.json().catch(() => ({}))).error }); });
await owner.getByRole('button', { name: '新建空间', exact: true }).click(); await owner.getByLabel('空间名称(加入者和服务端可见)').fill('独立数据域测试'); await owner.getByRole('button', { name: '创建', exact: true }).click();
await owner.getByRole('button', { name: '常规', exact: true }).waitFor(); const path = new URL(owner.url()).pathname;
await send(owner, '加入前的历史聊天');
const ownerExplorer = owner.getByRole('region', { name: '频道文件管理器' });
await ownerExplorer.locator('input[type=file]').setInputFiles({ name: 'old-domain-file.txt', mimeType: 'text/plain', buffer: Buffer.from('historical file must remain private') });
await owner.getByRole('button', { name: '继续上传', exact: true }).click(); await owner.getByRole('button', { name: 'old-domain-file.txt', exact: true }).waitFor();
await owner.getByRole('button', { name: '在未分组新建频道', exact: true }).click(); await owner.getByLabel('频道名称', { exact: true }).fill('单独授权频道'); await owner.getByRole('button', { name: '创建频道', exact: true }).click();
await owner.getByRole('button', { name: '单独授权频道', exact: true }).waitFor(); await owner.getByRole('button', { name: '常规', exact: true }).click();
await owner.getByRole('button', { name: '频道设置 常规', exact: true }).click(); await owner.getByLabel('新成员可读历史聊天').uncheck(); await owner.getByLabel('新成员可读历史文件').uncheck();
await owner.getByRole('button', { name: '保存', exact: true }).click(); await owner.getByRole('button', { name: '保存', exact: true }).waitFor({ state: 'hidden' });
await owner.getByRole('button', { name: '空间设置', exact: true }).click(); await owner.getByLabel('被邀请用户的用户名').fill(memberName); await owner.getByRole('button', { name: '发送邀请', exact: true }).click();
await owner.getByText(`${memberName} · 已邀请`, { exact: true }).waitFor(); await owner.getByRole('dialog').getByRole('button', { name: '关闭', exact: true }).click();
await member.goto(origin + path); await member.getByRole('button', { name: '接受并加入空间', exact: true }).click(); await member.getByRole('button', { name: '常规', exact: true }).waitFor();
if (await member.getByRole('button', { name: '单独授权频道', exact: true }).count()) throw new Error('Member received an unauthorized channel');
if (await member.getByRole('article').getByText('加入前的历史聊天', { exact: true }).count()) throw new Error('Member received historical chat keys');
if (await member.getByRole('button', { name: 'old-domain-file.txt', exact: true }).count()) throw new Error('Member received historical file keys');
await send(owner, '加入后的新聊天'); await member.getByRole('article').getByText('加入后的新聊天', { exact: true }).waitFor();
const ownerMessage = owner.locator('article').filter({ hasText: '加入后的新聊天' });
const memberCopy = member.locator('article').filter({ hasText: '加入后的新聊天' });
if (await ownerMessage.evaluate(element => getComputedStyle(element).flexDirection) !== 'row-reverse'
|| await memberCopy.evaluate(element => getComputedStyle(element).flexDirection) !== 'row') throw new Error('Messages are not aligned by sender');
await send(member, '成员发言验证'); await owner.getByRole('article').getByText('成员发言验证', { exact: true }).waitFor();
results.push('default-channel access is automatic; other channels and past epochs remain inaccessible');
const explorer = ownerExplorer;
await explorer.locator('input[type=file]').setInputFiles({ name: 'new-domain-file.txt', mimeType: 'text/plain', buffer: Buffer.from('independent random file key verification') });
await owner.getByRole('button', { name: '继续上传', exact: true }).click(); await member.getByRole('button', { name: 'new-domain-file.txt', exact: true }).waitFor();
await member.getByRole('button', { name: 'new-domain-file.txt', exact: true }).dblclick();
await member.getByRole('dialog').locator('pre').filter({ hasText: 'independent random file key verification' }).waitFor();
results.push('file-group key unwraps an independent file key and decrypts the file on another account');
await member.getByRole('dialog').getByRole('button', { name: '关闭', exact: true }).click(); await member.reload(); await member.getByRole('article').getByText('加入后的新聊天', { exact: true }).waitFor();
if (await member.getByRole('article').getByText('加入前的历史聊天', { exact: true }).count()) throw new Error('Reload restored disallowed history');
results.push('authorization and epoch boundaries survive reload');
return { passed: results };
} catch (error) {
throw new Error(`${error.message}; failures=${JSON.stringify(failures)}; owner alerts=${JSON.stringify(owner ? await owner.getByRole('alert').allTextContents() : [])}; member alerts=${JSON.stringify(member ? await member.getByRole('alert').allTextContents() : [])}`);
} finally { for (const context of contexts) await context.close(); }
}
+9 -2
View File
@@ -42,13 +42,20 @@ async (page) => {
await file.getByRole('img', { name: '小白 的头像', exact: true }).waitFor();
await viewer.reload(); await viewer.getByRole('article', { name: '小白 上传了 project-notes.txt', exact: true }).getByRole('img', { name: '小白 的头像', exact: true }).waitFor();
if (downloads !== 1) throw new Error('Reloading file bubbles consumed a download');
results.push('identity survives download updates and encrypted history replay without automatic content reads');
results.push('server-owned identity survives download updates and reload without automatic content reads');
await viewer.getByRole('region', { name: '常规 聊天', exact: true }).locator('textarea').fill('我收到了。'); await viewer.getByRole('button', { name: '发送消息', exact: true }).click();
await sender.getByRole('article', { name: '小黑 的消息', exact: true }).getByText('我收到了。', { exact: true }).waitFor();
results.push('each participant keeps their own sender identity');
await viewer.screenshot({ path: 'C:/Users/JustMe/Desktop/webdrop-proj/.playwright-mcp/chat-file-messages.png', fullPage: true });
for (const width of [1440, 390]) { await viewer.setViewportSize({ width, height: 900 }); for (const colorScheme of ['light', 'dark']) {
await viewer.emulateMedia({ colorScheme }); if (await viewer.evaluate(() => document.documentElement.scrollWidth > innerWidth)) throw new Error('Chat layout overflow');
await viewer.emulateMedia({ colorScheme });
// Allow the media-query subscription to switch the React workspace layout.
await viewer.evaluate(() => new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve))));
if (await viewer.evaluate(() => document.documentElement.scrollWidth > innerWidth)) {
const overflow = await viewer.evaluate(() => ({ width: innerWidth, scroll: document.documentElement.scrollWidth,
elements: [...document.querySelectorAll('body *')].filter(e => e.getBoundingClientRect().right > innerWidth + 1).slice(0, 12).map(e => ({ tag: e.tagName, cls: e.className, right: e.getBoundingClientRect().right })) }));
throw new Error(`Chat layout overflow: ${JSON.stringify(overflow)}`);
}
} }
return { passed: results };
} finally { for (const context of contexts) await context.close(); }
+44
View File
@@ -0,0 +1,44 @@
async (page) => {
const origin = new URL(page.url()).origin;
const context = await page.context().browser().newContext(); const owner = await context.newPage();
const cdp = await context.newCDPSession(owner); const results = [];
const username = `passkey_${Date.now()}`; const password = 'Passkey-test-password-2026';
const errors = []; const calls = [];
owner.on('pageerror', error => errors.push(error.message));
owner.on('request', request => { if (request.url().includes('/api/v1/')) calls.push(new URL(request.url()).pathname); });
await cdp.send('WebAuthn.enable');
const { authenticatorId } = await cdp.send('WebAuthn.addVirtualAuthenticator', { options: { protocol: 'ctap2', ctap2Version: 'ctap2_1', transport: 'internal', hasResidentKey: true,
hasUserVerification: true, isUserVerified: true, automaticPresenceSimulation: true, hasPrf: true } });
let secretExtensionSent = false;
owner.on('request', request => { if (request.url().includes('/accounts/passkeys/') && request.postData()) {
const body = JSON.parse(request.postData()); if (body.credential && JSON.parse(body.credential).clientExtensionResults?.prf?.results) secretExtensionSent = true;
} });
try {
await owner.goto(origin); await owner.getByLabel('用户名', { exact: true }).fill(username); await owner.getByLabel('密码', { exact: true }).fill(password);
await owner.getByRole('button', { name: '创建账号', exact: true }).click(); await owner.getByRole('button', { name: '新建空间', exact: true }).waitFor();
await owner.getByLabel('Passkey名称', { exact: true }).fill('测试恢复凭据'); await owner.getByLabel('验证账号密码', { exact: true }).fill(password);
await owner.getByRole('button', { name: '绑定Passkey', exact: true }).click();
await owner.getByText('测试恢复凭据 · 登录与解密恢复', { exact: true }).waitFor();
if (secretExtensionSent) throw new Error('PRF results were sent to the API');
results.push('verified WebAuthn binding enables encrypted recovery without sending PRF outputs to the server');
await owner.getByRole('button', { name: '新建空间', exact: true }).click(); await owner.getByLabel('空间名称(加入者和服务端可见)').fill('Passkey恢复测试');
await owner.getByRole('button', { name: '创建', exact: true }).click(); await owner.getByRole('button', { name: '常规', exact: true }).waitFor();
await owner.getByRole('region', { name: '常规 聊天', exact: true }).locator('textarea').fill('Passkey恢复后的私密内容'); await owner.getByRole('button', { name: '发送消息', exact: true }).click();
await owner.getByRole('article').getByText('Passkey恢复后的私密内容', { exact: true }).waitFor();
const path = new URL(owner.url()).pathname;
await owner.goto(origin); await owner.getByRole('button', { name: '退出登录', exact: true }).click();
await owner.getByRole('button', { name: '使用Passkey登录', exact: true }).waitFor(); await owner.getByRole('button', { name: '使用Passkey登录', exact: true }).click();
await owner.getByRole('button', { name: '新建空间', exact: true }).waitFor(); await owner.goto(origin + path);
await owner.getByRole('article').getByText('Passkey恢复后的私密内容', { exact: true }).waitFor();
results.push('Passkey restores the vault and decrypts resources without entering the password');
await owner.goto(origin); await owner.getByRole('button', { name: '撤销 测试恢复凭据', exact: true }).click(); await owner.getByText('Passkey已撤销', { exact: true }).waitFor();
await owner.getByRole('button', { name: '退出登录', exact: true }).click();
await owner.getByLabel('用户名', { exact: true }).fill(username); await owner.getByLabel('密码', { exact: true }).fill(password);
await owner.getByRole('button', { name: '登录并同步', exact: true }).click(); await owner.getByRole('button', { name: '新建空间', exact: true }).waitFor();
results.push('revocation removes the credential while password recovery remains available');
return { passed: results };
} catch (error) {
const inputs = await owner.locator('input').evaluateAll(inputs => inputs.map(input => ({ name: input.name, type: input.type, length: input.value.length, valid: input.validity.valid, message: input.validationMessage })));
throw new Error(`Passkey smoke failed after ${results.length} checks: ${error.message}; body=${(await owner.locator('body').innerText()).slice(0, 800)}; inputs=${JSON.stringify(inputs)}; errors=${JSON.stringify(errors)}; calls=${JSON.stringify(calls)}`);
} finally { await cdp.send('WebAuthn.removeVirtualAuthenticator', { authenticatorId }).catch(() => {}); await context.close(); }
}