feat(scanner): complete localized artifact quality checkpoint

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