diff --git a/apps/web/e2e/accessibility.spec.ts b/apps/web/e2e/accessibility.spec.ts index e166c67..a1ff99b 100644 --- a/apps/web/e2e/accessibility.spec.ts +++ b/apps/web/e2e/accessibility.spec.ts @@ -20,6 +20,7 @@ test('names the generator, describes a field by its error, and reads a mask and // The generator is a named group. await page.getByRole('button', { name: 'Generate', exact: true }).click(); await expect(page.getByRole('group', { name: 'Password generator' })).toBeVisible(); + await page.keyboard.press('Escape'); // A refused value: the field's error is its description. await page.getByTestId('item-title').fill('ACME'); @@ -57,6 +58,7 @@ test('reflows at 320 px with no horizontal scroll: start, list, editor and Setti await page.getByRole('button', { name: 'Login', exact: true }).click(); await page.getByRole('button', { name: 'Generate', exact: true }).click(); expect(await fits()).toBe(true); + await page.keyboard.press('Escape'); await page.getByRole('button', { name: 'Cancel', exact: true }).click(); await page.getByRole('button', { name: 'Settings' }).first().click(); await expect(page.getByRole('heading', { name: 'Settings', level: 1 })).toBeVisible(); diff --git a/apps/web/e2e/axe.spec.ts b/apps/web/e2e/axe.spec.ts index e6006b4..67c217f 100644 --- a/apps/web/e2e/axe.spec.ts +++ b/apps/web/e2e/axe.spec.ts @@ -66,6 +66,7 @@ for (const [layout, width] of WIDTHS) { await button(page, 'Login').click(); await button(page, 'Generate').click(); await audit(page, 'the item editor and the generator'); + await page.keyboard.press('Escape'); await button(page, 'Save').click(); await expect(page.getByText('Enter a title.')).toBeVisible(); await audit(page, 'the item editor with an error'); @@ -79,7 +80,6 @@ for (const [layout, width] of WIDTHS) { await button(page, 'Cancel').click(); await audit(page, 'the discard confirmation'); await button(page, 'Discard the changes').click(); - await button(page, 'More actions').click(); await button(page, 'History').click(); await audit(page, 'an item history'); diff --git a/apps/web/e2e/backup-merge.spec.ts b/apps/web/e2e/backup-merge.spec.ts index 7b852eb..d716ec1 100644 --- a/apps/web/e2e/backup-merge.spec.ts +++ b/apps/web/e2e/backup-merge.spec.ts @@ -131,7 +131,6 @@ test('merges a backup made on another installation, keeping the losing edit in h await button(a, 'ACME').click(); await button(a, 'Show').click(); await expect(a.getByTestId('secret-Password')).toHaveText(ON_B); - await button(a, 'More actions').click(); await button(a, 'History').click(); const versions = a.locator('article').getByRole('listitem'); await expect(versions).toHaveCount(2); diff --git a/apps/web/e2e/duplicate.spec.ts b/apps/web/e2e/duplicate.spec.ts index 01f7e7f..d36a4c1 100644 --- a/apps/web/e2e/duplicate.spec.ts +++ b/apps/web/e2e/duplicate.spec.ts @@ -42,7 +42,6 @@ for (const [layout, width] of WIDTHS) { await expect(page.getByRole('heading', { name: 'Harbor', level: 1 })).toBeVisible(); // The history says old values stay, and keeps them masked. - await button(page, 'More actions').click(); await button(page, 'History').click(); await expect(page.getByRole('heading', { name: 'History of Harbor' })).toBeVisible(); await expect(page.getByText(/Earlier values stay in this history/)).toBeVisible(); @@ -65,7 +64,6 @@ for (const [layout, width] of WIDTHS) { await expect(page.getByText('Work', { exact: true }).last()).toBeVisible(); await button(page, 'Show').click(); await expect(page.getByTestId('secret-Password')).toHaveText(OLD); - await button(page, 'More actions').click(); await button(page, 'History').click(); await expect(page.getByText('No earlier versions.')).toBeVisible(); await button(page, 'Back to the item').click(); diff --git a/apps/web/e2e/form-history.spec.ts b/apps/web/e2e/form-history.spec.ts index b959f22..2cf6460 100644 --- a/apps/web/e2e/form-history.spec.ts +++ b/apps/web/e2e/form-history.spec.ts @@ -118,6 +118,7 @@ test('keeps vault content out of the form history and the spellchecker (D174, D1 } await button(page, 'Generate').first().click(); await expectNoneKept(page); + await page.keyboard.press('Escape'); await button(page, 'Cancel').click(); // The added fields are a change: leaving asks first (D234). await button(page, 'Discard the changes').click(); diff --git a/apps/web/e2e/item-detail.spec.ts b/apps/web/e2e/item-detail.spec.ts index f46ea79..0345710 100644 --- a/apps/web/e2e/item-detail.spec.ts +++ b/apps/web/e2e/item-detail.spec.ts @@ -3,8 +3,8 @@ import { type Page, expect, test } from '@playwright/test'; import { createVault } from './create'; // The item detail (D247): a header with the avatar and chips, fields in titled cards, icon and text -// on a secret's buttons, Duplicate, History and Move to trash under "More actions", and a shown -// seed phrase as a numbered grid. +// on a secret's buttons and icon buttons on the others (D254), Duplicate, History and Move to trash +// under "More actions", and a shown seed phrase as a numbered grid. test.setTimeout(120_000); @@ -57,16 +57,27 @@ test('the detail shows chips, titled cards and the actions menu (D247)', async ( // A secret's buttons carry an icon beside their visible text. await expect(button(page, 'Show').locator('svg')).toHaveCount(1); await expect(button(page, 'Copy: Password').locator('svg')).toHaveCount(1); + // Other fields: Copy and Open are icon buttons, named and with a tooltip (D254). + const copyUser = button(page, 'Copy: Username'); + await expect(copyUser).toHaveAttribute('title', 'Copy'); + await expect(copyUser).toHaveText(''); + await expect(page.getByRole('link', { name: 'Open: Website', exact: true })).toHaveAttribute( + 'href', + 'https://bank.example/login', + ); // Edit is visible; the rest waits under More actions. await expect(button(page, 'Edit')).toBeVisible(); await expect(button(page, 'Duplicate')).toHaveCount(0); await button(page, 'More actions').click(); + const menu = page.locator(':popover-open'); for (const name of ['Duplicate', 'History', 'Move to trash']) { - await expect(button(page, name)).toBeVisible(); + await expect(menu.getByRole('button', { name, exact: true })).toBeVisible(); } await page.keyboard.press('Escape'); - await expect(button(page, 'History')).toHaveCount(0); + await expect(button(page, 'Duplicate')).toHaveCount(0); + // History is also a link at the detail's foot (D254). + await expect(button(page, 'History')).toBeVisible(); }); test('a shown seed phrase is a numbered grid of its words (D247)', async ({ page }) => { diff --git a/apps/web/e2e/keyboard.spec.ts b/apps/web/e2e/keyboard.spec.ts index 1254d79..74b40f1 100644 --- a/apps/web/e2e/keyboard.spec.ts +++ b/apps/web/e2e/keyboard.spec.ts @@ -109,7 +109,6 @@ for (const [layout, width] of WIDTHS) { await expect(page.getByTestId('discard-confirm')).toBeVisible(); await press(page, button(page, 'Discard the changes')); await expect(page.getByRole('heading', { name: 'Harbor', level: 1 })).toBeVisible(); - await press(page, button(page, 'More actions')); await press(page, button(page, 'History')); await expect(page.getByRole('heading', { name: 'History' }).first()).toBeVisible(); diff --git a/apps/web/e2e/layout.spec.ts b/apps/web/e2e/layout.spec.ts index 110ae9d..18df7dd 100644 --- a/apps/web/e2e/layout.spec.ts +++ b/apps/web/e2e/layout.spec.ts @@ -353,6 +353,7 @@ test.describe('on a coarse pointer', () => { await page.getByRole('button', { name: 'Generate', exact: true }).click(); await expect(page.getByTestId('generator-entropy')).toBeVisible(); expect(await smallTargets(page), `${layout}: the editor and its generator`).toEqual([]); + await page.keyboard.press('Escape'); await page.getByRole('button', { name: 'Cancel', exact: true }).click(); await page.getByRole('button', { name: 'Settings', exact: true }).first().click(); await expect(page.getByRole('heading', { name: 'Settings', level: 1 })).toBeVisible(); diff --git a/apps/web/e2e/logs.spec.ts b/apps/web/e2e/logs.spec.ts index 8960c36..5a5acee 100644 --- a/apps/web/e2e/logs.spec.ts +++ b/apps/web/e2e/logs.spec.ts @@ -57,7 +57,6 @@ test('logs nothing, and no secret, through a full use (CRYPTO-INV-005, 006)', as await page.getByRole('button', { name: 'Edit', exact: true }).click(); await page.getByTestId('input-Password').fill(ROTATED); await page.getByRole('button', { name: 'Save', exact: true }).click(); - await page.getByRole('button', { name: 'More actions', exact: true }).click(); await page.getByRole('button', { name: 'History', exact: true }).click(); await page.getByRole('button', { name: 'Show', exact: true }).first().click(); await expect(page.getByText(SECRET)).toBeVisible(); diff --git a/apps/web/e2e/screen-reader.spec.ts b/apps/web/e2e/screen-reader.spec.ts index 7854356..ffc54c5 100644 --- a/apps/web/e2e/screen-reader.spec.ts +++ b/apps/web/e2e/screen-reader.spec.ts @@ -132,8 +132,8 @@ for (const [layout, width] of WIDTHS) { - button "Show" - button "Generate" - alert: Fix the fields marked above. - - button "Save" - button "Cancel" + - button "Save" `); // An item: the secret is read as "Hidden", and a copy is announced with its clipboard note. diff --git a/apps/web/e2e/sync.spec.ts b/apps/web/e2e/sync.spec.ts index 86e4b94..be4a0e2 100644 --- a/apps/web/e2e/sync.spec.ts +++ b/apps/web/e2e/sync.spec.ts @@ -440,7 +440,6 @@ test('concurrent edits converge in one sync: the newer wins on both, the other i for (const page of [a, b]) { await expect(button(page, 'ACME on A')).toHaveCount(0); await button(page, 'ACME on B').click(); - await button(page, 'More actions').click(); await button(page, 'History').click(); // A's edit lost on both, and both keep it, above the first state. const versions = page.locator('article').getByRole('listitem'); @@ -478,7 +477,6 @@ test('the history cut drops an edit of A in one sync: only A tells its user, and await closed(a, b); for (const page of [a, b]) { await button(page, title).click(); - await button(page, 'More actions').click(); await button(page, 'History').click(); const versions = page.locator('article').getByRole('listitem'); await expect(versions).toHaveCount(20); @@ -516,7 +514,6 @@ test('an edit older than a trash never brings the item back: in the trash on bot await expect(page.getByText('The trash is empty.')).toBeVisible(); await button(page, 'All items').click(); await button(page, 'ACME').click(); - await button(page, 'More actions').click(); await button(page, 'History').click(); const versions = page.locator('article').getByRole('listitem'); await expect(versions.filter({ hasText: 'ACME on A' })).toHaveCount(1); diff --git a/apps/web/e2e/write-navigation.spec.ts b/apps/web/e2e/write-navigation.spec.ts index d5c4c19..1fbd865 100644 --- a/apps/web/e2e/write-navigation.spec.ts +++ b/apps/web/e2e/write-navigation.spec.ts @@ -77,7 +77,6 @@ test('another item chosen while a version is restored stays shown (D210)', async await page.getByTestId('item-title').fill('ACME 2'); await button(page, 'Save').click(); await expect(heading(page, 'ACME 2', 1)).toBeVisible(); - await button(page, 'More actions').click(); await button(page, 'History').click(); await together(button(page, 'Restore this version'), button(page, 'Beta')); await expect(button(page, 'ACME')).toBeVisible(); diff --git a/apps/web/src/ui/Generator.tsx b/apps/web/src/ui/Generator.tsx index 1abf0a3..f33d392 100644 --- a/apps/web/src/ui/Generator.tsx +++ b/apps/web/src/ui/Generator.tsx @@ -34,7 +34,13 @@ function entropy(s: GeneratorSettings): number | null { * The password generator of the item editor (overview §13.6). The generated value goes straight * into the field being edited; only the settings are kept. */ -export function Generator({ onUse }: { readonly onUse: (value: string) => void }) { +export function Generator({ + onUse, + onCancel, +}: { + readonly onUse: (value: string) => void; + readonly onCancel: () => void; +}) { const m = useMessages(); const [settings, setSettings] = useState(loadGeneratorSettings); // Counts typed as text, so a value can be typed through an invalid one. @@ -46,8 +52,8 @@ export function Generator({ onUse }: { readonly onUse: (value: string) => void } }; const bits = entropy(settings); return ( -