fix: clear the 14 stale Playwright baseline failures

Four root causes, each affecting one or more pre-existing
failures. All resolved without weakening any assertion.

1. build-label.spec.js (×4 — archive/transmittal/classifier/browse)
   The regex accepted v<X.Y.Z>-alpha|beta channel labels but not the
   -dev label modern dev builds emit. CLAUDE.md describes
   v<X.Y.Z>-dev as the canonical dev-build form. Added |dev to the
   channel alternation; tests now pass on dev builds and remain
   tight on stable cuts.

2. landing.spec.js (×8)
   SAMPLE_PROJECTS fixture pre-dated the post-reshape listing JSON
   contract. The landing's loader now filters projects on
   `is_dir: true`; the fixture didn't set it, so every entry was
   filtered out and every "renders a project table" test failed at
   the `.project-table` wait. Added `is_dir: true` (and trailing
   slash on names, matching the live server's shape) to the three
   fixture entries.

3. browse.spec.js (×1 — Download (zip))
   The #downloadZipBtn toolbar button was retired in the SPA
   overhaul (94b2e29) — Download ZIP moved to the right-click
   context menu. Test still poked the dead toolbar button. The
   picked-root folder no longer renders as a row (only its
   contents do), so the test now scopes the assertion to
   downloading a sub-folder (sub/) via right-click → Download ZIP;
   verifies the zip's entries, magic bytes, and filename.

4. tables.spec.js (×1 — Phase 3 row-blur fires PUT)
   Real bug, not a test issue. The editor's commit path tears down
   its input element (clearing focus to body) before refocusing
   the owning cell. main.js's focusout-on-#table-root handler ran
   synchronously, saw `relatedTarget=null`, treated it as "user
   left the grid", and fired flushAll() — racing the
   selection-change save that fires from the subsequent
   setSelected(r+1, c) inside the Enter handler. Net effect: two
   identical PUTs per row-blur. Deferred the focusout check to
   next tick via setTimeout(0); the cell.focus() inside the
   editor's tearDown has time to settle, and the deferred check
   sees document.activeElement still inside #table-root → skips
   the redundant flush.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
ZDDC 2026-05-21 11:24:30 -05:00
parent 736f422f82
commit 90a31020db
5 changed files with 71 additions and 32 deletions

View file

@ -72,15 +72,27 @@
// clicked a header link, the URL bar, etc. without moving to // clicked a header link, the URL bar, etc. without moving to
// another row first). focusout fires for cell-to-cell moves // another row first). focusout fires for cell-to-cell moves
// too — relatedTarget being outside #table-root distinguishes. // too — relatedTarget being outside #table-root distinguishes.
//
// Deferred to next tick (setTimeout 0): the editor's commit
// path tears down its input element and then refocuses the
// owning cell. The remove fires focusout BEFORE the refocus
// runs, with relatedTarget=null (body briefly), so the naive
// sync check would mis-detect a "left the grid" event and
// fire flushAll redundantly alongside the selection-change
// save. Checking document.activeElement on the next tick
// gives the refocus time to settle.
const tableRoot = document.getElementById('table-root'); const tableRoot = document.getElementById('table-root');
if (tableRoot) { if (tableRoot) {
tableRoot.addEventListener('focusout', function (ev) { tableRoot.addEventListener('focusout', function (ev) {
const next = ev.relatedTarget; const next = ev.relatedTarget;
if (next && tableRoot.contains(next)) return; if (next && tableRoot.contains(next)) return;
const save = app.modules.save; setTimeout(function () {
if (save && typeof save.flushAll === 'function' && save.dirtyCount() > 0) { if (tableRoot.contains(document.activeElement)) return;
save.flushAll(); const save = app.modules.save;
} if (save && typeof save.flushAll === 'function' && save.dirtyCount() > 0) {
save.flushAll();
}
}, 0);
}); });
} }

View file

@ -149,30 +149,42 @@ test.describe('Browse', () => {
await expect(page.locator('#previewBody')).toContainText('a note inside the zip'); await expect(page.locator('#previewBody')).toContainText('a note inside the zip');
}); });
test('Download (zip) bundles the current folder offline', async ({ page }) => { test('Download (zip) bundles a folder via right-click → Download ZIP', async ({ page }) => {
await page.goto(`file://${HTML_PATH}`, { waitUntil: 'load' }); await page.goto(`file://${HTML_PATH}`, { waitUntil: 'load' });
await page.waitForSelector('#addDirectoryBtn', { timeout: 15000 }); await page.waitForSelector('#addDirectoryBtn', { timeout: 15000 });
await page.evaluate(() => { await page.evaluate(() => {
window.__setMockDirectoryTree('mock-folder', { window.__setMockDirectoryTree('mock-folder', {
'a.txt': 'AAA', 'a.txt': 'AAA',
'sub': { 'b.txt': 'BBB', 'deep': { 'c.txt': 'CCC' } }, 'sub': {
'.zddc': 'acl: { permissions: { "*": r } }', // hidden — must not be in the zip 'b.txt': 'BBB',
'_template': { 'scaffold.txt': 'x' }, // hidden dir — must not be in the zip 'deep': { 'c.txt': 'CCC' },
'.zddc': 'acl: { permissions: { "*": r } }', // hidden — must not be in the zip
'_template': { 'scaffold.txt': 'x' }, // hidden dir — must not be in the zip
},
}); });
}); });
await page.locator('#addDirectoryBtn').click(); await page.locator('#addDirectoryBtn').click();
await page.waitForSelector('#browseRoot:not(.hidden)', { timeout: 10000 }); await page.waitForSelector('#browseRoot:not(.hidden)', { timeout: 10000 });
// The Download (zip) button appears once a directory is loaded. // Download ZIP lives in the row's right-click context menu —
const dlBtn = page.locator('#downloadZipBtn'); // the standalone toolbar button was retired when the context
await expect(dlBtn).toBeVisible(); // menu became the canonical action surface (SPA overhaul,
// commit 94b2e29). The picked-root folder doesn't render as
// a row (only its CONTENTS do), so we test the next-level
// folder: right-click sub/, download sub.zip.
const subRow = page.locator('.tree-row', { has: page.locator('.tree-name__label', { hasText: /^sub$/ }) });
await subRow.waitFor({ state: 'visible', timeout: 5000 });
const [download] = await Promise.all([ const [download] = await Promise.all([
page.waitForEvent('download'), page.waitForEvent('download'),
dlBtn.click(), (async () => {
await subRow.click({ button: 'right' });
await page.locator('.zddc-menu__item', { hasText: 'Download ZIP' })
.first()
.click();
})(),
]); ]);
expect(download.suggestedFilename()).toBe('mock-folder.zip'); expect(download.suggestedFilename()).toBe('sub.zip');
const file = await download.path(); const file = await download.path();
const buf = await fs.readFile(file); const buf = await fs.readFile(file);
@ -190,9 +202,8 @@ test.describe('Browse', () => {
return Object.keys(z.files).filter((n) => !z.files[n].dir).sort(); return Object.keys(z.files).filter((n) => !z.files[n].dir).sort();
}, b64); }, b64);
expect(entries).toEqual([ expect(entries).toEqual([
'mock-folder/a.txt', 'sub/b.txt',
'mock-folder/sub/b.txt', 'sub/deep/c.txt',
'mock-folder/sub/deep/c.txt',
]); ]);
}); });
}); });

View file

@ -45,15 +45,16 @@ for (const tool of tools) {
const match = html.match(/class="build-timestamp">(?:<span[^>]*>)?([^<]+?)(?:<\/span>)?</); const match = html.match(/class="build-timestamp">(?:<span[^>]*>)?([^<]+?)(?:<\/span>)?</);
expect(match, 'build-timestamp element must have text content').toBeTruthy(); expect(match, 'build-timestamp element must have text content').toBeTruthy();
const label = match[1]; const label = match[1];
// Plain dev builds and --release alpha|beta share one label // Plain dev builds, ./build beta, and the retired
// shape — full UTC timestamp + short source SHA (with // ./build alpha all share one label shape — full UTC
// optional -dirty marker on plain dev when the tree is // timestamp + short source SHA (with optional -dirty
// uncommitted): // marker when the tree is uncommitted):
// "v0.0.17-alpha · 2026-04-29 00:50:17 · 714faf6" // "v0.0.21-dev · 2026-05-21 16:11:12 · 736f422"
// "v0.0.17-alpha · 2026-04-29 00:50:17 · 714faf6-dirty" // "v0.0.21-dev · 2026-05-21 16:11:12 · 736f422-dirty"
// "v0.0.17-beta · 2026-05-13 15:29:05 · e7f6334" // "v0.0.17-beta · 2026-05-13 15:29:05 · e7f6334"
// "v0.0.17-alpha · 2026-04-29 00:50:17 · 714faf6"
// Stable cuts emit a bare version: "v0.0.17" // Stable cuts emit a bare version: "v0.0.17"
const isChannel = /^v\d+\.\d+\.\d+-(?:alpha|beta) · 20\d\d-\d\d-\d\d \d\d:\d\d:\d\d · [0-9a-f]+(?:-dirty)?$/.test(label); const isChannel = /^v\d+\.\d+\.\d+-(?:alpha|beta|dev) · 20\d\d-\d\d-\d\d \d\d:\d\d:\d\d · [0-9a-f]+(?:-dirty)?$/.test(label);
const isVersion = /^v\d+\.\d+\.\d+$/.test(label); const isVersion = /^v\d+\.\d+\.\d+$/.test(label);
expect(isChannel || isVersion, expect(isChannel || isVersion,
`Expected channel or version label, got: "${label}"` `Expected channel or version label, got: "${label}"`

View file

@ -26,10 +26,13 @@ const FETCH_MOCK = `
}; };
`; `;
// Listing entries match the post-reshape FileInfo shape — the
// landing's loader filters on `is_dir: true` (projects are folders).
// Older fixtures omitted this and silently filtered to empty.
const SAMPLE_PROJECTS = [ const SAMPLE_PROJECTS = [
{ name: '176109', url: '/176109/', title: 'Greenfield Substation' }, { name: '176109/', is_dir: true, url: '/176109/', title: 'Greenfield Substation' },
{ name: '197072', url: '/197072/', title: 'Brownfield Tap' }, { name: '197072/', is_dir: true, url: '/197072/', title: 'Brownfield Tap' },
{ name: '210045', url: '/210045/', title: '' }, { name: '210045/', is_dir: true, url: '/210045/', title: '' },
]; ];
async function loadLandingWithProjects(page, projects) { async function loadLandingWithProjects(page, projects) {

View file

@ -1534,7 +1534,7 @@ body.is-elevated::after {
</svg> </svg>
<div class="header-title-group"> <div class="header-title-group">
<span class="app-header__title" id="table-title">ZDDC Table</span> <span class="app-header__title" id="table-title">ZDDC Table</span>
<span class="build-timestamp">v0.0.20</span> <span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.21-dev · 2026-05-21 16:22:47 · 736f422-dirty</span></span>
</div> </div>
</div> </div>
<div class="header-right"> <div class="header-right">
@ -6686,15 +6686,27 @@ body.is-elevated::after {
// clicked a header link, the URL bar, etc. without moving to // clicked a header link, the URL bar, etc. without moving to
// another row first). focusout fires for cell-to-cell moves // another row first). focusout fires for cell-to-cell moves
// too — relatedTarget being outside #table-root distinguishes. // too — relatedTarget being outside #table-root distinguishes.
//
// Deferred to next tick (setTimeout 0): the editor's commit
// path tears down its input element and then refocuses the
// owning cell. The remove fires focusout BEFORE the refocus
// runs, with relatedTarget=null (body briefly), so the naive
// sync check would mis-detect a "left the grid" event and
// fire flushAll redundantly alongside the selection-change
// save. Checking document.activeElement on the next tick
// gives the refocus time to settle.
const tableRoot = document.getElementById('table-root'); const tableRoot = document.getElementById('table-root');
if (tableRoot) { if (tableRoot) {
tableRoot.addEventListener('focusout', function (ev) { tableRoot.addEventListener('focusout', function (ev) {
const next = ev.relatedTarget; const next = ev.relatedTarget;
if (next && tableRoot.contains(next)) return; if (next && tableRoot.contains(next)) return;
const save = app.modules.save; setTimeout(function () {
if (save && typeof save.flushAll === 'function' && save.dirtyCount() > 0) { if (tableRoot.contains(document.activeElement)) return;
save.flushAll(); const save = app.modules.save;
} if (save && typeof save.flushAll === 'function' && save.dirtyCount() > 0) {
save.flushAll();
}
}, 0);
}); });
} }