feat(scanner): complete localized artifact quality checkpoint
This commit is contained in:
@@ -0,0 +1,165 @@
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { parseArtifactCandidate } from "../src/lib/artifactOcrParser.js";
|
||||
import { artifactReviewReasons } from "../src/lib/scannerLearning.js";
|
||||
import type { CaptureResult } from "../src/types/global.js";
|
||||
|
||||
type ProcessingOcrEntry = NonNullable<CaptureResult["ocr"]>[number];
|
||||
|
||||
type ProcessingResult = {
|
||||
sequence: number;
|
||||
page?: number;
|
||||
capturedAt?: string;
|
||||
parsed?: boolean;
|
||||
needsReview?: boolean;
|
||||
artifactName?: string;
|
||||
slot?: string;
|
||||
setName?: string;
|
||||
ikMatch?: { matched?: boolean; notes?: string[] };
|
||||
notes?: string[];
|
||||
ocr?: ProcessingOcrEntry[];
|
||||
};
|
||||
|
||||
type ProcessingReport = {
|
||||
results?: ProcessingResult[];
|
||||
};
|
||||
|
||||
const options = parseArgs(process.argv.slice(2));
|
||||
const scanRoot = path.join(
|
||||
process.env.APPDATA || path.join(os.homedir(), "AppData", "Roaming"),
|
||||
"genshin-artifact-assistant",
|
||||
"native-scans",
|
||||
);
|
||||
const runDir = options.runDir ? path.resolve(options.runDir) : await latestRunDir(scanRoot);
|
||||
const sourcePath = path.join(runDir, "processing-report.json");
|
||||
const source = JSON.parse(await fs.readFile(sourcePath, "utf8")) as ProcessingReport;
|
||||
const sourceResults = Array.isArray(source.results) ? source.results : [];
|
||||
if (sourceResults.length === 0) throw new Error(`No processing results found in ${sourcePath}`);
|
||||
|
||||
const analyzed = sourceResults.map((sourceResult) => {
|
||||
const parsed = parseArtifactCandidate(captureFromProcessingResult(sourceResult));
|
||||
const reasons = artifactReviewReasons(parsed);
|
||||
if (sourceResult.ikMatch && !sourceResult.ikMatch.matched) reasons.push("ik_mismatch");
|
||||
const uniqueReasons = [...new Set(reasons)];
|
||||
const needsReview = uniqueReasons.length > 0;
|
||||
const oldNeedsReview = Boolean(sourceResult.needsReview);
|
||||
const repairedInitialMainValue = Boolean(
|
||||
parsed
|
||||
&& parsed.level === 0
|
||||
&& parsed.substats.length === 4
|
||||
&& parsed.fields.mainValue.source === "derived"
|
||||
&& parsed.fields.mainValue.confidence >= 90
|
||||
&& sourceResult.notes?.some((note) => /mainValue confidence is low/i.test(note)),
|
||||
);
|
||||
return {
|
||||
sequence: sourceResult.sequence,
|
||||
page: sourceResult.page ?? 0,
|
||||
oldNeedsReview,
|
||||
needsReview,
|
||||
reasons: uniqueReasons,
|
||||
level: parsed?.level ?? null,
|
||||
artifactName: parsed?.name ?? "Unknown artifact",
|
||||
slot: parsed?.slot ?? "Unknown slot",
|
||||
mainStat: parsed?.mainStat ?? "Unknown main stat",
|
||||
mainValue: parsed?.mainValue ?? "?",
|
||||
substatCount: parsed?.substats.length ?? 0,
|
||||
extractionConfidence: parsed?.confidence ?? 0,
|
||||
repairedInitialMainValue,
|
||||
};
|
||||
});
|
||||
|
||||
const review = analyzed.filter((entry) => entry.needsReview);
|
||||
const report = {
|
||||
version: "native-review-analysis-v1",
|
||||
createdAt: new Date().toISOString(),
|
||||
runId: path.basename(runDir),
|
||||
runDir,
|
||||
sourcePath,
|
||||
total: analyzed.length,
|
||||
parsed: analyzed.filter((entry) => entry.artifactName !== "Unknown artifact").length,
|
||||
oldReview: analyzed.filter((entry) => entry.oldNeedsReview).length,
|
||||
review: review.length,
|
||||
reviewRate: review.length / analyzed.length,
|
||||
reclassifiedToClean: analyzed.filter((entry) => entry.oldNeedsReview && !entry.needsReview).length,
|
||||
reclassifiedToReview: analyzed.filter((entry) => !entry.oldNeedsReview && entry.needsReview).length,
|
||||
repairedInitialMainValues: analyzed.filter((entry) => entry.repairedInitialMainValue).length,
|
||||
reasons: groupStrings(review.flatMap((entry) => entry.reasons)),
|
||||
levels: groupStrings(review.map((entry) => String(entry.level ?? "missing"))),
|
||||
pageBands: groupStrings(review.map((entry) => pageBand(entry.page))),
|
||||
remainingSamples: review.slice(0, 25),
|
||||
};
|
||||
|
||||
const outputDir = options.outputDir
|
||||
? path.resolve(options.outputDir)
|
||||
: path.resolve("outputs", "native-review-analysis", report.runId);
|
||||
await fs.mkdir(outputDir, { recursive: true });
|
||||
const reportPath = path.join(outputDir, "native-review-analysis.json");
|
||||
await fs.writeFile(reportPath, JSON.stringify(report, null, 2), "utf8");
|
||||
|
||||
console.log(`Native review analysis: ${report.runId}`);
|
||||
console.log(`Results: ${report.total}; old review=${report.oldReview}; current review=${report.review} (${(report.reviewRate * 100).toFixed(2)}%)`);
|
||||
console.log(`Reclassified: clean=${report.reclassifiedToClean}; review=${report.reclassifiedToReview}; initial-main repairs=${report.repairedInitialMainValues}`);
|
||||
console.log(`Top reasons: ${report.reasons.slice(0, 8).map((entry) => `${entry.name}=${entry.count}`).join(", ") || "none"}`);
|
||||
console.log(`Report: ${reportPath}`);
|
||||
|
||||
if (options.maxReviewRate !== null && report.reviewRate > options.maxReviewRate) {
|
||||
throw new Error(`Review rate ${(report.reviewRate * 100).toFixed(2)}% exceeds ${(options.maxReviewRate * 100).toFixed(2)}%.`);
|
||||
}
|
||||
|
||||
function captureFromProcessingResult(result: ProcessingResult): CaptureResult {
|
||||
return {
|
||||
id: `native-review-${result.sequence}`,
|
||||
name: result.artifactName || `native-review-${result.sequence}`,
|
||||
width: 492,
|
||||
height: 838,
|
||||
dataUrl: "",
|
||||
capturedAt: result.capturedAt || new Date(0).toISOString(),
|
||||
captureTarget: "genshin-client",
|
||||
ocr: Array.isArray(result.ocr) ? result.ocr : [],
|
||||
};
|
||||
}
|
||||
|
||||
function groupStrings(values: string[]) {
|
||||
const counts = new Map<string, number>();
|
||||
for (const value of values) counts.set(value, (counts.get(value) ?? 0) + 1);
|
||||
return [...counts.entries()]
|
||||
.map(([name, count]) => ({ name, count }))
|
||||
.sort((left, right) => right.count - left.count || left.name.localeCompare(right.name));
|
||||
}
|
||||
|
||||
function pageBand(page: number) {
|
||||
if (!Number.isFinite(page) || page < 1) return "missing";
|
||||
const start = Math.floor((page - 1) / 5) * 5 + 1;
|
||||
return `${String(start).padStart(2, "0")}-${String(start + 4).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
function parseArgs(args: string[]) {
|
||||
const runDir = valueArg(args, "--run-dir=");
|
||||
const outputDir = valueArg(args, "--output-dir=");
|
||||
const rawMaximum = valueArg(args, "--max-review-rate=");
|
||||
const parsedMaximum = rawMaximum ? Number(rawMaximum) : Number.NaN;
|
||||
const maxReviewRate = Number.isFinite(parsedMaximum)
|
||||
? Math.max(0, Math.min(1, parsedMaximum))
|
||||
: null;
|
||||
return { runDir, outputDir, maxReviewRate };
|
||||
}
|
||||
|
||||
function valueArg(args: string[], prefix: string) {
|
||||
return args.find((argument) => argument.startsWith(prefix))?.slice(prefix.length) ?? "";
|
||||
}
|
||||
|
||||
async function latestRunDir(root: string) {
|
||||
const entries = await fs.readdir(root, { withFileTypes: true });
|
||||
const candidates = entries.filter((entry) => entry.isDirectory()).map((entry) => entry.name).sort().reverse();
|
||||
for (const candidate of candidates) {
|
||||
const runDir = path.join(root, candidate);
|
||||
try {
|
||||
await fs.access(path.join(runDir, "processing-report.json"));
|
||||
return runDir;
|
||||
} catch {
|
||||
// Continue; this is a read-only saved-run analysis.
|
||||
}
|
||||
}
|
||||
throw new Error(`No processing-report.json found below ${root}`);
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[int]$ProcessId,
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$OutputPath,
|
||||
[int]$ClickX = -1,
|
||||
[int]$ClickY = -1,
|
||||
[int]$WaitAfterClickMs = 700,
|
||||
[switch]$CloseAfterCapture
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
trap {
|
||||
$errorPath = [IO.Path]::GetFullPath("$OutputPath.error.txt")
|
||||
New-Item -ItemType Directory -Force -Path (Split-Path -Parent $errorPath) | Out-Null
|
||||
[string]$_.Exception.Message | Set-Content -LiteralPath $errorPath -Encoding UTF8
|
||||
exit 1
|
||||
}
|
||||
|
||||
Add-Type -TypeDefinition @'
|
||||
using System;
|
||||
using System.Runtime.InteropServices;
|
||||
public static class WindowCaptureNative {
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
public struct RECT { public int Left; public int Top; public int Right; public int Bottom; }
|
||||
[DllImport("user32.dll")] public static extern bool GetWindowRect(IntPtr hWnd, out RECT rect);
|
||||
[DllImport("user32.dll")] public static extern bool SetForegroundWindow(IntPtr hWnd);
|
||||
[DllImport("user32.dll")] public static extern bool ShowWindowAsync(IntPtr hWnd, int command);
|
||||
[DllImport("user32.dll")] public static extern bool PrintWindow(IntPtr hWnd, IntPtr hdcBlt, uint flags);
|
||||
[DllImport("user32.dll")] public static extern IntPtr GetForegroundWindow();
|
||||
[DllImport("user32.dll")] public static extern bool SetCursorPos(int x, int y);
|
||||
[DllImport("user32.dll")] public static extern void mouse_event(uint flags, uint dx, uint dy, uint data, UIntPtr extraInfo);
|
||||
[DllImport("user32.dll")] public static extern bool PostMessage(IntPtr hWnd, uint message, UIntPtr wParam, IntPtr lParam);
|
||||
}
|
||||
'@
|
||||
Add-Type -AssemblyName System.Drawing
|
||||
|
||||
$process = Get-Process -Id $ProcessId -ErrorAction Stop
|
||||
$handle = [IntPtr]$process.MainWindowHandle
|
||||
if ($handle -eq [IntPtr]::Zero) { throw "Process $ProcessId has no main window." }
|
||||
|
||||
[WindowCaptureNative]::ShowWindowAsync($handle, 9) | Out-Null
|
||||
[WindowCaptureNative]::SetForegroundWindow($handle) | Out-Null
|
||||
Start-Sleep -Milliseconds 700
|
||||
|
||||
$rect = New-Object WindowCaptureNative+RECT
|
||||
if (-not [WindowCaptureNative]::GetWindowRect($handle, [ref]$rect)) { throw "GetWindowRect failed." }
|
||||
$width = $rect.Right - $rect.Left
|
||||
$height = $rect.Bottom - $rect.Top
|
||||
if ($width -lt 1 -or $height -lt 1) { throw "Window dimensions are invalid: ${width}x${height}." }
|
||||
|
||||
$clicked = $false
|
||||
if ($ClickX -ge 0 -or $ClickY -ge 0) {
|
||||
if ($ClickX -lt 0 -or $ClickY -lt 0 -or $ClickX -ge $width -or $ClickY -ge $height) {
|
||||
throw "Click coordinates must stay inside the target window."
|
||||
}
|
||||
if ([WindowCaptureNative]::GetForegroundWindow() -ne $handle) {
|
||||
throw "Target process is not foreground; no click was sent."
|
||||
}
|
||||
[WindowCaptureNative]::SetCursorPos($rect.Left + $ClickX, $rect.Top + $ClickY) | Out-Null
|
||||
[WindowCaptureNative]::mouse_event(0x0002, 0, 0, 0, [UIntPtr]::Zero)
|
||||
[WindowCaptureNative]::mouse_event(0x0004, 0, 0, 0, [UIntPtr]::Zero)
|
||||
$clicked = $true
|
||||
Start-Sleep -Milliseconds ([Math]::Max(0, $WaitAfterClickMs))
|
||||
}
|
||||
|
||||
$resolvedOutput = [IO.Path]::GetFullPath($OutputPath)
|
||||
$outputDirectory = Split-Path -Parent $resolvedOutput
|
||||
New-Item -ItemType Directory -Force -Path $outputDirectory | Out-Null
|
||||
$bitmap = New-Object System.Drawing.Bitmap $width, $height
|
||||
$graphics = [System.Drawing.Graphics]::FromImage($bitmap)
|
||||
$printed = $false
|
||||
try {
|
||||
$hdc = $graphics.GetHdc()
|
||||
try {
|
||||
$printed = [WindowCaptureNative]::PrintWindow($handle, $hdc, 2)
|
||||
} finally {
|
||||
$graphics.ReleaseHdc($hdc)
|
||||
}
|
||||
if (-not $printed) {
|
||||
$graphics.CopyFromScreen($rect.Left, $rect.Top, 0, 0, $bitmap.Size)
|
||||
}
|
||||
$bitmap.Save($resolvedOutput, [System.Drawing.Imaging.ImageFormat]::Png)
|
||||
} finally {
|
||||
$graphics.Dispose()
|
||||
$bitmap.Dispose()
|
||||
}
|
||||
|
||||
$closeRequested = $false
|
||||
if ($CloseAfterCapture) {
|
||||
$closeRequested = [WindowCaptureNative]::PostMessage($handle, 0x0010, [UIntPtr]::Zero, [IntPtr]::Zero)
|
||||
}
|
||||
|
||||
[pscustomobject]@{
|
||||
ok = $true
|
||||
processId = $ProcessId
|
||||
title = $process.MainWindowTitle
|
||||
width = $width
|
||||
height = $height
|
||||
printWindow = $printed
|
||||
clicked = $clicked
|
||||
closeRequested = $closeRequested
|
||||
outputPath = $resolvedOutput
|
||||
} | ConvertTo-Json
|
||||
@@ -0,0 +1,97 @@
|
||||
param(
|
||||
[string]$OutputPath = "build\icon.png"
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
Add-Type -AssemblyName System.Drawing
|
||||
|
||||
$resolvedOutput = [IO.Path]::GetFullPath($OutputPath)
|
||||
New-Item -ItemType Directory -Force -Path (Split-Path -Parent $resolvedOutput) | Out-Null
|
||||
|
||||
$size = 512
|
||||
$bitmap = New-Object System.Drawing.Bitmap $size, $size
|
||||
$graphics = [System.Drawing.Graphics]::FromImage($bitmap)
|
||||
$graphics.SmoothingMode = [System.Drawing.Drawing2D.SmoothingMode]::AntiAlias
|
||||
$graphics.CompositingQuality = [System.Drawing.Drawing2D.CompositingQuality]::HighQuality
|
||||
$graphics.Clear([System.Drawing.Color]::Transparent)
|
||||
|
||||
function New-RoundedRectanglePath([System.Drawing.RectangleF]$rectangle, [float]$radius) {
|
||||
$diameter = $radius * 2
|
||||
$path = New-Object System.Drawing.Drawing2D.GraphicsPath
|
||||
$path.AddArc($rectangle.X, $rectangle.Y, $diameter, $diameter, 180, 90)
|
||||
$path.AddArc($rectangle.Right - $diameter, $rectangle.Y, $diameter, $diameter, 270, 90)
|
||||
$path.AddArc($rectangle.Right - $diameter, $rectangle.Bottom - $diameter, $diameter, $diameter, 0, 90)
|
||||
$path.AddArc($rectangle.X, $rectangle.Bottom - $diameter, $diameter, $diameter, 90, 90)
|
||||
$path.CloseFigure()
|
||||
return $path
|
||||
}
|
||||
|
||||
$bounds = New-Object System.Drawing.RectangleF 22, 22, 468, 468
|
||||
$shape = New-RoundedRectanglePath $bounds 104
|
||||
$background = New-Object System.Drawing.Drawing2D.LinearGradientBrush(
|
||||
$bounds,
|
||||
[System.Drawing.Color]::FromArgb(255, 29, 17, 59),
|
||||
[System.Drawing.Color]::FromArgb(255, 33, 91, 125),
|
||||
135
|
||||
)
|
||||
$graphics.FillPath($background, $shape)
|
||||
|
||||
$glow = New-Object System.Drawing.SolidBrush ([System.Drawing.Color]::FromArgb(42, 157, 112, 255))
|
||||
$graphics.FillEllipse($glow, 66, 44, 330, 330)
|
||||
$cyanGlow = New-Object System.Drawing.SolidBrush ([System.Drawing.Color]::FromArgb(34, 82, 229, 242))
|
||||
$graphics.FillEllipse($cyanGlow, 206, 196, 262, 262)
|
||||
|
||||
$borderPen = New-Object System.Drawing.Pen ([System.Drawing.Color]::FromArgb(120, 221, 205, 255)), 6
|
||||
$graphics.DrawPath($borderPen, $shape)
|
||||
|
||||
$orbitPen = New-Object System.Drawing.Pen ([System.Drawing.Color]::FromArgb(235, 239, 232, 255)), 19
|
||||
$orbitPen.StartCap = [System.Drawing.Drawing2D.LineCap]::Round
|
||||
$orbitPen.EndCap = [System.Drawing.Drawing2D.LineCap]::Round
|
||||
$state = $graphics.Save()
|
||||
$graphics.TranslateTransform(256, 256)
|
||||
$graphics.RotateTransform(-38)
|
||||
$graphics.DrawArc($orbitPen, -167, -103, 334, 206, 18, 212)
|
||||
$graphics.DrawArc($orbitPen, -167, -103, 334, 206, 248, 92)
|
||||
$graphics.Restore($state)
|
||||
|
||||
$coreBrush = New-Object System.Drawing.SolidBrush ([System.Drawing.Color]::FromArgb(255, 151, 235, 242))
|
||||
$coreBorder = New-Object System.Drawing.Pen ([System.Drawing.Color]::FromArgb(255, 250, 247, 255)), 12
|
||||
$graphics.FillEllipse($coreBrush, 202, 202, 108, 108)
|
||||
$graphics.DrawEllipse($coreBorder, 202, 202, 108, 108)
|
||||
|
||||
$satelliteBrush = New-Object System.Drawing.SolidBrush ([System.Drawing.Color]::FromArgb(255, 241, 211, 142))
|
||||
$satelliteBorder = New-Object System.Drawing.Pen ([System.Drawing.Color]::FromArgb(255, 255, 248, 228)), 8
|
||||
$graphics.FillEllipse($satelliteBrush, 342, 88, 66, 66)
|
||||
$graphics.DrawEllipse($satelliteBorder, 342, 88, 66, 66)
|
||||
$graphics.FillEllipse($satelliteBrush, 104, 350, 54, 54)
|
||||
$graphics.DrawEllipse($satelliteBorder, 104, 350, 54, 54)
|
||||
|
||||
$starBrush = New-Object System.Drawing.SolidBrush ([System.Drawing.Color]::FromArgb(210, 255, 255, 255))
|
||||
$graphics.FillEllipse($starBrush, 124, 112, 13, 13)
|
||||
$graphics.FillEllipse($starBrush, 394, 282, 10, 10)
|
||||
$graphics.FillEllipse($starBrush, 286, 398, 8, 8)
|
||||
|
||||
try {
|
||||
$bitmap.Save($resolvedOutput, [System.Drawing.Imaging.ImageFormat]::Png)
|
||||
} finally {
|
||||
$starBrush.Dispose()
|
||||
$satelliteBorder.Dispose()
|
||||
$satelliteBrush.Dispose()
|
||||
$coreBorder.Dispose()
|
||||
$coreBrush.Dispose()
|
||||
$orbitPen.Dispose()
|
||||
$borderPen.Dispose()
|
||||
$cyanGlow.Dispose()
|
||||
$glow.Dispose()
|
||||
$background.Dispose()
|
||||
$shape.Dispose()
|
||||
$graphics.Dispose()
|
||||
$bitmap.Dispose()
|
||||
}
|
||||
|
||||
[pscustomobject]@{
|
||||
ok = $true
|
||||
width = $size
|
||||
height = $size
|
||||
outputPath = $resolvedOutput
|
||||
} | ConvertTo-Json -Compress
|
||||
@@ -13,11 +13,89 @@
|
||||
# den "predev"-Hook) ausgefuehrt und raeumt vorherige Instanzen kompromisslos
|
||||
# weg, egal wie sie beendet wurden.
|
||||
|
||||
param(
|
||||
[switch]$SelfTest
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
$project = (Resolve-Path -LiteralPath (Join-Path $PSScriptRoot "..")).Path
|
||||
$electronPath = Join-Path $project "node_modules\electron\dist\electron.exe"
|
||||
$devControlPort = 17317
|
||||
|
||||
function ConvertTo-ComparablePath([string]$Value) {
|
||||
if ([string]::IsNullOrWhiteSpace($Value)) { return "" }
|
||||
try {
|
||||
return [System.IO.Path]::GetFullPath($Value).TrimEnd("\").ToLowerInvariant()
|
||||
} catch {
|
||||
return $Value.Trim().TrimEnd("\").ToLowerInvariant()
|
||||
}
|
||||
}
|
||||
|
||||
function Test-CommandLineContainsPath([string]$CommandLine, [string]$CandidatePath) {
|
||||
if ([string]::IsNullOrWhiteSpace($CommandLine) -or [string]::IsNullOrWhiteSpace($CandidatePath)) { return $false }
|
||||
$normalizedCommand = $CommandLine.Replace("/", "\").ToLowerInvariant()
|
||||
$normalizedCandidate = (ConvertTo-ComparablePath $CandidatePath).Replace("/", "\")
|
||||
return $normalizedCommand.Contains($normalizedCandidate)
|
||||
}
|
||||
|
||||
function Test-GaaStaleProcess([object]$Process, [string]$ProjectRoot, [string]$ElectronExecutable) {
|
||||
$name = [string]$Process.Name
|
||||
$executable = ConvertTo-ComparablePath ([string]$Process.ExecutablePath)
|
||||
$commandLine = [string]$Process.CommandLine
|
||||
|
||||
if ($name -eq "electron.exe") {
|
||||
return $executable -eq (ConvertTo-ComparablePath $ElectronExecutable)
|
||||
}
|
||||
|
||||
if ($name -eq "InputHelper.exe") {
|
||||
$helperPaths = @(
|
||||
(Join-Path $ProjectRoot "native\input-helper\bin\publish\InputHelper.exe"),
|
||||
(Join-Path $ProjectRoot "outputs\dist\win-unpacked\resources\input-helper\InputHelper.exe")
|
||||
)
|
||||
return @($helperPaths | Where-Object { $executable -eq (ConvertTo-ComparablePath $_) }).Count -gt 0
|
||||
}
|
||||
|
||||
if ($name -eq "powershell.exe" -or $name -eq "pwsh.exe") {
|
||||
$legacyHelper = Join-Path $env:APPDATA "genshin-artifact-assistant\input-helper.ps1"
|
||||
return Test-CommandLineContainsPath $commandLine $legacyHelper
|
||||
}
|
||||
|
||||
if ($name -ne "node.exe") { return $false }
|
||||
|
||||
# Node itself is shared by Codex and many other tools. Only the known dev
|
||||
# entrypoints from this repository are safe to classify as stale GAA work.
|
||||
$knownDevEntrypoints = @(
|
||||
(Join-Path $ProjectRoot "node_modules\vite\bin\vite.js"),
|
||||
(Join-Path $ProjectRoot "node_modules\concurrently\dist\bin\concurrently.js"),
|
||||
(Join-Path $ProjectRoot "node_modules\wait-on\bin\wait-on"),
|
||||
(Join-Path $ProjectRoot "node_modules\cross-env\src\bin\cross-env.js")
|
||||
)
|
||||
return @($knownDevEntrypoints | Where-Object { Test-CommandLineContainsPath $commandLine $_ }).Count -gt 0
|
||||
}
|
||||
|
||||
if ($SelfTest) {
|
||||
$cases = @(
|
||||
@{ Name = "project electron"; Expected = $true; Process = [pscustomobject]@{ Name = "electron.exe"; ExecutablePath = $electronPath; CommandLine = "`"$electronPath`" ." } },
|
||||
@{ Name = "other electron"; Expected = $false; Process = [pscustomobject]@{ Name = "electron.exe"; ExecutablePath = "C:\Other\electron.exe"; CommandLine = "" } },
|
||||
@{ Name = "project vite"; Expected = $true; Process = [pscustomobject]@{ Name = "node.exe"; ExecutablePath = "C:\Program Files\nodejs\node.exe"; CommandLine = "node `"$(Join-Path $project 'node_modules\vite\bin\vite.js')`" --host 127.0.0.1" } },
|
||||
@{ Name = "project concurrently"; Expected = $true; Process = [pscustomobject]@{ Name = "node.exe"; ExecutablePath = "C:\Program Files\nodejs\node.exe"; CommandLine = "node `"$(Join-Path $project 'node_modules\concurrently\dist\bin\concurrently.js')`" -k" } },
|
||||
@{ Name = "Codex CUA working dir"; Expected = $false; Process = [pscustomobject]@{ Name = "node.exe"; ExecutablePath = "C:\Users\vmbao\AppData\Local\OpenAI\Codex\runtimes\cua_node\bin\node.exe"; CommandLine = "node kernel.js --working-dir `"$project`"" } },
|
||||
@{ Name = "unrelated node in repo"; Expected = $false; Process = [pscustomobject]@{ Name = "node.exe"; ExecutablePath = "C:\Program Files\nodejs\node.exe"; CommandLine = "node custom-task.js --cwd `"$project`"" } },
|
||||
@{ Name = "legacy helper"; Expected = $true; Process = [pscustomobject]@{ Name = "powershell.exe"; ExecutablePath = "$env:SystemRoot\System32\WindowsPowerShell\v1.0\powershell.exe"; CommandLine = "powershell -File `"$(Join-Path $env:APPDATA 'genshin-artifact-assistant\input-helper.ps1')`"" } }
|
||||
)
|
||||
$failedCases = @()
|
||||
foreach ($case in $cases) {
|
||||
$actual = Test-GaaStaleProcess $case.Process $project $electronPath
|
||||
if ($actual -ne $case.Expected) { $failedCases += "$($case.Name): expected=$($case.Expected), actual=$actual" }
|
||||
}
|
||||
if ($failedCases.Count -gt 0) {
|
||||
$failedCases | ForEach-Object { Write-Error $_ }
|
||||
throw "$($failedCases.Count) stale-process classifier self-test(s) failed."
|
||||
}
|
||||
Write-Host "Stale-process classifier: $($cases.Count)/$($cases.Count) tests passed."
|
||||
return
|
||||
}
|
||||
|
||||
$killed = 0
|
||||
$candidatePids = @{}
|
||||
|
||||
@@ -37,28 +115,11 @@ try {
|
||||
}
|
||||
|
||||
Get-CimInstance Win32_Process |
|
||||
Where-Object {
|
||||
($_.Name -eq "electron.exe" -and $_.ExecutablePath -eq $electronPath) -or
|
||||
($_.Name -eq "node.exe" -and $_.CommandLine -like "*$project*") -or
|
||||
($_.Name -eq "powershell.exe" -and $_.CommandLine -like "*input-helper.ps1*")
|
||||
} |
|
||||
Where-Object { Test-GaaStaleProcess $_ $project $electronPath } |
|
||||
ForEach-Object {
|
||||
$candidatePids[[int]$_.ProcessId] = $_.Name
|
||||
}
|
||||
|
||||
try {
|
||||
Get-NetTCPConnection -LocalPort $devControlPort -State Listen -ErrorAction Stop |
|
||||
ForEach-Object {
|
||||
if ($_.OwningProcess -gt 0) {
|
||||
$owner = Get-Process -Id $_.OwningProcess -ErrorAction SilentlyContinue
|
||||
$candidatePids[[int]$_.OwningProcess] = if ($owner) { "$($owner.ProcessName) port $devControlPort" } else { "port $devControlPort owner" }
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
# Get-NetTCPConnection can be unavailable on some machines; process matching
|
||||
# above still handles the normal non-elevated path.
|
||||
}
|
||||
|
||||
$failed = 0
|
||||
foreach ($entry in $candidatePids.GetEnumerator()) {
|
||||
Write-Host "Beende alte Instanz: $($entry.Value) (PID $($entry.Key))"
|
||||
|
||||
+262
-25
@@ -25,22 +25,44 @@ function Invoke-DevJson([string]$Path) {
|
||||
try {
|
||||
Invoke-RestMethod -Method Get -Uri $uri -TimeoutSec 90
|
||||
} catch {
|
||||
$response = $_.Exception.Response
|
||||
if ($response) {
|
||||
$stream = $response.GetResponseStream()
|
||||
if ($stream) {
|
||||
$reader = New-Object System.IO.StreamReader($stream)
|
||||
$body = $reader.ReadToEnd()
|
||||
if (-not [string]::IsNullOrWhiteSpace($body)) {
|
||||
try {
|
||||
return $body | ConvertFrom-Json
|
||||
} catch {
|
||||
throw "Dev endpoint $uri returned HTTP error with non-JSON body: $body"
|
||||
}
|
||||
}
|
||||
$requestError = $_
|
||||
$errorBody = [string]$requestError.ErrorDetails.Message
|
||||
if (-not [string]::IsNullOrWhiteSpace($errorBody)) {
|
||||
try {
|
||||
return $errorBody | ConvertFrom-Json
|
||||
} catch {
|
||||
# PowerShell 7 can put a generic message here while the JSON body remains on Response.Content.
|
||||
}
|
||||
}
|
||||
throw
|
||||
|
||||
$body = ""
|
||||
$response = $requestError.Exception.Response
|
||||
if ($response) {
|
||||
if ($response.PSObject.Methods.Name -contains "GetResponseStream") {
|
||||
$stream = $response.GetResponseStream()
|
||||
if ($stream) {
|
||||
$reader = New-Object System.IO.StreamReader($stream)
|
||||
try {
|
||||
$body = $reader.ReadToEnd()
|
||||
} finally {
|
||||
$reader.Dispose()
|
||||
}
|
||||
}
|
||||
} elseif ($response.Content) {
|
||||
$body = $response.Content.ReadAsStringAsync().GetAwaiter().GetResult()
|
||||
}
|
||||
}
|
||||
if (-not [string]::IsNullOrWhiteSpace($body)) {
|
||||
try {
|
||||
return $body | ConvertFrom-Json
|
||||
} catch {
|
||||
throw "Dev endpoint $uri returned HTTP error with non-JSON body: $body"
|
||||
}
|
||||
}
|
||||
if (-not [string]::IsNullOrWhiteSpace($errorBody)) {
|
||||
throw "Dev endpoint $uri returned HTTP error with non-JSON body: $errorBody"
|
||||
}
|
||||
throw $requestError
|
||||
}
|
||||
}
|
||||
|
||||
@@ -51,10 +73,15 @@ function Save-Json([string]$Name, [object]$Payload) {
|
||||
}
|
||||
|
||||
function Test-ProbeSucceeded([object]$ProbePayload) {
|
||||
if ($ProbePayload.ok) { return $true }
|
||||
if ($ProbePayload.changed) { return $true }
|
||||
if ($ProbePayload.click -and $ProbePayload.click.clicked -and $ProbePayload.click.moved -and -not $ProbePayload.click.inputBlocked) { return $true }
|
||||
return $false
|
||||
return [bool](
|
||||
$ProbePayload.ok `
|
||||
-and $ProbePayload.changed `
|
||||
-and $ProbePayload.click `
|
||||
-and $ProbePayload.click.ok `
|
||||
-and $ProbePayload.click.clicked `
|
||||
-and $ProbePayload.click.moved `
|
||||
-and -not $ProbePayload.click.inputBlocked
|
||||
)
|
||||
}
|
||||
|
||||
function Get-NativeScanner([object]$StatusPayload) {
|
||||
@@ -63,6 +90,31 @@ function Get-NativeScanner([object]$StatusPayload) {
|
||||
return $null
|
||||
}
|
||||
|
||||
function Stop-NativeScannerAfterError([int]$WaitSeconds = 10) {
|
||||
$stop = Invoke-DevJson "/scanner/stop"
|
||||
Save-Json "99-native-stop-after-error" $stop | Out-Null
|
||||
$lastPayload = $stop
|
||||
$lastScanner = Get-NativeScanner $stop
|
||||
$deadline = (Get-Date).AddSeconds($WaitSeconds)
|
||||
|
||||
while (($null -eq $lastScanner -or [bool]$lastScanner.running) -and (Get-Date) -lt $deadline) {
|
||||
Start-Sleep -Milliseconds 250
|
||||
$lastPayload = Invoke-DevJson "/scanner/status"
|
||||
$lastScanner = Get-NativeScanner $lastPayload
|
||||
}
|
||||
|
||||
Save-Json "99-native-stop-final-status" $lastPayload | Out-Null
|
||||
$stopped = $null -ne $lastScanner -and -not [bool]$lastScanner.running
|
||||
return [pscustomobject]@{
|
||||
requested = $true
|
||||
endpointOk = [bool]$stop.ok
|
||||
stopped = [bool]$stopped
|
||||
ok = [bool]($stop.ok -and $stopped)
|
||||
status = if ($lastScanner) { [string]$lastScanner.status } else { "missing" }
|
||||
runId = if ($lastScanner) { [string]$lastScanner.runId } else { "" }
|
||||
}
|
||||
}
|
||||
|
||||
function Get-ExpectedAppSignature() {
|
||||
$mainPath = Join-Path (Resolve-Path -LiteralPath ".").Path "electron\main.ts"
|
||||
$mainSource = Get-Content -LiteralPath $mainPath -Raw
|
||||
@@ -97,9 +149,16 @@ $summary = [ordered]@{
|
||||
finalStatus = $null
|
||||
process = $null
|
||||
results = $null
|
||||
timing = $null
|
||||
contract = $null
|
||||
cleanupStop = $null
|
||||
errors = @()
|
||||
}
|
||||
|
||||
$scannerStartAttempted = $false
|
||||
$scannerFinished = $false
|
||||
$startedRunId = ""
|
||||
|
||||
try {
|
||||
$expectedSignature = Get-ExpectedAppSignature
|
||||
$summary.expectedSignature = $expectedSignature
|
||||
@@ -109,7 +168,7 @@ try {
|
||||
signature = if ($health.appBuild) { [string]$health.appBuild.signature } else { "" }
|
||||
}
|
||||
Save-Json "01-health" $health | Out-Null
|
||||
if (-not $health.appBuild -or [string]$health.appBuild.signature -ne $expectedSignature) {
|
||||
if (-not $health.ok -or -not $health.appBuild -or [string]$health.appBuild.signature -ne $expectedSignature) {
|
||||
throw "Dev endpoint is stale: /health signature '$($summary.health.signature)' does not match source '$expectedSignature'. Restart the elevated app."
|
||||
}
|
||||
|
||||
@@ -121,7 +180,7 @@ try {
|
||||
valid = [bool]$data.status.valid
|
||||
}
|
||||
Save-Json "02-native-data" $data | Out-Null
|
||||
if (-not $data.ok) {
|
||||
if (-not $data.ok -or -not $data.status -or -not [bool]$data.status.valid) {
|
||||
throw "Native IK data check failed."
|
||||
}
|
||||
|
||||
@@ -142,7 +201,15 @@ try {
|
||||
blockReason = [string]$preflight.status.blockReason
|
||||
}
|
||||
Save-Json "03-native-preflight" $preflight | Out-Null
|
||||
if (-not $preflight.ok) {
|
||||
if (
|
||||
-not $preflight.ok `
|
||||
-or -not $preflight.status `
|
||||
-or -not [bool]$preflight.status.ready `
|
||||
-or -not [bool]$preflight.status.categoryReady `
|
||||
-or [string]$preflight.status.category -ne $Category `
|
||||
-or -not [bool]$preflight.status.genshinFound `
|
||||
-or -not [bool]$preflight.status.isSixteenNine
|
||||
) {
|
||||
$preflightReason = if ($preflight.status.blockReason) { [string]$preflight.status.blockReason } else { "unknown preflight block reason" }
|
||||
throw "Native scanner preflight is not ready for category '$Category': $preflightReason"
|
||||
}
|
||||
@@ -160,12 +227,24 @@ try {
|
||||
}
|
||||
}
|
||||
|
||||
$scannerStartAttempted = $true
|
||||
$start = Invoke-DevJson "/scanner/start?limit=$safeLimit&category=$Category"
|
||||
Save-Json "05-native-start" $start | Out-Null
|
||||
$startedScanner = Get-NativeScanner $start
|
||||
if ($null -eq $startedScanner) {
|
||||
if (-not $start.ok -or $null -eq $startedScanner) {
|
||||
throw "Native scanner start returned no scanner payload."
|
||||
}
|
||||
$startedRunId = [string]$startedScanner.runId
|
||||
if ([string]::IsNullOrWhiteSpace($startedRunId)) {
|
||||
throw "Native scanner start returned no runId."
|
||||
}
|
||||
if (-not [bool]$startedScanner.running) {
|
||||
$scannerFinished = $true
|
||||
throw "Native scanner did not enter running state: status='$($startedScanner.status)', message='$($startedScanner.message)'."
|
||||
}
|
||||
if ([int]$startedScanner.target -ne $safeLimit -or [string]$startedScanner.category -ne $Category) {
|
||||
throw "Native scanner start contract mismatch: target=$($startedScanner.target), category='$($startedScanner.category)', expected=$safeLimit/$Category."
|
||||
}
|
||||
|
||||
$deadline = (Get-Date).AddSeconds($TimeoutSeconds)
|
||||
$polls = @()
|
||||
@@ -195,27 +274,61 @@ try {
|
||||
}
|
||||
|
||||
$summary.finalStatus = @{
|
||||
runId = [string]$finalScanner.runId
|
||||
category = [string]$finalScanner.category
|
||||
target = [int]$finalScanner.target
|
||||
status = [string]$finalScanner.status
|
||||
running = [bool]$finalScanner.running
|
||||
runDir = [string]$finalScanner.runDir
|
||||
captured = [int]$finalScanner.captured
|
||||
queued = [int]$finalScanner.queued
|
||||
clicked = [int]$finalScanner.clicked
|
||||
pages = [int]$finalScanner.pages
|
||||
initialTopResetMs = [int]$finalScanner.initialTopResetMs
|
||||
initialTopResetCompleted = [bool]$finalScanner.initialTopResetCompleted
|
||||
activeMs = [int]$finalScanner.activeMs
|
||||
totalMs = [int]$finalScanner.totalMs
|
||||
message = [string]$finalScanner.message
|
||||
}
|
||||
|
||||
if ($finalScanner.running) {
|
||||
throw "Native scanner did not finish before timeout."
|
||||
}
|
||||
if ([string]$finalScanner.runId -ne $startedRunId) {
|
||||
throw "Native scanner final status belongs to run '$($finalScanner.runId)', expected '$startedRunId'."
|
||||
}
|
||||
$scannerFinished = $true
|
||||
if ([string]$finalScanner.status -ne "done") {
|
||||
throw "Native scanner finished with status '$($finalScanner.status)': $($finalScanner.message)"
|
||||
}
|
||||
if ([int]$finalScanner.captured -lt $safeLimit) {
|
||||
throw "Native scanner captured $($finalScanner.captured), expected at least $safeLimit."
|
||||
if ([int]$finalScanner.target -ne $safeLimit -or [string]$finalScanner.category -ne $Category) {
|
||||
throw "Native scanner final contract mismatch: target=$($finalScanner.target), category='$($finalScanner.category)', expected=$safeLimit/$Category."
|
||||
}
|
||||
if ([int]$finalScanner.captured -ne $safeLimit) {
|
||||
throw "Native scanner captured $($finalScanner.captured), expected exactly $safeLimit."
|
||||
}
|
||||
if ([int]$finalScanner.queued -ne $safeLimit) {
|
||||
throw "Native scanner queued $($finalScanner.queued), expected exactly $safeLimit."
|
||||
}
|
||||
if ([int]$finalScanner.clicked -ne $safeLimit) {
|
||||
throw "Native scanner clicked $($finalScanner.clicked), expected exactly $safeLimit."
|
||||
}
|
||||
if (-not [bool]$finalScanner.initialTopResetCompleted) {
|
||||
throw "Native scanner did not confirm the bounded initial inventory top reset."
|
||||
}
|
||||
if ([int]$finalScanner.initialTopResetMs -le 0) {
|
||||
throw "Native scanner reported invalid initialTopResetMs=$($finalScanner.initialTopResetMs)."
|
||||
}
|
||||
if ([int]$finalScanner.totalMs -lt [int]$finalScanner.activeMs -or [int]$finalScanner.totalMs -lt [int]$finalScanner.initialTopResetMs) {
|
||||
throw "Native scanner timing contract is invalid: reset=$($finalScanner.initialTopResetMs), active=$($finalScanner.activeMs), total=$($finalScanner.totalMs)."
|
||||
}
|
||||
|
||||
$runDirParam = UriEscape([string]$finalScanner.runDir)
|
||||
if ([string]::IsNullOrWhiteSpace([string]$finalScanner.runDir) -or -not (Test-Path -LiteralPath ([string]$finalScanner.runDir) -PathType Container)) {
|
||||
throw "Native scanner returned an invalid run directory '$($finalScanner.runDir)'."
|
||||
}
|
||||
$resolvedNativeRunDir = (Resolve-Path -LiteralPath ([string]$finalScanner.runDir)).Path
|
||||
|
||||
$runDirParam = UriEscape($resolvedNativeRunDir)
|
||||
$persistParam = if ($Persist) { "1" } else { "0" }
|
||||
$process = Invoke-DevJson "/scanner/native/process?runDir=$runDirParam&limit=$safeLimit&persist=$persistParam"
|
||||
Save-Json "08-native-process" $process | Out-Null
|
||||
@@ -233,6 +346,36 @@ try {
|
||||
if (-not $process.ok) {
|
||||
throw "Native post-capture processing failed."
|
||||
}
|
||||
if ([int]$process.status.processed -ne $safeLimit) {
|
||||
throw "Native processing handled $($process.status.processed), expected exactly $safeLimit."
|
||||
}
|
||||
if ([int]$process.status.parsed -ne $safeLimit) {
|
||||
throw "Native processing parsed $($process.status.parsed), expected exactly $safeLimit."
|
||||
}
|
||||
if ([int]$process.status.errors -ne 0) {
|
||||
throw "Native processing reported $($process.status.errors) errors."
|
||||
}
|
||||
$processResults = @($process.status.results)
|
||||
$actualParsed = @($processResults | Where-Object { [bool]$_.parsed }).Count
|
||||
$actualReview = @($processResults | Where-Object { [bool]$_.needsReview }).Count
|
||||
$actualErrors = @($processResults | Where-Object { -not [string]::IsNullOrWhiteSpace([string]$_.error) }).Count
|
||||
if ($processResults.Count -ne $safeLimit) {
|
||||
throw "Native processing returned $($processResults.Count) result rows, expected exactly $safeLimit."
|
||||
}
|
||||
if ($actualParsed -ne [int]$process.status.parsed -or $actualReview -ne [int]$process.status.review -or $actualErrors -ne [int]$process.status.errors) {
|
||||
throw "Native processing summary does not match result rows (parsed=$($process.status.parsed)/$actualParsed, review=$($process.status.review)/$actualReview, errors=$($process.status.errors)/$actualErrors)."
|
||||
}
|
||||
if ([int]$process.status.review -lt 0 -or [int]$process.status.review -gt $safeLimit) {
|
||||
throw "Native processing returned invalid review count $($process.status.review) for limit $safeLimit."
|
||||
}
|
||||
$reviewRate = if ($safeLimit -gt 0) { [double]$process.status.review / $safeLimit } else { 0 }
|
||||
if ($reviewRate -gt 0.15) {
|
||||
throw "Native processing review rate $([Math]::Round($reviewRate * 100, 1))% exceeds 15%."
|
||||
}
|
||||
$persistedProcessRows = @($processResults | Where-Object { [bool]$_.persisted })
|
||||
if (-not $Persist -and ([bool]$process.status.persisted -or [int]$process.status.stored -ne 0 -or $persistedProcessRows.Count -ne 0)) {
|
||||
throw "Dry native smoke wrote to the store (stored=$($process.status.stored), persisted=$($process.status.persisted), persistedRows=$($persistedProcessRows.Count))."
|
||||
}
|
||||
|
||||
$results = Invoke-DevJson "/scanner/native/results?runDir=$runDirParam&limit=$safeLimit"
|
||||
Save-Json "09-native-results" $results | Out-Null
|
||||
@@ -244,10 +387,104 @@ try {
|
||||
if (-not $results.ok) {
|
||||
throw "Native scan results could not be loaded."
|
||||
}
|
||||
$loadedResults = @($results.status.results)
|
||||
if ([int]$results.status.total -ne $safeLimit -or $loadedResults.Count -ne $safeLimit) {
|
||||
throw "Native result contract mismatch: total=$($results.status.total), loaded=$($loadedResults.Count), expected=$safeLimit."
|
||||
}
|
||||
$timing = $results.status.timing
|
||||
$summary.timing = @{
|
||||
requestStartedAt = if ($timing) { [string]$timing.requestStartedAt } else { "" }
|
||||
captureCompletedAt = if ($timing) { [string]$timing.captureCompletedAt } else { "" }
|
||||
processingCompletedAt = if ($timing) { [string]$timing.processingCompletedAt } else { "" }
|
||||
resultsDurableAt = if ($timing) { [string]$timing.resultsDurableAt } else { "" }
|
||||
resultsReconciledAt = if ($timing) { [string]$timing.resultsReconciledAt } else { "" }
|
||||
requestToResultsDurableMs = if ($timing) { [int64]$timing.requestToResultsDurableMs } else { -1 }
|
||||
requestToResultsReconciledMs = if ($timing) { [int64]$timing.requestToResultsReconciledMs } else { -1 }
|
||||
}
|
||||
$requiredTimingFields = @(
|
||||
"requestStartedAt",
|
||||
"captureCompletedAt",
|
||||
"processingCompletedAt",
|
||||
"resultsDurableAt",
|
||||
"resultsReconciledAt"
|
||||
)
|
||||
$missingTimingFields = @($requiredTimingFields | Where-Object { [string]::IsNullOrWhiteSpace([string]$timing.$_) })
|
||||
if ($null -eq $timing -or $missingTimingFields.Count -ne 0) {
|
||||
throw "Native run timing contract is incomplete: missing=$($missingTimingFields -join ', ')."
|
||||
}
|
||||
if ([int64]$timing.requestToResultsDurableMs -lt 0 -or [int64]$timing.requestToResultsReconciledMs -lt [int64]$timing.requestToResultsDurableMs) {
|
||||
throw "Native run timing contract has invalid derived durations: durable=$($timing.requestToResultsDurableMs), reconciled=$($timing.requestToResultsReconciledMs)."
|
||||
}
|
||||
$persistedLoadedRows = @($loadedResults | Where-Object {
|
||||
[bool]$_.persistedArtifact -or -not [string]::IsNullOrWhiteSpace([string]$_.artifactRecordId)
|
||||
})
|
||||
if (-not $Persist -and $persistedLoadedRows.Count -ne 0) {
|
||||
throw "Dry native smoke loaded $($persistedLoadedRows.Count) result rows marked as persisted."
|
||||
}
|
||||
|
||||
$jobsPath = Join-Path $resolvedNativeRunDir "capture-jobs.jsonl"
|
||||
$pngFiles = @(Get-ChildItem -LiteralPath $resolvedNativeRunDir -Filter "artifact-*.png" -File)
|
||||
$jobs = @(
|
||||
Get-Content -LiteralPath $jobsPath |
|
||||
Where-Object { -not [string]::IsNullOrWhiteSpace($_) } |
|
||||
ForEach-Object { $_ | ConvertFrom-Json }
|
||||
)
|
||||
$expectedPngNames = @(1..$safeLimit | ForEach-Object { "artifact-{0:D4}.png" -f $_ })
|
||||
$actualPngNames = @($pngFiles.Name | Sort-Object)
|
||||
$pngNameMismatches = @(Compare-Object -ReferenceObject $expectedPngNames -DifferenceObject $actualPngNames)
|
||||
$badSequenceJobs = @($jobs | Where-Object { [int]$_.sequence -lt 1 -or [int]$_.sequence -gt $safeLimit })
|
||||
$duplicateSequenceJobs = @($jobs | Group-Object sequence | Where-Object { $_.Count -ne 1 })
|
||||
$badClickJobs = @($jobs | Where-Object { [int]$_.clickEventsSent -ne 2 })
|
||||
$badPathJobs = @($jobs | Where-Object {
|
||||
$expectedName = "artifact-{0:D4}.png" -f [int]$_.sequence
|
||||
$expectedAbsolutePath = [System.IO.Path]::GetFullPath((Join-Path $resolvedNativeRunDir $expectedName))
|
||||
[System.IO.Path]::GetFileName([string]$_.relativePath) -ne $expectedName `
|
||||
-or [System.IO.Path]::GetFullPath([string]$_.absolutePath) -ne $expectedAbsolutePath
|
||||
})
|
||||
$emptyPngFiles = @($pngFiles | Where-Object { $_.Length -le 0 })
|
||||
$summary.contract = @{
|
||||
jobs = $jobs.Count
|
||||
pngs = $pngFiles.Count
|
||||
pngNameMismatches = $pngNameMismatches.Count
|
||||
badSequenceJobs = $badSequenceJobs.Count
|
||||
duplicateSequenceJobs = $duplicateSequenceJobs.Count
|
||||
badClickJobs = $badClickJobs.Count
|
||||
badPathJobs = $badPathJobs.Count
|
||||
emptyPngs = $emptyPngFiles.Count
|
||||
persistedProcessRows = $persistedProcessRows.Count
|
||||
persistedLoadedRows = $persistedLoadedRows.Count
|
||||
reviewRatePct = [Math]::Round($reviewRate * 100, 1)
|
||||
}
|
||||
if ($jobs.Count -ne $safeLimit -or $pngFiles.Count -ne $safeLimit) {
|
||||
throw "Native file contract mismatch: jobs=$($jobs.Count), pngs=$($pngFiles.Count), expected=$safeLimit."
|
||||
}
|
||||
if ($pngNameMismatches.Count -ne 0 -or $emptyPngFiles.Count -ne 0) {
|
||||
throw "Native PNG contract mismatch: names=$($pngNameMismatches.Count), empty=$($emptyPngFiles.Count)."
|
||||
}
|
||||
if ($badSequenceJobs.Count -ne 0 -or $duplicateSequenceJobs.Count -ne 0 -or $badPathJobs.Count -ne 0) {
|
||||
throw "Native job contract mismatch: invalidSequences=$($badSequenceJobs.Count), duplicateSequences=$($duplicateSequenceJobs.Count), invalidPaths=$($badPathJobs.Count)."
|
||||
}
|
||||
if ($badClickJobs.Count -ne 0) {
|
||||
throw "Native run contains $($badClickJobs.Count) jobs without exactly two click events."
|
||||
}
|
||||
|
||||
$summary.ok = $true
|
||||
} catch {
|
||||
$summary.errors += [string]$_.Exception.Message
|
||||
if ($scannerStartAttempted -and -not $scannerFinished) {
|
||||
try {
|
||||
$summary.cleanupStop = Stop-NativeScannerAfterError
|
||||
if (-not $summary.cleanupStop.ok) {
|
||||
$summary.errors += "Native scanner cleanup stop did not reach a confirmed stopped state."
|
||||
}
|
||||
} catch {
|
||||
$summary.cleanupStop = @{
|
||||
requested = $true
|
||||
ok = $false
|
||||
error = [string]$_.Exception.Message
|
||||
}
|
||||
}
|
||||
}
|
||||
throw
|
||||
} finally {
|
||||
$summaryPath = Save-Json "native-live-smoke-summary" ([pscustomobject]$summary)
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import os from "node:os";
|
||||
import { createHash } from "node:crypto";
|
||||
import { loadNativeScannerDeletedResultIds } from "../electron/services/nativeScannerResultTombstones.js";
|
||||
import { replayNativeScanResults } from "../src/eval/nativeScanReplay.js";
|
||||
import type { StoredScanResultEntry } from "../src/types/storage.js";
|
||||
|
||||
const options = parseArgs(process.argv.slice(2));
|
||||
const scanRoot = path.join(process.env.APPDATA || path.join(os.homedir(), "AppData", "Roaming"), "genshin-artifact-assistant", "native-scans");
|
||||
const runDir = options.runDir ? path.resolve(options.runDir) : await latestRunDir(scanRoot);
|
||||
const sourcePath = path.join(runDir, "scan-results.json");
|
||||
const source = await fs.readFile(sourcePath, "utf8");
|
||||
const parsed = JSON.parse(source);
|
||||
if (!Array.isArray(parsed) || parsed.length === 0) throw new Error(`No scan results found in ${sourcePath}`);
|
||||
const rawEntries = parsed.filter(isStoredScanResultEntry);
|
||||
if (rawEntries.length !== parsed.length) throw new Error(`Invalid scan-result entries: ${parsed.length - rawEntries.length}`);
|
||||
const deletedResultIds = await loadNativeScannerDeletedResultIds(runDir);
|
||||
const entries = rawEntries.filter((entry) => !deletedResultIds.has(entry.id));
|
||||
if (entries.length === 0) throw new Error(`No visible scan results found in ${sourcePath}`);
|
||||
|
||||
const report = replayNativeScanResults({
|
||||
entries,
|
||||
repeats: options.repeats,
|
||||
runId: path.basename(runDir),
|
||||
sourcePath,
|
||||
sourceSha256: createHash("sha256").update(source).digest("hex"),
|
||||
});
|
||||
const outputDir = path.resolve("outputs", "native-replay", report.runId);
|
||||
await fs.mkdir(outputDir, { recursive: true });
|
||||
const reportPath = path.join(outputDir, "native-replay-report.json");
|
||||
await fs.writeFile(reportPath, JSON.stringify(report, null, 2), "utf8");
|
||||
|
||||
console.log(`Native offline replay: ${report.runId}`);
|
||||
if (deletedResultIds.size > 0) console.log(`Locally removed results excluded: ${deletedResultIds.size}`);
|
||||
console.log(
|
||||
`Results: ${report.total}; evaluated=${report.values.evaluated}; excluded=${report.values.excluded}; `
|
||||
+ `review=${report.values.review}; unknown=${report.values.unknown}`,
|
||||
);
|
||||
console.log(`Projection: available=${report.projections.available}; complete=${report.projections.complete}; unavailable=${report.projections.unavailable}`);
|
||||
console.log(`Score: min=${report.score.min}; avg=${report.score.average}; max=${report.score.max}`);
|
||||
console.log(`Deterministic: ${report.deterministic} across ${report.repeats} repeats`);
|
||||
console.log(`Report: ${reportPath}`);
|
||||
|
||||
if (!report.deterministic) throw new Error("Offline replay produced different evaluation hashes.");
|
||||
if (report.cleanUnevaluated.length > 0) {
|
||||
console.error(`Clean but unevaluated results: ${report.cleanUnevaluated.map((entry) => `#${entry.sequence} ${entry.summary}`).join(" | ")}`);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
|
||||
function parseArgs(args: string[]) {
|
||||
const runDir = args.find((argument) => argument.startsWith("--run-dir="))?.slice("--run-dir=".length) ?? "";
|
||||
const repeatValue = Number(args.find((argument) => argument.startsWith("--repeats="))?.slice("--repeats=".length) ?? 3);
|
||||
return { runDir, repeats: Number.isFinite(repeatValue) ? repeatValue : 3 };
|
||||
}
|
||||
|
||||
async function latestRunDir(root: string) {
|
||||
const entries = await fs.readdir(root, { withFileTypes: true });
|
||||
const candidates = entries.filter((entry) => entry.isDirectory()).map((entry) => entry.name).sort().reverse();
|
||||
for (const candidate of candidates) {
|
||||
const runDir = path.join(root, candidate);
|
||||
try {
|
||||
await fs.access(path.join(runDir, "scan-results.json"));
|
||||
return runDir;
|
||||
} catch {
|
||||
// Continue to the next saved run; no live capture is started.
|
||||
}
|
||||
}
|
||||
throw new Error(`No saved native scan-results.json found below ${root}`);
|
||||
}
|
||||
|
||||
function isStoredScanResultEntry(value: unknown): value is StoredScanResultEntry {
|
||||
if (!value || typeof value !== "object") return false;
|
||||
const entry = value as Partial<StoredScanResultEntry>;
|
||||
return typeof entry.id === "string"
|
||||
&& typeof entry.runId === "string"
|
||||
&& Number.isFinite(entry.sequence)
|
||||
&& typeof entry.extractionStatus === "string"
|
||||
&& typeof entry.valueStatus === "string"
|
||||
&& Array.isArray(entry.notes);
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import { spawnSync } from "node:child_process";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const projectRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
||||
const npmCommand = process.platform === "win32" ? "npm.cmd" : "npm";
|
||||
const steps = [
|
||||
{ id: "dev-cleanup-safety", command: npmCommand, args: ["run", "dev-cleanup:test"] },
|
||||
{ id: "native-helper-safety", command: npmCommand, args: ["run", "helper:test"] },
|
||||
{ id: "lint", command: npmCommand, args: ["run", "lint"] },
|
||||
{ id: "tests", command: npmCommand, args: ["test"] },
|
||||
{ id: "ocr-eval", command: npmCommand, args: ["run", "eval"] },
|
||||
{ id: "assessment-self-test", command: npmCommand, args: ["run", "scan:assessment:test"] },
|
||||
{ id: "saved-native-validation", command: npmCommand, args: ["run", "scan:native:validate:saved"] },
|
||||
{ id: "package-offline-check", command: npmCommand, args: ["run", "package:offline-check"] },
|
||||
{ id: "dependency-audit", command: npmCommand, args: ["audit"] },
|
||||
{ id: "production-dependency-audit", command: npmCommand, args: ["audit", "--omit=dev"] },
|
||||
{ id: "git-diff-check", command: "git", args: ["diff", "--check"] },
|
||||
];
|
||||
|
||||
const startedAt = new Date();
|
||||
const results = [];
|
||||
for (const step of steps) {
|
||||
const stepStartedAt = Date.now();
|
||||
console.log(`\n=== Offline acceptance: ${step.id} ===`);
|
||||
const invocation = commandInvocation(step);
|
||||
const result = spawnSync(invocation.command, invocation.args, {
|
||||
cwd: projectRoot,
|
||||
encoding: "utf8",
|
||||
stdio: "inherit",
|
||||
windowsHide: true,
|
||||
});
|
||||
results.push({
|
||||
id: step.id,
|
||||
ok: result.status === 0 && !result.error,
|
||||
exitCode: result.status,
|
||||
signal: result.signal,
|
||||
error: result.error?.message ?? null,
|
||||
elapsedMs: Date.now() - stepStartedAt,
|
||||
});
|
||||
}
|
||||
|
||||
const outputDir = path.join(projectRoot, "outputs", "offline-acceptance");
|
||||
fs.mkdirSync(outputDir, { recursive: true });
|
||||
const reportPath = path.join(outputDir, "offline-acceptance-report.json");
|
||||
const report = {
|
||||
version: "offline-acceptance-v2",
|
||||
startedAt: startedAt.toISOString(),
|
||||
completedAt: new Date().toISOString(),
|
||||
ok: results.every((result) => result.ok),
|
||||
projectRoot,
|
||||
results,
|
||||
};
|
||||
fs.writeFileSync(reportPath, JSON.stringify(report, null, 2), "utf8");
|
||||
|
||||
console.log(`\nOffline acceptance: ${report.ok ? "PASS" : "FAIL"}`);
|
||||
for (const result of results) {
|
||||
console.log(`${result.ok ? "PASS" : "FAIL"} ${result.id} (${result.elapsedMs} ms)`);
|
||||
}
|
||||
console.log(`Report: ${reportPath}`);
|
||||
if (!report.ok) process.exitCode = 1;
|
||||
|
||||
function commandInvocation(step) {
|
||||
if (process.platform !== "win32" || !step.command.endsWith(".cmd")) return step;
|
||||
const tokens = [step.command, ...step.args];
|
||||
if (tokens.some((token) => !/^[A-Za-z0-9:._=@/-]+$/.test(token))) {
|
||||
throw new Error(`Unsafe Windows command token in offline acceptance step ${step.id}.`);
|
||||
}
|
||||
return {
|
||||
command: process.env.ComSpec || "cmd.exe",
|
||||
args: ["/d", "/s", "/c", tokens.join(" ")],
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
@@ -0,0 +1,941 @@
|
||||
import fs from "node:fs";
|
||||
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 mode = args.get("mode") ?? "inspect";
|
||||
const view = args.get("view") ?? "scan";
|
||||
const state = args.get("state") ?? "default";
|
||||
const closeAfter = args.get("close") === "true";
|
||||
const consoleCheck = args.get("console-check") === "true";
|
||||
const viewportWidth = args.has("viewport-width") ? Number(args.get("viewport-width")) : null;
|
||||
const viewportHeight = args.has("viewport-height") ? Number(args.get("viewport-height")) : null;
|
||||
const reprocessRunDir = args.has("run-dir") ? path.resolve(args.get("run-dir")) : "";
|
||||
const reprocessTarget = args.has("expected-target") ? Number(args.get("expected-target")) : null;
|
||||
const reprocessMaxReviewRate = Number(args.get("max-review-rate") ?? 0.15);
|
||||
const viewIds = new Set(["scan", "inventory", "triage", "builds", "overlay", "diagnose"]);
|
||||
const output = path.resolve(args.get("output") ?? `outputs/packaged-live/packaged-${mode}-${view}.json`);
|
||||
const screenshotPath = path.resolve(args.get("screenshot") ?? `outputs/packaged-live/packaged-${mode}-${view}.png`);
|
||||
|
||||
if (!Number.isInteger(port) || port < 1 || port > 65535) throw new Error(`Invalid CDP port: ${port}`);
|
||||
if ((viewportWidth === null) !== (viewportHeight === null)) throw new Error("Both --viewport-width and --viewport-height are required together.");
|
||||
if (viewportWidth !== null && (!Number.isInteger(viewportWidth) || !Number.isInteger(viewportHeight) || viewportWidth < 480 || viewportHeight < 320)) {
|
||||
throw new Error("Viewport dimensions must be integer CSS pixels of at least 480x320.");
|
||||
}
|
||||
if (!new Set(["inspect", "scan5", "ui-scan5", "ui-scan-row1", "ui-stream20", "ui-full-inventory", "reprocess-run"]).has(mode)) throw new Error(`Unsupported mode: ${mode}`);
|
||||
if (!viewIds.has(view)) throw new Error(`Unsupported view: ${view}`);
|
||||
if (mode === "reprocess-run" && (!reprocessRunDir || !Number.isInteger(reprocessTarget) || reprocessTarget < 1 || reprocessTarget > 2400)) {
|
||||
throw new Error("reprocess-run requires --run-dir and --expected-target in 1..2400.");
|
||||
}
|
||||
if (mode === "reprocess-run" && (!Number.isFinite(reprocessMaxReviewRate) || reprocessMaxReviewRate < 0 || reprocessMaxReviewRate > 1)) {
|
||||
throw new Error("--max-review-rate must be in 0..1.");
|
||||
}
|
||||
if (!new Set(["default", "scan-settings", "artifact-open", "artifact-probe", "post-scan-results", "review-deeplink", "keyboard-focus", "overlay-open", "inventory-confirm", "inventory-delete-confirm", "inventory-store-delete-confirm", "inventory-roving", "inventory-technical"]).has(state)) throw new Error(`Unsupported state: ${state}`);
|
||||
if (state === "scan-settings" && view !== "scan") throw new Error("scan-settings state requires --view=scan.");
|
||||
if (state === "artifact-open" && view !== "scan") throw new Error("artifact-open state requires --view=scan.");
|
||||
if (state === "artifact-probe" && view !== "scan") throw new Error("artifact-probe state requires --view=scan.");
|
||||
if (state === "post-scan-results" && view !== "scan") throw new Error("post-scan-results state requires --view=scan.");
|
||||
if (state === "review-deeplink" && view !== "triage") throw new Error("review-deeplink state requires --view=triage.");
|
||||
if (state === "overlay-open" && view !== "overlay") throw new Error("overlay-open state requires --view=overlay.");
|
||||
if (state === "inventory-confirm" && view !== "inventory") throw new Error("inventory-confirm state requires --view=inventory.");
|
||||
if (state === "inventory-delete-confirm" && view !== "inventory") throw new Error("inventory-delete-confirm state requires --view=inventory.");
|
||||
if (state === "inventory-store-delete-confirm" && view !== "inventory") throw new Error("inventory-store-delete-confirm state requires --view=inventory.");
|
||||
if (state === "inventory-roving" && view !== "inventory") throw new Error("inventory-roving state requires --view=inventory.");
|
||||
if (state === "inventory-technical" && view !== "inventory") throw new Error("inventory-technical state requires --view=inventory.");
|
||||
|
||||
const uiScanConfig = mode === "ui-full-inventory"
|
||||
? {
|
||||
scopeMode: "all",
|
||||
expectedTarget: null,
|
||||
deadlineMs: 900000,
|
||||
}
|
||||
: mode === "ui-scan-row1"
|
||||
? {
|
||||
scopeMode: "limit",
|
||||
limit: 1,
|
||||
expectedTarget: 8,
|
||||
unit: "rows",
|
||||
deadlineMs: 240000,
|
||||
}
|
||||
: mode === "ui-stream20"
|
||||
? {
|
||||
scopeMode: "limit",
|
||||
limit: 20,
|
||||
expectedTarget: 20,
|
||||
unit: "artifacts",
|
||||
deadlineMs: 240000,
|
||||
}
|
||||
: {
|
||||
scopeMode: "limit",
|
||||
limit: 5,
|
||||
expectedTarget: 5,
|
||||
unit: "artifacts",
|
||||
deadlineMs: 240000,
|
||||
};
|
||||
|
||||
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 file:// renderer target 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 rendererDiagnostics = {
|
||||
exceptions: [],
|
||||
consoleErrors: [],
|
||||
logErrors: [],
|
||||
};
|
||||
socket.addEventListener("message", (event) => {
|
||||
let message;
|
||||
try {
|
||||
message = JSON.parse(String(event.data));
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
if (message.method === "Runtime.exceptionThrown") {
|
||||
rendererDiagnostics.exceptions.push(message.params?.exceptionDetails?.exception?.description ?? message.params?.exceptionDetails?.text ?? "Unknown renderer exception");
|
||||
} else if (message.method === "Runtime.consoleAPICalled" && message.params?.type === "error") {
|
||||
rendererDiagnostics.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") {
|
||||
rendererDiagnostics.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);
|
||||
if (consoleCheck) {
|
||||
await call("Runtime.enable", {}, 10_000);
|
||||
await call("Log.enable", {}, 10_000);
|
||||
}
|
||||
if (viewportWidth !== null && viewportHeight !== null) {
|
||||
await call("Emulation.setDeviceMetricsOverride", {
|
||||
width: viewportWidth,
|
||||
height: viewportHeight,
|
||||
deviceScaleFactor: 1,
|
||||
mobile: false,
|
||||
}, 10_000);
|
||||
}
|
||||
await evaluate("new Promise((resolve) => setTimeout(resolve, 120))", 10_000);
|
||||
|
||||
const inspectExpression = `
|
||||
(async () => {
|
||||
const api = window.assistantApi;
|
||||
if (!api) throw new Error("Packaged preload bridge is unavailable.");
|
||||
const runtime = await api.getRuntimeInfo();
|
||||
const guard = await api.getAutomationGuard();
|
||||
const data = await api.nativeScannerDataStatus();
|
||||
const results = await api.nativeScannerLoadResults({ limit: 3 });
|
||||
const artifacts = await api.loadArtifacts();
|
||||
const sources = await api.listCaptureSources();
|
||||
const genshinSource = sources.find((source) => source.isGenshinCandidate) ?? null;
|
||||
const readinessCapture = genshinSource
|
||||
? await api.captureSource(genshinSource.id, 0, false, {
|
||||
skipOcr: true,
|
||||
omitFullFrame: true,
|
||||
omitDetailPreview: true,
|
||||
omitInventoryPreview: true,
|
||||
omitCrops: true,
|
||||
omitLockState: true,
|
||||
})
|
||||
: null;
|
||||
return {
|
||||
title: document.title,
|
||||
url: location.href,
|
||||
bridgeKeys: Object.keys(api).sort(),
|
||||
runtime,
|
||||
guard,
|
||||
data,
|
||||
latestResults: { ok: results.ok, runDir: results.runDir, total: results.total, loaded: results.results.length },
|
||||
artifactStore: { ok: artifacts.ok, total: artifacts.total },
|
||||
captureReadiness: readinessCapture ? {
|
||||
source: { id: genshinSource.id, name: genshinSource.name },
|
||||
captureTarget: readinessCapture.captureTarget,
|
||||
layout: readinessCapture.layout,
|
||||
artifactDetail: readinessCapture.artifactDetail,
|
||||
inventoryGrid: readinessCapture.inventoryGrid ? {
|
||||
rows: readinessCapture.inventoryGrid.rows,
|
||||
cols: readinessCapture.inventoryGrid.cols,
|
||||
confidence: readinessCapture.inventoryGrid.confidence,
|
||||
source: readinessCapture.inventoryGrid.source,
|
||||
} : null,
|
||||
} : { source: null },
|
||||
navigation: [...document.querySelectorAll("button,a")].map((element) => element.textContent.trim()).filter(Boolean).slice(0, 100),
|
||||
bodyText: document.body.innerText.slice(0, 16000),
|
||||
};
|
||||
})()`;
|
||||
|
||||
const scanExpression = `
|
||||
(async () => {
|
||||
const api = window.assistantApi;
|
||||
if (!api) throw new Error("Packaged preload bridge is unavailable.");
|
||||
const preflight = await api.nativeScannerPreflight({ category: "artifacts" });
|
||||
if (!preflight.ready) return { ok: false, phase: "preflight", preflight };
|
||||
let status = await api.nativeScannerStart({ limit: 5, category: "artifacts" });
|
||||
const deadline = Date.now() + 120000;
|
||||
while (status.running && Date.now() < deadline) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 250));
|
||||
status = await api.nativeScannerStatus();
|
||||
}
|
||||
if (status.running) {
|
||||
status = await api.nativeScannerStop();
|
||||
return { ok: false, phase: "timeout", status };
|
||||
}
|
||||
if (status.status !== "done" || status.captured !== 5 || !status.initialTopResetCompleted) {
|
||||
return { ok: false, phase: "capture", status };
|
||||
}
|
||||
const processing = await api.nativeScannerProcessRun({ runDir: status.runDir, limit: 5, persist: false });
|
||||
const results = await api.nativeScannerLoadResults({ runDir: status.runDir, limit: 5 });
|
||||
return {
|
||||
ok: Boolean(
|
||||
processing.ok && processing.processed === 5 && processing.parsed === 5
|
||||
&& processing.errors === 0 && processing.stored === 0 && !processing.persisted
|
||||
&& results.ok && results.total === 5 && results.results.length === 5
|
||||
&& results.results.every((result) => !result.persistedArtifact && !result.artifactRecordId)
|
||||
),
|
||||
phase: "complete",
|
||||
preflight,
|
||||
status,
|
||||
processing: {
|
||||
ok: processing.ok,
|
||||
processed: processing.processed,
|
||||
parsed: processing.parsed,
|
||||
review: processing.review,
|
||||
errors: processing.errors,
|
||||
stored: processing.stored,
|
||||
persisted: processing.persisted,
|
||||
elapsedMs: processing.elapsedMs,
|
||||
queueConcurrency: processing.queueConcurrency,
|
||||
},
|
||||
results: { ok: results.ok, runDir: results.runDir, total: results.total, loaded: results.results.length },
|
||||
};
|
||||
})()`;
|
||||
|
||||
const uiScanExpression = `
|
||||
(async () => {
|
||||
const config = ${JSON.stringify(uiScanConfig)};
|
||||
const api = window.assistantApi;
|
||||
if (!api) throw new Error("Packaged preload bridge is unavailable.");
|
||||
const priorSummary = document.querySelector('.scan-summary-modal');
|
||||
const priorSummaryClose = priorSummary?.querySelector('[data-scan-action="summary-close"]');
|
||||
priorSummaryClose?.click();
|
||||
if (priorSummaryClose) await new Promise((resolve) => setTimeout(resolve, 250));
|
||||
const existingStatus = await api.nativeScannerStatus();
|
||||
if (existingStatus.running || document.querySelector('.scan-progress-surface.is-busy')) {
|
||||
return { ok: false, phase: 'precondition', error: 'A scanner run is already active.', existingStatus };
|
||||
}
|
||||
let dialog = document.querySelector('.scanner-settings-modal');
|
||||
if (!dialog) {
|
||||
const settingsButton = document.querySelector('[data-scan-action="open-settings"]');
|
||||
if (!(settingsButton instanceof HTMLButtonElement)) return { ok: false, phase: 'settings', error: 'Scan settings action was not found.' };
|
||||
settingsButton.click();
|
||||
await new Promise((resolve) => setTimeout(resolve, 250));
|
||||
dialog = document.querySelector('.scanner-settings-modal');
|
||||
}
|
||||
let configuredLimit = null;
|
||||
let configuredScopeValid = false;
|
||||
if (config.scopeMode === 'all') {
|
||||
const fullScope = dialog?.querySelector('input[data-scan-scope="all"]');
|
||||
if (!(fullScope instanceof HTMLInputElement)) return { ok: false, phase: 'settings', error: 'Full inventory scope option was not found.' };
|
||||
if (!fullScope.checked) fullScope.click();
|
||||
await new Promise((resolve) => setTimeout(resolve, 250));
|
||||
configuredScopeValid = fullScope.checked;
|
||||
} else {
|
||||
const limitedScope = dialog?.querySelector('input[data-scan-scope="limit"]');
|
||||
if (!(limitedScope instanceof HTMLInputElement)) return { ok: false, phase: 'settings', error: 'Limited scope option was not found.' };
|
||||
if (!limitedScope.checked) limitedScope.click();
|
||||
await new Promise((resolve) => setTimeout(resolve, 150));
|
||||
const unitButton = dialog?.querySelector('[data-scan-limit-unit="' + config.unit + '"]');
|
||||
if (!(unitButton instanceof HTMLButtonElement)) return { ok: false, phase: 'settings', error: 'Limit unit action was not found.', unit: config.unit };
|
||||
unitButton.click();
|
||||
await new Promise((resolve) => setTimeout(resolve, 150));
|
||||
const inputSelector = 'input[data-scan-limit-input]';
|
||||
const limitInput = document.querySelector(inputSelector);
|
||||
if (!(limitInput instanceof HTMLInputElement)) return { ok: false, phase: 'settings', error: 'Limit input was not found.' };
|
||||
for (let attempt = 0; attempt < 30 && Number(document.querySelector(inputSelector)?.value) !== config.limit; attempt += 1) {
|
||||
const currentInput = document.querySelector(inputSelector);
|
||||
const current = Number(currentInput?.value);
|
||||
const action = current > config.limit ? 'decrease-limit' : 'increase-limit';
|
||||
const stepper = dialog?.querySelector('[data-scan-action="' + action + '"]');
|
||||
if (!(stepper instanceof HTMLButtonElement)) break;
|
||||
stepper.click();
|
||||
await new Promise((resolve) => setTimeout(resolve, 35));
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 250));
|
||||
configuredLimit = Number(document.querySelector(inputSelector)?.value);
|
||||
if (configuredLimit !== config.limit) return { ok: false, phase: 'settings', error: 'Scan limit could not be configured.', configuredLimit, config };
|
||||
configuredScopeValid = limitedScope.checked
|
||||
&& unitButton.getAttribute('aria-pressed') === 'true';
|
||||
}
|
||||
const configuredText = document.querySelector('[role="dialog"]')?.textContent ?? '';
|
||||
if (!configuredScopeValid) return { ok: false, phase: 'settings', error: 'Scan scope was not configured.', configuredLimit, configuredText, config };
|
||||
document.querySelector('[data-scan-action="close-settings"]')?.click();
|
||||
await new Promise((resolve) => setTimeout(resolve, 200));
|
||||
const startButton = document.querySelector('[data-scan-action="start-auto"]');
|
||||
if (!(startButton instanceof HTMLButtonElement) || startButton.disabled) return { ok: false, phase: 'start', error: 'Auto-Scan action is unavailable.', configuredText };
|
||||
startButton.click();
|
||||
|
||||
const deadline = Date.now() + config.deadlineMs;
|
||||
let sawBusy = false;
|
||||
let sawProcessing = false;
|
||||
let sawCaptureRunning = false;
|
||||
let sawLiveResultDuringCapture = false;
|
||||
let sawEvaluationAdvanceDuringCapture = false;
|
||||
let sawResultSlideIn = false;
|
||||
const reducedMotion = matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||||
let maxResultRowsDuringCapture = 0;
|
||||
let maxEvaluatedDuringCapture = 0;
|
||||
const resultObserver = new MutationObserver((mutations) => {
|
||||
for (const mutation of mutations) {
|
||||
for (const node of mutation.addedNodes) {
|
||||
if (!(node instanceof HTMLElement)) continue;
|
||||
const resultNode = node.matches('.result-rail-row') ? node : node.querySelector('.result-rail-row');
|
||||
if (!(resultNode instanceof HTMLElement)) continue;
|
||||
requestAnimationFrame(() => {
|
||||
if (getComputedStyle(resultNode).animationName.includes('scanner-result-slide-in')) {
|
||||
sawResultSlideIn = true;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
resultObserver.observe(document.body, { childList: true, subtree: true });
|
||||
let lastProgressText = '';
|
||||
while (Date.now() < deadline) {
|
||||
const progress = document.querySelector('.scan-progress-surface');
|
||||
const evaluation = document.querySelector('.scan-evaluation-progress');
|
||||
const progressText = [progress?.textContent?.trim(), evaluation?.textContent?.trim()].filter(Boolean).join(' ');
|
||||
if (progress?.classList.contains('is-busy')) sawBusy = true;
|
||||
const liveCaptureStatus = await api.nativeScannerStatus();
|
||||
const liveProcessingStatus = await api.nativeScannerProcessingStatus();
|
||||
if (evaluation && (liveProcessingStatus.running || liveProcessingStatus.processed > 0)) sawProcessing = true;
|
||||
if (progressText) lastProgressText = progressText;
|
||||
const liveResultRows = document.querySelectorAll('.result-rail-row').length;
|
||||
if (liveCaptureStatus.running) {
|
||||
sawCaptureRunning = true;
|
||||
maxResultRowsDuringCapture = Math.max(maxResultRowsDuringCapture, liveResultRows);
|
||||
maxEvaluatedDuringCapture = Math.max(maxEvaluatedDuringCapture, liveProcessingStatus.processed);
|
||||
if (liveResultRows > 0) sawLiveResultDuringCapture = true;
|
||||
if (liveProcessingStatus.processed > 0) sawEvaluationAdvanceDuringCapture = true;
|
||||
}
|
||||
const summary = document.querySelector('.scan-summary-modal');
|
||||
if (sawBusy && summary && !progress?.classList.contains('is-busy')) break;
|
||||
await new Promise((resolve) => setTimeout(resolve, 250));
|
||||
}
|
||||
resultObserver.disconnect();
|
||||
|
||||
const status = await api.nativeScannerStatus();
|
||||
const processing = await api.nativeScannerProcessingStatus();
|
||||
const resolvedTarget = config.expectedTarget ?? status.target;
|
||||
const targetValid = Number.isInteger(resolvedTarget) && resolvedTarget > 0 && resolvedTarget <= 2400;
|
||||
const results = await api.nativeScannerLoadResults({ runDir: status.runDir, limit: targetValid ? resolvedTarget : 2400 });
|
||||
const loadedEntries = Array.isArray(results.results) ? results.results : [];
|
||||
const summaryText = document.querySelector('.scan-summary-modal')?.textContent?.trim() ?? '';
|
||||
const resultRows = document.querySelectorAll('.result-rail-row').length;
|
||||
const expectedPages = targetValid ? Math.ceil(resolvedTarget / 32) : 0;
|
||||
const reviewRate = targetValid ? processing.review / resolvedTarget : 1;
|
||||
const persistenceDisabled = processing.stored === 0
|
||||
&& loadedEntries.every((result) => !result.persistedArtifact && !result.artifactRecordId);
|
||||
const documentFits = document.documentElement.scrollWidth === document.documentElement.clientWidth
|
||||
&& document.documentElement.scrollHeight === document.documentElement.clientHeight;
|
||||
return {
|
||||
ok: Boolean(
|
||||
sawBusy && sawProcessing && sawCaptureRunning
|
||||
&& sawLiveResultDuringCapture && sawEvaluationAdvanceDuringCapture && (sawResultSlideIn || reducedMotion)
|
||||
&& configuredScopeValid
|
||||
&& targetValid && status.status === 'done' && status.target === resolvedTarget && status.captured === resolvedTarget
|
||||
&& status.pages === expectedPages && status.initialTopResetCompleted
|
||||
&& !processing.running && processing.total === resolvedTarget && processing.processed === resolvedTarget
|
||||
&& processing.parsed === resolvedTarget && processing.errors === 0 && reviewRate <= 0.15
|
||||
&& results.ok && results.total === resolvedTarget && loadedEntries.length === resolvedTarget
|
||||
&& resultRows === resolvedTarget && persistenceDisabled && documentFits
|
||||
),
|
||||
phase: 'complete',
|
||||
configuredText,
|
||||
configuredLimit,
|
||||
configuredScopeValid,
|
||||
config,
|
||||
resolvedTarget,
|
||||
expectedPages,
|
||||
reviewRate,
|
||||
persistenceDisabled,
|
||||
documentFits,
|
||||
sawBusy,
|
||||
sawProcessing,
|
||||
sawCaptureRunning,
|
||||
sawLiveResultDuringCapture,
|
||||
sawEvaluationAdvanceDuringCapture,
|
||||
sawResultSlideIn,
|
||||
reducedMotion,
|
||||
maxResultRowsDuringCapture,
|
||||
maxEvaluatedDuringCapture,
|
||||
lastProgressText,
|
||||
summaryText,
|
||||
resultRows,
|
||||
status,
|
||||
processing,
|
||||
results: { ok: results.ok, runDir: results.runDir, total: results.total, loaded: loadedEntries.length },
|
||||
};
|
||||
})()`;
|
||||
|
||||
const report = {
|
||||
version: "packaged-live-acceptance-v1",
|
||||
createdAt: new Date().toISOString(),
|
||||
mode,
|
||||
view,
|
||||
state,
|
||||
target: { title: target.title, url: target.url },
|
||||
inspection: await evaluate(inspectExpression, 60_000),
|
||||
};
|
||||
|
||||
if (mode === "scan5") {
|
||||
report.scan = await evaluate(scanExpression, 180_000);
|
||||
if (!report.scan?.ok) process.exitCode = 1;
|
||||
}
|
||||
|
||||
report.navigation = await evaluate(`
|
||||
(async () => {
|
||||
const target = document.querySelector('[data-navigation-id=${JSON.stringify(view)}]');
|
||||
if (!(target instanceof HTMLElement)) return { ok: false, error: ${JSON.stringify(`${view} navigation was not found.`)} };
|
||||
target.click();
|
||||
await new Promise((resolve) => setTimeout(resolve, 1500));
|
||||
const activeNavigation = document.querySelector('.nav-item[aria-current="page"]');
|
||||
return {
|
||||
ok: activeNavigation?.getAttribute('data-navigation-id') === ${JSON.stringify(view)},
|
||||
clickedNavigation: target.textContent.trim(),
|
||||
activeNavigation: activeNavigation?.textContent?.trim() ?? null,
|
||||
activeNavigationId: activeNavigation?.getAttribute('data-navigation-id') ?? null,
|
||||
bodyText: document.body.innerText.slice(0, 16000),
|
||||
viewport: { width: innerWidth, height: innerHeight },
|
||||
document: {
|
||||
clientWidth: document.documentElement.clientWidth,
|
||||
clientHeight: document.documentElement.clientHeight,
|
||||
scrollWidth: document.documentElement.scrollWidth,
|
||||
scrollHeight: document.documentElement.scrollHeight,
|
||||
},
|
||||
};
|
||||
})()
|
||||
`, 30_000);
|
||||
if (!report.navigation?.ok) process.exitCode = 1;
|
||||
|
||||
if (mode === "ui-scan5" || mode === "ui-scan-row1" || mode === "ui-stream20" || mode === "ui-full-inventory") {
|
||||
report.scan = await evaluate(uiScanExpression, mode === "ui-full-inventory" ? 1_020_000 : 300_000);
|
||||
if (!report.scan?.ok) process.exitCode = 1;
|
||||
}
|
||||
|
||||
if (mode === "reprocess-run") {
|
||||
report.reprocess = await evaluate(`
|
||||
(async () => {
|
||||
const runDir = ${JSON.stringify(reprocessRunDir)};
|
||||
const expectedTarget = ${JSON.stringify(reprocessTarget)};
|
||||
const maxReviewRate = ${JSON.stringify(reprocessMaxReviewRate)};
|
||||
const processing = await window.assistantApi.nativeScannerProcessRun({
|
||||
runDir,
|
||||
persist: false,
|
||||
limit: expectedTarget,
|
||||
stream: false,
|
||||
expectedTotal: expectedTarget,
|
||||
});
|
||||
const resultCount = Array.isArray(processing?.results) ? processing.results.length : 0;
|
||||
const reviewRate = processing?.processed > 0 ? processing.review / processing.processed : 1;
|
||||
return {
|
||||
ok: Boolean(
|
||||
processing?.ok
|
||||
&& processing.processed === expectedTarget
|
||||
&& processing.parsed === expectedTarget
|
||||
&& resultCount === expectedTarget
|
||||
&& processing.errors === 0
|
||||
&& processing.stored === 0
|
||||
&& processing.persisted === false
|
||||
&& reviewRate <= maxReviewRate
|
||||
),
|
||||
runDir: processing?.runDir ?? runDir,
|
||||
processed: processing?.processed ?? 0,
|
||||
parsed: processing?.parsed ?? 0,
|
||||
review: processing?.review ?? 0,
|
||||
reviewRate,
|
||||
stored: processing?.stored ?? 0,
|
||||
errors: processing?.errors ?? 0,
|
||||
elapsedMs: processing?.elapsedMs ?? 0,
|
||||
resultCount,
|
||||
persistenceDisabled: processing?.persisted === false,
|
||||
maxReviewRate,
|
||||
};
|
||||
})()
|
||||
`, 1_020_000);
|
||||
if (!report.reprocess?.ok) process.exitCode = 1;
|
||||
}
|
||||
|
||||
if (state === "scan-settings") {
|
||||
report.interaction = await evaluate(`
|
||||
(async () => {
|
||||
const target = document.querySelector('[data-scan-action="open-settings"]');
|
||||
if (!(target instanceof HTMLButtonElement)) return { ok: false, error: "Scan settings action was not found." };
|
||||
target.click();
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
const dialog = document.querySelector('[role="dialog"], .modal');
|
||||
const visibleFocusable = dialog ? [...dialog.querySelectorAll('button,input,select,textarea,summary,[tabindex]:not([tabindex="-1"])')]
|
||||
.filter((element) => element.getClientRects().length > 0 && getComputedStyle(element).visibility !== 'hidden') : [];
|
||||
const firstFocusable = visibleFocusable[0];
|
||||
const lastFocusable = visibleFocusable.at(-1);
|
||||
lastFocusable?.focus();
|
||||
lastFocusable?.dispatchEvent(new KeyboardEvent('keydown', { key: 'Tab', bubbles: true, cancelable: true }));
|
||||
await new Promise((resolve) => requestAnimationFrame(resolve));
|
||||
return {
|
||||
ok: Boolean(dialog && firstFocusable && document.activeElement === firstFocusable),
|
||||
bodyText: document.body.innerText.slice(0, 16000),
|
||||
dialogText: dialog?.textContent?.trim() ?? null,
|
||||
focusLooped: Boolean(dialog?.contains(document.activeElement) && document.activeElement === firstFocusable),
|
||||
focusedLabel: document.activeElement?.getAttribute?.('aria-label') ?? document.activeElement?.textContent?.trim() ?? null,
|
||||
};
|
||||
})()
|
||||
`, 30_000);
|
||||
if (!report.interaction?.ok) process.exitCode = 1;
|
||||
}
|
||||
|
||||
if (state === "artifact-open") {
|
||||
report.interaction = await evaluate(`
|
||||
(async () => {
|
||||
const api = window.assistantApi;
|
||||
const priorSummary = document.querySelector('.scan-summary-modal');
|
||||
const priorSummaryClose = priorSummary?.querySelector('[data-scan-action="summary-close"]');
|
||||
priorSummaryClose?.click();
|
||||
if (priorSummaryClose) await new Promise((resolve) => setTimeout(resolve, 250));
|
||||
const sources = await api.listCaptureSources();
|
||||
const source = sources.find((candidate) => candidate.isGenshinCandidate);
|
||||
if (!source) return { ok: false, inputSent: false, error: "Genshin source was not found." };
|
||||
const options = {
|
||||
skipOcr: true,
|
||||
omitFullFrame: true,
|
||||
omitInventoryPreview: true,
|
||||
omitCrops: true,
|
||||
omitLockState: true,
|
||||
};
|
||||
const before = await api.captureSource(source.id, 0, false, options);
|
||||
const grid = before?.inventoryGrid;
|
||||
const gridReady = before?.captureTarget === "genshin-client"
|
||||
&& before?.width === 1920 && before?.height === 1080
|
||||
&& grid?.source === "detected" && grid.confidence >= 60
|
||||
&& grid.rows === 4 && grid.cols === 8 && grid.centers?.length >= 32;
|
||||
if (!gridReady) {
|
||||
return { ok: false, inputSent: false, error: "Accepted 1920x1080 4x8 Artifact grid was not detected.", before };
|
||||
}
|
||||
if (before.artifactDetail?.present && before.artifactDetail.confidence >= 45) {
|
||||
return { ok: true, inputSent: false, alreadyOpen: true, artifactDetail: before.artifactDetail };
|
||||
}
|
||||
const target = grid.centers[0];
|
||||
const focus = await api.focusGenshinForScanStart();
|
||||
if (!focus.focused) return { ok: false, inputSent: false, error: "Genshin focus failed.", focus };
|
||||
const click = await api.clickScreen(target.x, target.y);
|
||||
await new Promise((resolve) => setTimeout(resolve, 650));
|
||||
const after = await api.captureSource(source.id, 0, false, options);
|
||||
await api.focusMainWindow();
|
||||
return {
|
||||
ok: Boolean(click.clicked && !click.inputBlocked && after?.artifactDetail?.present && after.artifactDetail.confidence >= 45),
|
||||
inputSent: Boolean(click.clicked),
|
||||
target,
|
||||
click,
|
||||
artifactDetail: after?.artifactDetail ?? null,
|
||||
grid: after?.inventoryGrid ? { rows: after.inventoryGrid.rows, cols: after.inventoryGrid.cols, confidence: after.inventoryGrid.confidence } : null,
|
||||
};
|
||||
})()
|
||||
`, 30_000);
|
||||
if (!report.interaction?.ok) process.exitCode = 1;
|
||||
}
|
||||
|
||||
if (state === "artifact-probe") {
|
||||
report.interaction = await evaluate(`
|
||||
(async () => {
|
||||
const api = window.assistantApi;
|
||||
const preflight = await api.nativeScannerPreflight({ category: 'artifacts' });
|
||||
if (!preflight.ready || preflight.bounds?.width !== 1920 || preflight.bounds?.height !== 1080) {
|
||||
return { ok: false, error: 'Artifact probe requires the accepted 1920x1080 preflight.', preflight };
|
||||
}
|
||||
const focus = await api.focusGenshinForScanStart();
|
||||
if (!focus.focused) return { ok: false, error: 'Genshin focus failed.', focus, preflight };
|
||||
const click = await api.clickScreen(179, 254);
|
||||
await new Promise((resolve) => setTimeout(resolve, 450));
|
||||
const sources = await api.listCaptureSources();
|
||||
const source = sources.find((candidate) => candidate.isGenshinCandidate);
|
||||
const capture = source
|
||||
? await api.captureSource(source.id, 0, false, {
|
||||
skipOcr: true,
|
||||
omitFullFrame: true,
|
||||
omitDetailPreview: true,
|
||||
omitInventoryPreview: true,
|
||||
omitCrops: true,
|
||||
omitLockState: true,
|
||||
})
|
||||
: null;
|
||||
await api.focusMainWindow();
|
||||
return {
|
||||
ok: Boolean(click.clicked && !click.inputBlocked && capture?.artifactDetail?.present),
|
||||
focus,
|
||||
click,
|
||||
artifactDetail: capture?.artifactDetail ?? null,
|
||||
inventoryGrid: capture?.inventoryGrid ?? null,
|
||||
};
|
||||
})()
|
||||
`, 60_000);
|
||||
if (!report.interaction?.ok) process.exitCode = 1;
|
||||
}
|
||||
|
||||
if (state === "post-scan-results") {
|
||||
report.interaction = await evaluate(`
|
||||
(async () => {
|
||||
const summary = document.querySelector('.scan-summary-modal');
|
||||
const close = summary?.querySelector('[data-scan-action="summary-close"]');
|
||||
close?.click();
|
||||
await new Promise((resolve) => setTimeout(resolve, 350));
|
||||
const panel = document.querySelector('.scanner-result-panel');
|
||||
const evaluation = document.querySelector('.scan-evaluation-progress');
|
||||
const rail = document.querySelector('.result-rail-list');
|
||||
const rows = [...document.querySelectorAll('.result-rail-row')];
|
||||
const panelRect = panel?.getBoundingClientRect();
|
||||
return {
|
||||
ok: Boolean(
|
||||
!document.querySelector('.scan-summary-modal')
|
||||
&& panel && evaluation && rail && rows.length > 0
|
||||
&& document.documentElement.scrollWidth === document.documentElement.clientWidth
|
||||
&& document.documentElement.scrollHeight === document.documentElement.clientHeight
|
||||
),
|
||||
resultRows: rows.length,
|
||||
firstResult: rows[0]?.textContent?.trim() ?? null,
|
||||
lastResult: rows.at(-1)?.textContent?.trim() ?? null,
|
||||
evaluationText: evaluation?.textContent?.trim() ?? null,
|
||||
panelRect: panelRect ? { x: panelRect.x, y: panelRect.y, width: panelRect.width, height: panelRect.height } : null,
|
||||
rail: rail ? { clientHeight: rail.clientHeight, scrollHeight: rail.scrollHeight, scrollTop: rail.scrollTop } : null,
|
||||
document: {
|
||||
clientWidth: document.documentElement.clientWidth,
|
||||
clientHeight: document.documentElement.clientHeight,
|
||||
scrollWidth: document.documentElement.scrollWidth,
|
||||
scrollHeight: document.documentElement.scrollHeight,
|
||||
},
|
||||
};
|
||||
})()
|
||||
`, 15_000);
|
||||
if (!report.interaction?.ok) process.exitCode = 1;
|
||||
}
|
||||
|
||||
if (state === "review-deeplink") {
|
||||
report.interaction = await evaluate(`
|
||||
(async () => {
|
||||
const target = document.querySelector('.triage-primary-action');
|
||||
if (!target) return { ok: false, error: "Review deep-link action was not found." };
|
||||
target.click();
|
||||
await new Promise((resolve) => setTimeout(resolve, 1200));
|
||||
const activeNavigation = document.querySelector('.nav-item[aria-current="page"]');
|
||||
const activeFilter = document.querySelector('.inventory-filter-control button.active')?.textContent?.trim() ?? null;
|
||||
return {
|
||||
ok: activeNavigation?.getAttribute('data-navigation-id') === 'inventory' && Boolean(activeFilter),
|
||||
activeNavigation: activeNavigation?.textContent?.trim() ?? null,
|
||||
activeNavigationId: activeNavigation?.getAttribute('data-navigation-id') ?? null,
|
||||
activeFilter,
|
||||
};
|
||||
})()
|
||||
`, 30_000);
|
||||
if (!report.interaction?.ok) process.exitCode = 1;
|
||||
}
|
||||
|
||||
if (state === "keyboard-focus") {
|
||||
report.interaction = await evaluate(`
|
||||
(() => {
|
||||
const target = document.querySelector('.nav-item:not([aria-current="page"]):not(:disabled)');
|
||||
if (!(target instanceof HTMLElement)) return { ok: false, error: "Focusable navigation item was not found." };
|
||||
target.focus();
|
||||
const styles = getComputedStyle(target);
|
||||
return {
|
||||
ok: document.activeElement === target && styles.outlineStyle !== "none" && Number.parseFloat(styles.outlineWidth) >= 2,
|
||||
focusedText: target.textContent?.trim() ?? null,
|
||||
outline: styles.outline,
|
||||
boxShadow: styles.boxShadow,
|
||||
};
|
||||
})()
|
||||
`, 10_000);
|
||||
if (!report.interaction?.ok) process.exitCode = 1;
|
||||
}
|
||||
|
||||
if (state === "overlay-open") {
|
||||
report.interaction = await evaluate(`
|
||||
(async () => {
|
||||
const target = document.querySelector('.overlay-open-button');
|
||||
if (!(target instanceof HTMLButtonElement)) return { ok: false, error: "Overlay action was not found." };
|
||||
target.click();
|
||||
await new Promise((resolve) => setTimeout(resolve, 650));
|
||||
const toast = document.querySelector('.app-toast');
|
||||
return {
|
||||
ok: Boolean(toast),
|
||||
toastText: toast?.textContent?.trim() ?? null,
|
||||
};
|
||||
})()
|
||||
`, 15_000);
|
||||
if (!report.interaction?.ok) process.exitCode = 1;
|
||||
}
|
||||
|
||||
if (state === "inventory-confirm") {
|
||||
report.interaction = await evaluate(`
|
||||
(async () => {
|
||||
let target = document.querySelector('[data-inventory-action="promote"]:not(:disabled), [data-inventory-action="review"]:not(:disabled)');
|
||||
if (!target) {
|
||||
const reviewFilter = document.querySelector('[data-inventory-filter="review"]');
|
||||
reviewFilter?.click();
|
||||
await new Promise((resolve) => setTimeout(resolve, 250));
|
||||
const rows = [...document.querySelectorAll('.inventory-row')];
|
||||
for (const row of rows) {
|
||||
row.click();
|
||||
await new Promise((resolve) => setTimeout(resolve, 150));
|
||||
target = document.querySelector('[data-inventory-action="review"]:not(:disabled)');
|
||||
if (target) break;
|
||||
}
|
||||
}
|
||||
if (!target) return { ok: false, skipped: true, error: "No safe confirmation/editor action is available for the current corpus." };
|
||||
target.click();
|
||||
await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
|
||||
const confirmation = document.querySelector('.inventory-promotion-confirm, .inventory-review-editor');
|
||||
return {
|
||||
ok: Boolean(confirmation),
|
||||
action: target.textContent.trim(),
|
||||
confirmationText: confirmation?.textContent?.trim() ?? null,
|
||||
focusedText: document.activeElement?.textContent?.trim() ?? null,
|
||||
};
|
||||
})()
|
||||
`, 15_000);
|
||||
if (!report.interaction?.ok && !report.interaction?.skipped) process.exitCode = 1;
|
||||
}
|
||||
|
||||
if (state === "inventory-delete-confirm") {
|
||||
report.interaction = await evaluate(`
|
||||
(async () => {
|
||||
const target = document.querySelector('[data-inventory-action="delete"]');
|
||||
if (!(target instanceof HTMLButtonElement)) {
|
||||
return { ok: false, error: "No local-only artifact deletion action is available for the selected row." };
|
||||
}
|
||||
target.click();
|
||||
await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
|
||||
const confirmation = document.querySelector('.inventory-deletion-confirm');
|
||||
const confirmButton = document.querySelector('[data-inventory-action="confirm-delete"]');
|
||||
const cancelButton = document.querySelector('[data-inventory-action="cancel-delete"]');
|
||||
const prompt = confirmation?.querySelector('strong');
|
||||
const description = confirmation?.querySelector('small');
|
||||
const visibleInViewport = (element) => {
|
||||
if (!(element instanceof HTMLElement) || element.getClientRects().length === 0) return false;
|
||||
const rect = element.getBoundingClientRect();
|
||||
return rect.top >= 0 && rect.bottom <= innerHeight;
|
||||
};
|
||||
return {
|
||||
ok: Boolean(
|
||||
confirmation
|
||||
&& visibleInViewport(prompt)
|
||||
&& visibleInViewport(description)
|
||||
&& confirmButton instanceof HTMLButtonElement
|
||||
&& cancelButton instanceof HTMLButtonElement
|
||||
&& document.activeElement === confirmButton
|
||||
),
|
||||
action: target.textContent?.trim() ?? null,
|
||||
confirmationText: confirmation?.textContent?.trim() ?? null,
|
||||
promptVisible: visibleInViewport(prompt),
|
||||
descriptionVisible: visibleInViewport(description),
|
||||
focusedAction: document.activeElement?.getAttribute?.('data-inventory-action') ?? null,
|
||||
confirmDisabled: confirmButton instanceof HTMLButtonElement ? confirmButton.disabled : null,
|
||||
};
|
||||
})()
|
||||
`, 15_000);
|
||||
if (!report.interaction?.ok) process.exitCode = 1;
|
||||
}
|
||||
|
||||
if (state === "inventory-store-delete-confirm") {
|
||||
report.interaction = await evaluate(`
|
||||
(async () => {
|
||||
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
const storeFilter = document.querySelector('[data-inventory-filter="stored"]');
|
||||
if (!(storeFilter instanceof HTMLButtonElement)) {
|
||||
return { ok: false, error: "The local Store filter is unavailable." };
|
||||
}
|
||||
storeFilter.click();
|
||||
await sleep(160);
|
||||
const storeRow = document.querySelector('.inventory-list [role="option"]');
|
||||
if (!(storeRow instanceof HTMLElement)) {
|
||||
return { ok: false, error: "No local Store row is available for a non-mutating confirmation probe." };
|
||||
}
|
||||
storeRow.click();
|
||||
await sleep(120);
|
||||
const target = document.querySelector('[data-inventory-action="delete"]');
|
||||
if (!(target instanceof HTMLButtonElement)) {
|
||||
return { ok: false, error: "The selected local Store row did not expose its delete action." };
|
||||
}
|
||||
target.click();
|
||||
await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
|
||||
const confirmation = document.querySelector('.inventory-deletion-confirm');
|
||||
const prompt = confirmation?.querySelector('strong');
|
||||
const description = confirmation?.querySelector('small');
|
||||
const confirmButton = document.querySelector('[data-inventory-action="confirm-delete"]');
|
||||
const cancelButton = document.querySelector('[data-inventory-action="cancel-delete"]');
|
||||
const linkedStoreToggle = document.querySelector('[data-inventory-action="toggle-linked-store"]');
|
||||
const visibleInViewport = (element) => {
|
||||
if (!(element instanceof HTMLElement) || element.getClientRects().length === 0) return false;
|
||||
const rect = element.getBoundingClientRect();
|
||||
return rect.top >= 0 && rect.bottom <= innerHeight;
|
||||
};
|
||||
return {
|
||||
ok: Boolean(
|
||||
confirmation
|
||||
&& visibleInViewport(prompt)
|
||||
&& visibleInViewport(description)
|
||||
&& confirmButton instanceof HTMLButtonElement
|
||||
&& cancelButton instanceof HTMLButtonElement
|
||||
&& document.activeElement === confirmButton
|
||||
&& !linkedStoreToggle
|
||||
),
|
||||
action: target.textContent?.trim() ?? null,
|
||||
confirmationText: confirmation?.textContent?.trim() ?? null,
|
||||
promptVisible: visibleInViewport(prompt),
|
||||
descriptionVisible: visibleInViewport(description),
|
||||
focusedAction: document.activeElement?.getAttribute?.('data-inventory-action') ?? null,
|
||||
hasLinkedStoreToggle: Boolean(linkedStoreToggle),
|
||||
};
|
||||
})()
|
||||
`, 15_000);
|
||||
if (!report.interaction?.ok) process.exitCode = 1;
|
||||
}
|
||||
|
||||
if (state === "inventory-roving") {
|
||||
report.interaction = await evaluate(`
|
||||
(async () => {
|
||||
const listbox = document.querySelector('.inventory-list[role="listbox"]');
|
||||
const before = listbox?.querySelector('[role="option"][aria-selected="true"]');
|
||||
if (!(before instanceof HTMLElement)) return { ok: false, error: "Selected inventory row was not found." };
|
||||
before.focus();
|
||||
before.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true, cancelable: true }));
|
||||
await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
|
||||
const after = listbox.querySelector('[role="option"][aria-selected="true"]');
|
||||
return {
|
||||
ok: Boolean(after && after !== before && document.activeElement === after && after.getAttribute('tabindex') === '0'),
|
||||
before: before.textContent.trim(),
|
||||
after: after?.textContent?.trim() ?? null,
|
||||
activeMatchesSelection: document.activeElement === after,
|
||||
tabbableRows: listbox.querySelectorAll('[role="option"][tabindex="0"]').length,
|
||||
};
|
||||
})()
|
||||
`, 15_000);
|
||||
if (!report.interaction?.ok) process.exitCode = 1;
|
||||
}
|
||||
|
||||
if (state === "inventory-technical") {
|
||||
report.interaction = await evaluate(`
|
||||
(async () => {
|
||||
const target = document.querySelector('.inventory-technical-button');
|
||||
if (!(target instanceof HTMLButtonElement)) return { ok: false, error: "Technical details action was not found." };
|
||||
target.click();
|
||||
await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
|
||||
const dialog = document.querySelector('.inventory-technical-modal[role="dialog"]');
|
||||
const focusables = dialog ? [...dialog.querySelectorAll('button,input,select,textarea,[tabindex]:not([tabindex="-1"])')]
|
||||
.filter((element) => element.getClientRects().length > 0 && getComputedStyle(element).visibility !== 'hidden') : [];
|
||||
const firstFocusable = focusables[0];
|
||||
const lastFocusable = focusables.at(-1);
|
||||
lastFocusable?.focus();
|
||||
lastFocusable?.dispatchEvent(new KeyboardEvent('keydown', { key: 'Tab', bubbles: true, cancelable: true }));
|
||||
await new Promise((resolve) => requestAnimationFrame(resolve));
|
||||
const rect = dialog?.getBoundingClientRect();
|
||||
return {
|
||||
ok: Boolean(
|
||||
dialog && firstFocusable && document.activeElement === firstFocusable
|
||||
&& rect && rect.top >= 0 && rect.bottom <= innerHeight
|
||||
&& document.documentElement.scrollWidth === document.documentElement.clientWidth
|
||||
&& document.documentElement.scrollHeight === document.documentElement.clientHeight
|
||||
),
|
||||
dialogText: dialog?.textContent?.trim() ?? null,
|
||||
focusLooped: document.activeElement === firstFocusable,
|
||||
dialogRect: rect ? { x: rect.x, y: rect.y, width: rect.width, height: rect.height } : null,
|
||||
};
|
||||
})()
|
||||
`, 15_000);
|
||||
if (!report.interaction?.ok) process.exitCode = 1;
|
||||
}
|
||||
|
||||
await call("Page.bringToFront", {}, 10_000);
|
||||
await evaluate("new Promise((resolve) => setTimeout(resolve, 120))", 10_000);
|
||||
const screenshot = await call("Page.captureScreenshot", {
|
||||
format: "png",
|
||||
fromSurface: true,
|
||||
captureBeyondViewport: false,
|
||||
}, 30_000);
|
||||
fs.mkdirSync(path.dirname(screenshotPath), { recursive: true });
|
||||
fs.writeFileSync(screenshotPath, Buffer.from(screenshot.data, "base64"));
|
||||
report.screenshotPath = screenshotPath;
|
||||
report.rendererDiagnostics = { checked: consoleCheck, ...rendererDiagnostics };
|
||||
if (consoleCheck && (rendererDiagnostics.exceptions.length > 0 || rendererDiagnostics.consoleErrors.length > 0 || rendererDiagnostics.logErrors.length > 0)) {
|
||||
process.exitCode = 1;
|
||||
}
|
||||
|
||||
fs.mkdirSync(path.dirname(output), { recursive: true });
|
||||
fs.writeFileSync(output, JSON.stringify(report, null, 2), "utf8");
|
||||
console.log(JSON.stringify(report, null, 2));
|
||||
console.log(`Report: ${output}`);
|
||||
console.log(`Screenshot: ${screenshotPath}`);
|
||||
if (state === "scan-settings") await evaluate(`document.querySelector('[data-scan-action="close-settings"]')?.click(); true`, 5_000).catch(() => undefined);
|
||||
if (state === "inventory-delete-confirm") await evaluate(`document.querySelector('[data-inventory-action="cancel-delete"]')?.click(); true`, 5_000).catch(() => undefined);
|
||||
if (state === "inventory-store-delete-confirm") {
|
||||
await evaluate(`
|
||||
document.querySelector('[data-inventory-action="cancel-delete"]')?.click();
|
||||
document.querySelector('[data-inventory-filter="all"]')?.click();
|
||||
true
|
||||
`, 5_000).catch(() => undefined);
|
||||
}
|
||||
if (state === "inventory-technical") await evaluate(`document.querySelector('.inventory-technical-modal .icon-button')?.click(); true`, 5_000).catch(() => undefined);
|
||||
if (state === "overlay-open") await evaluate("window.assistantApi.hideOverlay(); true", 5_000).catch(() => undefined);
|
||||
if (closeAfter) {
|
||||
await evaluate("window.close(); true", 5_000).catch(() => undefined);
|
||||
}
|
||||
if (viewportWidth !== null && viewportHeight !== null) {
|
||||
await call("Emulation.clearDeviceMetricsOverride", {}, 5_000).catch(() => undefined);
|
||||
}
|
||||
socket.close();
|
||||
@@ -0,0 +1,411 @@
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { createHash } from "node:crypto";
|
||||
import { loadNativeScannerResultTombstones } from "../electron/services/nativeScannerResultTombstones.js";
|
||||
import { evaluateStoredScanResult, projectArtifactUpgrade } from "../src/lib/artifactEvaluation.js";
|
||||
import { replayNativeScanResults } from "../src/eval/nativeScanReplay.js";
|
||||
import type { ArtifactUpgradeProjection, StoredScanResultEntry } from "../src/types/storage.js";
|
||||
|
||||
type NativeRunManifest = {
|
||||
runId?: string;
|
||||
category?: string;
|
||||
target?: number;
|
||||
};
|
||||
|
||||
type NativeRunStatus = {
|
||||
scanner?: {
|
||||
status?: string;
|
||||
runId?: string;
|
||||
target?: number;
|
||||
captured?: number;
|
||||
queued?: number;
|
||||
clicked?: number;
|
||||
pages?: number;
|
||||
activeMs?: number;
|
||||
};
|
||||
};
|
||||
|
||||
type NativeProcessingReport = {
|
||||
ok?: boolean;
|
||||
processed?: number;
|
||||
review?: number;
|
||||
stored?: number;
|
||||
errors?: number;
|
||||
elapsedMs?: number;
|
||||
queueConcurrency?: number;
|
||||
persisted?: boolean;
|
||||
};
|
||||
|
||||
type NativeCaptureJob = {
|
||||
sequence?: number;
|
||||
page?: number;
|
||||
category?: string;
|
||||
absolutePath?: string;
|
||||
relativePath?: string;
|
||||
downstream?: string;
|
||||
};
|
||||
|
||||
type NativeReviewLogEntry = {
|
||||
ok?: boolean;
|
||||
action?: string;
|
||||
resultId?: string;
|
||||
};
|
||||
|
||||
type RunValidationSummary = {
|
||||
runId: string;
|
||||
runDir: string;
|
||||
target: number;
|
||||
issues: string[];
|
||||
evidence: {
|
||||
jobs: number;
|
||||
pngs: number;
|
||||
results: number;
|
||||
captured: number;
|
||||
pages: number;
|
||||
processingErrors: number;
|
||||
persisted: boolean;
|
||||
originalReview: number;
|
||||
currentReview: number;
|
||||
approvedReviewLogs: number;
|
||||
duplicatePngHashGroups: number;
|
||||
repeatedPagePairs: string[];
|
||||
identicalBoundaryPairs: string[];
|
||||
};
|
||||
performance: {
|
||||
captureMs: number;
|
||||
capturePerSecond: number | null;
|
||||
processingMs: number;
|
||||
processingPerSecond: number | null;
|
||||
};
|
||||
replay: ReturnType<typeof replayNativeScanResults>;
|
||||
};
|
||||
|
||||
const options = parseArgs(process.argv.slice(2));
|
||||
const scanRoot = options.scanRoot || path.join(
|
||||
process.env.APPDATA || path.join(os.homedir(), "AppData", "Roaming"),
|
||||
"genshin-artifact-assistant",
|
||||
"native-scans",
|
||||
);
|
||||
const selectedRuns = await selectLatestRuns(scanRoot, options.targets);
|
||||
const runs: RunValidationSummary[] = [];
|
||||
|
||||
for (const target of options.targets) {
|
||||
const runDir = selectedRuns.get(target);
|
||||
if (!runDir) throw new Error(`No saved native run with target ${target} found below ${scanRoot}`);
|
||||
runs.push(await validateRun(runDir, target, options.repeats));
|
||||
}
|
||||
|
||||
const result = {
|
||||
version: "saved-native-run-validation-v1",
|
||||
createdAt: new Date().toISOString(),
|
||||
scanRoot,
|
||||
targets: options.targets,
|
||||
repeats: options.repeats,
|
||||
ok: runs.every((run) => run.issues.length === 0),
|
||||
runs,
|
||||
};
|
||||
const outputDir = path.resolve("outputs", "native-replay");
|
||||
await fs.mkdir(outputDir, { recursive: true });
|
||||
const reportPath = path.join(outputDir, "saved-native-validation-report.json");
|
||||
await fs.writeFile(reportPath, JSON.stringify(result, null, 2), "utf8");
|
||||
|
||||
console.log(`Saved native validation: ${result.ok ? "PASS" : "FAIL"}`);
|
||||
for (const run of runs) {
|
||||
const replay = run.replay;
|
||||
console.log(
|
||||
`${run.target}: ${run.runId}; capture=${formatRate(run.performance.capturePerSecond)}/s; `
|
||||
+ `processing=${formatRate(run.performance.processingPerSecond)}/s; `
|
||||
+ `evaluated=${replay.values.evaluated}; excluded=${replay.values.excluded}; `
|
||||
+ `review=${replay.values.review}; unknown=${replay.values.unknown}; `
|
||||
+ `projection=${replay.projections.available}/${replay.projections.complete}; issues=${run.issues.length}`,
|
||||
);
|
||||
for (const issue of run.issues) console.error(` - ${issue}`);
|
||||
}
|
||||
console.log(`Report: ${reportPath}`);
|
||||
if (!result.ok) process.exitCode = 1;
|
||||
|
||||
async function validateRun(runDir: string, target: number, repeats: number): Promise<RunValidationSummary> {
|
||||
const issues: string[] = [];
|
||||
const manifestPath = path.join(runDir, "manifest.json");
|
||||
const statusPath = path.join(runDir, "status.json");
|
||||
const jobsPath = path.join(runDir, "capture-jobs.jsonl");
|
||||
const resultsPath = path.join(runDir, "scan-results.json");
|
||||
const processingPath = path.join(runDir, "processing-report.json");
|
||||
const [manifest, statusPayload, jobs, rawResults, processing, reviewLogs] = await Promise.all([
|
||||
readJson<NativeRunManifest>(manifestPath),
|
||||
readJson<NativeRunStatus>(statusPath),
|
||||
readJsonLines<NativeCaptureJob>(jobsPath),
|
||||
readJson<unknown[]>(resultsPath),
|
||||
readJson<NativeProcessingReport>(processingPath),
|
||||
readOptionalJsonLines<NativeReviewLogEntry>(path.join(runDir, "review-log.jsonl")),
|
||||
]);
|
||||
const status = statusPayload.scanner ?? {};
|
||||
const results = Array.isArray(rawResults) ? rawResults.filter(isStoredScanResultEntry) : [];
|
||||
const runId = manifest.runId || path.basename(runDir);
|
||||
|
||||
expectEqual(issues, "manifest target", manifest.target, target);
|
||||
expectEqual(issues, "manifest category", manifest.category, "artifacts");
|
||||
expectEqual(issues, "status run id", status.runId, runId);
|
||||
if (!new Set(["done", "completed"]).has(status.status ?? "")) issues.push(`scanner status is ${status.status || "missing"}`);
|
||||
expectEqual(issues, "status target", status.target, target);
|
||||
expectEqual(issues, "captured count", status.captured, target);
|
||||
expectEqual(issues, "queued count", status.queued, target);
|
||||
expectEqual(issues, "job count", jobs.length, target);
|
||||
expectEqual(issues, "raw result count", rawResults.length, target);
|
||||
expectEqual(issues, "valid result count", results.length, target);
|
||||
expectEqual(issues, "processed count", processing.processed, target);
|
||||
expectEqual(issues, "processing errors", processing.errors, 0);
|
||||
expectEqual(issues, "processing persisted", processing.persisted, false);
|
||||
if (processing.ok !== true) issues.push("processing report is not ok");
|
||||
if (!Number.isFinite(processing.queueConcurrency) || (processing.queueConcurrency ?? 0) < 1) {
|
||||
issues.push("processing queue concurrency is missing or invalid");
|
||||
}
|
||||
|
||||
validateSequences(issues, "capture job", jobs.map((job) => job.sequence), target);
|
||||
validateSequences(issues, "scan result", results.map((entry) => entry.sequence), target);
|
||||
const resolvedRunDir = path.resolve(runDir);
|
||||
let pngs = 0;
|
||||
const pngHashes: Array<{ sequence: number; page: number; hash: string }> = [];
|
||||
for (const job of jobs) {
|
||||
if (job.category !== "artifacts") issues.push(`job #${job.sequence ?? "?"} has category ${job.category ?? "missing"}`);
|
||||
if (job.downstream !== "ocr-parse-store") issues.push(`job #${job.sequence ?? "?"} has unexpected downstream contract`);
|
||||
const absolutePath = typeof job.absolutePath === "string" ? path.resolve(job.absolutePath) : "";
|
||||
if (!absolutePath || !isPathInside(resolvedRunDir, absolutePath)) {
|
||||
issues.push(`job #${job.sequence ?? "?"} image escapes or misses the run directory`);
|
||||
continue;
|
||||
}
|
||||
if (path.extname(absolutePath).toLowerCase() !== ".png") {
|
||||
issues.push(`job #${job.sequence ?? "?"} image is not PNG`);
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
const stat = await fs.stat(absolutePath);
|
||||
if (!stat.isFile() || stat.size === 0) issues.push(`job #${job.sequence ?? "?"} PNG is empty`);
|
||||
else {
|
||||
pngs += 1;
|
||||
const bytes = await fs.readFile(absolutePath);
|
||||
pngHashes.push({
|
||||
sequence: finiteNumber(job.sequence),
|
||||
page: finiteNumber(job.page),
|
||||
hash: createHash("sha256").update(bytes).digest("hex"),
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
issues.push(`job #${job.sequence ?? "?"} PNG is missing`);
|
||||
}
|
||||
}
|
||||
expectEqual(issues, "PNG count", pngs, target);
|
||||
const duplicatePngHashGroups = duplicateHashGroupCount(pngHashes);
|
||||
const repeatedPagePairs = identicalPagePairs(pngHashes);
|
||||
const identicalBoundaryPairs = identicalScrollBoundaryPairs(pngHashes);
|
||||
if (repeatedPagePairs.length > 0) {
|
||||
issues.push(`entire captured pages repeat exactly: ${repeatedPagePairs.join(", ")}`);
|
||||
}
|
||||
|
||||
for (const entry of results) {
|
||||
expectEqual(issues, `result #${entry.sequence} run id`, entry.runId, runId);
|
||||
expectEqual(issues, `result #${entry.sequence} category`, entry.category, "artifact");
|
||||
const evaluated = evaluateStoredScanResult(entry);
|
||||
if (evaluated.valueScore !== null && (evaluated.valueScore < 0 || evaluated.valueScore > 100)) {
|
||||
issues.push(`result #${entry.sequence} has score outside 0-100`);
|
||||
}
|
||||
validateProjection(issues, entry.sequence, evaluated);
|
||||
}
|
||||
|
||||
const replay = replayNativeScanResults({ entries: results, repeats, runId, sourcePath: resultsPath });
|
||||
if (!replay.deterministic) issues.push("replay payload is not deterministic");
|
||||
if (replay.cleanUnevaluated.length > 0) issues.push(`${replay.cleanUnevaluated.length} clean results remain unevaluated`);
|
||||
const currentReview = results.filter((entry) => entry.extractionStatus === "review" || entry.needsReview).length;
|
||||
const originalReview = Number(processing.review ?? 0);
|
||||
const approvedReviewLogs = reviewLogs.filter((entry) => entry.ok && entry.action === "approve" && entry.resultId).length;
|
||||
const resolvedReview = Math.max(0, originalReview - currentReview);
|
||||
if (resolvedReview > approvedReviewLogs) {
|
||||
issues.push(`${resolvedReview} processing reviews disappeared but only ${approvedReviewLogs} approvals are logged`);
|
||||
}
|
||||
|
||||
const captureMs = finiteNumber(status.activeMs);
|
||||
const processingMs = finiteNumber(processing.elapsedMs);
|
||||
return {
|
||||
runId,
|
||||
runDir,
|
||||
target,
|
||||
issues: [...new Set(issues)],
|
||||
evidence: {
|
||||
jobs: jobs.length,
|
||||
pngs,
|
||||
results: results.length,
|
||||
captured: finiteNumber(status.captured),
|
||||
pages: finiteNumber(status.pages),
|
||||
processingErrors: finiteNumber(processing.errors),
|
||||
persisted: processing.persisted === true,
|
||||
originalReview,
|
||||
currentReview,
|
||||
approvedReviewLogs,
|
||||
duplicatePngHashGroups,
|
||||
repeatedPagePairs,
|
||||
identicalBoundaryPairs,
|
||||
},
|
||||
performance: {
|
||||
captureMs,
|
||||
capturePerSecond: rate(target, captureMs),
|
||||
processingMs,
|
||||
processingPerSecond: rate(target, processingMs),
|
||||
},
|
||||
replay,
|
||||
};
|
||||
}
|
||||
|
||||
function validateProjection(issues: string[], sequence: number, entry: StoredScanResultEntry) {
|
||||
const evaluation = entry.valueEvaluation;
|
||||
if (!evaluation) {
|
||||
issues.push(`result #${sequence} has no derived evaluation`);
|
||||
return;
|
||||
}
|
||||
const projection = projectArtifactUpgrade(entry.artifact, evaluation);
|
||||
if (projection.status === "available") {
|
||||
const scores = [projection.worstScore, projection.middleScore, projection.bestScore];
|
||||
if (scores.some((score) => typeof score !== "number")) issues.push(`result #${sequence} projection misses a score`);
|
||||
else if (!(scores[0]! <= scores[1]! && scores[1]! <= scores[2]!)) issues.push(`result #${sequence} projection order is invalid`);
|
||||
if (evaluation.rarity !== 5 || (entry.artifact?.level ?? 20) >= 20 || (entry.artifact?.substats.length ?? 0) < 4) {
|
||||
issues.push(`result #${sequence} projection violates its 5-star under-level boundary`);
|
||||
}
|
||||
}
|
||||
validateProjectionScores(issues, sequence, projection);
|
||||
}
|
||||
|
||||
function validateProjectionScores(issues: string[], sequence: number, projection: ArtifactUpgradeProjection) {
|
||||
for (const score of [projection.worstScore, projection.middleScore, projection.bestScore]) {
|
||||
if (score !== null && (score < 0 || score > 100)) issues.push(`result #${sequence} projection score is outside 0-100`);
|
||||
}
|
||||
}
|
||||
|
||||
async function selectLatestRuns(scanRoot: string, targets: number[]) {
|
||||
const selected = new Map<number, string>();
|
||||
const directories = (await fs.readdir(scanRoot, { withFileTypes: true }))
|
||||
.filter((entry) => entry.isDirectory())
|
||||
.map((entry) => entry.name)
|
||||
.sort()
|
||||
.reverse();
|
||||
for (const directory of directories) {
|
||||
const runDir = path.join(scanRoot, directory);
|
||||
try {
|
||||
const manifest = await readJson<NativeRunManifest>(path.join(runDir, "manifest.json"));
|
||||
const target = Number(manifest.target);
|
||||
// A user may intentionally remove a bad local row and its crop. Such a
|
||||
// run is still valid for the app UI but no longer immutable acceptance
|
||||
// evidence, so select an untouched run for validation instead.
|
||||
if ((await loadNativeScannerResultTombstones(runDir)).length > 0) continue;
|
||||
if (targets.includes(target) && !selected.has(target)) selected.set(target, runDir);
|
||||
} catch {
|
||||
// Ignore unrelated or incomplete directories; selected evidence is validated strictly below.
|
||||
}
|
||||
}
|
||||
return selected;
|
||||
}
|
||||
|
||||
function validateSequences(issues: string[], label: string, values: Array<number | undefined>, target: number) {
|
||||
const sequences = values.filter((value): value is number => Number.isInteger(value)).sort((left, right) => left - right);
|
||||
const expected = Array.from({ length: target }, (_, index) => index + 1);
|
||||
if (sequences.length !== expected.length || sequences.some((value, index) => value !== expected[index])) {
|
||||
issues.push(`${label} sequence is not exactly 1-${target}`);
|
||||
}
|
||||
}
|
||||
|
||||
function duplicateHashGroupCount(entries: Array<{ hash: string }>) {
|
||||
const counts = new Map<string, number>();
|
||||
for (const entry of entries) counts.set(entry.hash, (counts.get(entry.hash) ?? 0) + 1);
|
||||
return [...counts.values()].filter((count) => count > 1).length;
|
||||
}
|
||||
|
||||
function identicalPagePairs(entries: Array<{ page: number; sequence: number; hash: string }>) {
|
||||
const pages = new Map<number, Array<{ sequence: number; hash: string }>>();
|
||||
for (const entry of entries) {
|
||||
const page = pages.get(entry.page) ?? [];
|
||||
page.push({ sequence: entry.sequence, hash: entry.hash });
|
||||
pages.set(entry.page, page);
|
||||
}
|
||||
const ordered = [...pages.entries()]
|
||||
.map(([page, values]) => ({ page, hashes: values.sort((left, right) => left.sequence - right.sequence).map((value) => value.hash) }))
|
||||
.sort((left, right) => left.page - right.page);
|
||||
const repeated: string[] = [];
|
||||
for (let leftIndex = 0; leftIndex < ordered.length; leftIndex += 1) {
|
||||
for (let rightIndex = leftIndex + 1; rightIndex < ordered.length; rightIndex += 1) {
|
||||
const left = ordered[leftIndex];
|
||||
const right = ordered[rightIndex];
|
||||
if (left.hashes.length > 0 && left.hashes.length === right.hashes.length && left.hashes.every((hash, index) => hash === right.hashes[index])) {
|
||||
repeated.push(`${left.page}/${right.page}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
return repeated;
|
||||
}
|
||||
|
||||
function identicalScrollBoundaryPairs(entries: Array<{ sequence: number; hash: string }>) {
|
||||
const hashes = new Map(entries.map((entry) => [entry.sequence, entry.hash]));
|
||||
return [[32, 33], [64, 65], [96, 97]]
|
||||
.filter(([left, right]) => hashes.has(left) && hashes.get(left) === hashes.get(right))
|
||||
.map(([left, right]) => `${left}/${right}`);
|
||||
}
|
||||
|
||||
function isStoredScanResultEntry(value: unknown): value is StoredScanResultEntry {
|
||||
if (!value || typeof value !== "object") return false;
|
||||
const entry = value as Partial<StoredScanResultEntry>;
|
||||
return typeof entry.id === "string"
|
||||
&& typeof entry.runId === "string"
|
||||
&& Number.isFinite(entry.sequence)
|
||||
&& typeof entry.extractionStatus === "string"
|
||||
&& typeof entry.valueStatus === "string"
|
||||
&& Array.isArray(entry.notes);
|
||||
}
|
||||
|
||||
function isPathInside(root: string, candidate: string) {
|
||||
const relative = path.relative(root, candidate);
|
||||
return relative !== "" && !relative.startsWith(`..${path.sep}`) && relative !== ".." && !path.isAbsolute(relative);
|
||||
}
|
||||
|
||||
function expectEqual(issues: string[], label: string, actual: unknown, expected: unknown) {
|
||||
if (actual !== expected) issues.push(`${label}: expected ${String(expected)}, got ${String(actual)}`);
|
||||
}
|
||||
|
||||
function rate(count: number, milliseconds: number) {
|
||||
return milliseconds > 0 ? Math.round((count * 100_000 / milliseconds)) / 100 : null;
|
||||
}
|
||||
|
||||
function formatRate(value: number | null) {
|
||||
return value === null ? "n/a" : value.toFixed(2);
|
||||
}
|
||||
|
||||
function finiteNumber(value: unknown) {
|
||||
return typeof value === "number" && Number.isFinite(value) ? value : 0;
|
||||
}
|
||||
|
||||
async function readJson<T>(filePath: string): Promise<T> {
|
||||
return JSON.parse(await fs.readFile(filePath, "utf8")) as T;
|
||||
}
|
||||
|
||||
async function readJsonLines<T>(filePath: string): Promise<T[]> {
|
||||
return (await fs.readFile(filePath, "utf8"))
|
||||
.split(/\r?\n/)
|
||||
.filter((line) => line.trim())
|
||||
.map((line) => JSON.parse(line.replace(/^\uFEFF/, "")) as T);
|
||||
}
|
||||
|
||||
async function readOptionalJsonLines<T>(filePath: string): Promise<T[]> {
|
||||
try {
|
||||
return await readJsonLines<T>(filePath);
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === "ENOENT") return [];
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function parseArgs(args: string[]) {
|
||||
const targetArg = args.find((argument) => argument.startsWith("--targets="))?.slice("--targets=".length) ?? "20,50,100";
|
||||
const targets = [...new Set(targetArg.split(",").map(Number).filter((value) => Number.isInteger(value) && value > 0))];
|
||||
if (targets.length === 0) throw new Error("--targets must contain at least one positive integer.");
|
||||
const repeatArg = Number(args.find((argument) => argument.startsWith("--repeats="))?.slice("--repeats=".length) ?? 5);
|
||||
const repeats = Number.isFinite(repeatArg) ? Math.max(2, Math.min(10, Math.round(repeatArg))) : 5;
|
||||
const scanRoot = args.find((argument) => argument.startsWith("--scan-root="))?.slice("--scan-root=".length) ?? "";
|
||||
return { targets, repeats, scanRoot: scanRoot ? path.resolve(scanRoot) : "" };
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import { extractFile, listPackage } from "@electron/asar";
|
||||
|
||||
export function rendererAssetReferences(html) {
|
||||
return [...String(html).matchAll(/\b(?:src|href)\s*=\s*["']([^"']+)["']/gi)]
|
||||
.map((match) => match[1].trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
export function absoluteRendererAssetReferences(html) {
|
||||
return rendererAssetReferences(html).filter((reference) => reference.startsWith("/"));
|
||||
}
|
||||
|
||||
export function runtimeSignature(source) {
|
||||
return String(source).match(/APP_RUNTIME_SIGNATURE\s*=\s*["']([^"']+)["']/)?.[1] ?? "";
|
||||
}
|
||||
|
||||
export function verifyProjectPackaging(projectRoot = process.cwd()) {
|
||||
const packageJson = JSON.parse(fs.readFileSync(path.join(projectRoot, "package.json"), "utf8"));
|
||||
const checks = [];
|
||||
const viteConfigPath = path.join(projectRoot, "vite.config.ts");
|
||||
const viteConfig = readText(viteConfigPath);
|
||||
const rendererIndexPath = path.join(projectRoot, "dist", "index.html");
|
||||
const rendererIndex = readText(rendererIndexPath);
|
||||
const mainSource = readText(path.join(projectRoot, "electron", "main.ts"));
|
||||
const sourceRuntimeSignature = runtimeSignature(mainSource);
|
||||
check(checks, packageJson.main === "dist-electron/electron/main.js", "main-entry", packageJson.main);
|
||||
check(checks, packageJson.build?.win?.requestedExecutionLevel === "requireAdministrator", "windows-elevation", packageJson.build?.win?.requestedExecutionLevel);
|
||||
check(checks, /\bbase\s*:\s*["']\.\/["']/.test(viteConfig), "vite-relative-base", "Vite base must be ./ for Electron loadFile");
|
||||
check(checks, resourceMapping(packageJson, "native/input-helper/bin/publish", "input-helper"), "helper-resource-mapping", "native helper -> resources/input-helper");
|
||||
check(checks, resourceMapping(packageJson, "data/ik-inventorylists", "ik-inventorylists"), "ik-resource-mapping", "IK lists -> resources/ik-inventorylists");
|
||||
checkFile(checks, path.join(projectRoot, "native", "input-helper", "bin", "publish", "InputHelper.exe"), "compiled-helper", 1_000_000);
|
||||
checkFile(checks, path.join(projectRoot, "data", "ik-inventorylists", "artifacts.json"), "ik-artifacts", 1_000);
|
||||
checkFile(checks, path.join(projectRoot, "data", "ik-inventorylists", "version.txt"), "ik-version", 1);
|
||||
checkFile(checks, rendererIndexPath, "compiled-renderer-index", 100);
|
||||
checkFile(checks, path.join(projectRoot, "dist-electron", "electron", "preload.cjs"), "compiled-preload", 100);
|
||||
checkFile(checks, path.join(projectRoot, "dist-electron", "electron", "runtimePaths.js"), "compiled-runtime-path-policy", 100);
|
||||
if (rendererIndex) checkRendererAssetPolicy(checks, rendererIndex, "project-renderer");
|
||||
check(checks, sourceRuntimeSignature.startsWith("2026-07-10-"), "current-runtime-signature", sourceRuntimeSignature || "missing");
|
||||
return packagingReport("project", projectRoot, checks);
|
||||
}
|
||||
|
||||
export function verifyPackagedDirectory(packageDir, projectRoot = process.cwd()) {
|
||||
const resolved = path.resolve(packageDir);
|
||||
const resources = path.join(resolved, "resources");
|
||||
const asarPath = path.join(resources, "app.asar");
|
||||
const checks = [];
|
||||
checkFile(checks, path.join(resolved, "Genshin Artifact Assistant.exe"), "packaged-executable", 1_000_000);
|
||||
checkFile(checks, asarPath, "app-asar", 1_000);
|
||||
checkFile(checks, path.join(resources, "input-helper", "InputHelper.exe"), "packaged-helper", 1_000_000);
|
||||
checkFile(checks, path.join(resources, "ik-inventorylists", "artifacts.json"), "packaged-ik-artifacts", 1_000);
|
||||
checkFile(checks, path.join(resources, "ik-inventorylists", "version.txt"), "packaged-ik-version", 1);
|
||||
if (fs.existsSync(asarPath)) {
|
||||
const files = new Set(listPackage(asarPath).map((entry) => entry.replaceAll("\\", "/").replace(/^\//, "")));
|
||||
for (const expected of ["dist/index.html", "dist-electron/electron/main.js", "dist-electron/electron/preload.cjs", "dist-electron/electron/runtimePaths.js", "package.json"]) {
|
||||
check(checks, files.has(expected), `asar:${expected}`, expected);
|
||||
}
|
||||
if (files.has("dist/index.html")) {
|
||||
const rendererIndex = extractFile(asarPath, asarExtractPath("dist/index.html")).toString("utf8");
|
||||
checkRendererAssetPolicy(checks, rendererIndex, "packaged-renderer");
|
||||
const missingAssets = rendererAssetReferences(rendererIndex)
|
||||
.filter(isLocalRendererReference)
|
||||
.map((reference) => rendererReferenceAsarPath(reference))
|
||||
.filter((reference) => !files.has(reference));
|
||||
check(checks, missingAssets.length === 0, "packaged-renderer-assets-present", missingAssets.join(", ") || "all referenced assets present");
|
||||
}
|
||||
if (files.has("dist-electron/electron/main.js")) {
|
||||
const packagedMain = extractFile(asarPath, asarExtractPath("dist-electron/electron/main.js")).toString("utf8");
|
||||
const sourceMain = readText(path.join(projectRoot, "electron", "main.ts"));
|
||||
const sourceSignature = runtimeSignature(sourceMain);
|
||||
const packagedSignature = runtimeSignature(packagedMain);
|
||||
check(
|
||||
checks,
|
||||
Boolean(sourceSignature) && packagedSignature === sourceSignature,
|
||||
"runtime-signature-match",
|
||||
`${sourceSignature || "missing source"} == ${packagedSignature || "missing package"}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
const sourceVersion = fs.readFileSync(path.join(projectRoot, "data", "ik-inventorylists", "version.txt"), "utf8").trim();
|
||||
const packagedVersionPath = path.join(resources, "ik-inventorylists", "version.txt");
|
||||
const packagedVersion = fs.existsSync(packagedVersionPath) ? fs.readFileSync(packagedVersionPath, "utf8").trim() : "";
|
||||
check(checks, sourceVersion === packagedVersion, "ik-version-match", `${sourceVersion} == ${packagedVersion || "missing"}`);
|
||||
return packagingReport("packaged", resolved, checks);
|
||||
}
|
||||
|
||||
function resourceMapping(packageJson, from, to) {
|
||||
return (packageJson.build?.extraResources ?? []).some((entry) => entry.from === from && entry.to === to);
|
||||
}
|
||||
|
||||
function readText(filePath) {
|
||||
return fs.existsSync(filePath) ? fs.readFileSync(filePath, "utf8") : "";
|
||||
}
|
||||
|
||||
function checkRendererAssetPolicy(checks, html, idPrefix) {
|
||||
const absoluteReferences = absoluteRendererAssetReferences(html);
|
||||
check(
|
||||
checks,
|
||||
absoluteReferences.length === 0,
|
||||
`${idPrefix}-assets-relative`,
|
||||
absoluteReferences.join(", ") || "all src/href references are relative",
|
||||
);
|
||||
}
|
||||
|
||||
function isLocalRendererReference(reference) {
|
||||
return !reference.startsWith("#") && !/^[a-z][a-z\d+.-]*:/i.test(reference);
|
||||
}
|
||||
|
||||
function rendererReferenceAsarPath(reference) {
|
||||
const withoutQuery = reference.split(/[?#]/, 1)[0].replace(/^\.\//, "");
|
||||
return path.posix.normalize(path.posix.join("dist", withoutQuery));
|
||||
}
|
||||
|
||||
function asarExtractPath(reference) {
|
||||
return reference.replaceAll("/", path.sep);
|
||||
}
|
||||
|
||||
function checkFile(checks, filePath, id, minimumBytes) {
|
||||
const exists = fs.existsSync(filePath);
|
||||
const size = exists ? fs.statSync(filePath).size : 0;
|
||||
check(checks, exists && size >= minimumBytes, id, `${filePath} (${size} bytes)`);
|
||||
}
|
||||
|
||||
function check(checks, ok, id, detail) {
|
||||
checks.push({ id, ok: Boolean(ok), detail: String(detail ?? "") });
|
||||
}
|
||||
|
||||
function packagingReport(mode, input, checks) {
|
||||
return {
|
||||
version: "packaging-offline-v2",
|
||||
mode,
|
||||
input,
|
||||
ok: checks.every((entry) => entry.ok),
|
||||
passed: checks.filter((entry) => entry.ok).length,
|
||||
failed: checks.filter((entry) => !entry.ok).length,
|
||||
checks,
|
||||
};
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const staticOnly = process.argv.includes("--static");
|
||||
const inputArg = process.argv.find((argument) => argument.startsWith("--input="));
|
||||
const reports = [verifyProjectPackaging()];
|
||||
if (!staticOnly) {
|
||||
const input = inputArg?.slice("--input=".length) || path.resolve("outputs", "dist", "win-unpacked");
|
||||
reports.push(verifyPackagedDirectory(input));
|
||||
}
|
||||
const result = { version: "packaging-offline-suite-v2", ok: reports.every((report) => report.ok), reports };
|
||||
const outputDir = path.resolve("outputs", "packaging");
|
||||
fs.mkdirSync(outputDir, { recursive: true });
|
||||
const reportPath = path.join(outputDir, "offline-package-report.json");
|
||||
fs.writeFileSync(reportPath, JSON.stringify(result, null, 2), "utf8");
|
||||
for (const report of reports) {
|
||||
console.log(`${report.mode}: ${report.passed}/${report.checks.length} checks passed`);
|
||||
for (const failed of report.checks.filter((entry) => !entry.ok)) console.error(`FAIL ${failed.id}: ${failed.detail}`);
|
||||
}
|
||||
console.log(`Report: ${reportPath}`);
|
||||
if (!result.ok) process.exitCode = 1;
|
||||
}
|
||||
|
||||
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) await main();
|
||||
Reference in New Issue
Block a user