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
84 changes: 84 additions & 0 deletions e2e/entry-copy.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
/** Real-browser coverage for the entry table's controls (issue #67). jsdom
* dispatches events straight at an element; only a real browser hit-tests a
* coordinate, so this is what proves the cells and the open control are
* actually clickable where they render.
*
* What gets copied is asserted in the jsdom tests instead: headless Chrome
* refuses `navigator.clipboard.writeText` outright ("Write permission denied"),
* so no toast ever appears here. */
import assert from 'node:assert/strict';
import { after, before, test } from 'node:test';
import { fileURLToPath } from 'node:url';
import puppeteer, { type Browser, type Frame, type Page } from 'puppeteer-core';
import { openApp } from './support/app.ts';
import { resolveChromePath } from './support/chrome.ts';
import { type DistServer, startDistServer } from './support/dist-server.ts';
import { type KdbxFixture, writeKdbxFixture } from './support/fixture.ts';
import { resolveLaunchOptions } from './support/launch-options.ts';

const distDir = fileURLToPath(new URL('../dist', import.meta.url));

let server: DistServer;
let browser: Browser;
let page: Page;
let app: Frame;
let fixture: KdbxFixture;

before(async () => {
server = await startDistServer(distDir);
browser = await puppeteer.launch({
executablePath: resolveChromePath(),
...resolveLaunchOptions(),
args: ['--no-sandbox'],
});
page = await browser.newPage();
await page.setViewport({ width: 1280, height: 900 });
fixture = await writeKdbxFixture();
app = await openApp(page, server.origin, fixture);
await app.click('[data-action="view-table"]');
await app.waitForSelector('.entry-table');
});

after(async () => {
await browser.close();
await server.close();
});

/** Centre of the cell holding the fixture entry's username. */
async function usernameCellCentre(): Promise<{ x: number; y: number }> {
const box = await app.$$eval(
'.entry-table tbody td',
(cells, name) => {
const cell = cells.find((c) => c.firstChild?.textContent === name);
if (!cell) return null;
const rect = cell.getBoundingClientRect();
return { x: rect.x + rect.width / 2, y: rect.y + rect.height / 2 };
},
'octocat',
);
assert.ok(box, 'the username cell is on screen');
const frameBox = await (await app.frameElement())?.boundingBox();
assert.ok(frameBox, 'the app frame is laid out');
return { x: frameBox.x + box.x, y: frameBox.y + box.y };
}

test('clicking a value never opens the entry', async () => {
const { x, y } = await usernameCellCentre();
await page.mouse.click(x, y);
// Opening is synchronous now, so there is nothing to wait out.
assert.equal(await app.$('#detail-title'), null, 'the card stayed shut');
});

test('the row control is the way into the card', async () => {
const openButton = await app.$('.entry-table-open button');
assert.ok(openButton, 'every row carries one');
await openButton.click();

const title = await app.waitForSelector('#detail-title');
assert.ok(title, 'the card opened');
assert.match(
(await title.evaluate((el) => el.textContent)) ?? '',
/Example Entry/,
'and it is the row that was clicked',
);
});
24 changes: 3 additions & 21 deletions e2e/group-rail.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import assert from 'node:assert/strict';
import { after, before, test } from 'node:test';
import { fileURLToPath } from 'node:url';
import puppeteer, { type Browser, type ElementHandle, type Frame, type Page } from 'puppeteer-core';
import { openApp } from './support/app.ts';
import { resolveChromePath } from './support/chrome.ts';
import { type DistServer, startDistServer } from './support/dist-server.ts';
import { type KdbxFixture, writeKdbxFixture } from './support/fixture.ts';
Expand All @@ -32,28 +33,9 @@ before(async () => {
await page.setViewport({ width: 1280, height: 900 });
fixture = await writeKdbxFixture();

app = await openApp(page);
app = await openApp(page, server.origin, fixture);
});

/** Upload the fixture to local.html and unlock the app it embeds, returning
* the app's frame. */
async function openApp(target: Page): Promise<Frame> {
await target.goto(`${server.origin}/local.html`, { waitUntil: 'networkidle0' });
const fileInput = (await target.waitForSelector(
'#file-input',
)) as ElementHandle<HTMLInputElement>;
await fileInput.uploadFile(fixture.path);
const frameElement = await target.waitForSelector('#app-frame');
assert.ok(frameElement, 'the app is embedded in an iframe');
const frame = (await frameElement.contentFrame()) as Frame;
const passwordInput = await frame.waitForSelector('#master-password');
assert.ok(passwordInput, 'the embedded app shows its unlock screen');
await passwordInput.type(fixture.password);
await frame.click('#unlock-btn');
await frame.waitForSelector('#group-tree .group-btn');
return frame;
}

after(async () => {
await browser.close();
await server.close();
Expand Down Expand Up @@ -119,7 +101,7 @@ test('dragging the handle resizes the rail', async () => {
test('at phone width the rail is a drawer: no resize handle, and ⋯ still reaches rename', async () => {
const phone = await browser.newPage();
await phone.setViewport({ width: 375, height: 812 });
const phoneApp = await openApp(phone);
const phoneApp = await openApp(phone, server.origin, fixture);

assert.equal(
await phoneApp.$eval('#sidebar-resize', (el) => getComputedStyle(el).display),
Expand Down
20 changes: 20 additions & 0 deletions e2e/support/app.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
/** Uploads the fixture to local.html and unlocks the app it embeds, returning
* the app's frame. Shared by every e2e that needs an open database. */
import assert from 'node:assert/strict';
import type { ElementHandle, Frame, Page } from 'puppeteer-core';
import type { KdbxFixture } from './fixture.ts';

export async function openApp(page: Page, origin: string, fixture: KdbxFixture): Promise<Frame> {
await page.goto(`${origin}/local.html`, { waitUntil: 'networkidle0' });
const fileInput = (await page.waitForSelector('#file-input')) as ElementHandle<HTMLInputElement>;
await fileInput.uploadFile(fixture.path);
const frameElement = await page.waitForSelector('#app-frame');
assert.ok(frameElement, 'the app is embedded in an iframe');
const frame = (await frameElement.contentFrame()) as Frame;
const passwordInput = await frame.waitForSelector('#master-password');
assert.ok(passwordInput, 'the embedded app shows its unlock screen');
await passwordInput.type(fixture.password);
await frame.click('#unlock-btn');
await frame.waitForSelector('#group-tree .group-btn');
return frame;
}
58 changes: 58 additions & 0 deletions pages/0x67/page.css
Original file line number Diff line number Diff line change
Expand Up @@ -791,7 +791,12 @@ so an absolutely positioned menu would be cut off. */
white-space: nowrap;
}

/* A tap on a cell copies (#67), so the cell must not start a text selection or
wait on a double-tap-to-zoom before that tap registers. */
.entry-table td {
touch-action: manipulation;
user-select: none;
-webkit-touch-callout: none;
padding: 0.5rem 0.75rem;
border-bottom: 1px solid var(--border);
max-width: 220px;
Expand All @@ -801,6 +806,30 @@ so an absolutely positioned menu would be cut off. */
cursor: pointer;
}

/* Present at rest, brighter on hover or focus (#67): hover may raise emphasis,
never be what makes the affordance discoverable, since touch has none. */
.copy-hint {
margin-left: 0.35rem;
padding: 0;
border: none;
background: none;
cursor: pointer;
font-size: 0.8em;
line-height: 1;
opacity: 0.55;
}

.entry-table td:hover .copy-hint,
.copy-hint:focus-visible {
opacity: 1;
}

.entry-table-open {
width: 1px;
text-align: right;
white-space: nowrap;
}

.entry-table tbody tr:hover {
background: var(--surface-2);
}
Expand Down Expand Up @@ -1464,3 +1493,32 @@ body.embedded footer {
display: none;
}
}

/* Visible for exactly as long as the clipboard holds the value (#67). */
.toast {
position: fixed;
left: 50%;
bottom: 1.5rem;
transform: translateX(-50%);
z-index: 50;
max-width: min(90vw, 32rem);
padding: 0.5rem 0.9rem;
border: 1px solid var(--border);
border-radius: 8px;
background: var(--surface);
box-shadow: 0 8px 30px rgba(0, 0, 0, 0.2);
color: var(--text);
font-size: 0.85rem;
}

/* Announced but not drawn: gives the open column a name for table navigation. */
.visually-hidden {
position: absolute;
width: 1px;
height: 1px;
margin: -1px;
padding: 0;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
2 changes: 2 additions & 0 deletions pages/0x67/page.html
Original file line number Diff line number Diff line change
Expand Up @@ -360,6 +360,8 @@ <h2 id="confirm-discard-title">Discard unsaved changes?</h2>
</div>
</dialog>

<div id="toast" class="toast" role="status" aria-live="polite" hidden></div>

<!-- ============================================================
Dialog: New Group
============================================================ -->
Expand Down
Loading