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
2 changes: 1 addition & 1 deletion apps/web/e2e/backup.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@ test('exports a backup, asking for the local password again, then one under a ge
expect(plain.bytes.includes(SECRET)).toBe(false);
expect(plain.bytes.includes('ACME')).toBe(false);
// The status names the phrase the file opens with, by its phraseId and date (D185).
await expect(page.getByRole('status')).toHaveText(
await expect(page.getByRole('main').getByRole('status')).toHaveText(
new RegExp(
`^Backup exported as ${plain.name.replaceAll('.', '\\.')}\\. It opens with the Recovery Phrase [0-9a-f]{8}, created on .+\\. Keep it somewhere other than this device\\.$`,
),
Expand Down
162 changes: 162 additions & 0 deletions apps/web/e2e/feedback.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
import { type Page, expect, test } from '@playwright/test';

import { createVault } from './create';

// Feedback (D250): the toast region, the confirmation as a bottom sheet on a phone, the empty
// vault's first steps, and the error boundary that locks the vault before its failure screen.

test.setTimeout(120_000);

const PASSWORD = 'quartz-lantern-velvet-harbor-9214';
const SECRET = 'Tulip-Orbit-5528-Granite';

const button = (page: Page, name: string) => page.getByRole('button', { name, exact: true });

function writes(page: Page): Promise<string[]> {
return page.evaluate(() => (window as unknown as { clipboardWrites: string[] }).clipboardWrites);
}

test.beforeEach(async ({ page }) => {
// Records what the app writes to the clipboard, the same way in every browser.
await page.addInitScript(() => {
const log: string[] = [];
Object.defineProperty(window, 'clipboardWrites', { value: log });
Object.defineProperty(navigator.clipboard, 'writeText', {
value: (text: string) => {
log.push(text);
return Promise.resolve();
},
});
});
});

async function login(page: Page, title: string) {
await page.getByTestId('item-title').fill(title);
await page.getByTestId('input-Password').fill(SECRET);
await button(page, 'Save').click();
await expect(page.getByRole('heading', { name: title, level: 1 })).toBeVisible();
}

test('the empty vault shows its first steps, each opening where it is done (D250)', async ({
page,
}) => {
await page.setViewportSize({ width: 1280, height: 900 });
await page.goto('/');
await createVault(page, PASSWORD);

const steps = page.getByTestId('first-steps');
await expect(steps.getByRole('heading', { name: 'First steps' })).toBeVisible();
await expect(steps.getByRole('listitem')).toHaveCount(4);
await expect(steps.getByRole('img', { name: 'Done' })).toBeVisible();

await steps.getByRole('button', { name: 'Import', exact: true }).click();
await expect(page.getByRole('heading', { name: 'Import', exact: true, level: 2 })).toBeFocused();
await button(page, 'Back').click();

await steps.getByRole('button', { name: 'Add an item', exact: true }).click();
await button(page, 'Login').click();
await login(page, 'ACME');
await expect(steps).toHaveCount(0);
});

test('copy, save, move to trash and restore show a toast, still under reduced motion (D250)', async ({
page,
}) => {
await page.setViewportSize({ width: 1280, height: 900 });
await page.emulateMedia({ reducedMotion: 'reduce' });
await page.goto('/');
await createVault(page, PASSWORD);
const region = page.getByTestId('toast-region');
await expect(region).toHaveAttribute('role', 'status');

await button(page, 'New item').click();
await button(page, 'Login').click();
await login(page, 'ACME');
await expect(region).toHaveText('Saved.');
// No entrance under reduced motion (D236).
expect(await page.getByTestId('toast').evaluate((t) => getComputedStyle(t).animationName)).toBe(
'none',
);

await button(page, 'Copy: Password').click();
await expect(region).toContainText(/^Copied\. .* not guaranteed\.$/);
expect(await writes(page)).toEqual([SECRET]);

await button(page, 'More actions').click();
await button(page, 'Move to trash').click();
await expect(region).toHaveText('Moved to the trash.');

await button(page, 'Trash').click();
await page.getByRole('button', { name: /^ACME/ }).click();
await button(page, 'Restore from the trash').click();
await expect(region).toHaveText('Restored.');
// It goes on its own.
await expect(page.getByTestId('toast')).toHaveCount(0, { timeout: 10_000 });
});

test('a confirmation is a bottom sheet on a phone and a centered dialog wider (D250)', async ({
page,
}) => {
await page.setViewportSize({ width: 390, height: 844 });
await page.goto('/');
await createVault(page, PASSWORD);
await page.getByTestId('first-steps').getByRole('button', { name: 'Add an item' }).click();
await button(page, 'Login').click();
await page.getByTestId('item-title').fill('Unsaved');
await button(page, 'Cancel').click();

const sheet = page.getByTestId('discard-confirm');
await expect(sheet).toBeVisible();
await expect(sheet).toHaveAttribute('data-sheet', 'true');
// Measured once its entrance ends.
await sheet.evaluate((d) => Promise.all(d.getAnimations().map((a) => a.finished)));
const box = await sheet.boundingBox();
expect(box?.width).toBe(390);
expect(Math.round((box?.y ?? 0) + (box?.height ?? 0))).toBe(844);
// The action first, both full width and at least 44 px tall.
const buttons = sheet.getByRole('button');
await expect(buttons.first()).toHaveText('Discard the changes');
for (const b of await buttons.all()) {
const at = await b.boundingBox();
expect(at?.height).toBeGreaterThanOrEqual(44);
}
await page.keyboard.press('Escape');
await expect(sheet).toHaveCount(0);

await page.setViewportSize({ width: 1280, height: 900 });
await button(page, 'Cancel').click();
await expect(sheet).toBeVisible();
await expect(sheet).not.toHaveAttribute('data-sheet');
await sheet.evaluate((d) => Promise.all(d.getAnimations().map((a) => a.finished)));
expect((await sheet.boundingBox())?.width).toBeLessThan(500);
});

test('a render error locks the vault and clears the clipboard before its failure screen (D250)', async ({
page,
}) => {
await page.setViewportSize({ width: 1280, height: 900 });
await page.goto('/');
await createVault(page, PASSWORD);
await button(page, 'New item').click();
await button(page, 'Login').click();
await login(page, 'ACME');
await button(page, 'Copy: Password').click();
await expect.poll(() => writes(page)).toEqual([SECRET]);

// The list sorts with Intl.Collator on every render: breaking it makes the next render throw.
await page.evaluate(() => {
Intl.Collator = function () {
throw new Error('render failure for the test');
} as unknown as typeof Intl.Collator;
});
await page.getByTestId('search').fill('A');

await expect(page.getByTestId('crash')).toBeVisible();
await expect(page.getByRole('heading', { name: 'Something went wrong' })).toBeVisible();
await expect(page.getByText('ACME')).toHaveCount(0);
await expect(page.getByTestId('lock-state')).toHaveCount(0);
await expect.poll(() => writes(page)).toEqual([SECRET, '']);

await button(page, 'Reload').click();
await expect(page.getByTestId('unlock-password')).toBeVisible();
});
2 changes: 1 addition & 1 deletion apps/web/e2e/folders.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -107,7 +107,7 @@ for (const [layout, width] of WIDTHS) {
await folders(page, layout);
await expect(button(page, 'Clients')).toHaveCount(0);
await button(page, 'All items').click();
await expect(page.getByText('No items yet.')).toBeVisible();
await expect(page.getByRole('heading', { name: 'First steps' })).toBeVisible();
await folders(page, layout);
await button(page, 'Trash').click();
await expect(page.getByText('Folder: Clients')).toBeVisible();
Expand Down
2 changes: 1 addition & 1 deletion apps/web/e2e/items.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,7 @@ for (const [layout, width] of WIDTHS) {
await page.getByRole('button', { name: 'More actions', exact: true }).click();
await page.getByRole('button', { name: 'Move to trash', exact: true }).click();
await expect(page.getByRole('heading', { name: TITLE })).toHaveCount(0);
await expect(page.getByText('No items yet.')).toBeVisible();
await expect(page.getByRole('heading', { name: 'First steps' })).toBeVisible();

await showList(page, layout, 'Trash');
await page.getByRole('button', { name: TITLE, exact: true }).click();
Expand Down
2 changes: 1 addition & 1 deletion apps/web/e2e/recovery-phrase.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -143,7 +143,7 @@ test('checks the Recovery Phrase while unlocked, keeping the typed words nowhere

// As written on paper: one word per line, some in capitals.
await check(words.map((w, i) => (i % 2 ? w.toUpperCase() : w)).join('\n'));
await expect(page.getByRole('status')).toHaveText(
await expect(page.getByRole('main').getByRole('status')).toHaveText(
'This phrase opens this vault and its backups.',
);

Expand Down
29 changes: 25 additions & 4 deletions apps/web/e2e/screen-reader.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,22 @@ for (const [layout, width] of WIDTHS) {
- region "Items":
- heading "Items" [level=2]
- searchbox "Search"
- paragraph: No items yet.
- region "First steps":
- heading "First steps" [level=3]
- paragraph: "Your vault lives only on your devices. To start:"
- list:
- listitem:
- img "Done"
- text: Create the vault and keep its Recovery Phrase
- listitem:
- text: "Add your first item: a login, a card, a secure note…"
- button "Add an item"
- listitem:
- text: Import from another password manager.
- button "Import"
- listitem:
- text: Pair another device, so the vault has a second copy you can sync with.
- button "Pair a device"
`);

// A refused save: the field is invalid and the reason is an alert, at the field and the button.
Expand Down Expand Up @@ -144,18 +159,24 @@ for (const [layout, width] of WIDTHS) {
`);
await button(page, 'Copy: Password').click();
await expect(page.locator('body')).toMatchAriaSnapshot(`
- status: /clear the clipboard in \\d+ s .* not guaranteed/
- status:
- paragraph: /clear the clipboard in \\d+ s .* not guaranteed/
`);

// The discard confirmation is a dialog named by its action.
await button(page, 'Edit').click();
await page.getByTestId('item-title').fill('Harbor Bank');
await button(page, 'Cancel').click();
// On a phone, a bottom sheet with the action first (D250).
const buttons =
layout === 'mobile'
? ['Discard the changes', 'Keep editing']
: ['Keep editing', 'Discard the changes'];
await expect(page.getByRole('dialog')).toMatchAriaSnapshot(`
- dialog "Discard the changes":
- note: The changes made to this item are not saved and will be lost.
- button "Keep editing"
- button "Discard the changes"
- button "${buttons[0] ?? ''}"
- button "${buttons[1] ?? ''}"
`);
await button(page, 'Discard the changes').click();

Expand Down
4 changes: 2 additions & 2 deletions apps/web/e2e/sync.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -488,7 +488,7 @@ test('an edit older than a trash never brings the item back: in the trash on bot

await closed(a, b);
for (const page of [a, b]) {
await expect(page.getByText('No items yet.')).toBeVisible();
await expect(page.getByRole('heading', { name: 'First steps' })).toBeVisible();
await button(page, 'Trash').click();
await button(page, 'ACME').click();
// An item in the trash offers only restore (D143): restored, its history shows A's edit.
Expand Down Expand Up @@ -524,7 +524,7 @@ test('an item trashed on A is in the trash of both with the same purge date, off
await closed(a, b);
const dates: string[] = [];
for (const page of [a, b]) {
await expect(page.getByText('No items yet.')).toBeVisible();
await expect(page.getByRole('heading', { name: 'First steps' })).toBeVisible();
await button(page, 'Trash').click();
await button(page, 'ACME').click();
const notice = page.getByText(/It can only be restored from the trash; it is purged on /);
Expand Down
5 changes: 4 additions & 1 deletion apps/web/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { type UpdateMonitor } from './app/updates';
import { LanguageProvider } from './i18n/context';
import { AboutContext } from './ui/about-context';
import { ChangedWorker } from './ui/ChangedWorker';
import { ErrorBoundary } from './ui/ErrorBoundary';
import { Start } from './ui/Start';
import { UpdatesContext } from './ui/update-context';
import { VaultShell } from './ui/VaultShell';
Expand Down Expand Up @@ -132,7 +133,9 @@ export function App({ controller, monitor }: RootProps) {
<LanguageProvider>
<UpdatesContext.Provider value={updates}>
<AboutContext.Provider value={about}>
<Root controller={controller} monitor={monitor} />
<ErrorBoundary controller={controller}>
<Root controller={controller} monitor={monitor} />
</ErrorBoundary>
</AboutContext.Provider>
</UpdatesContext.Provider>
</LanguageProvider>
Expand Down
2 changes: 1 addition & 1 deletion apps/web/src/app/nav.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ export type ListScreen =
* from "Vault ready" (D198).
*/
export type SettingsFocus =
'backup' | 'passkey' | 'recovery' | 'devices' | 'pair' | 'readmit' | 'refound';
'backup' | 'passkey' | 'recovery' | 'devices' | 'pair' | 'readmit' | 'refound' | 'import';

export type Screen =
| ListScreen
Expand Down
30 changes: 28 additions & 2 deletions apps/web/src/i18n/messages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -364,6 +364,10 @@ const en = {
'A record on {name} failed verification, so the sync stopped: {name} names it. Nothing was merged.',
},
},
crash: {
title: 'Something went wrong',
text: 'BlindVault hit an unexpected error and locked the vault. Nothing saved was lost: it stays on this device.',
},
ready: {
title: 'Vault ready',
intro: 'Your vault is on this device. Before you start:',
Expand Down Expand Up @@ -600,7 +604,13 @@ const en = {
allItems: 'All items',
items: 'Items',
detail: 'Details',
empty: 'No items yet.',
firstSteps: 'First steps',
firstStepsIntro: 'Your vault lives only on your devices. To start:',
stepCreated: 'Create the vault and keep its Recovery Phrase',
stepItem: 'Add your first item: a login, a card, a secure note…',
stepImport: 'Import from another password manager.',
stepItemAction: 'Add an item',
stepImportAction: 'Import',
nothingSelected: 'Select an item.',
trash: 'Trash',
trashEmpty: 'The trash is empty.',
Expand Down Expand Up @@ -664,6 +674,9 @@ const en = {
"The duplicate's title would be longer than 512 characters. Shorten the title first.",
history: 'History',
moreActions: 'More actions',
saved: 'Saved.',
trashed: 'Moved to the trash.',
restored: 'Restored.',
otherFields: 'Other fields',
strength: 'Strength: {level}.',
strengthLevels: { weak: 'Weak', fair: 'Fair', strong: 'Strong' },
Expand Down Expand Up @@ -1406,6 +1419,10 @@ const ptBR: Messages = {
'Um registro em {name} falhou na verificação, então a sincronização parou: {name} diz qual. Nada foi mesclado.',
},
},
crash: {
title: 'Algo deu errado',
text: 'O BlindVault encontrou um erro inesperado e bloqueou o cofre. Nada do que foi salvo se perdeu: continua neste dispositivo.',
},
ready: {
title: 'Cofre pronto',
intro: 'Seu cofre está neste dispositivo. Antes de começar:',
Expand Down Expand Up @@ -1643,7 +1660,13 @@ const ptBR: Messages = {
allItems: 'Todos os itens',
items: 'Itens',
detail: 'Detalhes',
empty: 'Nenhum item ainda.',
firstSteps: 'Primeiros passos',
firstStepsIntro: 'Seu cofre vive só nos seus dispositivos. Para começar:',
stepCreated: 'Criar o cofre e guardar a Frase de Recuperação',
stepItem: 'Criar o primeiro item: um login, um cartão, uma nota segura…',
stepImport: 'Importar de outro gerenciador de senhas.',
stepItemAction: 'Criar um item',
stepImportAction: 'Importar',
nothingSelected: 'Selecione um item.',
trash: 'Lixeira',
trashEmpty: 'A lixeira está vazia.',
Expand Down Expand Up @@ -1706,6 +1729,9 @@ const ptBR: Messages = {
copyTitleTooLong: 'O título da duplicata passaria de 512 caracteres. Encurte o título antes.',
history: 'Histórico',
moreActions: 'Mais ações',
saved: 'Salvo.',
trashed: 'Movido para a lixeira.',
restored: 'Restaurado.',
otherFields: 'Outros campos',
strength: 'Força: {level}.',
strengthLevels: { weak: 'Fraca', fair: 'Razoável', strong: 'Forte' },
Expand Down
Loading
Loading