Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .github/workflows/staging-e2e.yml
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ jobs:
staging-e2e:
name: Staging E2E
runs-on: ubuntu-latest
timeout-minutes: 45
timeout-minutes: 90
permissions:
contents: read

Expand Down Expand Up @@ -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 }}
Expand Down
5 changes: 3 additions & 2 deletions tests/web-e2e/baselines/staging-journey-timing.json
Original file line number Diff line number Diff line change
@@ -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 }
}
}
16 changes: 13 additions & 3 deletions tests/web-e2e/page-objects/bin.page.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void> {
async gone(name: string, timeout = 60_000): Promise<void> {
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<void> {
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<void> {
await expect
.poll(
async () => {
await this.readAgain();
return this.row(name).count();
},
{ timeout: 60_000 }
{ timeout }
)
.toBe(0);
.toBe(count);
}

get rows(): Locator {
Expand Down
72 changes: 70 additions & 2 deletions tests/web-e2e/page-objects/files.page.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void> {
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<void> {
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);
Expand All @@ -101,12 +108,28 @@ export class FilesPage {
}

async preview(name: string): Promise<string> {
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<void> {
await this.act(name, 'preview');
await expect(this.previewDialog).toBeVisible();
}

async closePreview(): Promise<void> {
await this.page.keyboard.press('Escape');
await expect(this.previewDialog).toHaveCount(0);
}

async save(name: string): Promise<Download> {
const [download] = await Promise.all([
this.page.waitForEvent('download'),
Expand All @@ -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<void> {
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<void> {
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<Download[]> {
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<void> {
await this.page.getByTestId('selection-move').click();
await this.pickDestination(destination);
}

/** Deletes every selected row, through the confirmation the batch takes. */
async removeSelected(): Promise<void> {
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<void> {
await this.page.getByRole('button', { name: `actions for ${name}`, exact: true }).click();
Expand Down
4 changes: 2 additions & 2 deletions tests/web-e2e/page-objects/invite.page.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,8 +38,8 @@ export class InvitePage {
}

/** Waits for the panel to report one claim state. */
async expectState(state: string): Promise<void> {
await expect(this.panel).toHaveAttribute('data-state', state);
async expectState(state: string, timeout?: number): Promise<void> {
await expect(this.panel).toHaveAttribute('data-state', state, { timeout });
}

/** Spends the link. The claim needs this gesture; nothing claims on mount. */
Expand Down
48 changes: 47 additions & 1 deletion tests/web-e2e/page-objects/share.page.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void> {
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<string> {
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.
Expand All @@ -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!);
Expand Down
23 changes: 23 additions & 0 deletions tests/web-e2e/page-objects/shared.page.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void> {
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}"]`);
Expand Down
23 changes: 23 additions & 0 deletions tests/web-e2e/staging/account-removal.spec.ts
Original file line number Diff line number Diff line change
@@ -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);
});
54 changes: 54 additions & 0 deletions tests/web-e2e/staging/batch.spec.ts
Original file line number Diff line number Diff line change
@@ -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);
});
Loading