mirror of
https://github.com/LisherSong/ps5-web-file-manager.git
synced 2026-10-06 08:00:23 +02:00
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:
1 parent
9d8c49a1d4
commit
edd1a1c177
9 files changed
+1177
-3
No files matched your search
@@ -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();
|
||||
@@ -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);
|
||||
@@ -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)")
|
||||
@@ -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);
|
||||
@@ -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>
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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
@@ -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/
|
||||
|
||||
|
||||
Reference in new issue
Block a user