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:
parent
736f422f82
commit
90a31020db
5 changed files with 71 additions and 32 deletions
|
|
@ -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);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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',
|
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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}"`
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue