211 lines
9.7 KiB
JavaScript
211 lines
9.7 KiB
JavaScript
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;
|