chore: track the .build validation harnesses in git

The eight validation scripts live under .build/, which is an otherwise
whitelisted scratch directory. They were not on the whitelist, so they were
tracked nowhere -- a scratch-tree cleanup deletes them permanently, and there
is no copy on the release page either.

- ui_retry_test.mjs, ui_upload_menu_test.mjs -- headless-DOM tests against
  assets/main.js (extract password retry flow; upload menu, i18n key coverage,
  CSS cascade scoping, extract-button state)
- preview_build.py, preview_stub.html, preview_check.mjs -- serve the real
  index.html against a stub API and drive it in headless Chromium (hidden and
  focus state, layout overflow, computed highlight values, wrap thresholds)
- readme_header_render.mjs, compare_html_check.mjs, compare_simple_check.mjs --
  render checks for the README header badges and the comparison pages

Scripts themselves are unchanged; this only stops .gitignore from hiding them.
This commit is contained in:
Songlx516 committed 2026-09-26 12:12:41 +08:00
1 parent 9d8c49a1d4
commit edd1a1c177
9 files changed
+1177 -3

No files matched your search

+65
View File
@@ -0,0 +1,65 @@
/* Render the fork-vs-upstream comparison page, verify the filter logic actually
filters, and shoot it for review.
Run: node .build/compare_html_check.mjs
*/
import playwright from "file:///C:/Users/songl/.workbuddy/binaries/node/workspace/node_modules/playwright/index.js";
const { chromium } = playwright;
const URL =
"file:///C:/Users/songl/Desktop/Web%20File%20Manager/ps5-wfm-fork-vs-upstream.html";
const OUT = "C:/Users/songl/AppData/Local/Temp/readme-header";
const browser = await chromium.launch();
const page = await browser.newPage({
viewport: { width: 1320, height: 1100 },
deviceScaleFactor: 2,
});
page.on("pageerror", e => console.log("pageerror: " + e.message));
page.on("console", m => {
if (m.type() === "error") console.log("console.error: " + m.text());
});
await page.goto(URL, { waitUntil: "load" });
await page.waitForTimeout(300);
const counts = () =>
page.evaluate(() => {
const vis = sel => document.querySelectorAll(sel).length;
const byKind = {};
document.querySelectorAll("tbody tr").forEach(r => {
const k = r.getAttribute("data-kind") || "common";
byKind[k] = (byKind[k] || 0) + 1;
});
return {
total: byKind.common + byKind.ours + byKind.theirs,
byKind,
visible: vis("tbody tr:not(.hide)"),
visibleSections: vis("section[data-section]:not(.hide)"),
chips: [...document.querySelectorAll(".filters button")].map(
b => b.dataset.filter + "=" + b.querySelector(".count").textContent
),
};
});
console.log("initial:", JSON.stringify(await counts()));
for (const f of ["ours", "theirs", "diff", "all"]) {
await page.click(`.filters button[data-filter="${f}"]`);
await page.waitForTimeout(120);
const c = await counts();
console.log(
`filter=${f.padEnd(6)} visible rows=${c.visible} visible sections=${c.visibleSections}`
);
}
await page.click('.filters button[data-filter="all"]');
await page.waitForTimeout(150);
await page.screenshot({ path: `${OUT}/compare-page.png`, fullPage: true });
await page.click('.filters button[data-filter="diff"]');
await page.waitForTimeout(150);
await page.screenshot({ path: `${OUT}/compare-diff.png`, fullPage: true });
console.log("screenshots -> " + OUT);
await browser.close();
+103
View File
@@ -0,0 +1,103 @@
// Verify the simplified comparison page: no external requests, no horizontal
// overflow at narrow widths, expected row count, and that the two "winner"
// columns carry readable contrast. Fails loudly (exit 1) on any problem.
import playwright from "file:///C:/Users/songl/.workbuddy/binaries/node/workspace/node_modules/playwright/index.js";
const FILE = "file:///C:/Users/songl/Desktop/Web File Manager/ps5-wfm-simple-compare.html";
const browser = await playwright.chromium.launch();
let fails = 0;
const check = (ok, msg) => { console.log(`${ok ? " ok " : " FAIL "} ${msg}`); if (!ok) fails++; };
// ---- 1. desktop: offline + structure ----
const page = await browser.newPage({ viewport: { width: 1100, height: 900 } });
const external = [];
page.on("request", r => { if (!r.url().startsWith("file://")) external.push(r.url()); });
page.on("requestfailed", r => external.push("FAILED " + r.url()));
await page.goto(FILE, { waitUntil: "networkidle" });
const info = await page.evaluate(() => {
const rows = [...document.querySelectorAll("tbody tr")];
const cells = rows.map(r => [...r.children].map(td => td.innerText.replace(/\s+/g, " ").trim()));
return {
h1: document.querySelector("h1").innerText.trim(),
rows: rows.length,
cols: new Set(rows.map(r => r.children.length)).size,
empty: cells.filter(c => c.some(t => t === "")).length,
pick: document.querySelectorAll(".pick div").length,
same: document.querySelectorAll("ul.same li").length,
bodyH: document.body.scrollHeight,
// per row: the verdict chip text in each of the two answer columns.
// Read the DOM (.tag) rather than regex-matching a hardcoded word list,
// so rewording a verdict never causes a false failure.
tags: rows.map(r => [...r.children].slice(1).map(td => {
const el = td.querySelector(".tag");
return el ? el.innerText.trim() : "";
})),
};
});
check(external.length === 0, `no external requests (got ${external.length}${external.length ? ": " + external[0] : ""})`);
check(info.rows === 10, `10 difference rows (got ${info.rows})`);
check(info.cols === 1, "every row has the same column count");
check(info.empty === 0, "no empty cells");
check(info.pick === 2, "two 'which to pick' cards");
check(info.same === 6, `6 'identical' bullet lines (got ${info.same})`);
check(/两个版本/.test(info.h1), `heading present: ${info.h1}`);
// every row must have a verdict tag in both answer columns -> no row is silent
const missing = info.tags.map((t, i) => (t[0] && t[1] ? null : `row${i + 1}:[${t}]`)).filter(Boolean);
check(missing.length === 0, `both columns give a verdict on every row${missing.length ? " -> " + missing.join(" ") : ""}`);
// ---- 2. responsive: table on desktop, stacked cards on phones ----
const BREAKPOINT = 700;
for (const w of [1280, 768, 701, 700, 390, 320]) {
const p = await browser.newPage({ viewport: { width: w, height: 900 } });
await p.goto(FILE, { waitUntil: "load" });
const r = await p.evaluate(() => {
const de = document.documentElement;
const tw = document.querySelector(".tw");
const thead = document.querySelector("thead");
const row = document.querySelector("tbody tr");
const cell = document.querySelector("tbody td:nth-child(2)");
const before = getComputedStyle(cell, "::before");
return {
pageOverflow: de.scrollWidth > de.clientWidth + 1,
tableScrolls: tw.scrollWidth > tw.clientWidth,
cardMode: getComputedStyle(thead).display === "none",
rowDisplay: getComputedStyle(row).display,
labelVisible: before.display !== "none" && before.content !== "none" && before.content !== "normal",
usable: tw.clientWidth,
tableMin: getComputedStyle(document.querySelector("table")).minWidth,
};
});
const expectCards = w <= BREAKPOINT;
check(!r.pageOverflow, `${w}px: no page-level horizontal overflow`);
check(r.usable > 240, `${w}px: content area usable (${r.usable}px)`);
check(r.cardMode === expectCards, `${w}px: ${expectCards ? "cards" : "table"} layout`);
// Nothing should ever need sideways scrolling: cards reflow, and the table
// only renders once the viewport can actually fit it.
check(r.tableScrolls === false, `${w}px: no sideways scrolling (table min-width ${r.tableMin})`);
check(
r.labelVisible === expectCards,
`${w}px: column labels ${expectCards ? "shown inside cards" : "hidden in table mode"}`
);
if (expectCards) {
check(r.rowDisplay === "block", `${w}px: rows stack as blocks`);
}
await p.close();
}
// ---- 3. screenshots ----
const shots = [
["wide", 1100, false],
["narrow", 390, true],
];
for (const [label, width, full] of shots) {
const p = await browser.newPage({ viewport: { width, height: 1000 }, deviceScaleFactor: 2 });
await p.goto(FILE, { waitUntil: "load" });
await p.screenshot({ path: `C:/Users/songl/AppData/Local/Temp/wfm-compare/simple-${label}.png`, fullPage: full });
await p.close();
}
console.log(`\nscreenshots -> C:/Users/songl/AppData/Local/Temp/wfm-compare/simple-{wide,narrow}.png`);
await browser.close();
console.log(fails ? `\n${fails} FAILURES` : "\nall checks passed");
process.exit(fails ? 1 : 0);
+31
View File
@@ -0,0 +1,31 @@
#!/usr/bin/env python3
"""Rebuild the offline preview under .build/preview/ from the real assets.
python .build/preview_build.py
Copies assets/* and re-injects .build/preview_stub.html into a COPY of
index.html, so the page can be served from a plain static server
(.build/preview_check.mjs drives it with a browser). assets/ is never touched.
"""
from pathlib import Path
import shutil
ROOT = Path(__file__).resolve().parent.parent
SRC = ROOT / "assets"
DST = ROOT / ".build" / "preview"
STUB = (ROOT / ".build" / "preview_stub.html").read_text(encoding="utf-8")
MARKER = ' <script src="/main.js"></script>'
DST.mkdir(parents=True, exist_ok=True)
for item in SRC.iterdir():
if item.is_file():
shutil.copy2(item, DST / item.name)
html = (DST / "index.html").read_text(encoding="utf-8")
if MARKER not in html:
raise SystemExit("index.html has no <script src=\"/main.js\"> tag to hook")
(DST / "index.html").write_text(html.replace(MARKER, STUB + MARKER), encoding="utf-8")
print("preview ready:", DST)
print("serve it with: python -m http.server 8899 --bind 127.0.0.1 (run inside .build/preview)")
+267
View File
@@ -0,0 +1,267 @@
/* Screenshot the toolbar out of the offline preview harness and report what the
upload menu did, so the change can be looked at instead of trusted.
Run: node .build/preview_check.mjs
*/
import playwright from "file:///C:/Users/songl/.workbuddy/binaries/node/workspace/node_modules/playwright/index.js";
const { chromium } = playwright;
const URL = "http://127.0.0.1:8899/index.html";
const out = [];
const fails = [];
function assert(cond, label) {
out.push((cond ? " ok " : " FAIL ") + label);
if (!cond) fails.push(label);
}
const browser = await chromium.launch();
const page = await browser.newPage({ viewport: { width: 1280, height: 720 }, locale: "zh-CN" });
page.on("pageerror", err => out.push("pageerror: " + err.message));
await page.goto(URL, { waitUntil: "load" });
await page.waitForTimeout(1200);
const boxOf = selector => page.$eval(selector, el => {
const r = el.getBoundingClientRect();
return { x: Math.round(r.x), y: Math.round(r.y), w: Math.round(r.width), h: Math.round(r.height) };
});
const hiddenOf = selector => page.$eval(selector, el => el.hidden);
out.push("menu hidden at rest: " + await hiddenOf("#uploadMenu"));
out.push("hint hidden? " + await hiddenOf("#dropHint") + " text=" +
await page.$eval("#dropHint", el => JSON.stringify(el.textContent)));
out.push("button label: " + JSON.stringify(await page.$eval("#uploadBtn", el => el.textContent)));
out.push("button box: " + JSON.stringify(await boxOf("#uploadBtn")));
out.push("toolbar box (one row is 69px tall inside it): " + JSON.stringify(await boxOf(".toolbar")));
out.push("footer box: " + JSON.stringify(await boxOf(".status")));
out.push("hint box: " + JSON.stringify(await boxOf("#dropHint")));
out.push("hint is between the status and the version: " +
await page.evaluate(() => {
const status = document.getElementById("statusText").getBoundingClientRect();
const hint = document.getElementById("dropHint").getBoundingClientRect();
const version = document.getElementById("versionText").getBoundingClientRect();
return status.right <= hint.left && hint.right <= version.left;
}));
out.push("status text box: " + JSON.stringify(await boxOf("#statusText")));
await page.click("#uploadBtn");
await page.waitForTimeout(250);
out.push("menu hidden after click: " + await hiddenOf("#uploadMenu"));
out.push("aria-expanded: " + await page.$eval("#uploadBtn", el => el.getAttribute("aria-expanded")));
out.push("menu box: " + JSON.stringify(await boxOf("#uploadMenu")));
out.push("items: " + JSON.stringify(await page.$$eval("#uploadMenu button",
els => els.map(el => el.textContent))));
out.push("focused: " + await page.evaluate(() => document.activeElement && document.activeElement.id));
await page.screenshot({ path: ".build/preview/menu-open.png" });
/* The row highlight is a cascade question -- specificity, source order, and a
generic rule that was never meant to reach a 46px list row -- so it can only
be checked by a real engine reading back the computed values. */
const rowStyle = sel => page.$eval(sel, el => {
const cs = getComputedStyle(el);
const panel = document.getElementById("uploadMenu").getBoundingClientRect();
const r = el.getBoundingClientRect();
return {
background: cs.backgroundColor,
outline: cs.outlineWidth + " " + cs.outlineStyle,
outlineStyle: cs.outlineStyle,
boxShadow: cs.boxShadow,
bleeds: (r.left < panel.left) || (r.right > panel.right) ||
(r.top < panel.top) || (r.bottom > panel.bottom),
};
});
const PANEL_FILL = "rgb(43, 52, 62)"; /* #2b343e -- the menu's own row fill */
const TOOLBAR_HOVER = "rgb(48, 57, 69)"; /* #303945 -- the toolbar's hover fill */
const focusedRow = await rowStyle("#uploadFilesItem");
out.push("a focused menu row carries no outer ring: " + (focusedRow.outlineStyle === "none"));
out.push(" outline was: " + focusedRow.outline);
out.push("a menu row never paints outside its panel: " + !focusedRow.bleeds);
await page.hover("#uploadFolderItem");
await page.waitForTimeout(200);
const hoveredRow = await rowStyle("#uploadFolderItem");
out.push("a hovered row uses the menu fill, not the toolbar's: " + (hoveredRow.background === PANEL_FILL));
out.push(" hover fill was: " + hoveredRow.background + " (the toolbar's would be " + TOOLBAR_HOVER + ")");
out.push(" and it does not bleed outside the panel: " + !hoveredRow.bleeds);
/* Keyboard navigation is where the focus cue has to be visible, and it must be
drawn inside the row so it cannot cross the panel edge. */
await page.mouse.move(20, 700);
await page.keyboard.press("ArrowDown");
await page.waitForTimeout(200);
const keyedRow = await rowStyle("#uploadFolderItem");
out.push("ArrowDown moves the highlight to the second row: " +
await page.evaluate(() => document.activeElement && document.activeElement.id));
out.push(" the keyboard focus cue is an inset ring: " + /inset/.test(keyedRow.boxShadow));
out.push(" its outline is still suppressed: " + (keyedRow.outlineStyle === "none"));
out.push(" and the row stays inside the panel: " + !keyedRow.bleeds);
out.push(" it is a single inset cue, not a stack of rings: " +
((keyedRow.boxShadow.match(/inset/g) || []).length === 1));
const menuClip = await page.$eval("#uploadMenu", el => {
const r = el.getBoundingClientRect();
return { x: Math.max(0, r.x - 36), y: Math.max(0, r.y - 36), width: r.width + 72, height: r.height + 72 };
});
await page.screenshot({ path: ".build/preview/menu-highlight.png", clip: menuClip });
await page.keyboard.press("ArrowUp");
await page.waitForTimeout(150);
out.push("ArrowUp wraps back to the first row: " +
await page.evaluate(() => document.activeElement && document.activeElement.id));
/* the file entry must reach the hidden <input type=file> */
await page.evaluate(() => {
const input = document.getElementById("uploadFiles");
window.__picked = false;
input.addEventListener("click", event => { window.__picked = true; event.preventDefault(); });
});
await page.click("#uploadFilesItem");
await page.waitForTimeout(200);
out.push("file input was clicked: " + await page.evaluate(() => window.__picked));
out.push("menu hidden after choosing: " + await hiddenOf("#uploadMenu"));
await page.click("#uploadBtn");
await page.waitForTimeout(150);
await page.mouse.click(640, 660);
await page.waitForTimeout(150);
out.push("menu hidden after an outside click: " + await hiddenOf("#uploadMenu"));
await page.click("#uploadBtn");
await page.waitForTimeout(150);
await page.keyboard.press("Escape");
await page.waitForTimeout(150);
out.push("menu hidden after Escape: " + await hiddenOf("#uploadMenu"));
/* the wide layout: the button must not fall off the right edge */
await page.setViewportSize({ width: 1920, height: 1080 });
await page.waitForTimeout(300);
await page.screenshot({ path: ".build/preview/wide.png" });
/* the narrow layout, where the toolbar wraps */
await page.setViewportSize({ width: 1024, height: 720 });
await page.waitForTimeout(300);
out.push("narrow button box: " + JSON.stringify(await boxOf("#uploadBtn")));
out.push("narrow toolbar box: " + JSON.stringify(await boxOf(".toolbar")));
out.push("narrow hint box: " + JSON.stringify(await boxOf("#dropHint")));
out.push("narrow hint still fits the footer: " +
await page.evaluate(() => {
const footer = document.querySelector(".status").getBoundingClientRect();
const hint = document.getElementById("dropHint").getBoundingClientRect();
return hint.bottom <= footer.bottom + 1 && hint.right <= footer.right + 1;
}));
/* a long status must not squeeze the hint out of the footer */
await page.evaluate(() => {
document.getElementById("statusText").textContent =
"正在上传 3/12: PPSA16608-2026-09-24-full-backup-part03.zip";
});
await page.waitForTimeout(150);
out.push("with a long status, footer box: " + JSON.stringify(await boxOf(".status")));
out.push(" hint box: " + JSON.stringify(await boxOf("#dropHint")));
await page.screenshot({ path: ".build/preview/long-status.png" });
await page.click("#uploadBtn");
await page.waitForTimeout(250);
out.push("narrow menu box: " + JSON.stringify(await boxOf("#uploadMenu")));
out.push("narrow menu is inside the window: " +
await page.$eval("#uploadMenu", el => el.getBoundingClientRect().right <= window.innerWidth + 1));
await page.screenshot({ path: ".build/preview/narrow.png" });
/* ---- the extract button: always on screen, greyed until it can be used --- */
await page.setViewportSize({ width: 1280, height: 800 });
await page.waitForTimeout(250);
const toolbarHeight = () => page.$eval(".toolbar", el => Math.round(el.getBoundingClientRect().height));
const toolbarFits = async widths => {
const result = [];
for (const width of widths) {
await page.setViewportSize({ width, height: 800 });
await page.waitForTimeout(200);
result.push(width + ":" + (await toolbarHeight()));
}
await page.setViewportSize({ width: 1280, height: 800 });
await page.waitForTimeout(200);
return result;
};
const extractState = () => page.$eval("#extractBtn", el => {
const r = el.getBoundingClientRect();
const cs = getComputedStyle(el);
const bar = document.querySelector(".toolbar").getBoundingClientRect();
return {
hidden: el.hidden,
disabled: el.disabled,
text: el.textContent,
title: el.title,
width: Math.round(r.width),
opacity: cs.opacity,
insideToolbar: r.top >= bar.top - 1 && r.bottom <= bar.bottom + 1,
onScreen: r.right <= window.innerWidth + 1 && r.left >= -1,
};
});
const selectPaths = async paths => {
await page.evaluate(() => {
for (const box of document.querySelectorAll("#content .select-cell input")) {
box.checked = false;
box.dispatchEvent(new Event("change", { bubbles: true }));
}
});
for (const p of paths) {
await page.evaluate(path => {
const row = document.querySelector('#content tr[data-path="' + CSS.escape(path) + '"]');
const box = row && row.querySelector(".select-cell input");
if (!box) throw new Error("no checkbox for " + path);
box.checked = true;
box.dispatchEvent(new Event("change", { bubbles: true }));
}, p);
}
await page.waitForTimeout(180);
};
const rest = await extractState();
out.push("extract button at rest: " + JSON.stringify(rest));
assert(!rest.hidden, "the extract button is on screen with nothing selected");
assert(rest.disabled, "and it is disabled");
assert(rest.opacity !== "1", "and it is drawn dimmed (opacity " + rest.opacity + ")");
assert(rest.insideToolbar && rest.onScreen, "and it sits inside the toolbar, on screen");
assert(rest.title.length > 0, "and its tooltip explains why: " + JSON.stringify(rest.title));
await page.screenshot({ path: ".build/preview/extract-disabled.png", clip: { x: 0, y: 54, width: 760, height: 90 } });
await page.screenshot({ path: ".build/preview/extract-disabled-tight.png", clip: { x: 524, y: 60, width: 176, height: 78 } });
await selectPaths(["/saves"]);
const onFolder = await extractState();
assert(onFolder.disabled, "selecting a folder keeps it disabled");
await selectPaths(["/PPSA16608.zip"]);
const onArchive = await extractState();
assert(!onArchive.disabled, "selecting one archive enables it");
assert(onArchive.opacity === "1", "and it becomes fully opaque");
assert(onArchive.title.indexOf("PPSA16608.zip") >= 0, "and the tooltip names the archive: " + JSON.stringify(onArchive.title));
await page.screenshot({ path: ".build/preview/extract-enabled.png", clip: { x: 0, y: 54, width: 760, height: 90 } });
await page.screenshot({ path: ".build/preview/extract-enabled-tight.png", clip: { x: 524, y: 60, width: 176, height: 78 } });
await selectPaths(["/PPSA16608.zip", "/\u6e38\u620f\u5907\u4efd.7z"]);
const onTwo = await extractState();
assert(onTwo.disabled, "selecting two archives disables it again");
assert(onTwo.title.indexOf("\u4e00\u6b21\u53ea\u80fd") >= 0, "with its own message: " + JSON.stringify(onTwo.title));
await selectPaths([]);
/* Keeping the button on screen widens the resting toolbar by its own width, so
the wrap point has to be pinned. Measured in the zh locale, which is the one
the console runs: it moved from 1080px to 1190px when the button stopped
being hidden. The English labels are wider and that build wraps 1280px. */
const fits = await toolbarFits([1920, 1600, 1280]);
out.push("toolbar height at 1920/1600/1280 (one row is 85): " + fits.join(" "));
assert(fits.every(entry => Number(entry.split(":")[1]) < 100),
"the toolbar still fits on one row at every realistic console width");
await browser.close();
console.log(out.join("\n"));
console.log(fails.length
? "\n" + fails.length + " FAILED:\n " + fails.join("\n ")
: "\nall " + out.filter(line => line.indexOf(" ok ") === 0).length + " assertions passed");
process.exit(fails.length ? 1 : 0);
+48
View File
@@ -0,0 +1,48 @@
<script>
/* Offline fixture: every /api call the page makes is answered here. Injected
into a copy of index.html by .build/preview_build.py, just before the real
script tag, so the toolbar can be rendered without a console. */
(function () {
var now = Math.floor(Date.now() / 1000);
var dirs = {
"/": [
{ name: "PPSA16608.zip", type: "-", mode: 420, size: 3980000000, mtime: now - 3600 },
{ name: "\u6e38\u620f\u5907\u4efd.7z", type: "-", mode: 420, size: 12345678, mtime: now - 7200 },
{ name: "saves", type: "d", mode: 493, size: 0, mtime: now - 86400 }
]
};
function json(payload) {
return Promise.resolve(new Response(JSON.stringify(payload), {
status: 200, headers: { "Content-Type": "application/json" }
}));
}
window.fetch = function (url) {
var target = String(url);
var query = target.split("?")[1] || "";
var path = "/";
query.split("&").forEach(function (kv) {
var pair = kv.split("=");
if (pair[0] === "path") path = decodeURIComponent(pair[1] || "/");
});
if (target.indexOf("/api/list") === 0) {
var entries = (dirs[path] || []).map(function (entry) {
return Object.assign({}, entry, { path: (path === "/" ? "" : path) + "/" + entry.name });
});
return json({ ok: true, path: path, parent: "/", entries: entries });
}
if (target.indexOf("/api/tasks") === 0) {
return json({ ok: true, tasks: [], completion: null, now: now });
}
if (target.indexOf("/api/space") === 0) {
return json({ ok: true, mounts: [
{ label_key: "storageRoot", path: "/", free: 812000000000, total: 2000000000000, current: true }
] });
}
if (target.indexOf("/api/version") === 0) {
return json({ ok: true, version: "v1.9.3M", title_id: "FMGR88888" });
}
if (target.indexOf("/api/") === 0) return json({ ok: true });
return new Promise(function () {});
};
})();
</script>
+114
View File
@@ -0,0 +1,114 @@
/* Render the README header the way GitHub would, with the real shields.io
images, and report whether every badge actually loaded.
Run: node .build/readme_header_render.mjs
*/
import playwright from "file:///C:/Users/songl/.workbuddy/binaries/node/workspace/node_modules/playwright/index.js";
import fs from "node:fs";
import path from "node:path";
const { chromium } = playwright;
const REPO = "C:/Users/songl/Desktop/Web File Manager/ps5-web-file-manager";
const OUT = "C:/Users/songl/AppData/Local/Temp/readme-header";
fs.mkdirSync(OUT, { recursive: true });
function extract(file) {
const text = fs.readFileSync(path.join(REPO, file), "utf8");
const div = (text.match(/<div align="right">[\s\S]*?<\/div>/) || [""])[0];
const ps = text.match(/<p align="center">[\s\S]*?<\/p>/g) || [];
const h1 = (text.match(/^# (.+)$/m) || [, ""])[1];
const quote = (text.match(/^(?:> .*\n)+/m) || [""])[0]
.split(/\r?\n/).filter(Boolean).map(l => l.replace(/^> ?/, "")).join(" ");
return { div, ps, h1, quote, file };
}
function page_html(d) {
return `<!doctype html><meta charset="utf-8">
<style>
body{font:16px/1.5 -apple-system,BlinkMacSystemFont,"Segoe UI","Noto Sans",Helvetica,Arial,"Microsoft YaHei",sans-serif;
color:#1f2328;background:#fff;margin:0;padding:32px;max-width:1012px}
h1{font-size:2em;font-weight:600;border-bottom:1px solid #d1d9e0;padding-bottom:.3em;margin:.67em 0}
blockquote{margin:0 0 16px;padding:0 1em;color:#59636e;border-left:.25em solid #d1d9e0}
p img{vertical-align:middle}
</style>
${d.div}
<h1>${d.h1}</h1>
${d.ps.join("\n")}
<blockquote>${d.quote}</blockquote>`;
}
const browser = await chromium.launch();
const report = [];
for (const file of ["README.md", "README.zh-CN.md"]) {
const d = extract(file);
for (const [label, width] of [["wide", 1280], ["narrow", 420]]) {
const page = await browser.newPage({
viewport: { width, height: 700 },
deviceScaleFactor: 2,
});
await page.setContent(page_html(d), { waitUntil: "load" });
await page
.waitForFunction(
() => [...document.images].every(i => i.complete),
null,
{ timeout: 20000 }
)
.catch(() => {});
await page.waitForTimeout(400);
const imgs = await page.$$eval("img", els =>
els.map(e => ({
src: e.currentSrc || e.src,
w: e.naturalWidth,
h: e.naturalHeight,
alt: e.alt,
box: Math.round(e.getBoundingClientRect().width),
}))
);
const ok = imgs.filter(i => i.w > 0).length;
report.push(`--- ${file} @${label}(${width}px): ${ok}/${imgs.length} badges loaded`);
for (const i of imgs) {
const tail = i.src.split("/").slice(-2).join("/");
report.push(` ${i.w}x${i.h} ${String(i.box).padStart(4)}px ${tail}`);
}
const overflow = await page.evaluate(() => document.body.scrollWidth > window.innerWidth);
report.push(` horizontal overflow: ${overflow}`);
await page.screenshot({
path: path.join(OUT, `${file.replace(/[^\w.]/g, "_")}.${label}.png`),
fullPage: label === "narrow",
});
await page.close();
if (label === "wide") {
// 5x zoom of the badge row — at 1x the colours are guesswork
const zoom = await browser.newPage({
viewport: { width: 640, height: 48 },
deviceScaleFactor: 5,
});
await zoom.setContent(
`<body style="margin:0;background:#fff">${d.ps[0]}</body>`,
{ waitUntil: "load" }
);
await zoom
.waitForFunction(
() => [...document.images].every(i => i.complete && i.naturalWidth > 0),
null,
{ timeout: 20000 }
)
.catch(() => {});
await zoom.waitForTimeout(300);
await zoom.screenshot({
path: path.join(OUT, `${file.replace(/[^\w.]/g, "_")}.badges-zoom.png`),
});
await zoom.close();
}
}
}
await browser.close();
console.log(report.join("\n"));
console.log("screenshots -> " + OUT);
+381
View File
@@ -0,0 +1,381 @@
/* Headless check for the extract "retry with a password" flow in
assets/main.js, plus the byte-mapped name translation its error messages go
through.
This project has no browser test runner, so the real script is loaded into a
stubbed DOM. Only the pieces the flow touches are faked: fetch (which also
records the /api/extract bodies), prompt, alert and a permissive element
object. init() is allowed to stall on its first real data fetch -- the retry
path is driven directly.
Run: node .build/ui_retry_test.mjs
*/
import fs from "node:fs";
import path from "node:path";
import vm from "node:vm";
const root = path.resolve(import.meta.dirname, "..");
const mainSrc = fs.readFileSync(path.join(root, "assets/main.js"), "utf8");
const langSrc = fs.readFileSync(path.join(root, "assets/lang-en.js"), "utf8");
let pass = 0;
let fail = 0;
function check(cond, label) {
if (cond) {
pass++;
console.log(" ok " + label);
} else {
fail++;
console.log(" FAIL " + label);
}
}
/* ---- stubs ------------------------------------------------------------- */
const extractCalls = [];
const alerts = [];
let promptReply = null;
let promptCount = 0;
let lastPromptText = null;
function fakeElement(tag) {
const node = {
tagName: String(tag || "div").toUpperCase(),
children: [],
style: {},
dataset: {},
classList: { add() {}, remove() {}, toggle() {}, contains() { return false; } },
innerHTML: "",
textContent: "",
value: "",
hidden: false,
checked: false,
disabled: false,
scrollTop: 0,
scrollHeight: 0,
clientHeight: 0,
files: [],
parentNode: { removeChild() {} },
title: "",
type: "",
addEventListener() {},
removeEventListener() {},
dispatchEvent() {},
appendChild() {},
removeChild() {},
insertBefore() {},
replaceChildren() {},
setAttribute() {},
getAttribute() { return null; },
removeAttribute() {},
focus() {},
blur() {},
click() {},
remove() {},
after() {},
before() {},
closest() { return null; },
contains() { return false; },
cloneNode() { return fakeElement(tag); },
querySelector() { return fakeElement("div"); },
querySelectorAll() { return []; },
getBoundingClientRect() { return { top: 0, left: 0, width: 0, height: 0, bottom: 0, right: 0 }; },
scrollIntoView() {},
};
return node;
}
/* The language <script> is fetched in the browser; here the tag is handed back
with an onload hook that fires as soon as main.js assigns the handler, which
is the order loadLanguage() expects. */
function fakeScriptElement() {
const node = fakeElement("script");
let src = "";
Object.defineProperty(node, "src", {
get() { return src; },
set(value) { src = String(value); },
});
Object.defineProperty(node, "onload", {
set(handler) { if (typeof handler === "function") queueMicrotask(handler); },
get() { return null; },
});
return node;
}
const documentStub = {
getElementById() { return fakeElement("div"); },
querySelector() { return fakeElement("div"); },
querySelectorAll() { return []; },
createElement(tag) {
return String(tag).toLowerCase() === "script" ? fakeScriptElement() : fakeElement(tag);
},
createDocumentFragment() { return fakeElement("div"); },
addEventListener() {},
removeEventListener() {},
body: fakeElement("body"),
head: fakeElement("head"),
documentElement: fakeElement("html"),
title: "",
cookie: "",
hidden: false,
};
function jsonResponse(payload) {
return { ok: true, status: 200, json: async () => payload, text: async () => "" };
}
function stubFetch(url, options) {
const target = String(url);
if (target.startsWith("/api/extract")) {
extractCalls.push(String((options && options.body) || ""));
return Promise.resolve(jsonResponse({ ok: true, task_id: extractCalls.length }));
}
if (target.startsWith("/api/tasks")) {
return Promise.resolve(jsonResponse({ ok: true, tasks: [], completion: null }));
}
if (target.startsWith("/api/")) {
return Promise.resolve(jsonResponse({ ok: true }));
}
/* Anything else (file listings, spaces) never settles: init() parks on it
instead of walking into code paths this harness does not stub. */
return new Promise(() => {});
}
const sandbox = {
console,
setTimeout,
clearTimeout,
setInterval() { return 0; },
clearInterval() {},
requestAnimationFrame(handler) { return setTimeout(handler, 0); },
fetch: stubFetch,
URLSearchParams,
AbortController,
Uint8Array,
TextDecoder,
TextEncoder,
addEventListener() {},
removeEventListener() {},
dispatchEvent() {},
alert(message) { alerts.push(String(message)); },
confirm() { return true; },
prompt(text) {
promptCount++;
lastPromptText = String(text);
return promptReply;
},
XMLHttpRequest: class {
abort() {}
open() {}
send() {}
setRequestHeader() {}
addEventListener() {}
},
localStorage: { getItem() { return null; }, setItem() {}, removeItem() {} },
navigator: { languages: ["en-US"], language: "en-US", userAgent: "retry-test" },
location: { href: "http://localhost:8888/", pathname: "/", search: "", hash: "", origin: "http://localhost:8888" },
history: { pushState() {}, replaceState() {} },
performance: { now: () => Date.now() },
document: documentStub,
};
sandbox.window = sandbox;
sandbox.self = sandbox;
sandbox.globalThis = sandbox;
process.on("unhandledRejection", reason => {
console.log(" (note) init() rejected as expected in the stub: " + (reason && reason.message));
});
const context = vm.createContext(sandbox);
vm.runInContext(langSrc, context, { filename: "lang-en.js" });
const EXPORTS = ";globalThis.__WFM_TEST = { startExtractTask, handleTerminalTask, " +
"retryExtractWithPassword, extractRequestRetries, extractRetryKey, " +
"backendErrorText, decodeFsText, encodeFsText };\n";
vm.runInContext(mainSrc + EXPORTS, context, { filename: "main.js" });
const T = sandbox.__WFM_TEST;
if (!T) {
console.log("FAIL: could not reach the functions under test");
process.exit(1);
}
const flush = async (ticks = 8) => {
for (let i = 0; i < ticks; i++) await new Promise(resolve => setTimeout(resolve, 0));
};
const failedExtract = (id, src, dst, code, arg) => ({
id,
op: "extract",
state: "failed",
error_code: code,
error_arg: arg || "",
error: "password required or wrong",
src,
dst,
current: src,
});
function bodyField(body, name) {
const params = new URLSearchParams(body);
return params.get(name);
}
/* The stub hands out task ids 1, 2, 3 ... in call order, so the id of the task a
startExtractTask() call created is simply the number of /api/extract calls
made so far. Every terminal payload below must carry the id of the task the
page actually remembered -- that is the whole point of keying by id. */
const startedId = () => extractCalls.length;
const lastField = name => bodyField(extractCalls[extractCalls.length - 1], name);
async function startExtract(...args) {
await T.startExtractTask(...args);
await flush();
return startedId();
}
/* ---- 1. the request is recorded when the task starts ------------------- */
console.log("recording the extract request");
const idA = await startExtract("/enc-aes256.zip", "/", "overwrite", false, "enc-aes256.zip", true, "", 0);
check(extractCalls.length === 1, "one /api/extract POST was sent");
check(bodyField(extractCalls[0], "path") === "/enc-aes256.zip", "path is the archive");
check(bodyField(extractCalls[0], "conflict") === "overwrite", "conflict policy is sent");
check(bodyField(extractCalls[0], "large") === "1", "large-file opt-in is sent");
check(bodyField(extractCalls[0], "password") === null, "no password field on a plain attempt");
check(T.extractRequestRetries.get(T.extractRetryKey(idA)) !== undefined,
"the request is remembered under the task id");
check([...T.extractRequestRetries.keys()].every(key => key.startsWith("task:")),
"nothing is keyed by a path -- see the regression below");
/* ---- 2. REGRESSION: a non-ASCII folder must still retry ---------------- */
/* The page holds the byte-mapped form of a directory (see decodeFsText in
main.js), the task reports the byte-repaired one, and the two differ whenever
a name is not pure ASCII. Keyed by path the lookup missed for exactly those
archives, so the password prompt never appeared and the user only ever saw the
raw "extract failed" alert. */
console.log("\nnon-ASCII folder, path as reported by the server differs");
promptCount = 0;
alerts.length = 0;
promptReply = "secret123";
const wireDir = "/mnt/\u00e6\u0088\u0091\u00e7\u009a\u0084"; /* "/mnt/我的", byte-mapped */
const idB = await startExtract(wireDir + "/games.zip", wireDir, "fail", false, "games.zip", false, "", 0);
const beforeRetry = extractCalls.length;
T.handleTerminalTask(failedExtract(idB, "/mnt/我的/games.zip", "/mnt/我的",
"extract_password", "a.psd (entry is encrypted and no password was given)"));
await flush();
check(promptCount === 1, "the password is still asked for");
check(extractCalls.length === beforeRetry + 1, "the retry was sent");
check(lastField("path") === "/mnt/我的/games.zip",
"the retry re-sends the path the server reported, got " + lastField("path"));
check(lastField("password") === "secret123", "with the typed password");
/* ---- 3. a password failure retries with what the user types ------------ */
console.log("\npassword failure -> prompt -> retry");
promptCount = 0;
alerts.length = 0;
promptReply = "secret123";
const idC = await startExtract("/enc-aes256.zip", "/", "overwrite", false, "enc-aes256.zip", true, "", 0);
const beforeC = extractCalls.length;
T.handleTerminalTask(failedExtract(idC, "/enc-aes256.zip", "/", "extract_password", "enc-aes256.zip"));
await flush();
const idC2 = startedId();
check(promptCount === 1, "the user was asked for a password once");
check(String(lastPromptText).startsWith("This archive is encrypted"),
"the prompt is the encrypted-archive one, not the up-front 7z one: " + lastPromptText);
check(String(lastPromptText).indexOf("did not work") < 0,
"a first failure does not blame a password that was never given: " + lastPromptText);
check(extractCalls.length === beforeC + 1, "a second /api/extract POST was sent");
check(lastField("password") === "secret123", "the typed password is sent");
check(lastField("conflict") === "overwrite",
"the original conflict policy survives the retry");
check(lastField("large") === "1", "the large-file opt-in survives the retry");
check(lastField("path") === "/enc-aes256.zip", "the same archive is retried");
check(alerts.length === 0, "no failure alert while the retry is running");
const afterFirstRetry = T.extractRequestRetries.get(T.extractRetryKey(idC2));
check(afterFirstRetry && afterFirstRetry.attempts === 1, "the retry count is tracked");
check(T.extractRequestRetries.get(T.extractRetryKey(idC)) === undefined,
"the consumed entry is dropped, so the old id cannot re-prompt");
/* ---- 4. cancelling gives up and reports the failure -------------------- */
console.log("\ncancelling the prompt");
promptCount = 0;
alerts.length = 0;
promptReply = null; // Cancel
const beforeCancel = extractCalls.length;
T.handleTerminalTask(failedExtract(idC2, "/enc-aes256.zip", "/", "extract_password", "enc-aes256.zip"));
await flush();
check(promptCount === 1, "the prompt was shown");
check(String(lastPromptText).indexOf("did not work") >= 0,
"the second failure does say the password was wrong: " + lastPromptText);
check(extractCalls.length === beforeCancel, "no new request after cancelling");
check(alerts.length === 1, "the failure is reported");
check(T.extractRequestRetries.get(T.extractRetryKey(idC2)) === undefined,
"the remembered request is dropped");
/* ---- 5. an empty password is the same as cancelling -------------------- */
console.log("\nempty password");
promptReply = "";
alerts.length = 0;
const idD = await startExtract("/enc-aes256.zip", "/", "fail", false, "enc-aes256.zip", false, "", 0);
const beforeEmpty = extractCalls.length;
T.handleTerminalTask(failedExtract(idD, "/enc-aes256.zip", "/", "extract_password", "enc-aes256.zip"));
await flush();
check(extractCalls.length === beforeEmpty, "an empty box does not fire a retry");
check(alerts.length === 1, "the failure is reported instead");
/* ---- 6. the retry count is capped ------------------------------------- */
console.log("\nretry cap");
promptCount = 0;
alerts.length = 0;
promptReply = "pw";
let cappedId = await startExtract("/capped.zip", "/", "fail", false, "capped.zip", false, "", 0);
for (let i = 0; i < 4; i++) {
T.handleTerminalTask(failedExtract(cappedId, "/capped.zip", "/", "extract_password", "capped.zip"));
await flush();
cappedId = startedId();
}
check(promptCount === 3, "exactly three prompts for four failures, got " + promptCount);
check(alerts.length === 1, "the fourth failure is reported instead of prompting again");
/* ---- 7. other failures are untouched ---------------------------------- */
console.log("\nunrelated failures");
promptCount = 0;
alerts.length = 0;
promptReply = "pw";
T.handleTerminalTask(failedExtract(startedId(), "/enc-aes256.zip", "/", "extract_ratio", "enc-aes256.zip"));
await flush();
check(promptCount === 0, "a ratio failure does not ask for a password");
check(alerts.length === 1, "a ratio failure is reported as before");
promptCount = 0;
alerts.length = 0;
T.handleTerminalTask(failedExtract(987654, "/elsewhere.zip", "/tmp", "extract_password", "elsewhere.zip"));
await flush();
check(promptCount === 0, "a task this page did not start is not retried");
check(alerts.length === 1, "it is reported as before");
/* ---- 8. names in the error text are translated back -------------------- */
console.log("\nbyte-mapped names reach the user as text");
const gbkName = "\u00c4\u00a3\u00b0\u00e5.psd"; /* 模板.psd as GBK bytes, byte-mapped */
check(T.decodeFsText(gbkName) === "模板.psd",
"a GBK entry name decodes to the real name, got " + T.decodeFsText(gbkName));
check(T.decodeFsText(T.encodeFsText("模板.psd")) === "模板.psd",
"encode/decode is a round trip");
check(T.encodeFsText("plain.zip") === "plain.zip", "ASCII names are left alone");
const errText = T.backendErrorText("extract_password",
gbkName + " (entry is encrypted and no password was given)", "hint");
check(errText.indexOf("模板.psd") >= 0, "the message carries the real name: " + errText);
check(errText.indexOf("\u00c4") < 0 && errText.indexOf("\u00a3") < 0,
"no mojibake is left in it: " + errText);
console.log("\n" + pass + " checks, " + fail + " failures");
process.exit(fail === 0 ? 0 : 1);
+153
View File
@@ -0,0 +1,153 @@
/* Static checks for the upload entry point in assets/index.html.
The upload button is markup plus i18n plus CSS plus three event bindings in
main.js, and nothing else in the project validates any of those: a missing
language key renders an empty button, a renamed id silently unbinds a click,
and a class that only exists in the HTML looks fine in the diff but not in the
browser. All three have happened here, so they are checked mechanically.
Run: node .build/ui_upload_menu_test.mjs
*/
import fs from "node:fs";
import path from "node:path";
import vm from "node:vm";
const root = path.resolve(import.meta.dirname, "..");
const read = name => fs.readFileSync(path.join(root, "assets", name), "utf8");
const html = read("index.html");
const css = read("main.css");
const js = read("main.js");
let pass = 0;
let fail = 0;
function check(cond, label) {
if (cond) {
pass++;
console.log(" ok " + label);
} else {
fail++;
console.log(" FAIL " + label);
}
}
function loadLang(name) {
const sandbox = { window: {} };
vm.runInNewContext(read(name), sandbox, { filename: name });
return sandbox.window.WFM_LANG;
}
const zh = loadLang("lang-zh.js");
const en = loadLang("lang-en.js");
/* ---- 1. every key the markup asks for exists, in both languages -------- */
console.log("i18n coverage");
const used = [...html.matchAll(/data-i18n="([^"]+)"/g)].map(match => match[1]);
const uniqueUsed = [...new Set(used)];
check(uniqueUsed.length > 0, "the markup uses data-i18n at all");
const missingZh = uniqueUsed.filter(key => !(key in zh));
const missingEn = uniqueUsed.filter(key => !(key in en));
check(missingZh.length === 0, "every key exists in lang-zh: " + (missingZh.join(", ") || "none missing"));
check(missingEn.length === 0, "every key exists in lang-en: " + (missingEn.join(", ") || "none missing"));
const zhKeys = Object.keys(zh).sort();
const enKeys = Object.keys(en).sort();
const onlyZh = zhKeys.filter(key => !(key in en));
const onlyEn = enKeys.filter(key => !(key in zh));
check(onlyZh.length === 0 && onlyEn.length === 0,
"the two language files carry the same keys" +
(onlyZh.length || onlyEn.length ? " (zh-only: " + onlyZh.join(",") + "; en-only: " + onlyEn.join(",") + ")" : ""));
const emptyZh = zhKeys.filter(key => !String(zh[key]).trim());
check(emptyZh.length === 0, "no empty values in lang-zh: " + (emptyZh.join(", ") || "none"));
/* ---- 2. the menu is there and wired ----------------------------------- */
console.log("\nupload menu markup");
check(/id="uploadBtn"[^>]*data-i18n="upload"/.test(html), "the upload button is present");
check(/id="uploadBtn"[^>]*aria-haspopup="menu"/.test(html) ||
/aria-haspopup="menu"[^>]*id="uploadBtn"/.test(html), "the button announces it opens a menu");
check(html.includes('id="uploadMenu"'), "the menu container is present");
check(/id="uploadMenu"[^>]*role="menu"/.test(html), "the menu has menu role");
check(/id="uploadMenu"[^>]*hidden/.test(html), "the menu starts hidden");
check(html.includes('id="uploadFilesItem"') && html.includes('data-i18n="uploadFiles"'),
"the file entry exists");
check(html.includes('id="uploadFolderItem"') && html.includes('data-i18n="uploadFolder"'),
"the folder entry exists");
check(!html.includes("split-arrow") && !html.includes("split-button"),
"the old main-button-plus-caret split is gone");
check(!css.includes("split-button") && !css.includes("split-arrow"),
"and so are its styles");
console.log("\ndrag hint");
check(html.includes('id="dropHint"') && html.includes('data-i18n="dropUploadHint"'),
"the drag hint element exists");
check(/id="dropHint"[^>]*remote-only/.test(html),
"the hint is remote-only, like the upload it advertises");
check(/id="dropHint"[^>]*hidden/.test(html), "the hint starts hidden");
check("dropUploadHint" in zh && zh.dropUploadHint.indexOf("拖") >= 0,
"the hint tells the user they can drag: " + zh.dropUploadHint);
console.log("\nextract button");
/* The extract entry used to be hidden until an archive was selected, so the
resting toolbar had no extract button at all. It now stays on screen and
only greys out, which means the markup must not hide it and main.js must
only ever disable it -- a stray `hidden = true` left behind would silently
restore the old behaviour on one code path. */
check(/id="extractBtn"[^>]*data-i18n="extract"/.test(html),
"the extract button carries the short toolbar label");
check(!/id="extractBtn"[^>]*\shidden/.test(html), "the extract button is not hidden in the markup");
check(!js.includes("extractBtn.hidden"), "main.js never hides it");
check(/extractBtn\.disabled\s*=\s*true/.test(js), "it is disabled when the selection cannot be extracted");
check(js.includes('t("extractSelectArchive")') && js.includes('t("extractOneAtATime")') &&
js.includes('t("extractSelectMainVolume")'), "each disabled reason is wired to a message");
check(/\.extract-action:disabled\s*\{[^}]*pointer-events:\s*auto/.test(css),
"a disabled extract button keeps its tooltip readable");
/* Keys reached only through t("...") in main.js are invisible to the markup
sweep above, which is exactly how a message can go missing unnoticed. */
const tKeys = [...new Set([...js.matchAll(/\bt\("([A-Za-z0-9_]+)"/g)].map(match => match[1]))];
const missingT = tKeys.filter(key => !(key in zh) || !(key in en));
check(missingT.length === 0,
"every t(\"...\") key in main.js exists in both languages (" + tKeys.length + " keys): " +
(missingT.join(", ") || "none missing"));
console.log("\nwiring in main.js");
check(js.includes('getElementById("uploadMenu")'), "main.js looks up the menu");
check(js.includes('const uploadFolderItemEl = document.getElementById("uploadFolderItem")'),
"main.js looks up the folder entry");
check(!js.includes("uploadFolderBtn"), "no reference to the removed folder button is left");
check(js.includes('uploadBtn.addEventListener("click", toggleUploadMenu)'),
"the button toggles the menu instead of opening a dialog");
check(js.includes('uploadFilesItemEl.addEventListener("click", actionUploadFiles)'),
"the file entry opens the file dialog");
check(js.includes('uploadFolderItemEl.addEventListener("click", actionUploadFolder)'),
"the folder entry opens the folder dialog");
check(js.includes("setupUploadMenu();"), "the menu behaviour is installed");
/* ---- 3. classes used by the markup exist in the stylesheet ------------- */
console.log("\nstylesheet");
for (const cls of ["upload-menu", "upload-menu-list", "status-hint"]) {
check(new RegExp("\\." + cls + "\\s*[,{]").test(css), "." + cls + " is styled");
}
check(/\.upload-menu-list\[hidden\]|\[hidden\]\s*\{\s*display:\s*none !important/.test(css),
"a hidden menu really is invisible");
/* The row highlight has to out-specify the generic button rules, so it must be
scoped to the panel id: the unscoped version tied on specificity (0,3,1)
with `button:not(.row-action):hover:not(:disabled)` and lost on source
order, which is why the menu's own hover fill never appeared. */
check(/#uploadMenu button:hover:not\(:disabled\)/.test(css),
"the menu row hover is scoped to the panel, not left to the generic rule");
check(/#uploadMenu button:focus\s*\{[^}]*outline:\s*none/.test(css),
"the toolbar's outer focus ring cannot land on a menu row");
check(/#uploadMenu button:focus-visible\s*\{[^}]*box-shadow:\s*inset/.test(css),
"the keyboard cue is drawn inside the row instead");
check(!/\.upload-menu-list button:focus/.test(css),
"no unscoped menu focus rule is left behind");
console.log("\n" + pass + " checks, " + fail + " failures");
process.exit(fail === 0 ? 0 : 1);
+15 -3
View File
@@ -6,9 +6,11 @@ gen/
web-file-mgr-linux
# Sandbox scratch under .build/: ignore the whole directory, then re-allow the
# handful of files that are actually part of the repo (build scripts + the
# ELF checker). A whitelist is the only thing that survives -- every debugging
# session drops a new probe directory in here.
# handful of files that are actually part of the repo -- the build scripts, the
# ELF checker, and the validation harnesses. A whitelist is the only thing that
# survives: every debugging session drops a new probe directory in here, and a
# scratch-tree cleanup would otherwise delete the harnesses for good (they exist
# nowhere else -- not in git, not on the release page).
.build/*
!.build/build-elf.sh
!.build/build-elf-wsl.sh
@@ -17,6 +19,16 @@ web-file-mgr-linux
!.build/extract-demo.html
!.build/build-elf.log
# Validation harnesses: headless-DOM unit tests against assets/main.js, the
# real-page preview server and its Chromium pass, and the README / comparison
# render checks. Run them before shipping a UI or docs change.
!.build/ui_*_test.mjs
!.build/preview_build.py
!.build/preview_stub.html
!.build/preview_check.mjs
!.build/readme_header_render.mjs
!.build/compare_*_check.mjs
# Generated 7z fixtures (tests/make_sevenz_fixtures.py rebuilds them).
tests/fixtures-7z/