forked from 27hrs/dsh-long-skin
fix: restore skin controls and desktop portrait compatibility
Respect host root layout, draw the scrim once, discover appearance controls without build hashes, and recover failed portraits independently. Add unit and real-browser regression coverage. Refs #8, #12, #15, #10, #13. Native Desktop acceptance remains pending; no release.
This commit is contained in:
280
scripts/test-client-compatibility.mjs
Normal file
280
scripts/test-client-compatibility.mjs
Normal file
@@ -0,0 +1,280 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { constants } from "node:fs";
|
||||
import { access, readFile } from "node:fs/promises";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { inflateSync } from "node:zlib";
|
||||
import { build } from "esbuild";
|
||||
import { chromium } from "playwright-core";
|
||||
|
||||
// Self-contained host contracts; native Desktop acceptance remains manual.
|
||||
const projectRoot = fileURLToPath(new URL("../", import.meta.url));
|
||||
const packageId = "@leon___/dsh-client-liang-intensity-skin";
|
||||
const viewport = { width: 1100, height: 700 };
|
||||
const css = await readFile(new URL("../src/client/skin.css", import.meta.url), "utf8");
|
||||
const compiled = await build({ absWorkingDir: projectRoot, entryPoints: ["src/client/appearance.ts"],
|
||||
bundle: true, write: false, format: "iife", globalName: "LiangAppearance", platform: "browser", target: "es2022" });
|
||||
|
||||
async function chromeExecutable() {
|
||||
const explicit = process.env.CHROME_PATH;
|
||||
const candidates = explicit ? [explicit] : [
|
||||
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
|
||||
"/Applications/Chromium.app/Contents/MacOS/Chromium",
|
||||
"/usr/bin/google-chrome", "/usr/bin/google-chrome-stable", "/usr/bin/chromium",
|
||||
"/usr/bin/chromium-browser", "/opt/google/chrome/chrome",
|
||||
];
|
||||
for (const candidate of candidates) {
|
||||
try { await access(candidate, constants.X_OK); return candidate; }
|
||||
catch { /* Use an existing local browser, never download one. */ }
|
||||
}
|
||||
throw new Error(`Chrome was not found. Set CHROME_PATH to an installed Chrome/Chromium executable. Checked: ${candidates.join(", ")}`);
|
||||
}
|
||||
|
||||
const portrait = `data:image/svg+xml,${encodeURIComponent('<svg xmlns="http://www.w3.org/2000/svg" width="10" height="10"><rect width="10" height="10" fill="rgb(32,208,96)"/></svg>')}`;
|
||||
function fixture({ desktop = false, top = 0, windows = false, mode = "compatibility" } = {}) {
|
||||
return `<!doctype html><html lang="zh-CN" ${desktop ? `data-platform="${windows ? "win32" : "darwin"}"` : ""} ${windows ? 'data-windows-titlebar=""' : ""}>
|
||||
<head><meta charset="utf-8"><style>
|
||||
* { box-sizing: border-box; transition: none !important; animation: none !important; }
|
||||
html, body { margin: 0; min-height: 100vh; }
|
||||
body { --dsw-alias-bg-base: rgb(240,244,248); --dsw-specific-sidebar-fill: rgb(11,29,49);
|
||||
--liang-page: rgb(240,244,248); --liang-bg-base: rgb(0 0 0 / 25%); --liang-portrait-opacity: 1;
|
||||
--liang-sidebar: rgb(11,29,49); --liang-ink: rgb(20,25,30); --liang-secondary: rgb(55,60,65);
|
||||
--liang-border: rgb(100,110,120); --liang-layer-1: white; --dsh-desktop-frame-height: ${top}px;
|
||||
background: rgb(240,244,248); }
|
||||
#root { position: ${desktop ? "fixed" : "static"}; ${desktop ? `inset: ${top}px 0 0;` : ""}
|
||||
height: ${desktop ? `calc(100vh - ${top}px)` : "100vh"}; z-index: auto; background: var(--dsw-alias-bg-base); }
|
||||
.fixture_frame { display: flex; height: 100%; ${windows ? "background: var(--dsw-specific-sidebar-fill);" : ""} }
|
||||
.fixture_sidebarCol { flex: 0 0 200px; background: var(--dsw-specific-sidebar-fill); }
|
||||
.fixture_centerCol { flex: 1; background: var(--dsw-alias-bg-base); }
|
||||
#caption { position: fixed; inset: 0 0 auto; height: ${top}px; background: rgb(90,40,80); }
|
||||
#portal { position: fixed; right: 30px; top: 85px; width: 150px; height: 50px; border: 0;
|
||||
background: rgb(140,60,180); color: white; }
|
||||
#appearance-host { position: absolute; top: ${top + 155}px; left: 225px; width: 620px; }
|
||||
.fixture_group { margin-bottom: 8px; } .fixture_group > div { display: flex; gap: 4px; }
|
||||
.native-choice { padding: 16px; flex: 1; }
|
||||
</style><style id="liang-styles">${css}</style></head>
|
||||
<body ${desktop ? `data-dsh-desktop-mode="${mode}"` : ""}>
|
||||
<div class="liang-skin-backdrop" data-media="sequence" aria-hidden="true"><img class="liang-skin-sequence-frame" alt="" src="${portrait}"></div>
|
||||
<div id="root"><div class="fixture_frame"><aside class="fixture_sidebarCol"></aside><main class="fixture_centerCol"></main></div></div>
|
||||
<div id="caption"></div><div id="appearance-host"></div><button id="portal" type="button">Native portal</button>
|
||||
</body></html>`;
|
||||
}
|
||||
|
||||
async function prepare(context, options = {}) {
|
||||
const page = await context.newPage();
|
||||
if (options.viewport) await page.setViewportSize(options.viewport);
|
||||
const errors = [];
|
||||
page.on("pageerror", error => errors.push(error.message));
|
||||
await page.setContent(fixture(options));
|
||||
await page.addScriptTag({ content: compiled.outputFiles[0].text });
|
||||
await page.evaluate(({ packageId, enabled }) => {
|
||||
let snapshot = { enabled, bindEffort: true };
|
||||
const listeners = new Set();
|
||||
const calls = [];
|
||||
let releaseChoice, holdNextChoice = false, failNextChoice = false, failNextBinding = false;
|
||||
let dispose = () => {};
|
||||
const publish = patch => {
|
||||
snapshot = { ...snapshot, ...patch };
|
||||
if (snapshot.enabled) document.body.dataset.liangSkin = "on";
|
||||
else delete document.body.dataset.liangSkin;
|
||||
for (const listener of listeners) listener();
|
||||
};
|
||||
const scope = {
|
||||
getSnapshot: () => snapshot,
|
||||
subscribe(listener) { listeners.add(listener); return () => listeners.delete(listener); },
|
||||
async setBindEffort(value) {
|
||||
calls.push(["binding", value]);
|
||||
if (failNextBinding) { failNextBinding = false; throw new Error("fixture write rejected"); }
|
||||
publish({ bindEffort: value });
|
||||
},
|
||||
};
|
||||
const presenter = {
|
||||
async choose(value) {
|
||||
calls.push(["choose", value]);
|
||||
if (holdNextChoice) { holdNextChoice = false; await new Promise(resolve => { releaseChoice = resolve; }); }
|
||||
if (failNextChoice) { failNextChoice = false; throw new Error("fixture choice rejected"); }
|
||||
publish({ enabled: value });
|
||||
},
|
||||
};
|
||||
const markup = hash => `<section class="fixture_group ${hash}_group extra-group"><div class="layout-row ${hash}_cubeRow extra-row" id="native-row">${["light", "dark", "system"].map(theme => `<button type="button" class="native-choice extra-button ${hash}_themeCube" data-theme="${theme}">${theme}</button>`).join("")}</div></section>`;
|
||||
const connectNative = () => {
|
||||
for (const button of document.querySelectorAll("#native-row > button[data-theme]")) {
|
||||
button.addEventListener("click", () => { window.fixture.nativeClicks++; window.fixture.nativeTheme = button.dataset.theme; });
|
||||
}
|
||||
};
|
||||
window.fixture = {
|
||||
calls, nativeClicks: 0, nativeTheme: null, portalClicks: 0,
|
||||
snapshot: () => ({ ...snapshot }), listenerCount: () => listeners.size,
|
||||
install(hash = "_8HJdBW") {
|
||||
document.querySelector("#appearance-host").innerHTML =
|
||||
'<section class="fixture_group other_group"><div class="other_cubeRow" id="unrelated"><button>one</button><button>two</button><button>three</button></div></section>' +
|
||||
'<section class="fixture_group nested_group"><div class="nested_cubeRow" id="nested-unrelated"><span><button class="nested_themeCube">one</button><button class="nested_themeCube">two</button><button class="nested_themeCube">three</button></span></div></section>' + markup(hash);
|
||||
connectNative();
|
||||
dispose = window.LiangAppearance.installLiangAppearanceButton(scope, presenter, packageId);
|
||||
},
|
||||
rebuild(hash = "_newHash42") {
|
||||
document.querySelector("#native-row").parentElement.outerHTML = markup(hash); connectNative();
|
||||
},
|
||||
removeRow() { document.querySelector("#native-row").parentElement.remove(); },
|
||||
restoreRow(hash = "_restored") {
|
||||
document.querySelector("#appearance-host").insertAdjacentHTML("beforeend", markup(hash)); connectNative();
|
||||
},
|
||||
dispose() { dispose(); }, setEnabled(value) { publish({ enabled: value }); },
|
||||
holdChoice() { holdNextChoice = true; }, releaseChoice() { releaseChoice?.(); },
|
||||
rejectChoice() { failNextChoice = true; }, rejectBinding() { failNextBinding = true; },
|
||||
};
|
||||
document.querySelector("#portal").addEventListener("click", () => window.fixture.portalClicks++);
|
||||
publish({});
|
||||
}, { packageId, enabled: options.enabled ?? true });
|
||||
await page.locator(".liang-skin-sequence-frame").evaluate(image => image.decode());
|
||||
return { page, errors };
|
||||
}
|
||||
|
||||
// For the first pixel of a 1x1 PNG, every PNG filter's predictors are zero.
|
||||
// Real rendered pixels catch opaque host layers that computed z-index misses.
|
||||
async function pixelAt(page, x, y) {
|
||||
const png = await page.screenshot({ clip: { x, y, width: 1, height: 1 }, animations: "disabled" });
|
||||
assert.equal(png.readUInt32BE(16), 1); assert.equal(png.readUInt32BE(20), 1);
|
||||
assert.equal(png[24], 8, "Expected an 8-bit screenshot");
|
||||
assert.ok(png[25] === 2 || png[25] === 6, "Expected an RGB/RGBA screenshot");
|
||||
const chunks = [];
|
||||
for (let offset = 8; offset < png.length;) {
|
||||
const length = png.readUInt32BE(offset);
|
||||
if (png.toString("ascii", offset + 4, offset + 8) === "IDAT") chunks.push(png.subarray(offset + 8, offset + 8 + length));
|
||||
offset += length + 12;
|
||||
}
|
||||
return [...inflateSync(Buffer.concat(chunks)).subarray(1, 4)];
|
||||
}
|
||||
function closeColor(actual, expected, label) {
|
||||
assert.ok(actual.every((channel, i) => Math.abs(channel - expected[i]) <= 1), `${label}: expected ${expected}, received ${actual}`);
|
||||
}
|
||||
|
||||
async function assertLayout(page, options, enabled = true) {
|
||||
const size = page.viewportSize();
|
||||
const result = await page.evaluate(() => {
|
||||
const root = document.querySelector("#root"), backdrop = document.querySelector(".liang-skin-backdrop");
|
||||
const box = element => { const rect = element.getBoundingClientRect(); return { x: rect.x, y: rect.y, width: rect.width, height: rect.height }; };
|
||||
return { root: box(root), rootPosition: getComputedStyle(root).position, rootZ: getComputedStyle(root).zIndex,
|
||||
backdrop: box(backdrop), portrait: box(backdrop.querySelector("img")), backdropZ: getComputedStyle(backdrop).zIndex, isolation: getComputedStyle(document.body).isolation,
|
||||
bgBase: getComputedStyle(document.body).getPropertyValue("--dsw-alias-bg-base").trim(),
|
||||
frameColor: getComputedStyle(document.querySelector(".fixture_frame")).backgroundColor };
|
||||
});
|
||||
assert.equal(result.rootPosition, options.desktop ? "fixed" : "static", "The host owns root positioning");
|
||||
assert.equal(result.rootZ, "auto", "The skin must not raise the root above native portals");
|
||||
const bounds = { x: 0, y: options.top ?? 0, width: size.width, height: size.height - (options.top ?? 0) };
|
||||
assert.deepEqual(result.root, bounds);
|
||||
if (enabled) {
|
||||
assert.equal(result.isolation, "isolate"); assert.equal(result.backdropZ, "-1");
|
||||
assert.equal(result.bgBase, "transparent", "Base surfaces must not apply a second portrait mask");
|
||||
assert.deepEqual(result.backdrop, bounds);
|
||||
assert.equal(result.portrait.height, bounds.height, "Portrait follows the background container height");
|
||||
closeColor(await pixelAt(page, size.width - 30, 430), [24, 156, 72], "Portrait visible with exactly one 25% mask");
|
||||
if (options.windows) assert.equal(result.frameColor, "rgba(0, 0, 0, 0)", "Only the Windows outer frame becomes transparent");
|
||||
} else {
|
||||
assert.equal(result.isolation, "auto", "Skin-off restores native stacking");
|
||||
assert.equal(result.bgBase, "rgb(240,244,248)");
|
||||
closeColor(await pixelAt(page, size.width - 30, 430), [240, 244, 248], "Skin-off native background");
|
||||
if (options.windows) assert.equal(result.frameColor, "rgb(11, 29, 49)");
|
||||
}
|
||||
closeColor(await pixelAt(page, 100, 430), [11, 29, 49], "Sidebar keeps its fill");
|
||||
if ((options.top ?? 0) > 0) closeColor(await pixelAt(page, 500, 5), [90, 40, 80], "Native titlebar remains painted");
|
||||
await page.locator("#portal").click();
|
||||
assert.equal(await page.evaluate(() => window.fixture.portalClicks), enabled ? 1 : 2, "Native body portal remains clickable");
|
||||
}
|
||||
|
||||
async function appearanceChecks(page, hash) {
|
||||
await page.evaluate(hash => window.fixture.install(hash), hash);
|
||||
const custom = page.locator("#native-row > .liang-appearance-choice"), binding = page.locator(".liang-appearance-binding__input");
|
||||
await custom.waitFor();
|
||||
assert.equal(await page.locator(".liang-appearance-choice").count(), 1);
|
||||
assert.equal(await page.locator("#unrelated .liang-appearance-choice, #nested-unrelated .liang-appearance-choice").count(), 0);
|
||||
assert.equal(await custom.getAttribute("data-plugin"), packageId);
|
||||
assert.equal(await custom.getAttribute("aria-pressed"), "true");
|
||||
await binding.uncheck();
|
||||
await page.waitForFunction(() => !window.fixture.snapshot().bindEffort);
|
||||
assert.equal(await binding.getAttribute("aria-checked"), "false");
|
||||
await binding.check(); await page.waitForFunction(() => window.fixture.snapshot().bindEffort);
|
||||
for (const theme of ["light", "dark", "system"]) {
|
||||
await page.locator(`[data-theme="${theme}"]`).click();
|
||||
await page.waitForFunction(() => !window.fixture.snapshot().enabled);
|
||||
assert.equal(await page.locator(".liang-appearance-binding").count(), 0);
|
||||
assert.equal(await custom.getAttribute("aria-pressed"), "false");
|
||||
assert.equal(await page.evaluate(() => window.fixture.nativeTheme), theme);
|
||||
await custom.click(); await page.waitForFunction(() => window.fixture.snapshot().enabled); await binding.waitFor();
|
||||
}
|
||||
assert.equal(await page.evaluate(() => window.fixture.nativeClicks), 3, "Native theme handlers still run");
|
||||
await page.evaluate(() => {
|
||||
window.oldNative = document.querySelector('[data-theme="light"]');
|
||||
window.oldCustom = document.querySelector(".liang-appearance-choice");
|
||||
window.oldBinding = document.querySelector(".liang-appearance-binding__input"); window.fixture.rebuild();
|
||||
});
|
||||
await page.locator("#native-row._newHash42_cubeRow > .liang-appearance-choice").waitFor();
|
||||
assert.equal(await page.locator(".liang-appearance-choice").count(), 1, "DOM reconstruction produces one control");
|
||||
const beforeStaleClicks = await page.evaluate(() => window.fixture.calls.length);
|
||||
await page.evaluate(() => { window.oldNative.click(); window.oldCustom.click(); window.oldBinding.dispatchEvent(new Event("change", { bubbles: true })); });
|
||||
assert.equal(await page.evaluate(() => window.fixture.calls.length), beforeStaleClicks, "Replaced nodes lose plugin listeners");
|
||||
await page.locator('[data-theme="dark"]').click(); await page.waitForFunction(() => !window.fixture.snapshot().enabled);
|
||||
await custom.click(); await page.waitForFunction(() => window.fixture.snapshot().enabled);
|
||||
await page.evaluate(() => window.fixture.removeRow());
|
||||
await page.waitForFunction(() => document.querySelector(".liang-appearance-choice") === null);
|
||||
await page.evaluate(() => window.fixture.restoreRow()); await custom.waitFor();
|
||||
await page.evaluate(() => {
|
||||
window.fixture.rejectBinding(); const input = document.querySelector(".liang-appearance-binding__input");
|
||||
input.checked = false; input.dispatchEvent(new Event("change", { bubbles: true }));
|
||||
});
|
||||
await page.waitForFunction(() => document.querySelector(".liang-appearance-binding__input").checked);
|
||||
await page.evaluate(() => window.fixture.rejectChoice()); await page.locator('[data-theme="light"]').click();
|
||||
await page.waitForFunction(() => !document.querySelector(".liang-appearance-choice").disabled);
|
||||
assert.equal(await custom.getAttribute("aria-pressed"), "true", "Rejected preference preserves skin state");
|
||||
await page.evaluate(() => window.fixture.holdChoice()); await page.locator('[data-theme="light"]').click();
|
||||
assert.equal(await custom.isDisabled(), true, "Outstanding choices disable injected controls");
|
||||
await page.evaluate(() => {
|
||||
window.oldCustom = document.querySelector(".liang-appearance-choice");
|
||||
window.fixture.dispose(); window.fixture.dispose(); window.fixture.releaseChoice();
|
||||
});
|
||||
await page.waitForFunction(() => !window.fixture.snapshot().enabled);
|
||||
assert.equal(await page.locator(".liang-appearance-choice, .liang-appearance-binding").count(), 0, "Late choice cannot resurrect disposed controls");
|
||||
assert.equal(await page.evaluate(() => window.fixture.listenerCount()), 0);
|
||||
const beforeDisposedClicks = await page.evaluate(() => window.fixture.calls.length);
|
||||
await page.evaluate(() => {
|
||||
window.oldCustom.click(); document.querySelector('[data-theme="dark"]').click();
|
||||
document.querySelector("#appearance-host").append(document.createElement("span"));
|
||||
});
|
||||
await page.evaluate(() => new Promise(resolve => requestAnimationFrame(resolve)));
|
||||
assert.equal(await page.evaluate(() => window.fixture.calls.length), beforeDisposedClicks);
|
||||
assert.equal(await page.locator(".liang-appearance-choice, .liang-appearance-binding").count(), 0);
|
||||
}
|
||||
|
||||
const browser = await chromium.launch({ executablePath: await chromeExecutable(), headless: true });
|
||||
try {
|
||||
const context = await browser.newContext({ viewport, reducedMotion: "reduce", deviceScaleFactor: 1 });
|
||||
await context.route("**/*", route => route.abort()); // No network needed by the fixture.
|
||||
const cases = [
|
||||
{ name: "Web static root" }, { name: "Desktop fixed top 0", desktop: true, top: 0 },
|
||||
{ name: "Windows fixed top 36", desktop: true, windows: true, top: 36 },
|
||||
{ name: "Windows extended top 40", desktop: true, windows: true, top: 40, mode: "extended" },
|
||||
{ name: "macOS extended top 36", desktop: true, top: 36, mode: "extended" },
|
||||
{ name: "macOS fixed top 40", desktop: true, top: 40 },
|
||||
{ name: "Compact Desktop", desktop: true, windows: true, top: 36, viewport: { width: 800, height: 600 } },
|
||||
{ name: "Large Web", viewport: { width: 1800, height: 1300 } },
|
||||
];
|
||||
for (const options of cases) {
|
||||
const { page, errors } = await prepare(context, options);
|
||||
await assertLayout(page, options); await page.evaluate(() => window.fixture.setEnabled(false));
|
||||
await assertLayout(page, options, false); assert.deepEqual(errors, [], `${options.name}: browser errors`);
|
||||
await page.close(); console.log(`PASS ${options.name}: backdrop, host geometry, native portal, skin off`);
|
||||
}
|
||||
for (const hash of ["_8HJdBW", "_currentHash89"]) {
|
||||
const { page, errors } = await prepare(context); await appearanceChecks(page, hash);
|
||||
assert.deepEqual(errors, [], `${hash}: browser errors`); await page.close();
|
||||
console.log(`PASS appearance ${hash}: multi-class discovery, choices, binding, DOM rebuild, disposal race`);
|
||||
}
|
||||
const first = await prepare(context), second = await prepare(context, { desktop: true, windows: true, top: 40 });
|
||||
await first.page.evaluate(() => window.fixture.install()); await second.page.evaluate(() => window.fixture.install("_otherWindow"));
|
||||
await first.page.locator('[data-theme="light"]').click(); await first.page.waitForFunction(() => !window.fixture.snapshot().enabled);
|
||||
assert.equal(await second.page.evaluate(() => window.fixture.snapshot().enabled), true);
|
||||
closeColor(await pixelAt(first.page, 1070, 430), [240, 244, 248], "First window skin off");
|
||||
closeColor(await pixelAt(second.page, 1070, 430), [24, 156, 72], "Second window remains visible");
|
||||
assert.deepEqual([...first.errors, ...second.errors], []); await context.close();
|
||||
console.log("PASS independent windows: host geometry and appearance state stay isolated");
|
||||
console.log("Client compatibility browser checks passed (11 scenarios). Native Desktop validation remains manual.");
|
||||
} finally { await browser.close(); }
|
||||
307
scripts/test-client-runtime.mjs
Normal file
307
scripts/test-client-runtime.mjs
Normal file
@@ -0,0 +1,307 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { chromium } from 'playwright-core';
|
||||
|
||||
const root = new URL('../', import.meta.url);
|
||||
const manifest = JSON.parse(readFileSync(new URL('package.json', root), 'utf8'));
|
||||
const assetPaths = manifest.files.filter((file) => /^assets\/portrait-source-v2\/[^/]+\.webp$/.test(file));
|
||||
assert.equal(assetPaths.length, 24, 'The published client must contain all 24 portrait anchors');
|
||||
const assets = new Map(assetPaths.map((file) => [file.split('/').at(-1), readFileSync(new URL(file, root))]));
|
||||
const client = readFileSync(new URL('lib/client.js', root), 'utf8');
|
||||
const imageSelector = '.liang-skin-backdrop[data-media="sequence"] > img';
|
||||
const browser = await chromium.launch({
|
||||
headless: true,
|
||||
...(process.env.CHROME_PATH
|
||||
? { executablePath: process.env.CHROME_PATH }
|
||||
: process.platform === 'darwin'
|
||||
? { executablePath: '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome' }
|
||||
: {}),
|
||||
});
|
||||
|
||||
function deferred() {
|
||||
let resolve;
|
||||
const promise = new Promise((done) => { resolve = done; });
|
||||
return { promise, resolve };
|
||||
}
|
||||
|
||||
async function bounded(promise, message) {
|
||||
let timer;
|
||||
try {
|
||||
return await Promise.race([
|
||||
promise,
|
||||
new Promise((_, reject) => { timer = setTimeout(() => reject(new Error(message)), 15000); }),
|
||||
]);
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
async function fixture({ fail = () => false, holdRetry = null } = {}) {
|
||||
const context = await browser.newContext({ viewport: { width: 1200, height: 900 } });
|
||||
const page = await context.newPage();
|
||||
const errors = [];
|
||||
const requests = [];
|
||||
const retriesReleased = deferred();
|
||||
let closing = false;
|
||||
let hold = null;
|
||||
page.on('pageerror', (error) => errors.push(error.message));
|
||||
await page.route('http://liang.test/**', async (route) => {
|
||||
const url = new URL(route.request().url());
|
||||
if (url.pathname === '/') {
|
||||
await route.fulfill({ contentType: 'text/html', body: '<!doctype html><html lang="zh-CN"><head></head><body><main id="root"><section class="current_group"><div class="current_cubeRow"><button type="button" class="current_themeCube">浅色</button><button type="button" class="current_themeCube">深色</button><button type="button" class="current_themeCube">跟随系统</button></div></section></main></body></html>' });
|
||||
return;
|
||||
}
|
||||
const match = url.pathname.match(/^\/plugins\/.+\/assets\/portrait-source-v2\/([^/]+\.webp)$/);
|
||||
assert.ok(match && assets.has(match[1]), `Unexpected runtime request: ${url.pathname}`);
|
||||
const name = match[1];
|
||||
const retry = Number(url.searchParams.get('retry') ?? 0);
|
||||
requests.push({ name, retry, url: url.href });
|
||||
const currentHold = hold;
|
||||
if (currentHold !== null) {
|
||||
currentHold.count += 1;
|
||||
if (currentHold.count === assets.size) currentHold.started.resolve();
|
||||
await currentHold.release.promise;
|
||||
}
|
||||
if (name === holdRetry && retry > 0) await retriesReleased.promise;
|
||||
const unavailable = fail(name, retry);
|
||||
try {
|
||||
await route.fulfill({
|
||||
status: unavailable ? 503 : 200,
|
||||
contentType: 'image/webp',
|
||||
headers: { 'cache-control': 'no-store' },
|
||||
body: unavailable ? Buffer.from('fixture: unavailable portrait') : assets.get(name),
|
||||
});
|
||||
} catch (error) {
|
||||
// Removing an Image's src during disposal may cancel its held request.
|
||||
if (!closing) throw error;
|
||||
} finally {
|
||||
if (currentHold !== null) {
|
||||
currentHold.finishedCount += 1;
|
||||
if (currentHold.finishedCount === assets.size) currentHold.finished.resolve();
|
||||
}
|
||||
}
|
||||
});
|
||||
await page.goto('http://liang.test/');
|
||||
await page.evaluate(() => {
|
||||
const runtime = {
|
||||
cleanups: [], decoded: new WeakSet(), decodedImages: [],
|
||||
registered: null, scope: null, presenter: null, module: null,
|
||||
activeLocales: 0, activeSlots: 0, errors: [],
|
||||
};
|
||||
window.__runtime = runtime;
|
||||
window.addEventListener('unhandledrejection', (event) => runtime.errors.push(String(event.reason)));
|
||||
// Observe successful native decodes without substituting Image, pixels,
|
||||
// timing, or the browser's decode result.
|
||||
const decode = HTMLImageElement.prototype.decode;
|
||||
HTMLImageElement.prototype.decode = function (...args) {
|
||||
return decode.apply(this, args).then((value) => {
|
||||
runtime.decoded.add(this);
|
||||
runtime.decodedImages.push(this);
|
||||
return value;
|
||||
});
|
||||
};
|
||||
const unexpectedRender = () => { throw new Error('The runtime fixture must not render React components'); };
|
||||
window.__ModuleLoader__ = {
|
||||
load({ id, factory }) {
|
||||
runtime.moduleId = id;
|
||||
runtime.module = factory((name) => {
|
||||
if (name === 'react') return {
|
||||
useEffect: unexpectedRender, useMemo: unexpectedRender,
|
||||
useRef: unexpectedRender, useState: unexpectedRender,
|
||||
useSyncExternalStore: unexpectedRender,
|
||||
};
|
||||
if (name === 'react/jsx-runtime') return { jsx: unexpectedRender, jsxs: unexpectedRender };
|
||||
throw new Error(`Unexpected client import: ${name}`);
|
||||
});
|
||||
},
|
||||
};
|
||||
});
|
||||
await page.addScriptTag({ content: client });
|
||||
await page.evaluate(() => {
|
||||
const runtime = window.__runtime;
|
||||
let preference = 'system';
|
||||
const theme = {
|
||||
getTheme: () => ({ preference }),
|
||||
setTheme: (next) => {
|
||||
preference = next;
|
||||
document.body.toggleAttribute('data-ds-dark-theme', next === 'dark');
|
||||
},
|
||||
};
|
||||
const ctx = {
|
||||
effect(setup) {
|
||||
const cleanup = setup();
|
||||
if (typeof cleanup === 'function') runtime.cleanups.push(cleanup);
|
||||
},
|
||||
get(name) {
|
||||
if (name === 'theme') return theme;
|
||||
throw new Error(`Unexpected host service: ${name}`);
|
||||
},
|
||||
locale: {
|
||||
register() {
|
||||
runtime.activeLocales += 1;
|
||||
return () => { runtime.activeLocales -= 1; };
|
||||
},
|
||||
},
|
||||
slots: {
|
||||
inject(name, callback) {
|
||||
const cleanup = callback();
|
||||
if (typeof cleanup === 'function') runtime.cleanups.push(cleanup);
|
||||
},
|
||||
register(definition) {
|
||||
runtime.activeSlots += 1;
|
||||
runtime.registered = definition.inject('fixture-session');
|
||||
runtime.scope = runtime.registered.scope;
|
||||
runtime.presenter = runtime.registered.presenter;
|
||||
return () => { runtime.activeSlots -= 1; };
|
||||
},
|
||||
},
|
||||
sessions: { subagentAddress: () => undefined },
|
||||
modelDirectories: {
|
||||
directoryFor: () => ({
|
||||
store: { getSnapshot: () => ({ current: null, groups: [], status: 'ready', error: null }), subscribe: () => () => {} },
|
||||
load: async () => {},
|
||||
select: async () => ({ ok: true }),
|
||||
}),
|
||||
},
|
||||
};
|
||||
if (typeof runtime.module?.apply !== 'function') throw new Error('Built client did not export apply(ctx)');
|
||||
runtime.module.apply(ctx);
|
||||
runtime.dispose = () => {
|
||||
for (const cleanup of runtime.cleanups.splice(0).reverse()) cleanup();
|
||||
};
|
||||
});
|
||||
assert.equal(await page.getAttribute('body', 'data-liang-skin'), 'on', 'apply(ctx) enables the real presenter');
|
||||
return {
|
||||
page, requests, errors,
|
||||
releaseRetries: () => retriesReleased.resolve(),
|
||||
holdImages() {
|
||||
hold = { count: 0, finishedCount: 0, started: deferred(), release: deferred(), finished: deferred() };
|
||||
return hold;
|
||||
},
|
||||
async dispose() {
|
||||
closing = true;
|
||||
await page.evaluate(() => window.__runtime.dispose());
|
||||
},
|
||||
async close() {
|
||||
closing = true;
|
||||
hold?.release.resolve();
|
||||
retriesReleased.resolve();
|
||||
await context.close();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function waitForPortrait(page, filename, retry = null) {
|
||||
await page.waitForFunction(({ selector, filename, retry }) => {
|
||||
const image = document.querySelector(selector);
|
||||
if (!(image instanceof HTMLImageElement) || !image.currentSrc || !image.complete || image.naturalWidth === 0) return false;
|
||||
const url = new URL(image.currentSrc);
|
||||
return url.pathname.endsWith(`/${filename}`)
|
||||
&& (retry === null || url.searchParams.get('retry') === String(retry))
|
||||
&& window.__runtime.decoded.has(image)
|
||||
&& getComputedStyle(image).display !== 'none'
|
||||
&& image.getBoundingClientRect().height > 0;
|
||||
}, { selector: imageSelector, filename, retry }, { timeout: 15000 });
|
||||
}
|
||||
|
||||
async function assertRemoved(page, fullyDisposed = false) {
|
||||
const result = await page.evaluate(() => ({
|
||||
backdrop: document.querySelectorAll('.liang-skin-backdrop').length,
|
||||
skin: document.body.hasAttribute('data-liang-skin'),
|
||||
stage: document.body.hasAttribute('data-liang-stage'),
|
||||
variables: [...document.body.style].filter((name) => name.startsWith('--liang-')),
|
||||
retainedSources: window.__runtime.decodedImages.filter((image) => image.hasAttribute('src')).length,
|
||||
styles: [...document.head.querySelectorAll('style')].filter((style) => style.dataset.plugin).length,
|
||||
controls: document.querySelectorAll('.liang-appearance-choice, .liang-appearance-binding').length,
|
||||
locales: window.__runtime.activeLocales,
|
||||
slots: window.__runtime.activeSlots,
|
||||
}));
|
||||
assert.equal(result.backdrop, 0, 'No portrait backdrop remains');
|
||||
assert.equal(result.skin, false, 'Skin marker is removed');
|
||||
assert.equal(result.stage, false, 'Stage marker is removed');
|
||||
assert.deepEqual(result.variables, [], 'All presenter CSS variables are removed');
|
||||
assert.equal(result.retainedSources, 0, 'Decoded images release their network sources');
|
||||
if (fullyDisposed) {
|
||||
assert.equal(result.styles, 0, 'Client stylesheet is removed');
|
||||
assert.equal(result.controls, 0, 'Appearance controls are removed');
|
||||
assert.equal(result.locales, 0, 'Locale registration is released');
|
||||
assert.equal(result.slots, 0, 'Slot registration is released');
|
||||
}
|
||||
}
|
||||
|
||||
async function assertNoErrors(test) {
|
||||
assert.deepEqual(test.errors, [], 'No browser runtime exception');
|
||||
assert.deepEqual(await test.page.evaluate(() => window.__runtime.errors), [], 'No unhandled runtime promise rejection');
|
||||
}
|
||||
|
||||
try {
|
||||
const success = await fixture();
|
||||
try {
|
||||
await success.page.waitForFunction(() => window.__runtime.decodedImages.length === 24, null, { timeout: 15000 });
|
||||
await waitForPortrait(success.page, 'stage-30.webp');
|
||||
assert.equal(success.requests.length, 24, 'Decoded display nodes do not start duplicate requests');
|
||||
assert.equal(new Set(success.requests.map(({ name }) => name)).size, 24, 'Every shipped anchor is requested');
|
||||
await success.page.evaluate(() => window.__runtime.presenter.setFrame(48));
|
||||
await waitForPortrait(success.page, 'stage-06.webp');
|
||||
await success.page.evaluate(() => window.__runtime.presenter.choose(false));
|
||||
await assertRemoved(success.page);
|
||||
await success.page.evaluate(() => window.__runtime.presenter.choose(true));
|
||||
await waitForPortrait(success.page, 'stage-06.webp');
|
||||
await success.page.evaluate(() => window.__runtime.presenter.choose(false));
|
||||
const held = success.holdImages();
|
||||
await success.page.evaluate(() => window.__runtime.presenter.choose(true));
|
||||
await bounded(held.started.promise, 'Reactivation did not launch all 24 real image requests');
|
||||
await success.page.evaluate(() => window.__runtime.presenter.setFrame(240));
|
||||
await success.dispose();
|
||||
held.release.resolve();
|
||||
await bounded(held.finished.promise, 'Held image responses did not finish');
|
||||
await success.page.evaluate(async () => {
|
||||
await window.__runtime.presenter.choose(true);
|
||||
window.__runtime.presenter.setFrame(0);
|
||||
window.__runtime.dispose();
|
||||
await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
|
||||
});
|
||||
await assertRemoved(success.page, true);
|
||||
await assertNoErrors(success);
|
||||
} finally {
|
||||
await success.close();
|
||||
}
|
||||
|
||||
const partial = await fixture({ fail: (name) => name === 'stage-12.webp' || name === 'level-01.webp' });
|
||||
try {
|
||||
await waitForPortrait(partial.page, 'stage-30.webp');
|
||||
const exhausted = partial.page.waitForResponse((response) => {
|
||||
const url = new URL(response.url());
|
||||
return url.pathname.endsWith('/stage-12.webp') && url.searchParams.get('retry') === '3';
|
||||
}, { timeout: 15000 });
|
||||
await partial.page.evaluate(() => window.__runtime.presenter.setFrame(96));
|
||||
await exhausted;
|
||||
await partial.page.evaluate(() => new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve))));
|
||||
await waitForPortrait(partial.page, 'stage-30.webp');
|
||||
assert.deepEqual(partial.requests.filter(({ name }) => name === 'stage-12.webp').map(({ retry }) => retry), [0, 1, 2, 3], 'A permanently failed target gets bounded cache-bypassing retries');
|
||||
await partial.dispose();
|
||||
await assertRemoved(partial.page, true);
|
||||
await assertNoErrors(partial);
|
||||
} finally {
|
||||
await partial.close();
|
||||
}
|
||||
|
||||
const recovery = await fixture({ fail: (name, retry) => name === 'stage-00.webp' && retry === 0, holdRetry: 'stage-00.webp' });
|
||||
try {
|
||||
await waitForPortrait(recovery.page, 'stage-30.webp');
|
||||
await recovery.page.evaluate(() => window.__runtime.presenter.setFrame(0));
|
||||
await recovery.page.evaluate(() => new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve))));
|
||||
await waitForPortrait(recovery.page, 'stage-30.webp');
|
||||
recovery.releaseRetries();
|
||||
await waitForPortrait(recovery.page, 'stage-00.webp', 1);
|
||||
assert.deepEqual(recovery.requests.filter(({ name }) => name === 'stage-00.webp').map(({ retry }) => retry), [0, 1], 'First-frame recovery retries the actual WebP request');
|
||||
await recovery.dispose();
|
||||
await assertRemoved(recovery.page, true);
|
||||
await assertNoErrors(recovery);
|
||||
} finally {
|
||||
await recovery.close();
|
||||
}
|
||||
console.log('PASS: built apply(ctx), 24 native WebP decodes, frame swaps, partial failure, first-frame retry, disable/enable, and disposal with late responses.');
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
Reference in New Issue
Block a user