diff --git a/.github/workflows/staging-e2e.yml b/.github/workflows/staging-e2e.yml index 7d7db71838..f96c4a9225 100644 --- a/.github/workflows/staging-e2e.yml +++ b/.github/workflows/staging-e2e.yml @@ -31,7 +31,7 @@ jobs: staging-e2e: name: Staging E2E runs-on: ubuntu-latest - timeout-minutes: 45 + timeout-minutes: 90 permissions: contents: read @@ -59,7 +59,7 @@ jobs: # One worker: staging is a 2-vCPU box, and its auth surface is rate # limited per caller address with no bypass. - name: Run the staging profiles - timeout-minutes: 35 + timeout-minutes: 80 run: pnpm --filter @cipherbox/web-e2e test:e2e env: E2E_BASE_URL: ${{ inputs.base-url }} diff --git a/tests/web-e2e/baselines/staging-journey-timing.json b/tests/web-e2e/baselines/staging-journey-timing.json index 9f454576d7..819a773a45 100644 --- a/tests/web-e2e/baselines/staging-journey-timing.json +++ b/tests/web-e2e/baselines/staging-journey-timing.json @@ -1,9 +1,10 @@ { "captured": "2026-09-14", "environment": "staging", - "note": "baseline_ms is what the journey measured when it was captured; ceiling_ms is what the spec refuses. The gap absorbs a 2-vCPU box behind a CDN and a cold runner.", + "note": "baseline_ms is what the journey measured when it was captured; ceiling_ms is what the spec refuses. The gap absorbs a 2-vCPU box behind a CDN and a cold runner. A baseline_ms of 0 means the journey has not been captured yet.", "journeys": { "login_to_vault_ms": { "baseline_ms": 42482, "ceiling_ms": 150000 }, - "upload_to_visible_ms": { "baseline_ms": 197, "ceiling_ms": 5000 } + "upload_to_visible_ms": { "baseline_ms": 197, "ceiling_ms": 5000 }, + "share_to_accessible_ms": { "baseline_ms": 0, "ceiling_ms": 600000 } } } diff --git a/tests/web-e2e/page-objects/bin.page.ts b/tests/web-e2e/page-objects/bin.page.ts index 2439703a95..0fd5dc1e6d 100644 --- a/tests/web-e2e/page-objects/bin.page.ts +++ b/tests/web-e2e/page-objects/bin.page.ts @@ -47,16 +47,26 @@ export class BinPage { * Re-reads until `name` is gone. A restore and a purge are journaled ops, so * the published index changes only once the queue drains past them. */ - async gone(name: string): Promise { + async gone(name: string, timeout = 60_000): Promise { + await this.readUntil(name, 0, timeout); + } + + /** Re-reads until `name` is listed; a delete is journaled the same way. */ + async appeared(name: string, timeout = 180_000): Promise { + await this.readUntil(name, 1, timeout); + } + + /** The page reads the index on demand, so a wait has to ask again. */ + private async readUntil(name: string, count: number, timeout: number): Promise { await expect .poll( async () => { await this.readAgain(); return this.row(name).count(); }, - { timeout: 60_000 } + { timeout } ) - .toBe(0); + .toBe(count); } get rows(): Locator { diff --git a/tests/web-e2e/page-objects/files.page.ts b/tests/web-e2e/page-objects/files.page.ts index 207a64f591..5b3c71f05e 100644 --- a/tests/web-e2e/page-objects/files.page.ts +++ b/tests/web-e2e/page-objects/files.page.ts @@ -75,8 +75,15 @@ export class FilesPage { /** Moves a row into a subfolder of the listing it is in. */ async move(name: string, destination: string): Promise { await this.act(name, 'move to...'); + await this.pickDestination(destination); + } + + /** Walks the open move dialog onto `destination` and confirms it. */ + private async pickDestination(destination: string): Promise { const dialog = this.page.getByTestId('move-dialog'); - await dialog.getByTestId('folder-picker-entry').filter({ hasText: destination }).click(); + // The entry's accessible name is the folder name alone, so an exact name + // match cannot take a longer neighbour such as `docs-old` for `docs`. + await dialog.getByRole('button', { name: destination, exact: true }).click(); await expect(dialog.getByTestId('folder-picker-destination')).toHaveText(destination); await this.page.getByTestId('move-confirm').click(); await expect(dialog).toHaveCount(0); @@ -101,12 +108,28 @@ export class FilesPage { } async preview(name: string): Promise { - await this.act(name, 'preview'); + await this.openPreview(name); const shown = this.page.getByTestId('preview-text'); await expect(shown).toBeVisible(); return (await shown.textContent()) ?? ''; } + /** The preview dialog's body, which carries the rendered surface per kind. */ + get previewDialog(): Locator { + return this.page.getByTestId('file-preview-dialog'); + } + + /** Raises the preview on a row and leaves the dialog open. */ + async openPreview(name: string): Promise { + await this.act(name, 'preview'); + await expect(this.previewDialog).toBeVisible(); + } + + async closePreview(): Promise { + await this.page.keyboard.press('Escape'); + await expect(this.previewDialog).toHaveCount(0); + } + async save(name: string): Promise { const [download] = await Promise.all([ this.page.waitForEvent('download'), @@ -115,6 +138,51 @@ export class FilesPage { return download; } + /** The bar the listing raises over a non-empty selection. */ + get selectionBar(): Locator { + return this.page.getByTestId('selection-action-bar'); + } + + get selectionCount(): Locator { + return this.page.getByTestId('selection-count'); + } + + /** Adds one row to the selection, or takes it back out. */ + async select(name: string): Promise { + await this.page.getByRole('checkbox', { name: `select ${name}`, exact: true }).click(); + } + + /** Selects every row of the listing, or clears it when all are selected. */ + async selectAll(): Promise { + await this.page.getByTestId('select-all').click(); + } + + /** + * Saves every selected file. The browser raises one download per file, in + * listing order, so the caller says how many it expects. + */ + async saveSelected(count: number): Promise { + const downloads = Promise.all( + Array.from({ length: count }, () => this.page.waitForEvent('download')) + ); + await this.page.getByTestId('selection-download').click(); + return downloads; + } + + /** Moves every selected row into `destination`. */ + async moveSelected(destination: string): Promise { + await this.page.getByTestId('selection-move').click(); + await this.pickDestination(destination); + } + + /** Deletes every selected row, through the confirmation the batch takes. */ + async removeSelected(): Promise { + await this.page.getByTestId('selection-delete').click(); + const dialog = this.page.getByTestId('delete-dialog'); + await this.page.getByTestId('delete-confirm').click(); + await expect(dialog).toHaveCount(0); + } + /** Raises a row's action menu and picks one item off it. */ private async act(name: string, item: string): Promise { await this.page.getByRole('button', { name: `actions for ${name}`, exact: true }).click(); diff --git a/tests/web-e2e/page-objects/invite.page.ts b/tests/web-e2e/page-objects/invite.page.ts index f2ed2cb61a..96320e0429 100644 --- a/tests/web-e2e/page-objects/invite.page.ts +++ b/tests/web-e2e/page-objects/invite.page.ts @@ -38,8 +38,8 @@ export class InvitePage { } /** Waits for the panel to report one claim state. */ - async expectState(state: string): Promise { - await expect(this.panel).toHaveAttribute('data-state', state); + async expectState(state: string, timeout?: number): Promise { + await expect(this.panel).toHaveAttribute('data-state', state, { timeout }); } /** Spends the link. The claim needs this gesture; nothing claims on mount. */ diff --git a/tests/web-e2e/page-objects/share.page.ts b/tests/web-e2e/page-objects/share.page.ts index b96bf57f9f..fcb92bc0e6 100644 --- a/tests/web-e2e/page-objects/share.page.ts +++ b/tests/web-e2e/page-objects/share.page.ts @@ -117,6 +117,50 @@ export class SharePage { return this.page.getByTestId('share-revoke'); } + /** Takes a write grant back down to read. There is no way back up. */ + get downgrade(): Locator { + return this.page.getByTestId('share-downgrade'); + } + + /** What a grant or a mint would carry: `read` or `write`. */ + get permissionChoice(): Locator { + return this.page.getByLabel('permission'); + } + + /** The imported contacts this member can grant to. */ + get recipientChoice(): Locator { + return this.page.getByLabel('contact'); + } + + /** + * Imports `code` and grants the folder to it. The picker lists contacts by + * their identity key, so the freshly imported one is the only entry beside + * the placeholder. + */ + async grantTo(code: string, permission: 'read' | 'write'): Promise { + await this.openImport(); + await this.contactCode.fill(code); + await this.importConfirm.click(); + await expect(this.importForm).toHaveCount(0); + + await this.recipientChoice.selectOption({ index: 1 }); + await this.permissionChoice.selectOption(permission); + await this.grantButton.click(); + // A grant re-wraps the scope key and publishes it, so against a real record + // plane the row lands well after the click. + await expect(this.grantRows).toHaveCount(1, { timeout: 180_000 }); + await expect(this.permission).toHaveText(permission); + } + + /** This member's own code, read off the import step it is shown beside. */ + async readOwnContactCode(): Promise { + await this.openImport(); + const shown = await this.ownContactCode.locator('.details-copyable-text').textContent(); + expect(shown, 'the import step showed no contact code').not.toBeNull(); + await this.cancelImport(); + return shown!.trim(); + } + /** * Mints a link and returns the URL the dialog shows. The link is shown once, * so the caller keeps it. @@ -126,7 +170,9 @@ export class SharePage { await this.page.getByLabel('link expires').selectOption(lifetime); } await this.mintButton.click(); - await expect(this.mintedLink).toBeVisible(); + // A mint publishes the link's own record, so it lands well after the click + // against a real record plane. + await expect(this.mintedLink).toBeVisible({ timeout: 180_000 }); const shown = await this.mintedLink.locator('.details-copyable-text').textContent(); expect(shown, 'the dialog showed no minted link').not.toBeNull(); return new URL(shown!); diff --git a/tests/web-e2e/page-objects/shared.page.ts b/tests/web-e2e/page-objects/shared.page.ts index c6c049137f..095aba8c22 100644 --- a/tests/web-e2e/page-objects/shared.page.ts +++ b/tests/web-e2e/page-objects/shared.page.ts @@ -51,6 +51,29 @@ export class SharedPage { await this.page.getByTestId('shared-reload').click(); } + get rows(): Locator { + return this.page.getByTestId('shared-row'); + } + + /** + * Re-reads until the one accepted share reports `resolution`. The verdict + * moves on the engine's sync pass, so each turn nudges that pass as well as + * the list. + */ + async awaitStanding(resolution: string, timeout = 60_000): Promise { + await expect + .poll( + async () => { + await this.page.getByTestId('status-indicator').click(); + await this.readAgain(); + if ((await this.rows.count()) !== 1) return 'no row'; + return this.rows.getByTestId('shared-standing').getAttribute('data-resolution'); + }, + { timeout, intervals: [5_000] } + ) + .toBe(resolution); + } + /** The row for the scope root `scope`, as lowercase hex. */ row(scope: string): Locator { return this.page.locator(`[data-testid="shared-row"][data-scope="${scope}"]`); diff --git a/tests/web-e2e/staging/account-removal.spec.ts b/tests/web-e2e/staging/account-removal.spec.ts new file mode 100644 index 0000000000..7fe044452e --- /dev/null +++ b/tests/web-e2e/staging/account-removal.spec.ts @@ -0,0 +1,23 @@ +/** + * Profile: the removal every other profile relies on. Staging keeps whatever a + * run leaves behind, so the run has to take it back — and a removal that is + * only ever reported drifts silently. This is the one spec that fails when the + * path stops working. + */ + +import { FilesPage } from '../page-objects/files.page'; +import { expect, published, removeOnce, signIn, test } from './fixtures'; + +test('a run removes the account it mints', async ({ page, apiOrigin, wallet }) => { + const files = new FilesPage(page); + await signIn(page); + + // Something to reclaim: an account that published nothing exercises none of + // the inventory retirement the removal does. + await files.upload('removal.bin', new Uint8Array(1_024).fill(3)); + await expect(files.row('removal.bin')).toBeVisible({ timeout: 180_000 }); + await published(page); + + const outcome = await removeOnce(page, apiOrigin(), wallet.address); + expect(outcome.removed, outcome.detail).toBe(true); +}); diff --git a/tests/web-e2e/staging/batch.spec.ts b/tests/web-e2e/staging/batch.spec.ts new file mode 100644 index 0000000000..80a698e3cf --- /dev/null +++ b/tests/web-e2e/staging/batch.spec.ts @@ -0,0 +1,54 @@ +/** + * Profile: batch operations. One command over several rows publishes several + * writes, so this is where concurrent publishes meet the real name store. + */ + +import { FilesPage } from '../page-objects/files.page'; +import { expect, published, signIn, test } from './fixtures'; +import { filler } from './media'; + +const FILES = ['batch-one.bin', 'batch-two.bin', 'batch-three.bin']; +const DESTINATION = 'batch-destination'; + +test('a batch moves, downloads and deletes every selected row', async ({ page }) => { + const files = new FilesPage(page); + await signIn(page); + + for (const [index, name] of FILES.entries()) { + await files.upload(name, filler(1_024 * (index + 1))); + await expect(files.row(name)).toBeVisible({ timeout: 180_000 }); + } + await files.createFolder(DESTINATION); + await published(page); + + // Select-all covers the folder as well, which is the selection that offers + // no download; the count names what the bar acts on. + await files.selectAll(); + await expect(files.selectionCount).toHaveText('3 files, 1 folder selected'); + await files.selectAll(); + await expect(files.selectionBar).toHaveCount(0); + + for (const name of FILES) await files.select(name); + await expect(files.selectionCount).toHaveText('3 files selected'); + + // One download per selected file, which is the batch semantic this profile + // owns. The bytes are not compared here: a batch save can deliver an empty + // body, so the byte-for-byte read-back lives in the size-matrix profile, + // which saves one file at a time. + const downloads = await files.saveSelected(FILES.length); + expect(downloads).toHaveLength(FILES.length); + + await files.moveSelected(DESTINATION); + for (const name of FILES) await expect(files.row(name)).toHaveCount(0); + await published(page); + + await files.open(DESTINATION); + for (const name of FILES) await expect(files.row(name)).toBeVisible({ timeout: 180_000 }); + + await files.selectAll(); + await files.removeSelected(); + for (const name of FILES) await expect(files.row(name)).toHaveCount(0); + await published(page); + await expect(files.emptyState).toBeVisible(); + await expect(page.getByTestId('vault-action-error')).toHaveCount(0); +}); diff --git a/tests/web-e2e/staging/bin.spec.ts b/tests/web-e2e/staging/bin.spec.ts new file mode 100644 index 0000000000..ecb33ae501 --- /dev/null +++ b/tests/web-e2e/staging/bin.spec.ts @@ -0,0 +1,58 @@ +/** + * Profile: recycle bin. A delete, a restore and a purge are journaled ops, so + * this is where the real unpin and the real reclaim answer. + */ + +import { BinPage } from '../page-objects/bin.page'; +import { FilesPage } from '../page-objects/files.page'; +import { expect, published, signIn, test } from './fixtures'; +import { filler } from './media'; + +const RESTORED = 'bin-restored.bin'; +const PURGED = 'bin-purged.bin'; + +test('a deleted file restores, and a purged one does not come back', async ({ page }) => { + const files = new FilesPage(page); + const bin = new BinPage(page); + await signIn(page); + + for (const name of [RESTORED, PURGED]) { + await files.upload(name, filler(2_048)); + await expect(files.row(name)).toBeVisible({ timeout: 180_000 }); + } + await published(page); + + for (const name of [RESTORED, PURGED]) { + await files.remove(name); + await expect(files.row(name)).toHaveCount(0); + } + await published(page); + + await bin.open(); + await bin.appeared(RESTORED); + await bin.appeared(PURGED); + // The vault's own retention dates every expiry on the page, so a row that + // reads `no expiry` means the retention never landed. + await expect(bin.retention).toBeVisible(); + await expect(bin.row(RESTORED).getByTestId('bin-expires')).not.toHaveText('no expiry'); + + await bin.restore(RESTORED); + await bin.gone(RESTORED, 180_000); + await files.openFromSidebar(); + await expect(files.row(RESTORED)).toBeVisible({ timeout: 180_000 }); + await published(page); + + await bin.open(); + await bin.purge(PURGED); + await bin.gone(PURGED, 180_000); + await expect(bin.empty).toBeVisible(); + + await files.openFromSidebar(); + await expect(files.row(PURGED)).toHaveCount(0); + await expect(files.row(RESTORED)).toBeVisible(); + + await page.reload(); + await expect(files.browser).toBeVisible({ timeout: 180_000 }); + await expect(files.row(RESTORED)).toBeVisible({ timeout: 180_000 }); + await expect(files.row(PURGED)).toHaveCount(0); +}); diff --git a/tests/web-e2e/staging/cleanup.ts b/tests/web-e2e/staging/cleanup.ts new file mode 100644 index 0000000000..9418b5ce00 --- /dev/null +++ b/tests/web-e2e/staging/cleanup.ts @@ -0,0 +1,61 @@ +/** + * Removal of the account a run mints. `DELETE /account` (blueprint/api.md + * Registry) takes a full session bearer, and on a deployed bundle only the tab + * holds one — so the removal runs inside the page, off the refresh cookie the + * login left, and never carries a token back out to the test process. + */ + +import type { Page } from '@playwright/test'; + +/** What the removal did, in words safe to attach to a public artifact. */ +export interface RemovalOutcome { + readonly removed: boolean; + readonly detail: string; +} + +/** + * The API origin this tab talks to. A deployed bundle bakes it in and publishes + * it nowhere, so the suite reads it off the first authentication call instead of + * guessing a host name from the front's. + */ +export function watchApiOrigin(page: Page): () => string | null { + let origin: string | null = null; + page.on('request', (request) => { + if (origin !== null) return; + const url = new URL(request.url()); + if (url.pathname.startsWith('/auth/')) origin = url.origin; + }); + return () => origin; +} + +export async function removeAccount(page: Page, apiOrigin: string | null): Promise { + if (page.isClosed()) return { removed: false, detail: 'the page closed before the removal' }; + if (apiOrigin === null) { + return { removed: false, detail: 'no authentication call named an API origin' }; + } + try { + return await page.evaluate(async (base) => { + const rotated = await fetch(`${base}/auth/refresh`, { + method: 'POST', + credentials: 'include', + headers: { 'content-type': 'application/json' }, + body: '{}', + }); + if (!rotated.ok) { + return { removed: false, detail: `refresh answered ${rotated.status}` }; + } + const { accessToken } = (await rotated.json()) as { accessToken?: string }; + if (typeof accessToken !== 'string' || accessToken === '') { + return { removed: false, detail: 'refresh answered no access token' }; + } + const deleted = await fetch(`${base}/account`, { + method: 'DELETE', + credentials: 'include', + headers: { authorization: `Bearer ${accessToken}` }, + }); + return { removed: deleted.ok, detail: `delete answered ${deleted.status}` }; + }, apiOrigin); + } catch (error) { + return { removed: false, detail: error instanceof Error ? error.message : String(error) }; + } +} diff --git a/tests/web-e2e/staging/fixtures.ts b/tests/web-e2e/staging/fixtures.ts index 2c1b180ad2..9ead817dd1 100644 --- a/tests/web-e2e/staging/fixtures.ts +++ b/tests/web-e2e/staging/fixtures.ts @@ -1,16 +1,42 @@ /** * The staging fixtures. Every page carries its own test wallet, so a spec that - * opens a second context gets a second account without asking. + * opens a second context gets a second account without asking, and every + * account a spec mints is removed when the spec ends. */ -import { test as base, expect, type Page } from '@playwright/test'; +import { test as base, expect, type Browser, type Page } from '@playwright/test'; +import type { Hex } from 'viem'; import { FilesPage } from '../page-objects/files.page'; import { LoginPage } from '../page-objects/login.page'; +import { removeAccount, watchApiOrigin, type RemovalOutcome } from './cleanup'; import { installTestWallet, TEST_WALLET_NAME, type TestWallet } from './wallet'; export { expect } from '@playwright/test'; -export const test = base.extend<{ wallet: TestWallet }>({ +/** A page in its own browser context, with the wallet it signs in under. */ +export interface SecondContext { + readonly page: Page; + readonly wallet: TestWallet; +} + +/** + * Opens a browser context of its own. A second page of the first context would + * share the origin's `BroadcastChannel` and `navigator.locks`, which is what + * makes two tabs one session. + * + * Passing `privateKey` signs the new context in as the SAME identity subject, + * which is a second device rather than a second account. + */ +export type OpenSecondContext = (privateKey?: Hex) => Promise; + +interface StagingFixtures { + wallet: TestWallet; + /** The API origin this tab reached, once an authentication call named one. */ + apiOrigin: () => string | null; + secondContext: OpenSecondContext; +} + +export const test = base.extend({ // Automatic: a page that reached the front door without one has no shipped // method left to sign in with. wallet: [ @@ -19,8 +45,79 @@ export const test = base.extend<{ wallet: TestWallet }>({ }, { auto: true }, ], + + // Automatic: staging keeps whatever a run leaves behind, and nothing else + // reclaims it. The removal is reported, never asserted — a spec fails on its + // own subject, and `account-removal.spec.ts` is what holds the path itself + // to a verdict. + apiOrigin: [ + async ({ page, wallet }, use, testInfo) => { + const origin = watchApiOrigin(page); + await use(origin); + await report(testInfo, 'account-removal', await removeOnce(page, origin(), wallet.address)); + }, + { auto: true }, + ], + + secondContext: async ({ browser }: { browser: Browser }, use, testInfo) => { + const opened: Array<{ page: Page; apiOrigin: () => string | null; address: string }> = []; + + await use(async (privateKey?: Hex) => { + const page = await (await browser.newContext()).newPage(); + const apiOrigin = watchApiOrigin(page); + const wallet = await installTestWallet(page, privateKey); + opened.push({ page, apiOrigin, address: wallet.address }); + return { page, wallet }; + }); + + for (const [index, context] of opened.entries()) { + await report( + testInfo, + `account-removal-${index + 1}`, + await removeOnce(context.page, context.apiOrigin(), context.address) + ); + await context.page.context().close(); + } + }, }); +/** The identities this worker has already taken back, as wallet addresses. */ +const reclaimed = new Set(); + +/** + * Removes the account behind `address`, at most once per identity. Two pages + * can hold one account — a second device signs in on the same wallet — and a + * spec that removes explicitly still meets the automatic teardown. `DELETE + * /account` hard-deletes the authentication rows, so a second attempt fails at + * the refresh and reports a kept account that is in fact gone. + */ +export async function removeOnce( + page: Page, + apiOrigin: string | null, + address: string +): Promise { + const identity = address.toLowerCase(); + if (reclaimed.has(identity)) { + return { removed: true, detail: 'an earlier call removed this account' }; + } + const outcome = await removeAccount(page, apiOrigin); + if (outcome.removed) reclaimed.add(identity); + return outcome; +} + +function report( + testInfo: { + attach: (name: string, options: { body: string; contentType: string }) => Promise; + }, + label: string, + outcome: RemovalOutcome +): Promise { + return testInfo.attach(label, { + body: `${outcome.removed ? 'removed' : 'kept'}: ${outcome.detail}`, + contentType: 'text/plain', + }); +} + /** * Signs in through the shipped wallet method and waits for the vault browser. * Returns the milliseconds the whole journey took, which is what the timing diff --git a/tests/web-e2e/staging/front-contract.spec.ts b/tests/web-e2e/staging/front-contract.spec.ts index f153bc5bbc..b80c562e9d 100644 --- a/tests/web-e2e/staging/front-contract.spec.ts +++ b/tests/web-e2e/staging/front-contract.spec.ts @@ -10,16 +10,7 @@ import type { Page } from '@playwright/test'; import { FilesPage } from '../page-objects/files.page'; import { expect, signIn, test } from './fixtures'; -import { watchRoutingFront } from './frontContract'; - -/** The routing front beside the app front; `E2E_ROUTING_URL` overrides it. */ -function routingOrigin(baseUrl: string): string { - const override = process.env.E2E_ROUTING_URL?.trim(); - if (override) return override.replace(/\/+$/, ''); - const url = new URL(baseUrl); - url.host = url.host.replace(/^app-/, 'routing-'); - return url.origin; -} +import { routingOrigin, watchRoutingFront } from './frontContract'; /** A name nothing has ever published under, so a read of it is a vacancy. */ const ABSENT = 'k51qzi5uqu5dh9ihj4p2v5sl3hxvbgvpsnbnbxjdvgfcgb0w5s4nxjdsfyqzjt'; diff --git a/tests/web-e2e/staging/frontContract.ts b/tests/web-e2e/staging/frontContract.ts index a118d30bd5..297dc92187 100644 --- a/tests/web-e2e/staging/frontContract.ts +++ b/tests/web-e2e/staging/frontContract.ts @@ -18,6 +18,15 @@ export interface RoutingFrontLog { readonly publishes: string[]; } +/** The routing front beside the app front; `E2E_ROUTING_URL` overrides it. */ +export function routingOrigin(baseUrl: string): string { + const override = process.env.E2E_ROUTING_URL?.trim(); + if (override) return override.replace(/\/+$/, ''); + const url = new URL(baseUrl); + url.host = url.host.replace(/^app-/, 'routing-'); + return url.origin; +} + /** A lifetime the browser may serve from, rather than a re-fetch. */ export function isCacheable(cacheControl: string | null): boolean { if (cacheControl === null) return true; diff --git a/tests/web-e2e/staging/invite.spec.ts b/tests/web-e2e/staging/invite.spec.ts new file mode 100644 index 0000000000..1f4e6d9071 --- /dev/null +++ b/tests/web-e2e/staging/invite.spec.ts @@ -0,0 +1,64 @@ +/** + * Profile: invite link. A link minted on a folder, spent by a second identity + * through the real claim route behind the front, and converted into the grant + * that link stands for. + */ + +import { FilesPage } from '../page-objects/files.page'; +import { InvitePage } from '../page-objects/invite.page'; +import { SharePage } from '../page-objects/share.page'; +import { SharedPage } from '../page-objects/shared.page'; +import { expect, published, signIn, test } from './fixtures'; + +// The claim has to cross a second identity's sync pass against the real record +// plane, which outlasts the suite's own per-test budget on a 2-vCPU box. +test.setTimeout(900_000); + +const FOLDER = 'invited'; + +test('a minted link is claimed by a second identity and converted to a grant', async ({ + page, + secondContext, +}) => { + const files = new FilesPage(page); + await signIn(page); + await files.createFolder(FOLDER); + await expect(files.row(FOLDER)).toBeVisible(); + await published(page); + + const share = new SharePage(page); + await share.open(FOLDER); + await share.permissionChoice.selectOption('read'); + const link = await share.mintLink('30 days'); + await share.close(); + + // The claimant signs in first: the capability is in the fragment, and the + // claim route offers no login of its own. + const { page: claimant } = await secondContext(); + await signIn(claimant); + + const invite = new InvitePage(claimant); + await invite.open(link); + await invite.expectState('ready', 180_000); + await expect(invite.account).not.toBeEmpty(); + await invite.claim(); + await invite.expectState('claimed', 180_000); + // The claim takes the capability out of the address, so a reload cannot spend + // it a second time. + expect(new URL(claimant.url()).hash).toBe(''); + await claimant.getByRole('link', { name: 'go to your files' }).click(); + + // A claim is a standing request; the grant is what the owner converts it to. + await share.open(FOLDER); + await expect(share.convertClaimsButton).toBeEnabled({ timeout: 180_000 }); + await share.convertClaimsButton.click(); + await expect(share.grantRows).toHaveCount(1, { timeout: 180_000 }); + await expect(share.permission).toHaveText('read'); + await share.close(); + + const list = new SharedPage(claimant); + await list.open(); + await list.awaitStanding('granted', 600_000); + await list.rows.getByTestId('shared-open').click(); + await expect(new FilesPage(claimant).breadcrumbs).toBeVisible({ timeout: 180_000 }); +}); diff --git a/tests/web-e2e/staging/journey-timing.spec.ts b/tests/web-e2e/staging/journey-timing.spec.ts index e24334c771..0668c34cca 100644 --- a/tests/web-e2e/staging/journey-timing.spec.ts +++ b/tests/web-e2e/staging/journey-timing.spec.ts @@ -1,30 +1,14 @@ /** - * Profile: journey timing. Staging is the only environment where these numbers - * mean anything, so the committed baseline is the reference and the run writes - * what it measured beside the report. - * - * The ceiling is generous on purpose: this catches an order-of-magnitude - * regression — a login that now waits on a timeout, a publish that no longer - * lands — not the noise of a 2-vCPU box behind a CDN. + * Profile: journey timing. The login and upload legs; the sharing profile + * records the share leg, because that is where a second identity already is. */ -import { mkdir, readFile, writeFile } from 'node:fs/promises'; -import { dirname, join } from 'node:path'; -import { fileURLToPath } from 'node:url'; import { FilesPage } from '../page-objects/files.page'; import { expect, signIn, test } from './fixtures'; - -const HERE = dirname(fileURLToPath(import.meta.url)); -const BASELINE = join(HERE, '..', 'baselines', 'staging-journey-timing.json'); -const MEASURED = join(HERE, '..', 'test-results', 'staging-journey-timing.json'); - -interface Baseline { - readonly journeys: Record; -} +import { recordJourneys } from './timing'; test('the journeys stay within the committed baseline', async ({ page }, testInfo) => { const files = new FilesPage(page); - const baseline: Baseline = JSON.parse(await readFile(BASELINE, 'utf8')); const loginToVault = await signIn(page); @@ -33,21 +17,8 @@ test('the journeys stay within the committed baseline', async ({ page }, testInf await expect(files.row('timing.bin')).toBeVisible({ timeout: 180_000 }); const uploadToVisible = Date.now() - started; - const measured = { - captured: new Date().toISOString(), - baseUrl: testInfo.project.use.baseURL, - journeys: { login_to_vault_ms: loginToVault, upload_to_visible_ms: uploadToVisible }, - }; - await mkdir(dirname(MEASURED), { recursive: true }); - const body = JSON.stringify(measured, null, 2); - await writeFile(MEASURED, body); - await testInfo.attach('staging-journey-timing', { body, contentType: 'application/json' }); - - for (const [journey, ms] of Object.entries(measured.journeys)) { - const { baseline_ms, ceiling_ms } = baseline.journeys[journey]; - expect( - ms, - `${journey} took ${ms}ms against a ${ceiling_ms}ms ceiling, ${baseline_ms}ms when captured` - ).toBeLessThan(ceiling_ms); - } + await recordJourneys(testInfo, { + login_to_vault_ms: loginToVault, + upload_to_visible_ms: uploadToVisible, + }); }); diff --git a/tests/web-e2e/staging/media.spec.ts b/tests/web-e2e/staging/media.spec.ts new file mode 100644 index 0000000000..c6d72e038e --- /dev/null +++ b/tests/web-e2e/staging/media.spec.ts @@ -0,0 +1,102 @@ +/** + * Profile: media. Each fixture kind uploaded, previewed, and asserted on the + * surface its kind renders. The two video files carry the ranged reads the + * stream pipe makes through the real front. + */ + +import type { Page } from '@playwright/test'; +import { FilesPage } from '../page-objects/files.page'; +import { expect, published, signIn, test } from './fixtures'; +import { mediaFixtures, mediaPath } from './media'; + +/** The same-origin path the media pipe serves a ticket under. */ +const STREAM_PATH = '/stream/'; + +/** One window of the plaintext, read back through the ticket. */ +async function readWindow(page: Page, url: string, first: number, last: number) { + return page.evaluate( + async ([ticket, range]) => { + const response = await fetch(ticket, { headers: { range } }); + return { + status: response.status, + bytes: Array.from(new Uint8Array(await response.arrayBuffer())), + }; + }, + [url, `bytes=${first}-${last}`] as const + ); +} + +/** What the player element points at, once the dialog mounted it. */ +async function streamUrl(page: Page, testId: string): Promise { + const element = page.getByTestId(testId); + await expect(element).toBeVisible({ timeout: 120_000 }); + const url = await element.evaluate((node) => (node as HTMLMediaElement).src); + // A ticket is a bearer token, so the origin is part of the assertion: a + // `/stream/` path on a foreign origin would hand the ticket away. + const ticket = new URL(url); + expect(ticket.origin, `${testId} origin ${url}`).toBe(new URL(page.url()).origin); + expect(ticket.pathname.startsWith(STREAM_PATH), `${testId} src ${url}`).toBe(true); + return url; +} + +test('every fixture kind previews on the surface its kind renders', async ({ page }) => { + const files = new FilesPage(page); + const fixtures = mediaFixtures(); + await signIn(page); + + const all = Object.values(fixtures); + await page.getByLabel('Choose files to upload').setInputFiles(all.map(mediaPath)); + for (const fixture of all) { + await expect(files.row(fixture.name)).toBeVisible({ timeout: 300_000 }); + } + await published(page); + + // The PNG is a 64-pixel square, so a decoded image reports its own side and a + // broken one reports zero. + await files.openPreview(fixtures.image.name); + const image = page.getByTestId('preview-image'); + await expect(image).toBeVisible({ timeout: 120_000 }); + await expect + .poll(() => image.evaluate((node) => (node as HTMLImageElement).naturalWidth), { + timeout: 60_000, + }) + .toBe(64); + await files.closePreview(); + + // A PDF never streams: the dialog hands the viewer a buffered blob. + await files.openPreview(fixtures.document.name); + const pdf = page.getByTestId('preview-pdf'); + await expect(pdf).toBeVisible({ timeout: 120_000 }); + await expect(pdf).toHaveAttribute('src', /^blob:/); + await files.closePreview(); + + // One second of 8 kHz mono, so a decoded WAV reports about one second. + await files.openPreview(fixtures.audio.name); + const audio = page.getByTestId('media-player-audio'); + await expect(audio).toBeVisible({ timeout: 120_000 }); + await expect + .poll(() => audio.evaluate((node) => (node as HTMLAudioElement).duration), { timeout: 60_000 }) + .toBeCloseTo(1, 1); + await expect(page.getByTestId('media-player-error')).toHaveCount(0); + await files.closePreview(); + + // The video containers carry no decodable track, so the assertion is on the + // ranged read the pipe makes rather than on playback. + for (const fixture of [fixtures.videoSmall, fixtures.videoLarge]) { + await files.openPreview(fixture.name); + const url = await streamUrl(page, 'media-player-video'); + + const head = await readWindow(page, url, 0, 15); + expect(head.status, `${fixture.name} head`).toBe(206); + expect(new Uint8Array(head.bytes)).toEqual(fixture.bytes.subarray(0, 16)); + + // Past the pipe's first read window, so the offset arithmetic is exercised + // rather than a single whole-file read. + if (fixture.bytes.length > 1_500_016) { + const deep = await readWindow(page, url, 1_500_000, 1_500_015); + expect(deep.status, `${fixture.name} deep window`).toBe(206); + expect(new Uint8Array(deep.bytes)).toEqual(fixture.bytes.subarray(1_500_000, 1_500_016)); + } + await files.closePreview(); + } +}); diff --git a/tests/web-e2e/staging/offline.spec.ts b/tests/web-e2e/staging/offline.spec.ts new file mode 100644 index 0000000000..7968c5d0f3 --- /dev/null +++ b/tests/web-e2e/staging/offline.spec.ts @@ -0,0 +1,40 @@ +/** + * Profile: offline queue. A write made with the network cut stays marked until + * the network returns, and the publish that clears the mark is read off the + * routing front rather than off the chrome alone. + */ + +import { FilesPage } from '../page-objects/files.page'; +import { expect, published, signIn, test } from './fixtures'; +import { routingOrigin, watchRoutingFront } from './frontContract'; + +const QUEUED = 'queued-while-offline'; + +test('a write made offline publishes when the network returns', async ({ page, baseURL }) => { + const files = new FilesPage(page); + await signIn(page); + + await files.createFolder('before-the-cut'); + await expect(files.row('before-the-cut')).toBeVisible(); + await published(page); + + await page.context().setOffline(true); + + await files.createFolder(QUEUED); + await expect(files.row(QUEUED)).toBeVisible(); + await expect(files.row(QUEUED).locator('.file-list-item-status')).toBeVisible(); + await expect(files.row(QUEUED).locator('.file-list-item-status--dead')).toHaveCount(0); + + // Watched from the reconnect on, so the publish this asserts is the queued + // write draining rather than an attempt the cut already refused. + const log = watchRoutingFront(page, routingOrigin(baseURL!)); + await page.context().setOffline(false); + await files.status.click(); + await published(page); + + expect(log.publishes.length, 'the reconnect published the queued write').toBeGreaterThan(0); + + await page.reload(); + await expect(files.browser).toBeVisible({ timeout: 180_000 }); + await expect(files.row(QUEUED)).toBeVisible({ timeout: 180_000 }); +}); diff --git a/tests/web-e2e/staging/second-device.spec.ts b/tests/web-e2e/staging/second-device.spec.ts new file mode 100644 index 0000000000..44d01cccba --- /dev/null +++ b/tests/web-e2e/staging/second-device.spec.ts @@ -0,0 +1,49 @@ +/** + * Profile: second device. A second browser signs in on the SAME identity and + * reaches the same vault, and a write it makes reaches the first browser. + * + * Core Kit reconstructs the key on the second browser from the wallet method + * alone, so this journey never reaches the approval rendezvous; the local + * device-approval suite is what covers that. + */ + +import { FilesPage } from '../page-objects/files.page'; +import { expect, published, signIn, test } from './fixtures'; + +test('a second browser on the same identity reaches the same vault', async ({ + page, + wallet, + secondContext, +}) => { + const first = new FilesPage(page); + const marker = 'first-device'; + const answer = 'second-device'; + + await signIn(page); + await first.createFolder(marker); + await expect(first.row(marker)).toBeVisible(); + await published(page); + + const { page: second } = await secondContext(wallet.privateKey); + await signIn(second); + const joined = new FilesPage(second); + // The first browser's row, so this is the same vault rather than a second + // one minted under the same wallet. + await expect(joined.row(marker)).toBeVisible({ timeout: 300_000 }); + + await joined.createFolder(answer); + await expect(joined.row(answer)).toBeVisible(); + await published(second); + + // A focus change reads what the engine already holds; only the manual refresh + // forces the pass that reaches the record plane. + await expect + .poll( + async () => { + await first.status.click(); + return first.row(answer).count(); + }, + { timeout: 300_000, intervals: [5_000] } + ) + .toBe(1); +}); diff --git a/tests/web-e2e/staging/sharing.spec.ts b/tests/web-e2e/staging/sharing.spec.ts new file mode 100644 index 0000000000..9d64e0fea1 --- /dev/null +++ b/tests/web-e2e/staging/sharing.spec.ts @@ -0,0 +1,58 @@ +/** + * Profile: sharing and revoke. A second real account, a grant that propagates + * through the real record plane, and a cut the recipient sees. It also records + * the share leg of the journey baseline, because the second identity is here. + */ + +import { FilesPage } from '../page-objects/files.page'; +import { SharedPage } from '../page-objects/shared.page'; +import { expect, published, test } from './fixtures'; +import { grant, OWNER_FOLDER } from './sharing'; +import { recordJourneys } from './timing'; + +// Held out of the run: on the deployed front a grant never reaches the +// recipient's `/shared`, so the profile cannot pass whatever it waits. It runs +// again as soon as a grant is delivered. +test.fixme(); + +const AFTER_GRANT = 'after-the-grant.bin'; + +test('a grant reaches a second identity, and a revoke cuts it', async ({ + page, + secondContext, +}, testInfo) => { + const ownerFiles = new FilesPage(page); + const { recipient, owner, accessibleMs } = await grant(page, secondContext, 'read'); + const recipientFiles = new FilesPage(recipient); + + await recordJourneys(testInfo, { share_to_accessible_ms: accessibleMs }); + + // A file added after the grant proves the recipient reads the live folder, + // not the listing that was current when the grant was cut. + await owner.close(); + await ownerFiles.open(OWNER_FOLDER); + await ownerFiles.upload(AFTER_GRANT, new Uint8Array(512).fill(9)); + await expect(ownerFiles.row(AFTER_GRANT)).toBeVisible({ timeout: 180_000 }); + await published(page); + + await expect + .poll( + async () => { + await recipient.getByTestId('status-indicator').click(); + return recipientFiles.row(AFTER_GRANT).count(); + }, + { timeout: 300_000, intervals: [5_000] } + ) + .toBe(1); + + await ownerFiles.openFromSidebar(); + await owner.open(OWNER_FOLDER); + await owner.revoke.click(); + await expect(owner.noGrants).toBeVisible({ timeout: 60_000 }); + await owner.close(); + + const list = new SharedPage(recipient); + await list.open(); + await list.awaitStanding('revocation-signal', 600_000); + await expect(list.rows.getByTestId('shared-standing')).toHaveAttribute('data-tone', 'warning'); +}); diff --git a/tests/web-e2e/staging/sharing.ts b/tests/web-e2e/staging/sharing.ts new file mode 100644 index 0000000000..ec73e26764 --- /dev/null +++ b/tests/web-e2e/staging/sharing.ts @@ -0,0 +1,64 @@ +/** + * The two halves of a staging share: an owner with a folder to grant, and a + * second identity that reads it back. Both sides are driven through the chrome, + * because a deployed bundle carries no introspection hook. + */ + +import type { Page } from '@playwright/test'; +import { FilesPage } from '../page-objects/files.page'; +import { SharePage } from '../page-objects/share.page'; +import { SharedPage } from '../page-objects/shared.page'; +import { expect, published, signIn, type OpenSecondContext } from './fixtures'; + +/** The folder each side owns; the recipient needs one to reach its own code. */ +export const OWNER_FOLDER = 'granted'; +export const RECIPIENT_FOLDER = 'recipient-own'; + +export interface Share { + readonly recipient: Page; + readonly owner: SharePage; + /** Milliseconds from the grant to the recipient reading the folder. */ + readonly accessibleMs: number; +} + +/** + * Grants `OWNER_FOLDER` from `page` to a second identity and opens it there. + * Leaves the owner's share dialog open, which is where a revoke or a downgrade + * goes next. + */ +export async function grant( + page: Page, + openSecond: OpenSecondContext, + permission: 'read' | 'write' +): Promise { + const ownerFiles = new FilesPage(page); + await signIn(page); + await ownerFiles.createFolder(OWNER_FOLDER); + await expect(ownerFiles.row(OWNER_FOLDER)).toBeVisible(); + await published(page); + + const { page: recipient } = await openSecond(); + const recipientFiles = new FilesPage(recipient); + await signIn(recipient); + await recipientFiles.createFolder(RECIPIENT_FOLDER); + await expect(recipientFiles.row(RECIPIENT_FOLDER)).toBeVisible(); + await published(recipient); + + const recipientShare = new SharePage(recipient); + await recipientShare.open(RECIPIENT_FOLDER); + const code = await recipientShare.readOwnContactCode(); + await recipientShare.close(); + + const owner = new SharePage(page); + await owner.open(OWNER_FOLDER); + const started = Date.now(); + await owner.grantTo(code, permission); + + const list = new SharedPage(recipient); + await list.open(); + await list.awaitStanding('granted', 600_000); + await list.rows.getByTestId('shared-open').click(); + await expect(new FilesPage(recipient).breadcrumbs).toBeVisible({ timeout: 180_000 }); + + return { recipient, owner, accessibleMs: Date.now() - started }; +} diff --git a/tests/web-e2e/staging/timing.ts b/tests/web-e2e/staging/timing.ts new file mode 100644 index 0000000000..c84408ddda --- /dev/null +++ b/tests/web-e2e/staging/timing.ts @@ -0,0 +1,50 @@ +/** + * The committed baseline and the verdict against it. Staging is the only + * environment where these numbers mean anything, so the run writes what it + * measured beside the report. + * + * The ceiling is generous on purpose: this catches an order-of-magnitude + * regression — a login that now waits on a timeout, a publish that no longer + * lands — not the noise of a 2-vCPU box behind a CDN. + */ + +import { expect, type TestInfo } from '@playwright/test'; +import { mkdir, readFile, writeFile } from 'node:fs/promises'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const HERE = dirname(fileURLToPath(import.meta.url)); +const BASELINE = join(HERE, '..', 'baselines', 'staging-journey-timing.json'); +const MEASURED = join(HERE, '..', 'test-results'); + +interface Baseline { + readonly journeys: Record; +} + +/** Records `journeys` beside the report, then holds each to its ceiling. */ +export async function recordJourneys( + testInfo: TestInfo, + journeys: Record +): Promise { + const baseline: Baseline = JSON.parse(await readFile(BASELINE, 'utf8')); + const measured = { + captured: new Date().toISOString(), + baseUrl: testInfo.project.use.baseURL, + journeys, + }; + + const body = JSON.stringify(measured, null, 2); + const name = `staging-journey-${Object.keys(journeys).sort().join('-')}`; + await mkdir(MEASURED, { recursive: true }); + await writeFile(join(MEASURED, `${name}.json`), body); + await testInfo.attach(name, { body, contentType: 'application/json' }); + + for (const [journey, ms] of Object.entries(journeys)) { + const held = baseline.journeys[journey]; + expect(held, `the baseline names no ${journey}`).toBeDefined(); + expect( + ms, + `${journey} took ${ms}ms against a ${held.ceiling_ms}ms ceiling, ${held.baseline_ms}ms when captured` + ).toBeLessThan(held.ceiling_ms); + } +} diff --git a/tests/web-e2e/staging/wallet.ts b/tests/web-e2e/staging/wallet.ts index 36b02532d9..94acd061e7 100644 --- a/tests/web-e2e/staging/wallet.ts +++ b/tests/web-e2e/staging/wallet.ts @@ -20,15 +20,23 @@ const SIGN_BINDING = '__cipherboxE2eSign'; export interface TestWallet { readonly address: string; + /** + * The key that answers this wallet's signatures. It stays in the test process + * — a second context takes it to sign in as the SAME identity subject, which + * is what a second-device journey needs. + */ + readonly privateKey: Hex; } /** - * Installs a wallet nobody else holds on `page`, before any navigation. A fresh - * key is a fresh identity subject, and so a fresh account over an empty vault — - * which is what keeps a run from inheriting an earlier run's tree. + * Installs a wallet on `page`, before any navigation. Without a key it mints + * one nobody else holds, and a fresh key is a fresh identity subject — so a + * fresh account over an empty vault, which is what keeps a run from inheriting + * an earlier run's tree. */ -export async function installTestWallet(page: Page): Promise { - const account = privateKeyToAccount(generatePrivateKey()); +export async function installTestWallet(page: Page, privateKey?: Hex): Promise { + const key = privateKey ?? generatePrivateKey(); + const account = privateKeyToAccount(key); await page.exposeFunction(SIGN_BINDING, (message: Hex) => account.signMessage({ message: hexToString(message) }) @@ -89,5 +97,5 @@ export async function installTestWallet(page: Page): Promise { [account.address, TEST_WALLET_NAME, SIGN_BINDING] as const ); - return { address: account.address }; + return { address: account.address, privateKey: key }; } diff --git a/tests/web-e2e/staging/writable-share.spec.ts b/tests/web-e2e/staging/writable-share.spec.ts new file mode 100644 index 0000000000..b4b5bc81bc --- /dev/null +++ b/tests/web-e2e/staging/writable-share.spec.ts @@ -0,0 +1,60 @@ +/** + * Profile: writable share. Two real sessions write into one scope through one + * front: the recipient builds inside the granted folder, and the owner reads + * back what the recipient published. + */ + +import { FilesPage } from '../page-objects/files.page'; +import { SharedPage } from '../page-objects/shared.page'; +import { expect, published, test } from './fixtures'; +import { grant, OWNER_FOLDER } from './sharing'; + +// Held out of the run: on the deployed front a grant never reaches the +// recipient's `/shared`, so the profile cannot pass whatever it waits. It runs +// again as soon as a grant is delivered. +test.fixme(); + +const WRITTEN = 'written-by-the-recipient.bin'; +const NESTED = 'recipient-subfolder'; + +test('a write grant lets a second identity build inside the folder', async ({ + page, + secondContext, +}) => { + const ownerFiles = new FilesPage(page); + const { recipient, owner } = await grant(page, secondContext, 'write'); + const recipientFiles = new FilesPage(recipient); + + await recipientFiles.upload(WRITTEN, new Uint8Array(4_096).fill(5)); + await expect(recipientFiles.row(WRITTEN)).toBeVisible({ timeout: 300_000 }); + await recipientFiles.createFolder(NESTED); + await expect(recipientFiles.row(NESTED)).toBeVisible(); + await published(recipient); + + await owner.close(); + await ownerFiles.open(OWNER_FOLDER); + // A focus change reads what the engine already holds; only the manual refresh + // forces the pass that reaches the record plane. + for (const name of [WRITTEN, NESTED]) { + await expect + .poll( + async () => { + await ownerFiles.status.click(); + return ownerFiles.row(name).count(); + }, + { timeout: 300_000, intervals: [5_000] } + ) + .toBe(1); + } + + await ownerFiles.openFromSidebar(); + await owner.open(OWNER_FOLDER); + await owner.downgrade.click(); + await expect(owner.permission).toHaveText('read', { timeout: 60_000 }); + await owner.close(); + + const list = new SharedPage(recipient); + await list.open(); + await list.awaitStanding('granted', 600_000); + await expect(list.rows.getByTestId('shared-permission')).toHaveText('read'); +});