feat(scanner): complete localized artifact quality checkpoint
This commit is contained in:
@@ -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;
|
||||
Reference in New Issue
Block a user