430 lines
20 KiB
JavaScript
430 lines
20 KiB
JavaScript
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: [],
|
|
registrations: new Map(), dictionaries: new Map(), binding: null,
|
|
bindingHooks: [], bindingHookIndex: 0, renderingBinding: false,
|
|
};
|
|
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 a host React component'); };
|
|
// This identity stands for the host's imported component. Never implement
|
|
// its DOM or styles here: native Switch visuals are verified in real DSH.
|
|
runtime.nativeSwitch = unexpectedRender;
|
|
const jsx = (type, props, key) => ({ type, props, key });
|
|
const useState = (initial) => {
|
|
if (!runtime.renderingBinding) return unexpectedRender();
|
|
const index = runtime.bindingHookIndex++;
|
|
if (!(index in runtime.bindingHooks)) runtime.bindingHooks[index] = initial;
|
|
return [runtime.bindingHooks[index], (next) => {
|
|
runtime.bindingHooks[index] = typeof next === 'function' ? next(runtime.bindingHooks[index]) : next;
|
|
}];
|
|
};
|
|
const useSyncExternalStore = (_subscribe, getSnapshot) => {
|
|
if (!runtime.renderingBinding) return unexpectedRender();
|
|
return getSnapshot();
|
|
};
|
|
window.__ModuleLoader__ = {
|
|
load({ id, factory }) {
|
|
runtime.moduleId = id;
|
|
runtime.module = factory((name) => {
|
|
if (name === 'react') return {
|
|
useEffect: unexpectedRender, useMemo: unexpectedRender,
|
|
useRef: unexpectedRender, useState,
|
|
useSyncExternalStore,
|
|
};
|
|
if (name === 'react/jsx-runtime') return { jsx, jsxs: jsx };
|
|
if (name === '@deepseek-ai/dsh-client-ui-primitives') return { Switch: runtime.nativeSwitch };
|
|
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(namespace, dictionaries) {
|
|
runtime.activeLocales += 1;
|
|
runtime.dictionaries.set(namespace, dictionaries);
|
|
return () => {
|
|
runtime.activeLocales -= 1;
|
|
runtime.dictionaries.delete(namespace);
|
|
};
|
|
},
|
|
},
|
|
slots: {
|
|
inject(name, callback) {
|
|
const cleanup = callback();
|
|
if (typeof cleanup === 'function') runtime.cleanups.push(cleanup);
|
|
},
|
|
register(definition, component) {
|
|
runtime.activeSlots += 1;
|
|
const props = definition.inject?.('fixture-session') ?? {};
|
|
const registration = { definition, component, props };
|
|
runtime.registrations.set(definition.id, registration);
|
|
if (definition.id === 'liang-intensity-control') {
|
|
runtime.registered = props;
|
|
runtime.scope = props.scope;
|
|
runtime.presenter = props.presenter;
|
|
} else if (definition.id === 'liang-effort-binding') {
|
|
runtime.binding = registration;
|
|
} else {
|
|
throw new Error(`Unexpected client slot: ${definition.id}`);
|
|
}
|
|
return () => {
|
|
runtime.activeSlots -= 1;
|
|
runtime.registrations.delete(definition.id);
|
|
};
|
|
},
|
|
},
|
|
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.renderBinding = (language = 'zh') => {
|
|
if (runtime.binding === null) throw new Error('The native binding row was not registered');
|
|
const { definition, component, props } = runtime.binding;
|
|
runtime.bindingHookIndex = 0;
|
|
runtime.renderingBinding = true;
|
|
try {
|
|
return component({ ...props, t: (key) => runtime.dictionaries.get(definition.locale)?.[language]?.[key] ?? key });
|
|
} finally {
|
|
runtime.renderingBinding = false;
|
|
}
|
|
};
|
|
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((fullyDisposed) => ({
|
|
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,
|
|
bindingVisible: !fullyDisposed && window.__runtime.renderBinding() !== null,
|
|
}), fullyDisposed);
|
|
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');
|
|
assert.equal(result.bindingVisible, false, 'The binding slot returns null while the skin is off');
|
|
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 assertNativeBinding(page) {
|
|
const result = await page.evaluate(async () => {
|
|
const runtime = window.__runtime;
|
|
const check = (condition, message) => { if (!condition) throw new Error(message); };
|
|
const flush = () => new Promise((resolve) => setTimeout(resolve, 0));
|
|
const binding = runtime.binding;
|
|
check(binding?.definition.name === 'settings.general.item'
|
|
&& binding.definition.id === 'liang-effort-binding'
|
|
&& binding.definition.order === 10.5, 'Binding uses the native General Settings slot after appearance');
|
|
check(binding.props.scope === runtime.scope, 'Binding and portrait share the real preference store');
|
|
const controls = (language = 'zh') => {
|
|
const tree = runtime.renderBinding(language);
|
|
const nodes = [];
|
|
const visit = (node) => {
|
|
if (Array.isArray(node)) return node.forEach(visit);
|
|
if (node === null || typeof node !== 'object') return;
|
|
nodes.push(node);
|
|
visit(node.props?.children);
|
|
};
|
|
visit(tree);
|
|
const switches = nodes.filter((node) => node.type === runtime.nativeSwitch);
|
|
check(switches.length === 1, 'Binding must reference the imported host Switch exactly once');
|
|
check(tree.type === 'div', 'The binding row is a non-interactive div');
|
|
check(nodes.every((node) => !['input', 'button', 'label'].includes(node.type)
|
|
&& node.props?.onClick === undefined && node.props?.htmlFor === undefined),
|
|
'No hand-drawn switch or whole-row/text click handler');
|
|
check(switches[0].props.className === undefined && switches[0].props.style === undefined,
|
|
'Native Switch receives no skin shape or color overrides');
|
|
const text = nodes.find((node) => node.type === 'span');
|
|
check(text?.props.children === switches[0].props.label, 'Visible text is the native Switch accessible name');
|
|
return { text, control: switches[0] };
|
|
};
|
|
let { text, control } = controls();
|
|
check(text.props.children === '滑动变祖绑定思考等级', 'Original Chinese binding label is preserved');
|
|
check(controls('en').text.props.children === 'Bind slider to reasoning level', 'English binding label comes from locale');
|
|
check(control.props.checked === true && control.props.disabled === false, 'Binding starts with persisted state');
|
|
text.props.onClick?.();
|
|
check(runtime.scope.getSnapshot().bindEffort === true, 'Text has no binding write callback');
|
|
control.props.onChange(false);
|
|
check(controls().control.props.disabled === true, 'Native Switch is disabled while saving');
|
|
await flush();
|
|
control = controls().control;
|
|
check(runtime.scope.getSnapshot().bindEffort === false && control.props.checked === false
|
|
&& control.props.disabled === false, 'Only Switch onChange writes the binding preference');
|
|
const setBindEffort = runtime.scope.setBindEffort;
|
|
let rejectWrite, writes = 0;
|
|
runtime.scope.setBindEffort = () => {
|
|
writes += 1;
|
|
return new Promise((_, reject) => { rejectWrite = reject; });
|
|
};
|
|
try {
|
|
control.props.onChange(true);
|
|
control = controls().control;
|
|
check(control.props.disabled === true, 'Pending write keeps the native control disabled');
|
|
control.props.onChange(true);
|
|
check(writes === 1, 'Pending change cannot enqueue a second write');
|
|
rejectWrite(new Error('fixture: binding write rejected'));
|
|
await flush();
|
|
control = controls().control;
|
|
check(control.props.checked === false && control.props.disabled === false,
|
|
'Failed write preserves accepted state and unlocks the native control');
|
|
} finally {
|
|
runtime.scope.setBindEffort = setBindEffort;
|
|
}
|
|
control.props.onChange(true);
|
|
await flush();
|
|
check(runtime.scope.getSnapshot().bindEffort === true, 'Binding remains usable after a failed write');
|
|
return { passed: true, slots: runtime.activeSlots };
|
|
});
|
|
assert.equal(result.passed, true);
|
|
assert.equal(result.slots, 2, 'Runtime registers both conversation and native binding slots');
|
|
}
|
|
|
|
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 assertNativeBinding(success.page);
|
|
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, native Switch binding contract, disable/enable, and disposal with late responses.');
|
|
} finally {
|
|
await browser.close();
|
|
}
|