feat(scanner): complete localized artifact quality checkpoint

This commit is contained in:
AzuTear
2026-07-11 15:59:19 +02:00
parent 639b0b7f59
commit 8b9f948c6b
215 changed files with 35440 additions and 7273 deletions
+210
View File
@@ -0,0 +1,210 @@
import fs from "node:fs/promises";
import path from "node:path";
const args = new Map(process.argv.slice(2).map((entry) => {
const [key, ...value] = entry.replace(/^--/, "").split("=");
return [key, value.join("=") || "true"];
}));
const port = Number(args.get("port") ?? 9223);
const outputPath = path.resolve(args.get("output") ?? "outputs/packaged-live/builds-functional-acceptance.json");
const screenshotPath = path.resolve(args.get("screenshot") ?? "outputs/packaged-live/builds-functional-acceptance.png");
if (!Number.isInteger(port) || port < 1 || port > 65535) {
throw new Error("--port must be an integer between 1 and 65535.");
}
const targets = await fetch(`http://127.0.0.1:${port}/json/list`).then((response) => response.json());
const target = targets.find((candidate) => (
candidate.type === "page"
&& String(candidate.title).includes("Genshin Artifact Assistant")
&& !String(candidate.url).includes("overlay=1")
));
if (!target?.webSocketDebuggerUrl || !String(target.url).startsWith("file:")) {
throw new Error("No packaged Genshin Artifact Assistant renderer with CDP was found.");
}
const socket = new WebSocket(target.webSocketDebuggerUrl);
await new Promise((resolve, reject) => {
const timer = setTimeout(() => reject(new Error("Timed out connecting to packaged Electron CDP.")), 10_000);
socket.addEventListener("open", () => {
clearTimeout(timer);
resolve();
}, { once: true });
socket.addEventListener("error", () => {
clearTimeout(timer);
reject(new Error("Packaged Electron CDP connection failed."));
}, { once: true });
});
let nextId = 0;
const pending = new Map();
const diagnostics = { exceptions: [], consoleErrors: [], logErrors: [] };
socket.addEventListener("message", (event) => {
let message;
try {
message = JSON.parse(String(event.data));
} catch {
return;
}
if (message.method === "Runtime.exceptionThrown") {
diagnostics.exceptions.push(message.params?.exceptionDetails?.exception?.description ?? message.params?.exceptionDetails?.text ?? "Unknown renderer exception");
} else if (message.method === "Runtime.consoleAPICalled" && message.params?.type === "error") {
diagnostics.consoleErrors.push((message.params.args ?? []).map((entry) => entry.value ?? entry.description ?? entry.type).join(" "));
} else if (message.method === "Log.entryAdded" && message.params?.entry?.level === "error") {
diagnostics.logErrors.push(message.params.entry.text ?? "Unknown renderer log error");
}
if (!message.id || !pending.has(message.id)) return;
const request = pending.get(message.id);
pending.delete(message.id);
clearTimeout(request.timer);
if (message.error) request.reject(new Error(JSON.stringify(message.error)));
else request.resolve(message.result);
});
function call(method, params = {}, timeoutMs = 30_000) {
const id = ++nextId;
return new Promise((resolve, reject) => {
const timer = setTimeout(() => {
pending.delete(id);
reject(new Error(`CDP ${method} timed out after ${timeoutMs} ms.`));
}, timeoutMs);
pending.set(id, { resolve, reject, timer });
socket.send(JSON.stringify({ id, method, params }));
});
}
async function evaluate(expression, timeoutMs = 30_000) {
const response = await call("Runtime.evaluate", { expression, awaitPromise: true, returnByValue: true }, timeoutMs);
if (response.exceptionDetails) {
throw new Error(response.exceptionDetails.exception?.description ?? response.exceptionDetails.text ?? "Renderer evaluation failed.");
}
return response.result?.value;
}
await call("Page.bringToFront", {}, 10_000);
await call("Runtime.enable", {}, 10_000);
await call("Log.enable", {}, 10_000);
await call("Emulation.setDeviceMetricsOverride", {
width: 1304,
height: 821,
deviceScaleFactor: 1,
mobile: false,
}, 10_000);
await call("Emulation.setEmulatedMedia", {
features: [{ name: "prefers-reduced-motion", value: "reduce" }],
}, 10_000);
await evaluate("localStorage.removeItem('gaa-ui-locale'); 'locale-cleared'", 10_000);
await call("Page.reload", { ignoreCache: true }, 10_000);
await new Promise((resolve) => setTimeout(resolve, 350));
const acceptance = await evaluate(`
(async () => {
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
const waitFor = async (selector, timeout = 10_000) => {
const deadline = Date.now() + timeout;
while (Date.now() < deadline) {
const element = document.querySelector(selector);
if (element) return element;
await sleep(50);
}
throw new Error('Timed out waiting for ' + selector);
};
const api = window.assistantApi;
if (!api) throw new Error('Packaged preload bridge is unavailable.');
const initialLocale = document.documentElement.lang;
if (initialLocale !== 'en' || document.documentElement.dataset.appLocale !== 'en') {
throw new Error('Fresh packaged UI did not default to English.');
}
const settingsTrigger = await waitFor('[data-app-settings-trigger]');
settingsTrigger.click();
const languageSelect = await waitFor('[data-app-language-select]');
languageSelect.value = 'de';
languageSelect.dispatchEvent(new Event('change', { bubbles: true }));
await sleep(100);
const germanLocaleApplied = document.documentElement.lang === 'de'
&& document.querySelector('[data-navigation-id="inventory"]')?.textContent?.trim() === 'Artefakte';
languageSelect.value = 'en';
languageSelect.dispatchEvent(new Event('change', { bubbles: true }));
await sleep(100);
const englishLocaleRestored = document.documentElement.lang === 'en'
&& document.querySelector('[data-navigation-id="inventory"]')?.textContent?.trim() === 'Artifacts';
document.querySelector('[data-app-settings-backdrop]')?.dispatchEvent(new MouseEvent('click', { bubbles: true }));
const buildsNavigation = await waitFor('[data-navigation-id="builds"]');
buildsNavigation.focus();
const navigationFocusBeforeOpen = document.activeElement === buildsNavigation;
buildsNavigation.click();
const view = await waitFor('.build-preview-page');
await sleep(250);
const headingFocusAfterOpen = document.activeElement?.id === 'app-view-heading';
const results = await api.nativeScannerLoadResults({ limit: 2400 });
const contextInput = document.querySelector('.build-context-number input');
const contextCheckbox = document.querySelector('.build-context-confirm input[type="checkbox"]');
let contextInteraction = { available: false, accepted: false };
if (contextInput instanceof HTMLInputElement && contextCheckbox instanceof HTMLInputElement) {
// React tracks controlled input values on the instance. Assigning the
// property directly makes the browser value change, but React can
// restore the old draft on the next checkbox render. Use the native
// prototype setter, then dispatch a real input event so this CDP probe
// exercises the same state transition as a typed value.
const nativeValueSetter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')?.set;
nativeValueSetter?.call(contextInput, '106.6');
contextInput.dispatchEvent(new Event('input', { bubbles: true }));
await sleep(0);
contextCheckbox.click();
await sleep(100);
contextInteraction = { available: true, accepted: contextInput.value === '106.6' && contextCheckbox.checked };
}
const viewStyle = getComputedStyle(view);
const reducedMotionRespected = viewStyle.animationDuration === '0s'
|| viewStyle.animationDuration === '0ms'
|| matchMedia('(prefers-reduced-motion: reduce)').matches;
const noDocumentOverflow = document.documentElement.scrollWidth <= document.documentElement.clientWidth
&& document.documentElement.scrollHeight <= document.documentElement.clientHeight;
return {
ok: Boolean(
results.ok
&& germanLocaleApplied
&& englishLocaleRestored
&& navigationFocusBeforeOpen
&& headingFocusAfterOpen
&& contextInteraction.accepted
&& reducedMotionRespected
&& noDocumentOverflow
),
locale: { initial: initialLocale, germanLocaleApplied, englishLocaleRestored },
focus: { navigationFocusBeforeOpen, headingFocusAfterOpen },
contextInteraction,
results: { ok: results.ok, runDir: results.runDir, total: results.total, loaded: results.results.length, error: results.error ?? '' },
rendered: {
bodyText: view.textContent?.slice(0, 6_000) ?? '',
hasSuggestions: Boolean(document.querySelector('.build-suggestion-card')),
deferredProfiles: document.querySelectorAll('.build-deferred-list li').length,
overflow: { width: document.documentElement.scrollWidth, clientWidth: document.documentElement.clientWidth, height: document.documentElement.scrollHeight, clientHeight: document.documentElement.clientHeight },
reducedMotionRespected,
},
};
})()
`);
const screenshot = await call("Page.captureScreenshot", { format: "png", captureBeyondViewport: false }, 30_000);
await fs.mkdir(path.dirname(outputPath), { recursive: true });
await fs.writeFile(screenshotPath, Buffer.from(screenshot.data, "base64"));
const report = {
version: "packaged-builds-functional-acceptance-v1",
createdAt: new Date().toISOString(),
target: { title: target.title, url: target.url },
acceptance,
diagnostics,
screenshotPath,
ok: Boolean(acceptance?.ok) && diagnostics.exceptions.length === 0 && diagnostics.consoleErrors.length === 0 && diagnostics.logErrors.length === 0,
};
await fs.writeFile(outputPath, `${JSON.stringify(report, null, 2)}\n`, "utf8");
socket.close();
console.log(`Packaged Builds acceptance: ${report.ok ? "PASS" : "FAIL"}`);
console.log(`Report: ${outputPath}`);
console.log(`Screenshot: ${screenshotPath}`);
if (!report.ok) process.exitCode = 1;