@qatonic_innovations/qaios 0.3.2 → 0.4.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.js +2539 -201
- package/dist/migrations/0003_xray_capture.sql +75 -0
- package/dist/xray/scripts/xray-step-reporter.mjs +62 -0
- package/package.json +2 -1
package/dist/index.js
CHANGED
|
@@ -3,13 +3,14 @@ import { useApp, useInput, Box, Text } from 'ink';
|
|
|
3
3
|
import { useState } from 'react';
|
|
4
4
|
import { jsx, jsxs } from 'react/jsx-runtime';
|
|
5
5
|
import { realpathSync, readFileSync, existsSync, rmSync, mkdirSync, writeFileSync, statSync, mkdtempSync, copyFileSync, readdirSync } from 'fs';
|
|
6
|
-
import
|
|
6
|
+
import path15 from 'path';
|
|
7
7
|
import { fileURLToPath } from 'url';
|
|
8
8
|
import { Command, InvalidArgumentError } from 'commander';
|
|
9
|
-
import { createHash } from 'crypto';
|
|
10
|
-
import { createRequire } from 'module';
|
|
11
9
|
import { z, ZodError } from 'zod';
|
|
12
10
|
import { monotonicFactory } from 'ulid';
|
|
11
|
+
import { gzipSync, gunzipSync } from 'zlib';
|
|
12
|
+
import { createHash } from 'crypto';
|
|
13
|
+
import { createRequire } from 'module';
|
|
13
14
|
import Anthropic from '@anthropic-ai/sdk';
|
|
14
15
|
import OpenAI from 'openai';
|
|
15
16
|
import { zodToJsonSchema } from 'zod-to-json-schema';
|
|
@@ -384,6 +385,13 @@ var AuditEvent = z.enum([
|
|
|
384
385
|
"execution.started",
|
|
385
386
|
"execution.completed",
|
|
386
387
|
"execution.failed",
|
|
388
|
+
// xray (network capture) — hash-chained like everything else.
|
|
389
|
+
"xray.capture",
|
|
390
|
+
// a net_run + its requests were persisted
|
|
391
|
+
"xray.diff",
|
|
392
|
+
// a run was diffed against a baseline
|
|
393
|
+
"xray.baseline.set",
|
|
394
|
+
// a baseline was pinned/cleared
|
|
387
395
|
"audit.verified",
|
|
388
396
|
"system.config_changed"
|
|
389
397
|
]);
|
|
@@ -587,6 +595,10 @@ var ResultClassification = z.object({
|
|
|
587
595
|
"environmental",
|
|
588
596
|
"locator_broken",
|
|
589
597
|
"expectation_outdated",
|
|
598
|
+
// The UI behaved but the BACKEND diverged from its baseline contract — a
|
|
599
|
+
// 4xx/5xx, status change, or response-shape change on a call the test made.
|
|
600
|
+
// Sourced from xray's network diff (v0.4). Routes to create_defect.
|
|
601
|
+
"backend_divergence",
|
|
590
602
|
"unknown"
|
|
591
603
|
]),
|
|
592
604
|
confidence: z.number().min(0).max(1),
|
|
@@ -878,8 +890,11 @@ var QaiosConfig = z.object({
|
|
|
878
890
|
}).default({}),
|
|
879
891
|
// Lower-bounded: a 0/negative cap would silently disable enforcement.
|
|
880
892
|
maxRetriesPerSkill: z.number().int().nonnegative().default(2),
|
|
881
|
-
|
|
882
|
-
|
|
893
|
+
// Raised from 15/50¢ after real-app e2e — a real OpenAPI spec or complex
|
|
894
|
+
// site legitimately costs ~$0.70 to generate from, so the old cap aborted
|
|
895
|
+
// valid single-feature generation. Still a runaway-loop guard.
|
|
896
|
+
maxLlmCallsPerWorkflow: z.number().int().positive().default(20),
|
|
897
|
+
costAlertThresholdUsdCents: z.number().int().positive().default(100)
|
|
883
898
|
}).default({}),
|
|
884
899
|
testing: z.object({
|
|
885
900
|
framework: z.literal("playwright").default("playwright"),
|
|
@@ -925,6 +940,31 @@ var QaiosConfig = z.object({
|
|
|
925
940
|
telemetry: z.object({
|
|
926
941
|
enabled: z.boolean().default(false),
|
|
927
942
|
endpoint: z.string().default("https://telemetry.qaios.dev/v1")
|
|
943
|
+
}).default({}),
|
|
944
|
+
// Network capture + API-divergence (`qaios run --xray`, `qaios explore`).
|
|
945
|
+
// Safe to `.default({})`: every field has a defaulted scalar/array (no
|
|
946
|
+
// optional probe like `app`), so `qaios init` serializes a complete block,
|
|
947
|
+
// not a stub. `enabled: false` keeps capture opt-in for v0.4 — the `--xray`
|
|
948
|
+
// flag turns it on per-run regardless. See QAIOS xray spec §9.
|
|
949
|
+
xray: z.object({
|
|
950
|
+
enabled: z.boolean().default(false),
|
|
951
|
+
level: z.enum(["off", "headers", "summary", "full"]).default("summary"),
|
|
952
|
+
// Quiet window after an action with no new request before a step's
|
|
953
|
+
// network window closes (capped at 15s by the causality engine).
|
|
954
|
+
quiescenceMs: z.number().int().positive().default(500),
|
|
955
|
+
// Response bodies above this are hashed/shape-summarized but not stored.
|
|
956
|
+
// Spec: 16 at summary, 256 at full — the resolver bumps it when level=full.
|
|
957
|
+
bodyMaxKb: z.number().int().positive().default(16),
|
|
958
|
+
// Extends the built-in volatile-query-param denylist (t, ts, nonce, …).
|
|
959
|
+
volatileParams: z.array(z.string()).default([]),
|
|
960
|
+
// JSONPaths redacted (→ "<redacted>") BEFORE hashing/storage, so secrets
|
|
961
|
+
// never touch disk. The header denylist (authorization, cookie, …) is
|
|
962
|
+
// always applied regardless.
|
|
963
|
+
redact: z.array(z.string()).default([]),
|
|
964
|
+
// Glob hosts whose traffic is dropped at capture time (analytics noise).
|
|
965
|
+
ignoreHosts: z.array(z.string()).default([]),
|
|
966
|
+
// Raw rows retained per (test, browser) beyond baselines; older pruned.
|
|
967
|
+
retainRuns: z.number().int().positive().default(10)
|
|
928
968
|
}).default({})
|
|
929
969
|
});
|
|
930
970
|
var RunStatus = z.enum(["running", "passed", "failed", "mixed", "errored", "flaky"]);
|
|
@@ -964,6 +1004,101 @@ z.object({
|
|
|
964
1004
|
// serialized to value_json
|
|
965
1005
|
updatedAt: z.string()
|
|
966
1006
|
});
|
|
1007
|
+
var XrayTier = z.enum(["A", "B"]);
|
|
1008
|
+
var XrayCaptureLevel = z.enum(["off", "headers", "summary", "full"]);
|
|
1009
|
+
var XrayAttribution = z.enum([
|
|
1010
|
+
"initiator",
|
|
1011
|
+
// Tier-A initiator stack tied into a step window
|
|
1012
|
+
"temporal",
|
|
1013
|
+
// started inside exactly one open step window
|
|
1014
|
+
"temporal_ambiguous",
|
|
1015
|
+
// inside multiple overlapping windows → most-recent
|
|
1016
|
+
"background"
|
|
1017
|
+
// no step window (polling/analytics/heartbeats)
|
|
1018
|
+
]);
|
|
1019
|
+
var XrayTargetType = z.enum(["page", "iframe", "service_worker"]);
|
|
1020
|
+
var XrayBodyStatus = z.enum(["stored", "evicted", "truncated", "streaming", "skipped"]);
|
|
1021
|
+
var XrayRequestEvent = z.object({
|
|
1022
|
+
method: z.string(),
|
|
1023
|
+
url: z.string(),
|
|
1024
|
+
// HAR `_initiator` is coarse (type + optional url); no deep stack. Kept for
|
|
1025
|
+
// forward-compat with an opt-in initiator fixture; null for HAR capture.
|
|
1026
|
+
initiator: z.object({
|
|
1027
|
+
type: z.string(),
|
|
1028
|
+
// script|parser|preload|other|...
|
|
1029
|
+
url: z.string().nullable().default(null)
|
|
1030
|
+
}).nullable().default(null),
|
|
1031
|
+
status: z.number().int().nullable().default(null),
|
|
1032
|
+
errorText: z.string().nullable().default(null),
|
|
1033
|
+
// populated for failed entries
|
|
1034
|
+
mime: z.string().nullable().default(null),
|
|
1035
|
+
fromServiceWorker: z.boolean().default(false),
|
|
1036
|
+
fromDiskCache: z.boolean().default(false),
|
|
1037
|
+
// Raw bodies as captured (subject to level/MIME/size gates). The runtime
|
|
1038
|
+
// canonicalizes + redacts + hashes these; they never round-trip raw.
|
|
1039
|
+
reqBody: z.string().nullable().default(null),
|
|
1040
|
+
respBody: z.string().nullable().default(null),
|
|
1041
|
+
bodyStatus: XrayBodyStatus.default("skipped"),
|
|
1042
|
+
redirects: z.array(z.object({ url: z.string(), status: z.number().int() })).default([]),
|
|
1043
|
+
timings: z.record(z.string(), z.number()).default({}),
|
|
1044
|
+
// dns/connect/ttfb/total ms
|
|
1045
|
+
wallStart: z.number()
|
|
1046
|
+
// epoch ms — drives temporal attribution + display
|
|
1047
|
+
});
|
|
1048
|
+
z.object({
|
|
1049
|
+
stepId: z.string(),
|
|
1050
|
+
title: z.string(),
|
|
1051
|
+
category: z.string(),
|
|
1052
|
+
// 'pw:api' | 'test.step' | ...
|
|
1053
|
+
startWall: z.number(),
|
|
1054
|
+
// epoch ms
|
|
1055
|
+
endWall: z.number()
|
|
1056
|
+
// epoch ms
|
|
1057
|
+
});
|
|
1058
|
+
var NetRunRow = z.object({
|
|
1059
|
+
runId: z.string(),
|
|
1060
|
+
workflowId: z.string(),
|
|
1061
|
+
testId: z.string(),
|
|
1062
|
+
browser: z.string(),
|
|
1063
|
+
tier: XrayTier,
|
|
1064
|
+
captureLevel: XrayCaptureLevel,
|
|
1065
|
+
startedAt: z.string(),
|
|
1066
|
+
isGreen: z.boolean().default(false),
|
|
1067
|
+
isBaseline: z.boolean().default(false)
|
|
1068
|
+
});
|
|
1069
|
+
var NetRequestRow = z.object({
|
|
1070
|
+
id: z.string(),
|
|
1071
|
+
runId: z.string(),
|
|
1072
|
+
stepId: z.string().nullable(),
|
|
1073
|
+
attribution: XrayAttribution,
|
|
1074
|
+
targetType: XrayTargetType,
|
|
1075
|
+
method: z.string(),
|
|
1076
|
+
urlRaw: z.string(),
|
|
1077
|
+
urlTemplate: z.string(),
|
|
1078
|
+
gqlOperation: z.string().nullable(),
|
|
1079
|
+
identityKey: z.string(),
|
|
1080
|
+
status: z.number().int().nullable(),
|
|
1081
|
+
errorText: z.string().nullable(),
|
|
1082
|
+
mime: z.string().nullable(),
|
|
1083
|
+
reqHash: z.string().nullable(),
|
|
1084
|
+
respHash: z.string().nullable(),
|
|
1085
|
+
shapeHash: z.string().nullable(),
|
|
1086
|
+
reqBodyRef: z.string().nullable(),
|
|
1087
|
+
respBodyRef: z.string().nullable(),
|
|
1088
|
+
bodyStatus: XrayBodyStatus.nullable(),
|
|
1089
|
+
redirectsJson: z.string().nullable(),
|
|
1090
|
+
timingsJson: z.string().nullable(),
|
|
1091
|
+
initiatorJson: z.string().nullable(),
|
|
1092
|
+
wallStart: z.string()
|
|
1093
|
+
});
|
|
1094
|
+
var NetVolatilityRow = z.object({
|
|
1095
|
+
testId: z.string(),
|
|
1096
|
+
identityKey: z.string(),
|
|
1097
|
+
jsonPath: z.string(),
|
|
1098
|
+
// '' => presence-level
|
|
1099
|
+
kind: z.enum(["value", "presence"]),
|
|
1100
|
+
learnedAt: z.string()
|
|
1101
|
+
});
|
|
967
1102
|
var ulidImpl = monotonicFactory();
|
|
968
1103
|
function ulid() {
|
|
969
1104
|
return ulidImpl();
|
|
@@ -998,6 +1133,12 @@ var LlmError = class extends QaiosError {
|
|
|
998
1133
|
this.name = "LlmError";
|
|
999
1134
|
}
|
|
1000
1135
|
};
|
|
1136
|
+
var XrayError = class extends QaiosError {
|
|
1137
|
+
constructor(opts) {
|
|
1138
|
+
super(opts);
|
|
1139
|
+
this.name = "XrayError";
|
|
1140
|
+
}
|
|
1141
|
+
};
|
|
1001
1142
|
function isQaiosShaped(err) {
|
|
1002
1143
|
if (!err || typeof err !== "object") return false;
|
|
1003
1144
|
const e = err;
|
|
@@ -1054,6 +1195,1376 @@ function formatQaiosError(err, opts = {}) {
|
|
|
1054
1195
|
}
|
|
1055
1196
|
return lines.join("\n") + "\n";
|
|
1056
1197
|
}
|
|
1198
|
+
var __defProp2 = Object.defineProperty;
|
|
1199
|
+
var __getOwnPropNames2 = Object.getOwnPropertyNames;
|
|
1200
|
+
var __esm2 = (fn, res) => function __init() {
|
|
1201
|
+
return fn && (res = (0, fn[__getOwnPropNames2(fn)[0]])(fn = 0)), res;
|
|
1202
|
+
};
|
|
1203
|
+
var __export2 = (target, all) => {
|
|
1204
|
+
for (var name in all)
|
|
1205
|
+
__defProp2(target, name, { get: all[name], enumerable: true });
|
|
1206
|
+
};
|
|
1207
|
+
function rowToNetRun(row) {
|
|
1208
|
+
return NetRunRow.parse({
|
|
1209
|
+
runId: row.run_id,
|
|
1210
|
+
workflowId: row.workflow_id,
|
|
1211
|
+
testId: row.test_id,
|
|
1212
|
+
browser: row.browser,
|
|
1213
|
+
tier: row.tier,
|
|
1214
|
+
captureLevel: row.capture_level,
|
|
1215
|
+
startedAt: row.started_at,
|
|
1216
|
+
isGreen: row.is_green === 1,
|
|
1217
|
+
isBaseline: row.is_baseline === 1
|
|
1218
|
+
});
|
|
1219
|
+
}
|
|
1220
|
+
var NetRunsRepository;
|
|
1221
|
+
var init_net_runs = __esm2({
|
|
1222
|
+
"src/storage/repositories/net-runs.ts"() {
|
|
1223
|
+
NetRunsRepository = class {
|
|
1224
|
+
constructor(db) {
|
|
1225
|
+
this.db = db;
|
|
1226
|
+
}
|
|
1227
|
+
db;
|
|
1228
|
+
create(input) {
|
|
1229
|
+
const r = NetRunRow.parse(input);
|
|
1230
|
+
this.db.prepare(
|
|
1231
|
+
`INSERT INTO net_runs
|
|
1232
|
+
(run_id, workflow_id, test_id, browser, tier, capture_level,
|
|
1233
|
+
started_at, is_green, is_baseline)
|
|
1234
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
|
1235
|
+
).run(
|
|
1236
|
+
r.runId,
|
|
1237
|
+
r.workflowId,
|
|
1238
|
+
r.testId,
|
|
1239
|
+
r.browser,
|
|
1240
|
+
r.tier,
|
|
1241
|
+
r.captureLevel,
|
|
1242
|
+
r.startedAt,
|
|
1243
|
+
r.isGreen ? 1 : 0,
|
|
1244
|
+
r.isBaseline ? 1 : 0
|
|
1245
|
+
);
|
|
1246
|
+
return r;
|
|
1247
|
+
}
|
|
1248
|
+
findById(runId) {
|
|
1249
|
+
const row = this.db.prepare("SELECT * FROM net_runs WHERE run_id = ?").get(runId);
|
|
1250
|
+
return row ? rowToNetRun(row) : null;
|
|
1251
|
+
}
|
|
1252
|
+
listByWorkflow(workflowId) {
|
|
1253
|
+
const rows = this.db.prepare("SELECT * FROM net_runs WHERE workflow_id = ? ORDER BY started_at DESC").all(workflowId);
|
|
1254
|
+
return rows.map(rowToNetRun);
|
|
1255
|
+
}
|
|
1256
|
+
/**
|
|
1257
|
+
* Runs for a test, newest first. Used by baseline selection (last green per
|
|
1258
|
+
* test+browser) and volatility learning (compare consecutive green runs).
|
|
1259
|
+
*/
|
|
1260
|
+
listByTest(testId, browser) {
|
|
1261
|
+
const sql = browser === void 0 ? "SELECT * FROM net_runs WHERE test_id = ? ORDER BY started_at DESC" : "SELECT * FROM net_runs WHERE test_id = ? AND browser = ? ORDER BY started_at DESC";
|
|
1262
|
+
const rows = browser === void 0 ? this.db.prepare(sql).all(testId) : this.db.prepare(sql).all(testId, browser);
|
|
1263
|
+
return rows.map(rowToNetRun);
|
|
1264
|
+
}
|
|
1265
|
+
/** Flip green/baseline flags as the run is finalized / a baseline is pinned. */
|
|
1266
|
+
update(runId, patch) {
|
|
1267
|
+
const fields = [];
|
|
1268
|
+
const params = [];
|
|
1269
|
+
if (patch.isGreen !== void 0) {
|
|
1270
|
+
fields.push("is_green = ?");
|
|
1271
|
+
params.push(patch.isGreen ? 1 : 0);
|
|
1272
|
+
}
|
|
1273
|
+
if (patch.isBaseline !== void 0) {
|
|
1274
|
+
fields.push("is_baseline = ?");
|
|
1275
|
+
params.push(patch.isBaseline ? 1 : 0);
|
|
1276
|
+
}
|
|
1277
|
+
if (fields.length === 0) return this.findById(runId);
|
|
1278
|
+
params.push(runId);
|
|
1279
|
+
this.db.prepare(`UPDATE net_runs SET ${fields.join(", ")} WHERE run_id = ?`).run(...params);
|
|
1280
|
+
return this.findById(runId);
|
|
1281
|
+
}
|
|
1282
|
+
/** Clear the baseline pin for every run of a (test, browser). */
|
|
1283
|
+
clearBaseline(testId, browser) {
|
|
1284
|
+
this.db.prepare("UPDATE net_runs SET is_baseline = 0 WHERE test_id = ? AND browser = ?").run(testId, browser);
|
|
1285
|
+
}
|
|
1286
|
+
delete(runId) {
|
|
1287
|
+
this.db.prepare("DELETE FROM net_runs WHERE run_id = ?").run(runId);
|
|
1288
|
+
}
|
|
1289
|
+
};
|
|
1290
|
+
}
|
|
1291
|
+
});
|
|
1292
|
+
function rowToNetRequest(row) {
|
|
1293
|
+
return NetRequestRow.parse({
|
|
1294
|
+
id: row.id,
|
|
1295
|
+
runId: row.run_id,
|
|
1296
|
+
stepId: row.step_id,
|
|
1297
|
+
attribution: row.attribution,
|
|
1298
|
+
targetType: row.target_type,
|
|
1299
|
+
method: row.method,
|
|
1300
|
+
urlRaw: row.url_raw,
|
|
1301
|
+
urlTemplate: row.url_template,
|
|
1302
|
+
gqlOperation: row.gql_operation,
|
|
1303
|
+
identityKey: row.identity_key,
|
|
1304
|
+
status: row.status,
|
|
1305
|
+
errorText: row.error_text,
|
|
1306
|
+
mime: row.mime,
|
|
1307
|
+
reqHash: row.req_hash,
|
|
1308
|
+
respHash: row.resp_hash,
|
|
1309
|
+
shapeHash: row.shape_hash,
|
|
1310
|
+
reqBodyRef: row.req_body_ref,
|
|
1311
|
+
respBodyRef: row.resp_body_ref,
|
|
1312
|
+
bodyStatus: row.body_status,
|
|
1313
|
+
redirectsJson: row.redirects_json,
|
|
1314
|
+
timingsJson: row.timings_json,
|
|
1315
|
+
initiatorJson: row.initiator_json,
|
|
1316
|
+
wallStart: row.wall_start
|
|
1317
|
+
});
|
|
1318
|
+
}
|
|
1319
|
+
var NetRequestsRepository;
|
|
1320
|
+
var init_net_requests = __esm2({
|
|
1321
|
+
"src/storage/repositories/net-requests.ts"() {
|
|
1322
|
+
NetRequestsRepository = class {
|
|
1323
|
+
constructor(db) {
|
|
1324
|
+
this.db = db;
|
|
1325
|
+
}
|
|
1326
|
+
db;
|
|
1327
|
+
create(input) {
|
|
1328
|
+
const r = NetRequestRow.parse(input);
|
|
1329
|
+
this.db.prepare(
|
|
1330
|
+
`INSERT INTO net_requests
|
|
1331
|
+
(id, run_id, step_id, attribution, target_type, method, url_raw,
|
|
1332
|
+
url_template, gql_operation, identity_key, status, error_text, mime,
|
|
1333
|
+
req_hash, resp_hash, shape_hash, req_body_ref, resp_body_ref,
|
|
1334
|
+
body_status, redirects_json, timings_json, initiator_json, wall_start)
|
|
1335
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
|
1336
|
+
).run(
|
|
1337
|
+
r.id,
|
|
1338
|
+
r.runId,
|
|
1339
|
+
r.stepId,
|
|
1340
|
+
r.attribution,
|
|
1341
|
+
r.targetType,
|
|
1342
|
+
r.method,
|
|
1343
|
+
r.urlRaw,
|
|
1344
|
+
r.urlTemplate,
|
|
1345
|
+
r.gqlOperation,
|
|
1346
|
+
r.identityKey,
|
|
1347
|
+
r.status,
|
|
1348
|
+
r.errorText,
|
|
1349
|
+
r.mime,
|
|
1350
|
+
r.reqHash,
|
|
1351
|
+
r.respHash,
|
|
1352
|
+
r.shapeHash,
|
|
1353
|
+
r.reqBodyRef,
|
|
1354
|
+
r.respBodyRef,
|
|
1355
|
+
r.bodyStatus,
|
|
1356
|
+
r.redirectsJson,
|
|
1357
|
+
r.timingsJson,
|
|
1358
|
+
r.initiatorJson,
|
|
1359
|
+
r.wallStart
|
|
1360
|
+
);
|
|
1361
|
+
return r;
|
|
1362
|
+
}
|
|
1363
|
+
/** Bulk insert in one transaction — a run can produce hundreds of requests. */
|
|
1364
|
+
createMany(rows) {
|
|
1365
|
+
const insert = this.db.transaction((items) => {
|
|
1366
|
+
for (const item of items) this.create(item);
|
|
1367
|
+
});
|
|
1368
|
+
insert(rows);
|
|
1369
|
+
}
|
|
1370
|
+
listByRun(runId) {
|
|
1371
|
+
const rows = this.db.prepare("SELECT * FROM net_requests WHERE run_id = ? ORDER BY wall_start ASC").all(runId);
|
|
1372
|
+
return rows.map(rowToNetRequest);
|
|
1373
|
+
}
|
|
1374
|
+
listByRunStep(runId, stepId) {
|
|
1375
|
+
const sql = stepId === null ? "SELECT * FROM net_requests WHERE run_id = ? AND step_id IS NULL ORDER BY wall_start ASC" : "SELECT * FROM net_requests WHERE run_id = ? AND step_id = ? ORDER BY wall_start ASC";
|
|
1376
|
+
const rows = stepId === null ? this.db.prepare(sql).all(runId) : this.db.prepare(sql).all(runId, stepId);
|
|
1377
|
+
return rows.map(rowToNetRequest);
|
|
1378
|
+
}
|
|
1379
|
+
deleteByRun(runId) {
|
|
1380
|
+
this.db.prepare("DELETE FROM net_requests WHERE run_id = ?").run(runId);
|
|
1381
|
+
}
|
|
1382
|
+
};
|
|
1383
|
+
}
|
|
1384
|
+
});
|
|
1385
|
+
var NetBodiesRepository;
|
|
1386
|
+
var init_net_bodies = __esm2({
|
|
1387
|
+
"src/storage/repositories/net-bodies.ts"() {
|
|
1388
|
+
NetBodiesRepository = class {
|
|
1389
|
+
constructor(db) {
|
|
1390
|
+
this.db = db;
|
|
1391
|
+
}
|
|
1392
|
+
db;
|
|
1393
|
+
/**
|
|
1394
|
+
* Store a canonical body and return its hash (the ref stored on the request
|
|
1395
|
+
* row). Idempotent: an already-present hash is left untouched (`INSERT OR
|
|
1396
|
+
* IGNORE`), so two runs producing the same body share one BLOB. The caller
|
|
1397
|
+
* passes the ALREADY-canonicalized + redacted bytes — secrets must never
|
|
1398
|
+
* reach here.
|
|
1399
|
+
*/
|
|
1400
|
+
putBody(canonical) {
|
|
1401
|
+
const hash = createHash("sha256").update(canonical).digest("hex");
|
|
1402
|
+
const compressed = gzipSync(canonical);
|
|
1403
|
+
this.db.prepare("INSERT OR IGNORE INTO net_bodies (hash, compressed, size_raw) VALUES (?, ?, ?)").run(hash, compressed, canonical.byteLength);
|
|
1404
|
+
return hash;
|
|
1405
|
+
}
|
|
1406
|
+
/** Retrieve + decompress a body by hash, or null if absent (e.g. GC'd). */
|
|
1407
|
+
getBody(hash) {
|
|
1408
|
+
const row = this.db.prepare("SELECT * FROM net_bodies WHERE hash = ?").get(hash);
|
|
1409
|
+
if (row === void 0) return null;
|
|
1410
|
+
return gunzipSync(Buffer.from(row.compressed));
|
|
1411
|
+
}
|
|
1412
|
+
has(hash) {
|
|
1413
|
+
const row = this.db.prepare("SELECT 1 AS present FROM net_bodies WHERE hash = ?").get(hash);
|
|
1414
|
+
return row !== void 0;
|
|
1415
|
+
}
|
|
1416
|
+
/**
|
|
1417
|
+
* Garbage-collect bodies no longer referenced by any net_requests row. Called
|
|
1418
|
+
* after pruning old runs (retainRuns). Refcounting is implicit: a body is live
|
|
1419
|
+
* iff some request still points at it via req_body_ref / resp_body_ref.
|
|
1420
|
+
* Returns the number of orphaned bodies deleted.
|
|
1421
|
+
*/
|
|
1422
|
+
gcOrphans() {
|
|
1423
|
+
const result = this.db.prepare(
|
|
1424
|
+
`DELETE FROM net_bodies
|
|
1425
|
+
WHERE hash NOT IN (
|
|
1426
|
+
SELECT req_body_ref FROM net_requests WHERE req_body_ref IS NOT NULL
|
|
1427
|
+
UNION
|
|
1428
|
+
SELECT resp_body_ref FROM net_requests WHERE resp_body_ref IS NOT NULL
|
|
1429
|
+
)`
|
|
1430
|
+
).run();
|
|
1431
|
+
return Number(result.changes);
|
|
1432
|
+
}
|
|
1433
|
+
};
|
|
1434
|
+
}
|
|
1435
|
+
});
|
|
1436
|
+
function rowToVolatility(row) {
|
|
1437
|
+
return NetVolatilityRow.parse({
|
|
1438
|
+
testId: row.test_id,
|
|
1439
|
+
identityKey: row.identity_key,
|
|
1440
|
+
jsonPath: row.json_path,
|
|
1441
|
+
kind: row.kind,
|
|
1442
|
+
learnedAt: row.learned_at
|
|
1443
|
+
});
|
|
1444
|
+
}
|
|
1445
|
+
var NetVolatilityRepository;
|
|
1446
|
+
var init_net_volatility = __esm2({
|
|
1447
|
+
"src/storage/repositories/net-volatility.ts"() {
|
|
1448
|
+
NetVolatilityRepository = class {
|
|
1449
|
+
constructor(db) {
|
|
1450
|
+
this.db = db;
|
|
1451
|
+
}
|
|
1452
|
+
db;
|
|
1453
|
+
/** Upsert a volatility entry (idempotent on the composite PK). */
|
|
1454
|
+
mark(entry) {
|
|
1455
|
+
const e = NetVolatilityRow.parse(entry);
|
|
1456
|
+
this.db.prepare(
|
|
1457
|
+
`INSERT INTO net_volatility (test_id, identity_key, json_path, kind, learned_at)
|
|
1458
|
+
VALUES (?, ?, ?, ?, ?)
|
|
1459
|
+
ON CONFLICT(test_id, identity_key, json_path)
|
|
1460
|
+
DO UPDATE SET kind = excluded.kind, learned_at = excluded.learned_at`
|
|
1461
|
+
).run(e.testId, e.identityKey, e.jsonPath, e.kind, e.learnedAt);
|
|
1462
|
+
}
|
|
1463
|
+
listByTest(testId) {
|
|
1464
|
+
const rows = this.db.prepare("SELECT * FROM net_volatility WHERE test_id = ?").all(testId);
|
|
1465
|
+
return rows.map(rowToVolatility);
|
|
1466
|
+
}
|
|
1467
|
+
reset(testId) {
|
|
1468
|
+
const result = testId === void 0 ? this.db.prepare("DELETE FROM net_volatility").run() : this.db.prepare("DELETE FROM net_volatility WHERE test_id = ?").run(testId);
|
|
1469
|
+
return Number(result.changes);
|
|
1470
|
+
}
|
|
1471
|
+
};
|
|
1472
|
+
}
|
|
1473
|
+
});
|
|
1474
|
+
function stepReporterPath() {
|
|
1475
|
+
const candidates = [
|
|
1476
|
+
path15.resolve(here, "scripts", "xray-step-reporter.mjs"),
|
|
1477
|
+
// dist/xray/scripts
|
|
1478
|
+
path15.resolve(here, "xray", "scripts", "xray-step-reporter.mjs"),
|
|
1479
|
+
path15.resolve(here, "..", "xray", "scripts", "xray-step-reporter.mjs")
|
|
1480
|
+
];
|
|
1481
|
+
for (const c of candidates) if (existsSync(c)) return c;
|
|
1482
|
+
return candidates[0];
|
|
1483
|
+
}
|
|
1484
|
+
function resolveUserConfig(cwd) {
|
|
1485
|
+
for (const name of USER_CONFIG_NAMES) {
|
|
1486
|
+
const p = path15.join(cwd, name);
|
|
1487
|
+
if (existsSync(p)) return p;
|
|
1488
|
+
}
|
|
1489
|
+
return null;
|
|
1490
|
+
}
|
|
1491
|
+
function wrapperWithUserConfig(spec) {
|
|
1492
|
+
return `// AUTO-GENERATED by QAIOS xray \u2014 temporary; deleted after the run. Extends your
|
|
1493
|
+
// playwright config with recordHar so QAIOS can capture network traffic. Your
|
|
1494
|
+
// config is never modified. This file sits beside your config so all relative
|
|
1495
|
+
// paths in it (webServer, globalSetup, testDir, \u2026) resolve normally.
|
|
1496
|
+
import base from ${JSON.stringify(spec)};
|
|
1497
|
+
|
|
1498
|
+
const harPath = process.env.QAIOS_XRAY_HAR_PATH;
|
|
1499
|
+
const level = process.env.QAIOS_XRAY_LEVEL ?? 'summary';
|
|
1500
|
+
const harContent = level === 'headers' ? 'omit' : 'embed';
|
|
1501
|
+
|
|
1502
|
+
const baseConfig = base && base.default ? base.default : base;
|
|
1503
|
+
|
|
1504
|
+
export default {
|
|
1505
|
+
...baseConfig,
|
|
1506
|
+
use: {
|
|
1507
|
+
...(baseConfig && baseConfig.use ? baseConfig.use : {}),
|
|
1508
|
+
contextOptions: {
|
|
1509
|
+
...(baseConfig && baseConfig.use && baseConfig.use.contextOptions
|
|
1510
|
+
? baseConfig.use.contextOptions
|
|
1511
|
+
: {}),
|
|
1512
|
+
recordHar: { path: harPath, content: harContent, mode: 'full' },
|
|
1513
|
+
},
|
|
1514
|
+
},
|
|
1515
|
+
};
|
|
1516
|
+
`;
|
|
1517
|
+
}
|
|
1518
|
+
function wrapperStandalone() {
|
|
1519
|
+
return `// AUTO-GENERATED by QAIOS xray \u2014 do not edit. No user playwright config was
|
|
1520
|
+
// found, so this is a minimal config with recordHar enabled.
|
|
1521
|
+
import path from 'node:path';
|
|
1522
|
+
|
|
1523
|
+
const harPath = process.env.QAIOS_XRAY_HAR_PATH;
|
|
1524
|
+
const level = process.env.QAIOS_XRAY_LEVEL ?? 'summary';
|
|
1525
|
+
const cwd = process.env.QAIOS_XRAY_CWD ?? process.cwd();
|
|
1526
|
+
const testDirRel = process.env.QAIOS_XRAY_TESTDIR ?? 'tests';
|
|
1527
|
+
const harContent = level === 'headers' ? 'omit' : 'embed';
|
|
1528
|
+
|
|
1529
|
+
export default {
|
|
1530
|
+
testDir: path.isAbsolute(testDirRel) ? testDirRel : path.resolve(cwd, testDirRel),
|
|
1531
|
+
use: {
|
|
1532
|
+
contextOptions: { recordHar: { path: harPath, content: harContent, mode: 'full' } },
|
|
1533
|
+
},
|
|
1534
|
+
};
|
|
1535
|
+
`;
|
|
1536
|
+
}
|
|
1537
|
+
function materializeWrapperConfig(opts) {
|
|
1538
|
+
mkdirSync(opts.xrayDir, { recursive: true });
|
|
1539
|
+
const userConfig = resolveUserConfig(opts.cwd);
|
|
1540
|
+
let source;
|
|
1541
|
+
let ext;
|
|
1542
|
+
if (userConfig !== null) {
|
|
1543
|
+
const userExt = path15.extname(userConfig);
|
|
1544
|
+
ext = userExt === ".cjs" ? ".cjs" : userExt;
|
|
1545
|
+
const baseName = path15.basename(userConfig).replace(/\.[^.]+$/, "");
|
|
1546
|
+
const spec = userExt === ".mjs" || userExt === ".cjs" ? `./${baseName}${userExt}` : `./${baseName}`;
|
|
1547
|
+
source = wrapperWithUserConfig(spec);
|
|
1548
|
+
} else {
|
|
1549
|
+
ext = ".mjs";
|
|
1550
|
+
source = wrapperStandalone();
|
|
1551
|
+
}
|
|
1552
|
+
const configPath = path15.join(opts.cwd, `${WRAPPER_BASENAME}${ext}`);
|
|
1553
|
+
try {
|
|
1554
|
+
writeFileSync(configPath, source, "utf-8");
|
|
1555
|
+
} catch (err) {
|
|
1556
|
+
throw new XrayError({
|
|
1557
|
+
code: "qaios.xray.config_resolve_failed",
|
|
1558
|
+
message: "Failed to write the xray wrapper Playwright config.",
|
|
1559
|
+
detail: `Could not write ${configPath}.`,
|
|
1560
|
+
cause: err
|
|
1561
|
+
});
|
|
1562
|
+
}
|
|
1563
|
+
const stepsPath = path15.join(opts.xrayDir, `${path15.basename(opts.harPath, ".har")}.steps.ndjson`);
|
|
1564
|
+
return {
|
|
1565
|
+
configPath,
|
|
1566
|
+
cleanupPath: configPath,
|
|
1567
|
+
stepReporterPath: stepReporterPath(),
|
|
1568
|
+
stepsPath,
|
|
1569
|
+
env: {
|
|
1570
|
+
QAIOS_XRAY_USER_CONFIG: userConfig ?? "",
|
|
1571
|
+
QAIOS_XRAY_HAR_PATH: opts.harPath,
|
|
1572
|
+
QAIOS_XRAY_LEVEL: opts.level,
|
|
1573
|
+
QAIOS_XRAY_CWD: opts.cwd,
|
|
1574
|
+
QAIOS_XRAY_TESTDIR: opts.testDir ?? "tests",
|
|
1575
|
+
QAIOS_XRAY_STEPS_PATH: stepsPath
|
|
1576
|
+
}
|
|
1577
|
+
};
|
|
1578
|
+
}
|
|
1579
|
+
var here;
|
|
1580
|
+
var USER_CONFIG_NAMES;
|
|
1581
|
+
var WRAPPER_BASENAME;
|
|
1582
|
+
var init_wrapper_config = __esm2({
|
|
1583
|
+
"src/xray/wrapper-config.ts"() {
|
|
1584
|
+
here = path15.dirname(fileURLToPath(import.meta.url));
|
|
1585
|
+
USER_CONFIG_NAMES = [
|
|
1586
|
+
"playwright.config.ts",
|
|
1587
|
+
"playwright.config.js",
|
|
1588
|
+
"playwright.config.mjs",
|
|
1589
|
+
"playwright.config.cjs"
|
|
1590
|
+
];
|
|
1591
|
+
WRAPPER_BASENAME = ".qaios-xray.pw.config";
|
|
1592
|
+
}
|
|
1593
|
+
});
|
|
1594
|
+
function mimeOf(entry) {
|
|
1595
|
+
const ct = entry.response?.content?.mimeType;
|
|
1596
|
+
if (!ct) return null;
|
|
1597
|
+
return ct.split(";")[0].trim() || null;
|
|
1598
|
+
}
|
|
1599
|
+
function bodyOf(entry) {
|
|
1600
|
+
const content = entry.response?.content;
|
|
1601
|
+
if (!content || typeof content.text !== "string" || content.text.length === 0) {
|
|
1602
|
+
return { text: null, status: "skipped" };
|
|
1603
|
+
}
|
|
1604
|
+
if (content.encoding === "base64") {
|
|
1605
|
+
try {
|
|
1606
|
+
return { text: Buffer.from(content.text, "base64").toString("utf-8"), status: "stored" };
|
|
1607
|
+
} catch {
|
|
1608
|
+
return { text: null, status: "skipped" };
|
|
1609
|
+
}
|
|
1610
|
+
}
|
|
1611
|
+
return { text: content.text, status: "stored" };
|
|
1612
|
+
}
|
|
1613
|
+
function timingsOf(entry) {
|
|
1614
|
+
const t = entry.timings ?? {};
|
|
1615
|
+
const out = {};
|
|
1616
|
+
if (typeof t.dns === "number" && t.dns >= 0) out.dns = t.dns;
|
|
1617
|
+
if (typeof t.connect === "number" && t.connect >= 0) out.connect = t.connect;
|
|
1618
|
+
if (typeof t.wait === "number" && t.wait >= 0) out.ttfb = t.wait;
|
|
1619
|
+
if (typeof entry.time === "number" && entry.time >= 0) out.total = entry.time;
|
|
1620
|
+
return out;
|
|
1621
|
+
}
|
|
1622
|
+
function parseHarFile(harPath) {
|
|
1623
|
+
if (!existsSync(harPath)) {
|
|
1624
|
+
return [];
|
|
1625
|
+
}
|
|
1626
|
+
let har;
|
|
1627
|
+
try {
|
|
1628
|
+
har = JSON.parse(readFileSync(harPath, "utf-8"));
|
|
1629
|
+
} catch (err) {
|
|
1630
|
+
throw new XrayError({
|
|
1631
|
+
code: "qaios.xray.har_parse_failed",
|
|
1632
|
+
message: "Failed to parse the Playwright HAR file produced by xray capture.",
|
|
1633
|
+
detail: `HAR at ${harPath} was not valid JSON.`,
|
|
1634
|
+
cause: err
|
|
1635
|
+
});
|
|
1636
|
+
}
|
|
1637
|
+
const entries = har.log?.entries ?? [];
|
|
1638
|
+
const events = [];
|
|
1639
|
+
for (const entry of entries) {
|
|
1640
|
+
const url = entry.request?.url;
|
|
1641
|
+
if (typeof url !== "string" || !/^https?:/i.test(url)) continue;
|
|
1642
|
+
const wallStart = typeof entry.startedDateTime === "string" ? Date.parse(entry.startedDateTime) : NaN;
|
|
1643
|
+
const mime = mimeOf(entry);
|
|
1644
|
+
const isTextual = mime !== null && /^(application\/json|application\/graphql|text\/)/i.test(mime);
|
|
1645
|
+
const body = isTextual ? bodyOf(entry) : { text: null, status: "skipped" };
|
|
1646
|
+
const failure = entry.response?._failureText ?? entry._failureText ?? null;
|
|
1647
|
+
const event = XrayRequestEvent.parse({
|
|
1648
|
+
method: entry.request?.method ?? "GET",
|
|
1649
|
+
url,
|
|
1650
|
+
initiator: null,
|
|
1651
|
+
// HAR carries no usable initiator stack
|
|
1652
|
+
status: typeof entry.response?.status === "number" ? entry.response.status : null,
|
|
1653
|
+
errorText: failure,
|
|
1654
|
+
mime,
|
|
1655
|
+
fromServiceWorker: false,
|
|
1656
|
+
fromDiskCache: Boolean(entry.cache && Object.keys(entry.cache).length > 0),
|
|
1657
|
+
reqBody: typeof entry.request?.postData?.text === "string" ? entry.request.postData.text : null,
|
|
1658
|
+
respBody: body.text,
|
|
1659
|
+
bodyStatus: body.status,
|
|
1660
|
+
redirects: typeof entry.response?.redirectURL === "string" && entry.response.redirectURL.length > 0 ? [{ url: entry.response.redirectURL, status: entry.response?.status ?? 0 }] : [],
|
|
1661
|
+
timings: timingsOf(entry),
|
|
1662
|
+
wallStart: Number.isFinite(wallStart) ? wallStart : 0
|
|
1663
|
+
});
|
|
1664
|
+
events.push(event);
|
|
1665
|
+
}
|
|
1666
|
+
events.sort((a, b) => a.wallStart - b.wallStart);
|
|
1667
|
+
return events;
|
|
1668
|
+
}
|
|
1669
|
+
var init_har_parser = __esm2({
|
|
1670
|
+
"src/xray/har-parser.ts"() {
|
|
1671
|
+
}
|
|
1672
|
+
});
|
|
1673
|
+
function testIdFor(file, title) {
|
|
1674
|
+
return `${file.replace(/\\/g, "/")}::${title}`;
|
|
1675
|
+
}
|
|
1676
|
+
function flattenSteps(steps, out) {
|
|
1677
|
+
if (!steps) return;
|
|
1678
|
+
for (const s of steps) {
|
|
1679
|
+
if (s.category === "pw:api" && typeof s.startTime === "string") {
|
|
1680
|
+
const start = Date.parse(s.startTime);
|
|
1681
|
+
if (Number.isFinite(start)) {
|
|
1682
|
+
const dur = typeof s.duration === "number" && s.duration >= 0 ? s.duration : 0;
|
|
1683
|
+
out.push({
|
|
1684
|
+
stepId: ulid(),
|
|
1685
|
+
title: s.title ?? "(step)",
|
|
1686
|
+
category: s.category,
|
|
1687
|
+
startWall: start,
|
|
1688
|
+
endWall: start + dur
|
|
1689
|
+
});
|
|
1690
|
+
}
|
|
1691
|
+
}
|
|
1692
|
+
flattenSteps(s.steps, out);
|
|
1693
|
+
}
|
|
1694
|
+
}
|
|
1695
|
+
function walkSuite(suite, acc) {
|
|
1696
|
+
for (const spec of suite.specs ?? []) {
|
|
1697
|
+
const file = spec.file ?? suite.file ?? "";
|
|
1698
|
+
const rel = file ? path15.basename(file) : "";
|
|
1699
|
+
const title = spec.title ?? "(test)";
|
|
1700
|
+
const test = spec.tests?.[spec.tests.length - 1];
|
|
1701
|
+
const finalResult = test?.results?.[test.results.length - 1];
|
|
1702
|
+
const windows = [];
|
|
1703
|
+
flattenSteps(finalResult?.steps, windows);
|
|
1704
|
+
windows.sort((a, b) => a.startWall - b.startWall);
|
|
1705
|
+
acc.push({
|
|
1706
|
+
testId: testIdFor(rel, title),
|
|
1707
|
+
testTitle: title,
|
|
1708
|
+
file: rel,
|
|
1709
|
+
passed: finalResult?.status === "passed",
|
|
1710
|
+
windows
|
|
1711
|
+
});
|
|
1712
|
+
}
|
|
1713
|
+
for (const child of suite.suites ?? []) walkSuite(child, acc);
|
|
1714
|
+
}
|
|
1715
|
+
function parseStepWindows(reportPath) {
|
|
1716
|
+
if (!existsSync(reportPath)) return [];
|
|
1717
|
+
let report;
|
|
1718
|
+
try {
|
|
1719
|
+
report = JSON.parse(readFileSync(reportPath, "utf-8"));
|
|
1720
|
+
} catch {
|
|
1721
|
+
return [];
|
|
1722
|
+
}
|
|
1723
|
+
const acc = [];
|
|
1724
|
+
for (const suite of report.suites ?? []) walkSuite(suite, acc);
|
|
1725
|
+
return acc;
|
|
1726
|
+
}
|
|
1727
|
+
function parseStepSidecar(stepsPath) {
|
|
1728
|
+
const byTest = /* @__PURE__ */ new Map();
|
|
1729
|
+
if (!existsSync(stepsPath)) return byTest;
|
|
1730
|
+
let raw;
|
|
1731
|
+
try {
|
|
1732
|
+
raw = readFileSync(stepsPath, "utf-8");
|
|
1733
|
+
} catch {
|
|
1734
|
+
return byTest;
|
|
1735
|
+
}
|
|
1736
|
+
for (const line of raw.split("\n")) {
|
|
1737
|
+
const trimmed = line.trim();
|
|
1738
|
+
if (trimmed.length === 0) continue;
|
|
1739
|
+
let parsed;
|
|
1740
|
+
try {
|
|
1741
|
+
parsed = JSON.parse(trimmed);
|
|
1742
|
+
} catch {
|
|
1743
|
+
continue;
|
|
1744
|
+
}
|
|
1745
|
+
const list = byTest.get(parsed.testId) ?? [];
|
|
1746
|
+
list.push({
|
|
1747
|
+
stepId: ulid(),
|
|
1748
|
+
title: parsed.title,
|
|
1749
|
+
category: parsed.category,
|
|
1750
|
+
startWall: parsed.startWall,
|
|
1751
|
+
endWall: parsed.endWall
|
|
1752
|
+
});
|
|
1753
|
+
byTest.set(parsed.testId, list);
|
|
1754
|
+
}
|
|
1755
|
+
for (const list of byTest.values()) list.sort((a, b) => a.startWall - b.startWall);
|
|
1756
|
+
return byTest;
|
|
1757
|
+
}
|
|
1758
|
+
function parseStepWindowsWithSidecar(reportPath, stepsPath) {
|
|
1759
|
+
const fromReport = parseStepWindows(reportPath);
|
|
1760
|
+
if (stepsPath === void 0) return fromReport;
|
|
1761
|
+
const sidecar = parseStepSidecar(stepsPath);
|
|
1762
|
+
if (sidecar.size === 0) return fromReport;
|
|
1763
|
+
const merged = fromReport.map((t) => {
|
|
1764
|
+
const windows = sidecar.get(t.testId);
|
|
1765
|
+
return windows !== void 0 ? { ...t, windows } : t;
|
|
1766
|
+
});
|
|
1767
|
+
const seen = new Set(merged.map((t) => t.testId));
|
|
1768
|
+
for (const [testId, windows] of sidecar) {
|
|
1769
|
+
if (!seen.has(testId)) {
|
|
1770
|
+
const title = testId.split("::").slice(1).join("::") || testId;
|
|
1771
|
+
merged.push({
|
|
1772
|
+
testId,
|
|
1773
|
+
testTitle: title,
|
|
1774
|
+
file: testId.split("::")[0] ?? "",
|
|
1775
|
+
passed: false,
|
|
1776
|
+
windows
|
|
1777
|
+
});
|
|
1778
|
+
}
|
|
1779
|
+
}
|
|
1780
|
+
return merged;
|
|
1781
|
+
}
|
|
1782
|
+
var init_step_windows = __esm2({
|
|
1783
|
+
"src/xray/step-windows.ts"() {
|
|
1784
|
+
}
|
|
1785
|
+
});
|
|
1786
|
+
function attributeRequests(requests, windows, opts = {}) {
|
|
1787
|
+
const quiescence = opts.quiescenceMs ?? DEFAULT_QUIESCENCE_MS;
|
|
1788
|
+
const maxWindow = opts.maxWindowMs ?? DEFAULT_MAX_WINDOW_MS;
|
|
1789
|
+
const effective = windows.map((w) => {
|
|
1790
|
+
const rawEnd = w.endWall + quiescence;
|
|
1791
|
+
const cappedEnd = Math.min(rawEnd, w.startWall + maxWindow);
|
|
1792
|
+
return { stepId: w.stepId, start: w.startWall, end: Math.max(cappedEnd, w.startWall) };
|
|
1793
|
+
});
|
|
1794
|
+
return requests.map((event) => {
|
|
1795
|
+
const t = event.wallStart;
|
|
1796
|
+
const containing = effective.filter((w) => t >= w.start && t <= w.end);
|
|
1797
|
+
if (containing.length === 0) {
|
|
1798
|
+
return { event, stepId: null, attribution: "background" };
|
|
1799
|
+
}
|
|
1800
|
+
if (containing.length === 1) {
|
|
1801
|
+
return { event, stepId: containing[0].stepId, attribution: "temporal" };
|
|
1802
|
+
}
|
|
1803
|
+
const winner = containing.reduce((a, b) => b.start > a.start ? b : a);
|
|
1804
|
+
return { event, stepId: winner.stepId, attribution: "temporal_ambiguous" };
|
|
1805
|
+
});
|
|
1806
|
+
}
|
|
1807
|
+
var DEFAULT_QUIESCENCE_MS;
|
|
1808
|
+
var DEFAULT_MAX_WINDOW_MS;
|
|
1809
|
+
var init_causality_engine = __esm2({
|
|
1810
|
+
"src/xray/causality-engine.ts"() {
|
|
1811
|
+
DEFAULT_QUIESCENCE_MS = 500;
|
|
1812
|
+
DEFAULT_MAX_WINDOW_MS = 15e3;
|
|
1813
|
+
}
|
|
1814
|
+
});
|
|
1815
|
+
function isVolatileParamName(name, extra) {
|
|
1816
|
+
const lower = name.toLowerCase();
|
|
1817
|
+
if (DEFAULT_VOLATILE_PARAMS.includes(lower)) return true;
|
|
1818
|
+
if (/^(session|token|sig)/.test(lower)) return true;
|
|
1819
|
+
return extra.some((p) => p.toLowerCase() === lower);
|
|
1820
|
+
}
|
|
1821
|
+
function templateSegment(seg) {
|
|
1822
|
+
if (seg.length === 0) return seg;
|
|
1823
|
+
if (NUMERIC_RE.test(seg)) return "{id}";
|
|
1824
|
+
if (UUID_RE.test(seg) || ULID_RE.test(seg) || OBJECTID_RE.test(seg) || LONGHEX_RE.test(seg)) {
|
|
1825
|
+
return "{id}";
|
|
1826
|
+
}
|
|
1827
|
+
if (BASE64URL_RE.test(seg)) return "{token}";
|
|
1828
|
+
return seg;
|
|
1829
|
+
}
|
|
1830
|
+
function templateUrl(rawUrl, volatileParams = []) {
|
|
1831
|
+
let u;
|
|
1832
|
+
try {
|
|
1833
|
+
u = new URL(rawUrl);
|
|
1834
|
+
} catch {
|
|
1835
|
+
return { host: "", urlTemplate: rawUrl };
|
|
1836
|
+
}
|
|
1837
|
+
const segs = u.pathname.split("/").map(templateSegment);
|
|
1838
|
+
const pathTemplate = segs.join("/");
|
|
1839
|
+
const params = [...u.searchParams.entries()].sort(([a], [b]) => a.localeCompare(b));
|
|
1840
|
+
const queryParts = params.map(
|
|
1841
|
+
([k, v]) => isVolatileParamName(k, volatileParams) ? k : `${k}=${templateSegment(v)}`
|
|
1842
|
+
);
|
|
1843
|
+
const query = queryParts.length > 0 ? `?${queryParts.join("&")}` : "";
|
|
1844
|
+
return { host: u.host, urlTemplate: `${pathTemplate}${query}` };
|
|
1845
|
+
}
|
|
1846
|
+
function identityKey(method, host, urlTemplate, gqlOperation) {
|
|
1847
|
+
const base = `${method.toUpperCase()}|${host}|${urlTemplate}`;
|
|
1848
|
+
return gqlOperation ? `${base}|${gqlOperation}` : base;
|
|
1849
|
+
}
|
|
1850
|
+
var UUID_RE;
|
|
1851
|
+
var ULID_RE;
|
|
1852
|
+
var OBJECTID_RE;
|
|
1853
|
+
var LONGHEX_RE;
|
|
1854
|
+
var NUMERIC_RE;
|
|
1855
|
+
var BASE64URL_RE;
|
|
1856
|
+
var DEFAULT_VOLATILE_PARAMS;
|
|
1857
|
+
var init_identity = __esm2({
|
|
1858
|
+
"src/xray/identity.ts"() {
|
|
1859
|
+
UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
1860
|
+
ULID_RE = /^[0-9A-HJKMNP-TV-Z]{26}$/;
|
|
1861
|
+
OBJECTID_RE = /^[0-9a-f]{24}$/i;
|
|
1862
|
+
LONGHEX_RE = /^[0-9a-f]{16,}$/i;
|
|
1863
|
+
NUMERIC_RE = /^\d+$/;
|
|
1864
|
+
BASE64URL_RE = /^[A-Za-z0-9_-]{22,}$/;
|
|
1865
|
+
DEFAULT_VOLATILE_PARAMS = [
|
|
1866
|
+
"t",
|
|
1867
|
+
"ts",
|
|
1868
|
+
"timestamp",
|
|
1869
|
+
"_",
|
|
1870
|
+
"cb",
|
|
1871
|
+
"nonce",
|
|
1872
|
+
"state",
|
|
1873
|
+
"code"
|
|
1874
|
+
];
|
|
1875
|
+
}
|
|
1876
|
+
});
|
|
1877
|
+
function placeholderFor(value) {
|
|
1878
|
+
if (ISO_DATE_RE.test(value)) return "<iso-date>";
|
|
1879
|
+
if (JWT_RE.test(value) && value.length > 30) return "<jwt>";
|
|
1880
|
+
if (UUID_RE2.test(value)) return "<uuid>";
|
|
1881
|
+
if (ULID_RE2.test(value)) return "<ulid>";
|
|
1882
|
+
if (EPOCH_MS_RE.test(value)) return "<epoch-ms>";
|
|
1883
|
+
if (EPOCH_S_RE.test(value)) return "<epoch-s>";
|
|
1884
|
+
return null;
|
|
1885
|
+
}
|
|
1886
|
+
function sha256Hex3(s) {
|
|
1887
|
+
return createHash("sha256").update(s).digest("hex");
|
|
1888
|
+
}
|
|
1889
|
+
function parseRedactionRules(jsonPaths) {
|
|
1890
|
+
const rules = [];
|
|
1891
|
+
for (const raw of jsonPaths) {
|
|
1892
|
+
const p = raw.trim();
|
|
1893
|
+
if (p.startsWith("$..")) {
|
|
1894
|
+
rules.push({ recursiveField: p.slice(3), path: null });
|
|
1895
|
+
} else if (p.startsWith("$.")) {
|
|
1896
|
+
const segs = p.slice(2).split(".").map((s) => s.replace(/\[[^\]]*\]/g, "")).filter((s) => s.length > 0);
|
|
1897
|
+
rules.push({ recursiveField: null, path: segs });
|
|
1898
|
+
}
|
|
1899
|
+
}
|
|
1900
|
+
return rules;
|
|
1901
|
+
}
|
|
1902
|
+
function redactJson(value, rules) {
|
|
1903
|
+
if (rules.length === 0) return value;
|
|
1904
|
+
const recursiveFields = new Set(
|
|
1905
|
+
rules.map((r) => r.recursiveField).filter((f) => f !== null)
|
|
1906
|
+
);
|
|
1907
|
+
const anchored = rules.map((r) => r.path).filter((p) => p !== null);
|
|
1908
|
+
function walk(node, trail) {
|
|
1909
|
+
if (Array.isArray(node)) return node.map((v) => walk(v, trail));
|
|
1910
|
+
if (node !== null && typeof node === "object") {
|
|
1911
|
+
const out = {};
|
|
1912
|
+
for (const [k, v] of Object.entries(node)) {
|
|
1913
|
+
const here22 = [...trail, k];
|
|
1914
|
+
if (recursiveFields.has(k)) {
|
|
1915
|
+
out[k] = REDACTED;
|
|
1916
|
+
} else if (anchored.some((p) => pathEquals(p, here22))) {
|
|
1917
|
+
out[k] = REDACTED;
|
|
1918
|
+
} else {
|
|
1919
|
+
out[k] = walk(v, here22);
|
|
1920
|
+
}
|
|
1921
|
+
}
|
|
1922
|
+
return out;
|
|
1923
|
+
}
|
|
1924
|
+
return node;
|
|
1925
|
+
}
|
|
1926
|
+
return walk(value, []);
|
|
1927
|
+
}
|
|
1928
|
+
function pathEquals(a, b) {
|
|
1929
|
+
return a.length === b.length && a.every((s, i) => s === b[i]);
|
|
1930
|
+
}
|
|
1931
|
+
function canonicalizeValue(value) {
|
|
1932
|
+
if (Array.isArray(value)) return value.map(canonicalizeValue);
|
|
1933
|
+
if (value !== null && typeof value === "object") {
|
|
1934
|
+
const out = {};
|
|
1935
|
+
for (const key of Object.keys(value).sort()) {
|
|
1936
|
+
out[key] = canonicalizeValue(value[key]);
|
|
1937
|
+
}
|
|
1938
|
+
return out;
|
|
1939
|
+
}
|
|
1940
|
+
if (typeof value === "string") {
|
|
1941
|
+
const ph = placeholderFor(value);
|
|
1942
|
+
return ph ?? value;
|
|
1943
|
+
}
|
|
1944
|
+
return value;
|
|
1945
|
+
}
|
|
1946
|
+
function shapeOf(value) {
|
|
1947
|
+
if (Array.isArray(value)) {
|
|
1948
|
+
return value.length > 0 ? ["array", shapeOf(value[0])] : ["array"];
|
|
1949
|
+
}
|
|
1950
|
+
if (value !== null && typeof value === "object") {
|
|
1951
|
+
const out = {};
|
|
1952
|
+
for (const key of Object.keys(value).sort()) {
|
|
1953
|
+
out[key] = shapeOf(value[key]);
|
|
1954
|
+
}
|
|
1955
|
+
return out;
|
|
1956
|
+
}
|
|
1957
|
+
if (value === null) return "null";
|
|
1958
|
+
return typeof value;
|
|
1959
|
+
}
|
|
1960
|
+
function canonicalizeBody(body, mime, redactionRules) {
|
|
1961
|
+
const looksJson = mime !== null && /json|graphql/i.test(mime) || /^\s*[{[]/.test(body);
|
|
1962
|
+
if (looksJson) {
|
|
1963
|
+
try {
|
|
1964
|
+
const parsed = JSON.parse(body);
|
|
1965
|
+
const redacted = redactJson(parsed, redactionRules);
|
|
1966
|
+
const canonical = canonicalizeValue(redacted);
|
|
1967
|
+
const canonicalStr = JSON.stringify(canonical);
|
|
1968
|
+
return {
|
|
1969
|
+
hash: sha256Hex3(canonicalStr),
|
|
1970
|
+
shapeHash: sha256Hex3(JSON.stringify(shapeOf(redacted))),
|
|
1971
|
+
canonical: Buffer.from(canonicalStr, "utf-8"),
|
|
1972
|
+
isJson: true
|
|
1973
|
+
};
|
|
1974
|
+
} catch {
|
|
1975
|
+
}
|
|
1976
|
+
}
|
|
1977
|
+
return {
|
|
1978
|
+
hash: sha256Hex3(body),
|
|
1979
|
+
shapeHash: sha256Hex3("text"),
|
|
1980
|
+
canonical: Buffer.from(body, "utf-8"),
|
|
1981
|
+
isJson: false
|
|
1982
|
+
};
|
|
1983
|
+
}
|
|
1984
|
+
function detectGraphql(url, reqBody) {
|
|
1985
|
+
const looksGraphqlUrl = /\/graphql\b/i.test(url);
|
|
1986
|
+
if (reqBody === null) return null;
|
|
1987
|
+
let parsed;
|
|
1988
|
+
try {
|
|
1989
|
+
parsed = JSON.parse(reqBody);
|
|
1990
|
+
} catch {
|
|
1991
|
+
return null;
|
|
1992
|
+
}
|
|
1993
|
+
if (parsed === null || typeof parsed !== "object") return null;
|
|
1994
|
+
const obj = parsed;
|
|
1995
|
+
if (typeof obj.query !== "string") return null;
|
|
1996
|
+
if (!looksGraphqlUrl && !/\b(query|mutation|subscription)\b/.test(obj.query)) return null;
|
|
1997
|
+
const operationName = typeof obj.operationName === "string" && obj.operationName.length > 0 ? obj.operationName : extractOpName(obj.query) ?? "anonymous";
|
|
1998
|
+
const queryHash = sha256Hex3(obj.query.replace(/\s+/g, " ").trim()).slice(0, 16);
|
|
1999
|
+
return { operationName, queryHash };
|
|
2000
|
+
}
|
|
2001
|
+
function extractOpName(query) {
|
|
2002
|
+
const m = query.match(/\b(?:query|mutation|subscription)\s+([A-Za-z_][A-Za-z0-9_]*)/);
|
|
2003
|
+
return m ? m[1] : null;
|
|
2004
|
+
}
|
|
2005
|
+
function normalizeRequest(input) {
|
|
2006
|
+
const { host, urlTemplate } = templateUrl(input.url, input.volatileParams);
|
|
2007
|
+
const gql = detectGraphql(input.url, input.reqBody);
|
|
2008
|
+
const idKey = gql ? identityKey(input.method, host, urlTemplate, `${gql.operationName}:${gql.queryHash}`) : identityKey(input.method, host, urlTemplate, null);
|
|
2009
|
+
const req = input.reqBody !== null && input.reqBody.length > 0 ? canonicalizeBody(input.reqBody, input.mime, input.redactionRules) : null;
|
|
2010
|
+
const resp = input.respBody !== null && input.respBody.length > 0 ? canonicalizeBody(input.respBody, input.mime, input.redactionRules) : null;
|
|
2011
|
+
return {
|
|
2012
|
+
host,
|
|
2013
|
+
urlTemplate,
|
|
2014
|
+
identityKey: idKey,
|
|
2015
|
+
gqlOperation: gql ? gql.operationName : null,
|
|
2016
|
+
reqHash: req ? req.hash : null,
|
|
2017
|
+
// shapeHash describes the response shape (what SCHEMA_CHANGED compares).
|
|
2018
|
+
respHash: resp ? resp.hash : null,
|
|
2019
|
+
shapeHash: resp ? resp.shapeHash : null,
|
|
2020
|
+
reqCanonical: req ? req.canonical : null,
|
|
2021
|
+
respCanonical: resp ? resp.canonical : null
|
|
2022
|
+
};
|
|
2023
|
+
}
|
|
2024
|
+
var ISO_DATE_RE;
|
|
2025
|
+
var EPOCH_MS_RE;
|
|
2026
|
+
var EPOCH_S_RE;
|
|
2027
|
+
var UUID_RE2;
|
|
2028
|
+
var ULID_RE2;
|
|
2029
|
+
var JWT_RE;
|
|
2030
|
+
var REDACTED;
|
|
2031
|
+
var DEFAULT_HEADER_DENYLIST;
|
|
2032
|
+
var init_normalizer = __esm2({
|
|
2033
|
+
"src/xray/normalizer.ts"() {
|
|
2034
|
+
init_identity();
|
|
2035
|
+
ISO_DATE_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:?\d{2})?$/;
|
|
2036
|
+
EPOCH_MS_RE = /^\d{13}$/;
|
|
2037
|
+
EPOCH_S_RE = /^\d{10}$/;
|
|
2038
|
+
UUID_RE2 = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
2039
|
+
ULID_RE2 = /^[0-9A-HJKMNP-TV-Z]{26}$/;
|
|
2040
|
+
JWT_RE = /^[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$/;
|
|
2041
|
+
REDACTED = "<redacted>";
|
|
2042
|
+
DEFAULT_HEADER_DENYLIST = [
|
|
2043
|
+
"authorization",
|
|
2044
|
+
"cookie",
|
|
2045
|
+
"set-cookie",
|
|
2046
|
+
"x-api-key",
|
|
2047
|
+
"proxy-authorization"
|
|
2048
|
+
];
|
|
2049
|
+
}
|
|
2050
|
+
});
|
|
2051
|
+
function buildRunGraph(requests) {
|
|
2052
|
+
const graph = /* @__PURE__ */ new Map();
|
|
2053
|
+
for (const r of requests) {
|
|
2054
|
+
const key = r.identityKey;
|
|
2055
|
+
const existing = graph.get(key);
|
|
2056
|
+
if (existing === void 0) {
|
|
2057
|
+
graph.set(key, {
|
|
2058
|
+
identityKey: r.identityKey,
|
|
2059
|
+
stepId: r.stepId,
|
|
2060
|
+
attribution: r.attribution,
|
|
2061
|
+
method: r.method,
|
|
2062
|
+
status: r.status,
|
|
2063
|
+
errorText: r.errorText,
|
|
2064
|
+
shapeHash: r.shapeHash,
|
|
2065
|
+
respHash: r.respHash,
|
|
2066
|
+
count: 1
|
|
2067
|
+
});
|
|
2068
|
+
} else {
|
|
2069
|
+
existing.count += 1;
|
|
2070
|
+
if (isErrorStatus(r.status) && !isErrorStatus(existing.status)) {
|
|
2071
|
+
existing.status = r.status;
|
|
2072
|
+
existing.errorText = r.errorText;
|
|
2073
|
+
}
|
|
2074
|
+
}
|
|
2075
|
+
}
|
|
2076
|
+
return graph;
|
|
2077
|
+
}
|
|
2078
|
+
function isErrorStatus(status) {
|
|
2079
|
+
return status === null || status >= 400 || status < 0;
|
|
2080
|
+
}
|
|
2081
|
+
var BaselineSelector;
|
|
2082
|
+
var init_baseline = __esm2({
|
|
2083
|
+
"src/xray/baseline.ts"() {
|
|
2084
|
+
init_net_runs();
|
|
2085
|
+
init_net_requests();
|
|
2086
|
+
BaselineSelector = class {
|
|
2087
|
+
netRuns;
|
|
2088
|
+
netRequests;
|
|
2089
|
+
constructor(db) {
|
|
2090
|
+
this.netRuns = new NetRunsRepository(db);
|
|
2091
|
+
this.netRequests = new NetRequestsRepository(db);
|
|
2092
|
+
}
|
|
2093
|
+
/**
|
|
2094
|
+
* The baseline run for a (test, browser): the pinned baseline if one exists,
|
|
2095
|
+
* else the most recent green run. Returns null if there's no green run yet.
|
|
2096
|
+
* `excludeRunId` skips a candidate run (so a run never baselines against itself).
|
|
2097
|
+
*/
|
|
2098
|
+
select(testId, browser, excludeRunId) {
|
|
2099
|
+
const runs = this.netRuns.listByTest(testId, browser);
|
|
2100
|
+
const pinned = runs.find((r) => r.isBaseline && r.runId !== excludeRunId);
|
|
2101
|
+
if (pinned !== void 0) return pinned;
|
|
2102
|
+
return runs.find((r) => r.isGreen && r.runId !== excludeRunId) ?? null;
|
|
2103
|
+
}
|
|
2104
|
+
/** Build the causal graph for a run by id (or null if the run is unknown). */
|
|
2105
|
+
graphFor(runId) {
|
|
2106
|
+
if (this.netRuns.findById(runId) === null) return null;
|
|
2107
|
+
return buildRunGraph(this.netRequests.listByRun(runId));
|
|
2108
|
+
}
|
|
2109
|
+
/** Pin a run as the baseline for its (test, browser), clearing any prior pin. */
|
|
2110
|
+
pin(runId) {
|
|
2111
|
+
const run = this.netRuns.findById(runId);
|
|
2112
|
+
if (run === null) return null;
|
|
2113
|
+
this.netRuns.clearBaseline(run.testId, run.browser);
|
|
2114
|
+
return this.netRuns.update(runId, { isBaseline: true });
|
|
2115
|
+
}
|
|
2116
|
+
/** Clear the baseline pin for a (test, browser). */
|
|
2117
|
+
clear(testId, browser) {
|
|
2118
|
+
this.netRuns.clearBaseline(testId, browser);
|
|
2119
|
+
}
|
|
2120
|
+
};
|
|
2121
|
+
}
|
|
2122
|
+
});
|
|
2123
|
+
function learnVolatility(testId, graphA, graphB, learnedAt) {
|
|
2124
|
+
const out = [];
|
|
2125
|
+
for (const [key, a] of graphA) {
|
|
2126
|
+
const b = graphB.get(key);
|
|
2127
|
+
if (b === void 0) continue;
|
|
2128
|
+
const sameShape = a.shapeHash !== null && b.shapeHash !== null && a.shapeHash === b.shapeHash;
|
|
2129
|
+
const valueDiffers = a.respHash !== null && b.respHash !== null && a.respHash !== b.respHash;
|
|
2130
|
+
if (sameShape && valueDiffers) {
|
|
2131
|
+
out.push(
|
|
2132
|
+
NetVolatilityRow.parse({
|
|
2133
|
+
testId,
|
|
2134
|
+
identityKey: key,
|
|
2135
|
+
jsonPath: "",
|
|
2136
|
+
kind: "value",
|
|
2137
|
+
learnedAt
|
|
2138
|
+
})
|
|
2139
|
+
);
|
|
2140
|
+
}
|
|
2141
|
+
}
|
|
2142
|
+
const onlyInOne = (from, other) => {
|
|
2143
|
+
for (const key of from.keys()) {
|
|
2144
|
+
if (!other.has(key)) {
|
|
2145
|
+
out.push(
|
|
2146
|
+
NetVolatilityRow.parse({
|
|
2147
|
+
testId,
|
|
2148
|
+
identityKey: key,
|
|
2149
|
+
jsonPath: "",
|
|
2150
|
+
kind: "presence",
|
|
2151
|
+
learnedAt
|
|
2152
|
+
})
|
|
2153
|
+
);
|
|
2154
|
+
}
|
|
2155
|
+
}
|
|
2156
|
+
};
|
|
2157
|
+
onlyInOne(graphA, graphB);
|
|
2158
|
+
onlyInOne(graphB, graphA);
|
|
2159
|
+
const seen = /* @__PURE__ */ new Set();
|
|
2160
|
+
return out.filter((r) => {
|
|
2161
|
+
const k = `${r.identityKey}|${r.kind}`;
|
|
2162
|
+
if (seen.has(k)) return false;
|
|
2163
|
+
seen.add(k);
|
|
2164
|
+
return true;
|
|
2165
|
+
});
|
|
2166
|
+
}
|
|
2167
|
+
var VolatilityLearner;
|
|
2168
|
+
var init_volatility = __esm2({
|
|
2169
|
+
"src/xray/volatility.ts"() {
|
|
2170
|
+
init_net_runs();
|
|
2171
|
+
init_net_requests();
|
|
2172
|
+
init_net_volatility();
|
|
2173
|
+
init_baseline();
|
|
2174
|
+
VolatilityLearner = class {
|
|
2175
|
+
netRuns;
|
|
2176
|
+
netRequests;
|
|
2177
|
+
volatility;
|
|
2178
|
+
constructor(db) {
|
|
2179
|
+
this.netRuns = new NetRunsRepository(db);
|
|
2180
|
+
this.netRequests = new NetRequestsRepository(db);
|
|
2181
|
+
this.volatility = new NetVolatilityRepository(db);
|
|
2182
|
+
}
|
|
2183
|
+
/**
|
|
2184
|
+
* Learn from the two most recent green runs of (test, browser). No-op (returns
|
|
2185
|
+
* 0) until at least two green runs exist. Returns the number of rows learned.
|
|
2186
|
+
*/
|
|
2187
|
+
learnForTest(testId, browser, learnedAt) {
|
|
2188
|
+
const green = this.netRuns.listByTest(testId, browser).filter((r) => r.isGreen);
|
|
2189
|
+
if (green.length < 2) return 0;
|
|
2190
|
+
const [newest, prev] = green;
|
|
2191
|
+
const graphA = buildRunGraph(this.netRequests.listByRun(newest.runId));
|
|
2192
|
+
const graphB = buildRunGraph(this.netRequests.listByRun(prev.runId));
|
|
2193
|
+
const rows = learnVolatility(testId, graphA, graphB, learnedAt);
|
|
2194
|
+
for (const row of rows) this.volatility.mark(row);
|
|
2195
|
+
return rows.length;
|
|
2196
|
+
}
|
|
2197
|
+
};
|
|
2198
|
+
}
|
|
2199
|
+
});
|
|
2200
|
+
function windowEnvelope(windows) {
|
|
2201
|
+
if (windows.length === 0) return null;
|
|
2202
|
+
let start = Infinity;
|
|
2203
|
+
let end = -Infinity;
|
|
2204
|
+
for (const w of windows) {
|
|
2205
|
+
if (w.startWall < start) start = w.startWall;
|
|
2206
|
+
if (w.endWall > end) end = w.endWall;
|
|
2207
|
+
}
|
|
2208
|
+
return { start: start - 1e3, end: end + 15e3 };
|
|
2209
|
+
}
|
|
2210
|
+
var CaptureSession;
|
|
2211
|
+
var init_capture_session = __esm2({
|
|
2212
|
+
"src/xray/capture-session.ts"() {
|
|
2213
|
+
init_net_runs();
|
|
2214
|
+
init_net_requests();
|
|
2215
|
+
init_net_bodies();
|
|
2216
|
+
init_har_parser();
|
|
2217
|
+
init_step_windows();
|
|
2218
|
+
init_causality_engine();
|
|
2219
|
+
init_normalizer();
|
|
2220
|
+
init_volatility();
|
|
2221
|
+
CaptureSession = class {
|
|
2222
|
+
constructor(deps) {
|
|
2223
|
+
this.deps = deps;
|
|
2224
|
+
this.netRuns = new NetRunsRepository(deps.db);
|
|
2225
|
+
this.netRequests = new NetRequestsRepository(deps.db);
|
|
2226
|
+
this.netBodies = new NetBodiesRepository(deps.db);
|
|
2227
|
+
this.volatilityLearner = new VolatilityLearner(deps.db);
|
|
2228
|
+
}
|
|
2229
|
+
deps;
|
|
2230
|
+
netRuns;
|
|
2231
|
+
netRequests;
|
|
2232
|
+
netBodies;
|
|
2233
|
+
volatilityLearner;
|
|
2234
|
+
/**
|
|
2235
|
+
* Ingest a finished run's HAR + report. Because recordHar writes ONE HAR for
|
|
2236
|
+
* the whole subprocess (all tests share it), requests are bucketed to tests by
|
|
2237
|
+
* their step windows: a request that falls in a test's window-span belongs to
|
|
2238
|
+
* that test; requests outside every test's span are attributed to that test's
|
|
2239
|
+
* `background` only if they still fall in its outer [firstStep, lastStep]
|
|
2240
|
+
* envelope, else dropped from per-test runs (true cross-test noise).
|
|
2241
|
+
*/
|
|
2242
|
+
ingest(opts) {
|
|
2243
|
+
const startedAt = opts.startedAt ?? (/* @__PURE__ */ new Date()).toISOString();
|
|
2244
|
+
const events = parseHarFile(opts.harPath);
|
|
2245
|
+
const perTest = parseStepWindowsWithSidecar(opts.reportPath, opts.stepsPath);
|
|
2246
|
+
const tier = opts.browser === "chromium" ? "A" : "B";
|
|
2247
|
+
const volatileParams = opts.volatileParams ?? [];
|
|
2248
|
+
const redactionRules = parseRedactionRules(opts.redact ?? []);
|
|
2249
|
+
const runIds = [];
|
|
2250
|
+
let requestCount = 0;
|
|
2251
|
+
const greenTests = /* @__PURE__ */ new Set();
|
|
2252
|
+
for (const test of perTest) {
|
|
2253
|
+
const envelope = windowEnvelope(test.windows);
|
|
2254
|
+
const testEvents = envelope === null ? perTest.length === 1 ? events : [] : events.filter((e) => e.wallStart >= envelope.start && e.wallStart <= envelope.end);
|
|
2255
|
+
if (testEvents.length === 0 && perTest.length > 1) continue;
|
|
2256
|
+
const attributed = attributeRequests(
|
|
2257
|
+
testEvents,
|
|
2258
|
+
test.windows,
|
|
2259
|
+
opts.quiescenceMs !== void 0 ? { quiescenceMs: opts.quiescenceMs } : {}
|
|
2260
|
+
);
|
|
2261
|
+
const runId = ulid();
|
|
2262
|
+
const isGreen = test.passed;
|
|
2263
|
+
this.netRuns.create(
|
|
2264
|
+
NetRunRow.parse({
|
|
2265
|
+
runId,
|
|
2266
|
+
workflowId: opts.workflowId,
|
|
2267
|
+
testId: test.testId,
|
|
2268
|
+
browser: opts.browser,
|
|
2269
|
+
tier,
|
|
2270
|
+
captureLevel: opts.level,
|
|
2271
|
+
startedAt,
|
|
2272
|
+
isGreen,
|
|
2273
|
+
isBaseline: false
|
|
2274
|
+
})
|
|
2275
|
+
);
|
|
2276
|
+
const rows = attributed.map(
|
|
2277
|
+
(a) => this.toRequestRow(runId, a, volatileParams, redactionRules)
|
|
2278
|
+
);
|
|
2279
|
+
if (rows.length > 0) this.netRequests.createMany(rows);
|
|
2280
|
+
runIds.push(runId);
|
|
2281
|
+
requestCount += rows.length;
|
|
2282
|
+
if (isGreen) greenTests.add(test.testId);
|
|
2283
|
+
}
|
|
2284
|
+
if (perTest.length === 0 && events.length > 0) {
|
|
2285
|
+
const runId = ulid();
|
|
2286
|
+
const testId = testIdFor(path15.basename(opts.harPath), "(unattributed)");
|
|
2287
|
+
this.netRuns.create(
|
|
2288
|
+
NetRunRow.parse({
|
|
2289
|
+
runId,
|
|
2290
|
+
workflowId: opts.workflowId,
|
|
2291
|
+
testId,
|
|
2292
|
+
browser: opts.browser,
|
|
2293
|
+
tier,
|
|
2294
|
+
captureLevel: opts.level,
|
|
2295
|
+
startedAt,
|
|
2296
|
+
isGreen: false,
|
|
2297
|
+
isBaseline: false
|
|
2298
|
+
})
|
|
2299
|
+
);
|
|
2300
|
+
const rows = events.map(
|
|
2301
|
+
(e) => this.toRequestRow(
|
|
2302
|
+
runId,
|
|
2303
|
+
{ event: e, stepId: null, attribution: "background" },
|
|
2304
|
+
volatileParams,
|
|
2305
|
+
redactionRules
|
|
2306
|
+
)
|
|
2307
|
+
);
|
|
2308
|
+
if (rows.length > 0) this.netRequests.createMany(rows);
|
|
2309
|
+
runIds.push(runId);
|
|
2310
|
+
requestCount += rows.length;
|
|
2311
|
+
}
|
|
2312
|
+
for (const testId of greenTests) {
|
|
2313
|
+
try {
|
|
2314
|
+
this.volatilityLearner.learnForTest(testId, opts.browser, startedAt);
|
|
2315
|
+
} catch {
|
|
2316
|
+
}
|
|
2317
|
+
}
|
|
2318
|
+
this.deps.auditLogger.append({
|
|
2319
|
+
workflowId: opts.workflowId,
|
|
2320
|
+
phase: "EXECUTION",
|
|
2321
|
+
skillId: null,
|
|
2322
|
+
event: "xray.capture",
|
|
2323
|
+
payload: {
|
|
2324
|
+
runIds,
|
|
2325
|
+
requestCount,
|
|
2326
|
+
browser: opts.browser,
|
|
2327
|
+
tier,
|
|
2328
|
+
level: opts.level
|
|
2329
|
+
},
|
|
2330
|
+
actor: "system"
|
|
2331
|
+
});
|
|
2332
|
+
return { runIds, requestCount };
|
|
2333
|
+
}
|
|
2334
|
+
/** Build a persisted net_requests row from an attributed event. */
|
|
2335
|
+
toRequestRow(runId, a, volatileParams, redactionRules) {
|
|
2336
|
+
const e = a.event;
|
|
2337
|
+
const norm = normalizeRequest({
|
|
2338
|
+
method: e.method,
|
|
2339
|
+
url: e.url,
|
|
2340
|
+
mime: e.mime,
|
|
2341
|
+
reqBody: e.reqBody,
|
|
2342
|
+
respBody: e.bodyStatus === "stored" ? e.respBody : null,
|
|
2343
|
+
volatileParams,
|
|
2344
|
+
redactionRules
|
|
2345
|
+
});
|
|
2346
|
+
let reqBodyRef = null;
|
|
2347
|
+
let respBodyRef = null;
|
|
2348
|
+
if (norm.reqCanonical !== null) reqBodyRef = this.netBodies.putBody(norm.reqCanonical);
|
|
2349
|
+
if (norm.respCanonical !== null) respBodyRef = this.netBodies.putBody(norm.respCanonical);
|
|
2350
|
+
return NetRequestRow.parse({
|
|
2351
|
+
id: ulid(),
|
|
2352
|
+
runId,
|
|
2353
|
+
stepId: a.stepId,
|
|
2354
|
+
attribution: a.attribution,
|
|
2355
|
+
targetType: "page",
|
|
2356
|
+
method: e.method,
|
|
2357
|
+
urlRaw: e.url,
|
|
2358
|
+
urlTemplate: norm.urlTemplate,
|
|
2359
|
+
gqlOperation: norm.gqlOperation,
|
|
2360
|
+
identityKey: norm.identityKey,
|
|
2361
|
+
status: e.status,
|
|
2362
|
+
errorText: e.errorText,
|
|
2363
|
+
mime: e.mime,
|
|
2364
|
+
reqHash: norm.reqHash,
|
|
2365
|
+
respHash: norm.respHash,
|
|
2366
|
+
shapeHash: norm.shapeHash,
|
|
2367
|
+
reqBodyRef,
|
|
2368
|
+
respBodyRef,
|
|
2369
|
+
bodyStatus: e.bodyStatus,
|
|
2370
|
+
redirectsJson: e.redirects.length > 0 ? JSON.stringify(e.redirects) : null,
|
|
2371
|
+
timingsJson: Object.keys(e.timings).length > 0 ? JSON.stringify(e.timings) : null,
|
|
2372
|
+
initiatorJson: e.initiator ? JSON.stringify(e.initiator) : null,
|
|
2373
|
+
wallStart: new Date(e.wallStart).toISOString()
|
|
2374
|
+
});
|
|
2375
|
+
}
|
|
2376
|
+
};
|
|
2377
|
+
}
|
|
2378
|
+
});
|
|
2379
|
+
function statusClass(status) {
|
|
2380
|
+
if (status === null || status <= 0) return "unknown";
|
|
2381
|
+
if (status >= 500) return "5xx";
|
|
2382
|
+
if (status >= 400) return "4xx";
|
|
2383
|
+
if (status >= 300) return "3xx";
|
|
2384
|
+
if (status >= 200) return "2xx";
|
|
2385
|
+
return "other";
|
|
2386
|
+
}
|
|
2387
|
+
function isErrorStatus2(status) {
|
|
2388
|
+
return status !== null && status >= 400;
|
|
2389
|
+
}
|
|
2390
|
+
function isUnknownStatus(status, errorText) {
|
|
2391
|
+
return errorText === null && (status === null || status <= 0);
|
|
2392
|
+
}
|
|
2393
|
+
function diffGraphs(run, baseline, opts = {}) {
|
|
2394
|
+
const presenceUnstable = new Set(
|
|
2395
|
+
(opts.volatility ?? []).filter((v) => v.kind === "presence").map((v) => v.identityKey)
|
|
2396
|
+
);
|
|
2397
|
+
const valueVolatile = new Set(
|
|
2398
|
+
(opts.volatility ?? []).filter((v) => v.kind === "value").map((v) => v.identityKey)
|
|
2399
|
+
);
|
|
2400
|
+
const backgroundFailSignal = opts.backgroundIsFailSignal === true;
|
|
2401
|
+
const divergences = [];
|
|
2402
|
+
const emit2 = (diffClass, e, detail) => {
|
|
2403
|
+
const isFail = FAIL_SIGNAL_CLASSES.has(diffClass) && (backgroundFailSignal || e.attribution !== "background");
|
|
2404
|
+
divergences.push({
|
|
2405
|
+
diffClass,
|
|
2406
|
+
identityKey: e.identityKey,
|
|
2407
|
+
stepId: e.stepId,
|
|
2408
|
+
attribution: e.attribution,
|
|
2409
|
+
detail,
|
|
2410
|
+
failSignal: isFail
|
|
2411
|
+
});
|
|
2412
|
+
};
|
|
2413
|
+
for (const [key, entry] of run) {
|
|
2414
|
+
const base = baseline.get(key);
|
|
2415
|
+
if (isUnknownStatus(entry.status, entry.errorText)) continue;
|
|
2416
|
+
if (isErrorStatus2(entry.status) || entry.errorText !== null) {
|
|
2417
|
+
const ui = opts.uiPassed === true ? " (UI assertions passed)" : "";
|
|
2418
|
+
emit2(
|
|
2419
|
+
"ERROR_RESPONSE",
|
|
2420
|
+
entry,
|
|
2421
|
+
`${describe(entry)} returned ${entry.errorText ?? entry.status ?? "error"}${ui}`
|
|
2422
|
+
);
|
|
2423
|
+
continue;
|
|
2424
|
+
}
|
|
2425
|
+
if (base !== void 0) {
|
|
2426
|
+
const runClass = statusClass(entry.status);
|
|
2427
|
+
const baseClass = statusClass(base.status);
|
|
2428
|
+
if (runClass !== "unknown" && baseClass !== "unknown" && runClass !== baseClass) {
|
|
2429
|
+
emit2(
|
|
2430
|
+
"STATUS_CHANGED",
|
|
2431
|
+
entry,
|
|
2432
|
+
`${describe(entry)} status ${base.status ?? "\u2014"} \u2192 ${entry.status ?? "\u2014"}`
|
|
2433
|
+
);
|
|
2434
|
+
} else if (entry.shapeHash !== null && base.shapeHash !== null && entry.shapeHash !== base.shapeHash) {
|
|
2435
|
+
emit2("SCHEMA_CHANGED", entry, `${describe(entry)} response shape changed`);
|
|
2436
|
+
} else if (entry.respHash !== null && base.respHash !== null && entry.respHash !== base.respHash && !valueVolatile.has(key)) {
|
|
2437
|
+
emit2("VALUE_CHANGED", entry, `${describe(entry)} response value changed`);
|
|
2438
|
+
}
|
|
2439
|
+
} else if (!presenceUnstable.has(key)) {
|
|
2440
|
+
emit2("NEW_CALL", entry, `${describe(entry)} is new vs baseline`);
|
|
2441
|
+
}
|
|
2442
|
+
}
|
|
2443
|
+
for (const [key, base] of baseline) {
|
|
2444
|
+
if (!run.has(key) && !presenceUnstable.has(key)) {
|
|
2445
|
+
emit2("MISSING_CALL", base, `${describe(base)} was expected but did not occur`);
|
|
2446
|
+
}
|
|
2447
|
+
}
|
|
2448
|
+
divergences.sort(
|
|
2449
|
+
(a, b) => SEVERITY[b.diffClass] - SEVERITY[a.diffClass] || a.identityKey.localeCompare(b.identityKey)
|
|
2450
|
+
);
|
|
2451
|
+
return {
|
|
2452
|
+
divergences,
|
|
2453
|
+
hasFailSignal: divergences.some((d) => d.failSignal)
|
|
2454
|
+
};
|
|
2455
|
+
}
|
|
2456
|
+
function describe(e) {
|
|
2457
|
+
const parts = e.identityKey.split("|");
|
|
2458
|
+
return parts.length >= 3 ? `${parts[0]} ${parts[1]}${parts[2]}` : e.identityKey;
|
|
2459
|
+
}
|
|
2460
|
+
function summarizeForClassifier(result, maxLines = 20) {
|
|
2461
|
+
if (result.divergences.length === 0) return "No backend network divergences detected.";
|
|
2462
|
+
const lines = result.divergences.slice(0, maxLines).map((d) => `- [${d.diffClass}] ${d.detail} {attribution: ${d.attribution}}`);
|
|
2463
|
+
const omitted = result.divergences.length - lines.length;
|
|
2464
|
+
if (omitted > 0) lines.push(`- \u2026and ${omitted} more (lower severity).`);
|
|
2465
|
+
return lines.join("\n");
|
|
2466
|
+
}
|
|
2467
|
+
var FAIL_SIGNAL_CLASSES;
|
|
2468
|
+
var SEVERITY;
|
|
2469
|
+
var init_diff_engine = __esm2({
|
|
2470
|
+
"src/xray/diff-engine.ts"() {
|
|
2471
|
+
FAIL_SIGNAL_CLASSES = /* @__PURE__ */ new Set([
|
|
2472
|
+
"ERROR_RESPONSE",
|
|
2473
|
+
"STATUS_CHANGED"
|
|
2474
|
+
]);
|
|
2475
|
+
SEVERITY = {
|
|
2476
|
+
ERROR_RESPONSE: 6,
|
|
2477
|
+
STATUS_CHANGED: 5,
|
|
2478
|
+
MISSING_CALL: 4,
|
|
2479
|
+
NEW_CALL: 3,
|
|
2480
|
+
SCHEMA_CHANGED: 2,
|
|
2481
|
+
VALUE_CHANGED: 1
|
|
2482
|
+
};
|
|
2483
|
+
}
|
|
2484
|
+
});
|
|
2485
|
+
function networkDiffForTest(db, workflowId, testFile, testName, uiPassed) {
|
|
2486
|
+
const netRuns = new NetRunsRepository(db);
|
|
2487
|
+
const netRequests = new NetRequestsRepository(db);
|
|
2488
|
+
const volatility = new NetVolatilityRepository(db);
|
|
2489
|
+
const baselines = new BaselineSelector(db);
|
|
2490
|
+
const base = testFile.split(/[\\/]/).pop() ?? testFile;
|
|
2491
|
+
const title = testName.includes(" > ") ? testName.slice(testName.lastIndexOf(" > ") + 3) : testName;
|
|
2492
|
+
const candidates = [
|
|
2493
|
+
testIdFor(base, title),
|
|
2494
|
+
testIdFor(testFile, testName),
|
|
2495
|
+
// exact, in case conventions already align
|
|
2496
|
+
testIdFor(base, testName)
|
|
2497
|
+
];
|
|
2498
|
+
const runsForWorkflow = netRuns.listByWorkflow(workflowId);
|
|
2499
|
+
const runRow = runsForWorkflow.find((r) => candidates.includes(r.testId)) ?? // Last resort: match on the bare title alone (suffix).
|
|
2500
|
+
runsForWorkflow.find((r) => r.testId.endsWith(`::${title}`));
|
|
2501
|
+
if (runRow === void 0) return null;
|
|
2502
|
+
const baseline = baselines.select(runRow.testId, runRow.browser, runRow.runId);
|
|
2503
|
+
if (baseline === null) return null;
|
|
2504
|
+
const runGraph = buildRunGraph(netRequests.listByRun(runRow.runId));
|
|
2505
|
+
const baseGraph = buildRunGraph(netRequests.listByRun(baseline.runId));
|
|
2506
|
+
const result = diffGraphs(runGraph, baseGraph, {
|
|
2507
|
+
volatility: volatility.listByTest(runRow.testId),
|
|
2508
|
+
uiPassed
|
|
2509
|
+
});
|
|
2510
|
+
if (result.divergences.length === 0) return null;
|
|
2511
|
+
return {
|
|
2512
|
+
summary: summarizeForClassifier(result),
|
|
2513
|
+
hasFailSignal: result.hasFailSignal
|
|
2514
|
+
};
|
|
2515
|
+
}
|
|
2516
|
+
var init_classify_bridge = __esm2({
|
|
2517
|
+
"src/xray/classify-bridge.ts"() {
|
|
2518
|
+
init_net_runs();
|
|
2519
|
+
init_net_requests();
|
|
2520
|
+
init_net_volatility();
|
|
2521
|
+
init_baseline();
|
|
2522
|
+
init_diff_engine();
|
|
2523
|
+
init_step_windows();
|
|
2524
|
+
}
|
|
2525
|
+
});
|
|
2526
|
+
var xray_exports = {};
|
|
2527
|
+
__export2(xray_exports, {
|
|
2528
|
+
BaselineSelector: () => BaselineSelector,
|
|
2529
|
+
CaptureSession: () => CaptureSession,
|
|
2530
|
+
DEFAULT_HEADER_DENYLIST: () => DEFAULT_HEADER_DENYLIST,
|
|
2531
|
+
DEFAULT_VOLATILE_PARAMS: () => DEFAULT_VOLATILE_PARAMS,
|
|
2532
|
+
FAIL_SIGNAL_CLASSES: () => FAIL_SIGNAL_CLASSES,
|
|
2533
|
+
VolatilityLearner: () => VolatilityLearner,
|
|
2534
|
+
attributeRequests: () => attributeRequests,
|
|
2535
|
+
buildRunGraph: () => buildRunGraph,
|
|
2536
|
+
canonicalizeBody: () => canonicalizeBody,
|
|
2537
|
+
detectGraphql: () => detectGraphql,
|
|
2538
|
+
diffGraphs: () => diffGraphs,
|
|
2539
|
+
identityKey: () => identityKey,
|
|
2540
|
+
learnVolatility: () => learnVolatility,
|
|
2541
|
+
materializeWrapperConfig: () => materializeWrapperConfig,
|
|
2542
|
+
networkDiffForTest: () => networkDiffForTest,
|
|
2543
|
+
normalizeRequest: () => normalizeRequest,
|
|
2544
|
+
parseHarFile: () => parseHarFile,
|
|
2545
|
+
parseRedactionRules: () => parseRedactionRules,
|
|
2546
|
+
parseStepWindows: () => parseStepWindows,
|
|
2547
|
+
redactJson: () => redactJson,
|
|
2548
|
+
resolveUserConfig: () => resolveUserConfig,
|
|
2549
|
+
summarizeForClassifier: () => summarizeForClassifier,
|
|
2550
|
+
templateUrl: () => templateUrl,
|
|
2551
|
+
testIdFor: () => testIdFor
|
|
2552
|
+
});
|
|
2553
|
+
var init_xray = __esm2({
|
|
2554
|
+
"src/xray/index.ts"() {
|
|
2555
|
+
init_wrapper_config();
|
|
2556
|
+
init_har_parser();
|
|
2557
|
+
init_step_windows();
|
|
2558
|
+
init_causality_engine();
|
|
2559
|
+
init_identity();
|
|
2560
|
+
init_capture_session();
|
|
2561
|
+
init_normalizer();
|
|
2562
|
+
init_baseline();
|
|
2563
|
+
init_diff_engine();
|
|
2564
|
+
init_volatility();
|
|
2565
|
+
init_classify_bridge();
|
|
2566
|
+
}
|
|
2567
|
+
});
|
|
1057
2568
|
var StorageError = class extends Error {
|
|
1058
2569
|
code;
|
|
1059
2570
|
cause;
|
|
@@ -1086,7 +2597,7 @@ function runMigrations(db, migrationsDir) {
|
|
|
1086
2597
|
const applied = [];
|
|
1087
2598
|
const skipped = [];
|
|
1088
2599
|
for (const filename of files) {
|
|
1089
|
-
const fullPath =
|
|
2600
|
+
const fullPath = path15.join(migrationsDir, filename);
|
|
1090
2601
|
const sql = readFileSync(fullPath, "utf-8");
|
|
1091
2602
|
const checksum = sha256Hex(sql);
|
|
1092
2603
|
const knownChecksum = appliedMap.get(filename);
|
|
@@ -1228,8 +2739,8 @@ var SqliteDb = class {
|
|
|
1228
2739
|
this.db.close();
|
|
1229
2740
|
}
|
|
1230
2741
|
};
|
|
1231
|
-
var __dirname$1 =
|
|
1232
|
-
var DEFAULT_MIGRATIONS_DIR =
|
|
2742
|
+
var __dirname$1 = path15.dirname(fileURLToPath(import.meta.url));
|
|
2743
|
+
var DEFAULT_MIGRATIONS_DIR = path15.resolve(__dirname$1, "migrations");
|
|
1233
2744
|
var Storage = class _Storage {
|
|
1234
2745
|
db;
|
|
1235
2746
|
path;
|
|
@@ -1923,6 +3434,10 @@ var VisualDiffsRepository = class {
|
|
|
1923
3434
|
return this.findById(id);
|
|
1924
3435
|
}
|
|
1925
3436
|
};
|
|
3437
|
+
init_net_runs();
|
|
3438
|
+
init_net_requests();
|
|
3439
|
+
init_net_bodies();
|
|
3440
|
+
init_net_volatility();
|
|
1926
3441
|
function canonicalize(value) {
|
|
1927
3442
|
return JSON.stringify(sortedDeep(value));
|
|
1928
3443
|
}
|
|
@@ -2681,7 +4196,7 @@ function tempForTier(tier) {
|
|
|
2681
4196
|
return 0.1;
|
|
2682
4197
|
}
|
|
2683
4198
|
}
|
|
2684
|
-
var DEFAULT_LLM_TIMEOUT_MS =
|
|
4199
|
+
var DEFAULT_LLM_TIMEOUT_MS = 24e4;
|
|
2685
4200
|
function llmTimeoutMs() {
|
|
2686
4201
|
const raw = process.env["QAIOS_LLM_TIMEOUT_MS"];
|
|
2687
4202
|
if (raw === void 0) return DEFAULT_LLM_TIMEOUT_MS;
|
|
@@ -2965,14 +4480,14 @@ function formatZodError(err) {
|
|
|
2965
4480
|
}
|
|
2966
4481
|
var PROJECT_MEMORY_FILENAME = "QAIOS.md";
|
|
2967
4482
|
function loadProjectMemory(projectRoot) {
|
|
2968
|
-
const fullPath =
|
|
4483
|
+
const fullPath = path15.join(projectRoot, PROJECT_MEMORY_FILENAME);
|
|
2969
4484
|
if (!existsSync(fullPath)) return null;
|
|
2970
4485
|
const text = readFileSync(fullPath, "utf-8");
|
|
2971
4486
|
return parseProjectMemory(text);
|
|
2972
4487
|
}
|
|
2973
4488
|
var DEFAULT_COST_CAP = {
|
|
2974
|
-
maxCalls:
|
|
2975
|
-
maxUsdCents:
|
|
4489
|
+
maxCalls: 20,
|
|
4490
|
+
maxUsdCents: 100
|
|
2976
4491
|
};
|
|
2977
4492
|
var CostTracker = class {
|
|
2978
4493
|
constructor(db) {
|
|
@@ -3109,8 +4624,8 @@ function expectedCostCents(skillId) {
|
|
|
3109
4624
|
}
|
|
3110
4625
|
var whereCache = /* @__PURE__ */ new Map();
|
|
3111
4626
|
function resolveBinaryWindows(name) {
|
|
3112
|
-
if (
|
|
3113
|
-
if (name.includes(
|
|
4627
|
+
if (path15.isAbsolute(name)) return name;
|
|
4628
|
+
if (name.includes(path15.sep) || name.includes("/")) return path15.resolve(name);
|
|
3114
4629
|
if (whereCache.has(name)) return whereCache.get(name) ?? null;
|
|
3115
4630
|
const r = spawnSync("where", [name], { encoding: "utf8", shell: false });
|
|
3116
4631
|
if (r.status !== 0) {
|
|
@@ -3291,9 +4806,9 @@ var PlaywrightSubprocessAdapter = class {
|
|
|
3291
4806
|
const runId = opts.runId ?? ulid();
|
|
3292
4807
|
const startedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
3293
4808
|
const startTimeMs = Date.now();
|
|
3294
|
-
const artifactsDir = opts.artifactsDir ??
|
|
4809
|
+
const artifactsDir = opts.artifactsDir ?? path15.join(opts.cwd, ".qaios", "runs", runId);
|
|
3295
4810
|
mkdirSync(artifactsDir, { recursive: true });
|
|
3296
|
-
const reportPath =
|
|
4811
|
+
const reportPath = path15.join(artifactsDir, "results.json");
|
|
3297
4812
|
const runningRow = {
|
|
3298
4813
|
id: runId,
|
|
3299
4814
|
workflowId: opts.workflowId,
|
|
@@ -3318,20 +4833,59 @@ var PlaywrightSubprocessAdapter = class {
|
|
|
3318
4833
|
);
|
|
3319
4834
|
}
|
|
3320
4835
|
const [bin, ...baseArgs] = command;
|
|
4836
|
+
const xrayDir = path15.join(opts.cwd, ".qaios", "xray");
|
|
4837
|
+
const harPath = path15.join(xrayDir, `${runId}.har`);
|
|
4838
|
+
let xrayConfigArgs = [];
|
|
4839
|
+
let xrayEnv = {};
|
|
4840
|
+
let xrayActive = false;
|
|
4841
|
+
let xrayCleanupPath = null;
|
|
4842
|
+
let xrayStepsPath = null;
|
|
4843
|
+
let reporterArg = "--reporter=json";
|
|
4844
|
+
if (opts.xray !== void 0) {
|
|
4845
|
+
try {
|
|
4846
|
+
const { materializeWrapperConfig: materializeWrapperConfig2 } = await Promise.resolve().then(() => (init_xray(), xray_exports));
|
|
4847
|
+
const wrapper = materializeWrapperConfig2({
|
|
4848
|
+
cwd: opts.cwd,
|
|
4849
|
+
xrayDir,
|
|
4850
|
+
harPath,
|
|
4851
|
+
level: opts.xray.level,
|
|
4852
|
+
...opts.xray.testDir !== void 0 ? { testDir: opts.xray.testDir } : {}
|
|
4853
|
+
});
|
|
4854
|
+
xrayConfigArgs = [`--config=${wrapper.configPath}`];
|
|
4855
|
+
xrayEnv = wrapper.env;
|
|
4856
|
+
xrayCleanupPath = wrapper.cleanupPath;
|
|
4857
|
+
xrayStepsPath = wrapper.stepsPath;
|
|
4858
|
+
reporterArg = `--reporter=json,${wrapper.stepReporterPath}`;
|
|
4859
|
+
xrayActive = true;
|
|
4860
|
+
} catch (err) {
|
|
4861
|
+
process.stderr.write(
|
|
4862
|
+
`[qaios xray] capture disabled for this run: ${err.message}
|
|
4863
|
+
`
|
|
4864
|
+
);
|
|
4865
|
+
}
|
|
4866
|
+
}
|
|
3321
4867
|
const args = [
|
|
3322
4868
|
...baseArgs,
|
|
3323
|
-
|
|
4869
|
+
...xrayConfigArgs,
|
|
4870
|
+
reporterArg,
|
|
3324
4871
|
...opts.pattern !== void 0 ? [opts.pattern] : [],
|
|
3325
4872
|
...opts.extraArgs ?? []
|
|
3326
4873
|
];
|
|
3327
4874
|
const env = {
|
|
3328
4875
|
...process.env,
|
|
4876
|
+
...xrayEnv,
|
|
3329
4877
|
PLAYWRIGHT_JSON_OUTPUT_NAME: reportPath
|
|
3330
4878
|
};
|
|
3331
4879
|
const subprocessResult = await this.spawnPlaywright(bin, args, opts.cwd, env, {
|
|
3332
4880
|
...opts.timeoutMs !== void 0 ? { timeoutMs: opts.timeoutMs } : {},
|
|
3333
4881
|
...opts.cancelSignal !== void 0 ? { cancelSignal: opts.cancelSignal } : {}
|
|
3334
4882
|
});
|
|
4883
|
+
if (xrayCleanupPath !== null) {
|
|
4884
|
+
try {
|
|
4885
|
+
rmSync(xrayCleanupPath, { force: true });
|
|
4886
|
+
} catch {
|
|
4887
|
+
}
|
|
4888
|
+
}
|
|
3335
4889
|
let report;
|
|
3336
4890
|
let parseFailedReason = null;
|
|
3337
4891
|
try {
|
|
@@ -3387,6 +4941,32 @@ var PlaywrightSubprocessAdapter = class {
|
|
|
3387
4941
|
},
|
|
3388
4942
|
actor: "system"
|
|
3389
4943
|
});
|
|
4944
|
+
if (xrayActive && opts.xray !== void 0) {
|
|
4945
|
+
try {
|
|
4946
|
+
const { CaptureSession: CaptureSession2 } = await Promise.resolve().then(() => (init_xray(), xray_exports));
|
|
4947
|
+
const session = new CaptureSession2({
|
|
4948
|
+
db: this.deps.storage.db,
|
|
4949
|
+
auditLogger: this.deps.auditLogger
|
|
4950
|
+
});
|
|
4951
|
+
session.ingest({
|
|
4952
|
+
workflowId: opts.workflowId,
|
|
4953
|
+
harPath,
|
|
4954
|
+
reportPath,
|
|
4955
|
+
browser: "chromium",
|
|
4956
|
+
level: opts.xray.level,
|
|
4957
|
+
startedAt,
|
|
4958
|
+
...xrayStepsPath !== null ? { stepsPath: xrayStepsPath } : {},
|
|
4959
|
+
...opts.xray.quiescenceMs !== void 0 ? { quiescenceMs: opts.xray.quiescenceMs } : {},
|
|
4960
|
+
...opts.xray.volatileParams !== void 0 ? { volatileParams: opts.xray.volatileParams } : {},
|
|
4961
|
+
...opts.xray.redact !== void 0 ? { redact: opts.xray.redact } : {}
|
|
4962
|
+
});
|
|
4963
|
+
} catch (err) {
|
|
4964
|
+
process.stderr.write(
|
|
4965
|
+
`[qaios xray] capture ingest failed (run still recorded): ${err.message}
|
|
4966
|
+
`
|
|
4967
|
+
);
|
|
4968
|
+
}
|
|
4969
|
+
}
|
|
3390
4970
|
const finalRow = this.runsRepo.findById(runId);
|
|
3391
4971
|
if (!finalRow) {
|
|
3392
4972
|
throw new PlaywrightAdapterError(
|
|
@@ -3495,16 +5075,16 @@ function truncateHtmlSafe(html, max = PAGE_CAPTURE_HTML_MAX_CHARS) {
|
|
|
3495
5075
|
return slice + "\n<!-- truncated -->";
|
|
3496
5076
|
}
|
|
3497
5077
|
function defaultCaptureScriptPath() {
|
|
3498
|
-
const
|
|
5078
|
+
const here22 = path15.dirname(fileURLToPath(import.meta.url));
|
|
3499
5079
|
const candidates = [
|
|
3500
5080
|
// tsup flattens src/healing/page-capture.ts to dist/index.js, so
|
|
3501
5081
|
// `here` is dist/. The mirror lives at dist/healing/scripts/.
|
|
3502
|
-
|
|
5082
|
+
path15.resolve(here22, "healing", "scripts", "capture-page.mjs"),
|
|
3503
5083
|
// Co-located when bundled to dist/healing/.
|
|
3504
|
-
|
|
5084
|
+
path15.resolve(here22, "scripts", "capture-page.mjs"),
|
|
3505
5085
|
// Source layout under vitest.
|
|
3506
|
-
|
|
3507
|
-
|
|
5086
|
+
path15.resolve(here22, "scripts", "capture-page.mjs"),
|
|
5087
|
+
path15.resolve(here22, "..", "healing", "scripts", "capture-page.mjs")
|
|
3508
5088
|
];
|
|
3509
5089
|
for (const c of candidates) {
|
|
3510
5090
|
if (existsSync(c)) return c;
|
|
@@ -3512,8 +5092,8 @@ function defaultCaptureScriptPath() {
|
|
|
3512
5092
|
return candidates[0];
|
|
3513
5093
|
}
|
|
3514
5094
|
async function capturePageSnapshot(opts) {
|
|
3515
|
-
const tmp = mkdtempSync(
|
|
3516
|
-
const out =
|
|
5095
|
+
const tmp = mkdtempSync(path15.join(tmpdir(), "qaios-capture-"));
|
|
5096
|
+
const out = path15.join(tmp, "snapshot.json");
|
|
3517
5097
|
mkdirSync(tmp, { recursive: true });
|
|
3518
5098
|
const timeoutMs = opts.timeoutMs ?? 3e4;
|
|
3519
5099
|
const command = opts.command ?? ["node", defaultCaptureScriptPath()];
|
|
@@ -3699,14 +5279,14 @@ function applyLocatorFix(opts) {
|
|
|
3699
5279
|
};
|
|
3700
5280
|
}
|
|
3701
5281
|
const cwd = opts.cwd ?? process.cwd();
|
|
3702
|
-
const absPath =
|
|
5282
|
+
const absPath = path15.isAbsolute(opts.testFile) ? opts.testFile : path15.resolve(cwd, opts.testFile);
|
|
3703
5283
|
const oldContents = readFileSync(absPath, "utf-8");
|
|
3704
5284
|
const occurrences = countOccurrences(oldContents, opts.fix.oldLocator);
|
|
3705
5285
|
if (occurrences === 0) {
|
|
3706
5286
|
return {
|
|
3707
5287
|
applied: false,
|
|
3708
5288
|
reason: "old_locator_not_found",
|
|
3709
|
-
detail: `old locator not present in ${
|
|
5289
|
+
detail: `old locator not present in ${path15.relative(cwd, absPath)}: ${opts.fix.oldLocator}`,
|
|
3710
5290
|
oldContents,
|
|
3711
5291
|
newContents: null
|
|
3712
5292
|
};
|
|
@@ -3715,7 +5295,7 @@ function applyLocatorFix(opts) {
|
|
|
3715
5295
|
return {
|
|
3716
5296
|
applied: false,
|
|
3717
5297
|
reason: "old_locator_not_unique",
|
|
3718
|
-
detail: `old locator appears ${occurrences} times in ${
|
|
5298
|
+
detail: `old locator appears ${occurrences} times in ${path15.relative(cwd, absPath)}; refusing to patch ambiguously.`,
|
|
3719
5299
|
oldContents,
|
|
3720
5300
|
newContents: null
|
|
3721
5301
|
};
|
|
@@ -3753,7 +5333,7 @@ function applyLocatorFix(opts) {
|
|
|
3753
5333
|
event: "artifact.updated",
|
|
3754
5334
|
payload: {
|
|
3755
5335
|
kind: "locator_fix",
|
|
3756
|
-
testFile:
|
|
5336
|
+
testFile: path15.relative(cwd, absPath),
|
|
3757
5337
|
oldLocator: opts.fix.oldLocator,
|
|
3758
5338
|
newLocator: opts.fix.newLocator,
|
|
3759
5339
|
fixKind: opts.fix.fixKind,
|
|
@@ -3780,12 +5360,12 @@ function extractLocatorFromError(errorMessage) {
|
|
|
3780
5360
|
return null;
|
|
3781
5361
|
}
|
|
3782
5362
|
function resolveTestFile(testFile, cwd, configuredTestDir) {
|
|
3783
|
-
if (
|
|
5363
|
+
if (path15.isAbsolute(testFile)) return testFile;
|
|
3784
5364
|
const candidateDirs = ["", configuredTestDir ?? "tests", "tests", "test", "e2e"];
|
|
3785
5365
|
for (const dir of candidateDirs) {
|
|
3786
5366
|
if (dir === void 0) continue;
|
|
3787
|
-
if (existsSync(
|
|
3788
|
-
return
|
|
5367
|
+
if (existsSync(path15.join(cwd, dir, testFile))) {
|
|
5368
|
+
return path15.join(dir, testFile);
|
|
3789
5369
|
}
|
|
3790
5370
|
}
|
|
3791
5371
|
return testFile;
|
|
@@ -3812,19 +5392,19 @@ async function fileDefect(opts) {
|
|
|
3812
5392
|
const { defect, target } = opts;
|
|
3813
5393
|
const allowedTools = opts.allowedTools ?? ["github:create_issue", "jira:create_issue"];
|
|
3814
5394
|
if (target.integration === "stdout") {
|
|
3815
|
-
const
|
|
3816
|
-
|
|
5395
|
+
const write2 = opts.stdoutWrite ?? ((s) => process.stdout.write(s));
|
|
5396
|
+
write2(`
|
|
3817
5397
|
\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 DEFECT (stdout target) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
|
|
3818
5398
|
`);
|
|
3819
|
-
|
|
5399
|
+
write2(`${defect.title}
|
|
3820
5400
|
|
|
3821
5401
|
`);
|
|
3822
|
-
|
|
5402
|
+
write2(`${defect.body}
|
|
3823
5403
|
`);
|
|
3824
|
-
if (defect.labels.length > 0)
|
|
5404
|
+
if (defect.labels.length > 0) write2(`
|
|
3825
5405
|
Labels: ${defect.labels.join(", ")}
|
|
3826
5406
|
`);
|
|
3827
|
-
|
|
5407
|
+
write2(`\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
|
|
3828
5408
|
`);
|
|
3829
5409
|
audit(opts, "defect.filed", {
|
|
3830
5410
|
target: "stdout",
|
|
@@ -4313,13 +5893,13 @@ function buildGatePayload(gateType, prior) {
|
|
|
4313
5893
|
}
|
|
4314
5894
|
if (gateType === "writing_review") {
|
|
4315
5895
|
const writeKey2 = Object.keys(prior).find((k) => k.startsWith("write."));
|
|
4316
|
-
const
|
|
4317
|
-
const files = Array.isArray(
|
|
5896
|
+
const write2 = writeKey2 !== void 0 ? prior[writeKey2] : null;
|
|
5897
|
+
const files = Array.isArray(write2?.["files"]) ? write2["files"] : [];
|
|
4318
5898
|
return {
|
|
4319
5899
|
writeSkill: writeKey2 ?? null,
|
|
4320
5900
|
fileCount: files.length,
|
|
4321
|
-
qaiosMdSuggestionCount: Array.isArray(
|
|
4322
|
-
reasoning:
|
|
5901
|
+
qaiosMdSuggestionCount: Array.isArray(write2?.["qaiosMdSuggestions"]) ? write2["qaiosMdSuggestions"].length : 0,
|
|
5902
|
+
reasoning: write2?.["reasoning"] ?? null
|
|
4323
5903
|
};
|
|
4324
5904
|
}
|
|
4325
5905
|
return {};
|
|
@@ -4504,6 +6084,11 @@ var readyToRun = async (deps) => {
|
|
|
4504
6084
|
}
|
|
4505
6085
|
return { nextState: "completed" };
|
|
4506
6086
|
};
|
|
6087
|
+
function isXrayArg(v) {
|
|
6088
|
+
if (v === null || typeof v !== "object") return false;
|
|
6089
|
+
const level = v.level;
|
|
6090
|
+
return level === "headers" || level === "summary" || level === "full" || level === "off";
|
|
6091
|
+
}
|
|
4507
6092
|
var running = async (deps) => {
|
|
4508
6093
|
const cwd = typeof deps.args["cwd"] === "string" ? deps.args["cwd"] : process.cwd();
|
|
4509
6094
|
const adapterOpts = {
|
|
@@ -4521,7 +6106,14 @@ var running = async (deps) => {
|
|
|
4521
6106
|
)
|
|
4522
6107
|
} : {},
|
|
4523
6108
|
...typeof deps.args["executionTimeoutMs"] === "number" ? { timeoutMs: deps.args["executionTimeoutMs"] } : {},
|
|
4524
|
-
...deps.skillCtx.cancelSignal !== void 0 ? { cancelSignal: deps.skillCtx.cancelSignal } : {}
|
|
6109
|
+
...deps.skillCtx.cancelSignal !== void 0 ? { cancelSignal: deps.skillCtx.cancelSignal } : {},
|
|
6110
|
+
...isXrayArg(deps.args["xray"]) ? {
|
|
6111
|
+
xray: {
|
|
6112
|
+
...deps.args["xray"],
|
|
6113
|
+
// Anchor capture's testDir to the same dir healing/writing use.
|
|
6114
|
+
...typeof deps.args["testDir"] === "string" ? { testDir: deps.args["testDir"] } : {}
|
|
6115
|
+
}
|
|
6116
|
+
} : {}
|
|
4525
6117
|
};
|
|
4526
6118
|
const adapterResult = await deps.executionAdapter.run(adapterOpts);
|
|
4527
6119
|
return {
|
|
@@ -4543,14 +6135,39 @@ var running = async (deps) => {
|
|
|
4543
6135
|
}
|
|
4544
6136
|
};
|
|
4545
6137
|
};
|
|
4546
|
-
|
|
4547
|
-
if (deps.
|
|
4548
|
-
|
|
6138
|
+
async function emitPassWithDivergence(deps, testResults, networkDiffFor) {
|
|
6139
|
+
if (deps.storage === void 0) return;
|
|
6140
|
+
const passed = testResults.filter((t) => t.status === "passed");
|
|
6141
|
+
const flagged = [];
|
|
6142
|
+
for (const tr of passed) {
|
|
6143
|
+
const diff = networkDiffFor(deps.storage.db, deps.workflow.id, tr.testFile, tr.testName, true);
|
|
6144
|
+
if (diff !== null && diff.hasFailSignal) {
|
|
6145
|
+
flagged.push({ test: tr.testName, summary: diff.summary });
|
|
6146
|
+
}
|
|
4549
6147
|
}
|
|
6148
|
+
if (flagged.length === 0) return;
|
|
6149
|
+
deps.auditLogger.append({
|
|
6150
|
+
workflowId: deps.workflow.id,
|
|
6151
|
+
phase: "EXECUTION",
|
|
6152
|
+
skillId: null,
|
|
6153
|
+
event: "xray.diff",
|
|
6154
|
+
payload: { passWithDivergence: true, count: flagged.length, flagged },
|
|
6155
|
+
actor: "system"
|
|
6156
|
+
});
|
|
6157
|
+
}
|
|
6158
|
+
var classifying = async (deps) => {
|
|
4550
6159
|
const exec = deps.prior["execute.playwright"];
|
|
4551
6160
|
if (!exec || !Array.isArray(exec.testResults)) {
|
|
4552
6161
|
return { nextState: "completed" };
|
|
4553
6162
|
}
|
|
6163
|
+
const xrayRan = isXrayArg(deps.args["xray"]) && deps.storage !== void 0;
|
|
6164
|
+
const networkDiffFor = xrayRan ? (await Promise.resolve().then(() => (init_xray(), xray_exports))).networkDiffForTest : null;
|
|
6165
|
+
if (networkDiffFor !== null && deps.storage !== void 0) {
|
|
6166
|
+
await emitPassWithDivergence(deps, exec.testResults, networkDiffFor);
|
|
6167
|
+
}
|
|
6168
|
+
if (deps.args["noClassify"] === true) {
|
|
6169
|
+
return { nextState: "completed" };
|
|
6170
|
+
}
|
|
4554
6171
|
const failed2 = exec.testResults.filter((t) => t.status === "failed");
|
|
4555
6172
|
if (failed2.length === 0) {
|
|
4556
6173
|
return { nextState: "completed" };
|
|
@@ -4568,6 +6185,7 @@ var classifying = async (deps) => {
|
|
|
4568
6185
|
excludeRunId: tr.runId,
|
|
4569
6186
|
limit: 10
|
|
4570
6187
|
});
|
|
6188
|
+
const netDiff = networkDiffFor !== null && deps.storage !== void 0 ? networkDiffFor(deps.storage.db, deps.workflow.id, tr.testFile, tr.testName, false) : null;
|
|
4571
6189
|
const classifyInput = {
|
|
4572
6190
|
testResult: {
|
|
4573
6191
|
file: tr.testFile,
|
|
@@ -4577,7 +6195,8 @@ var classifying = async (deps) => {
|
|
|
4577
6195
|
attachments: [],
|
|
4578
6196
|
historicalRuns,
|
|
4579
6197
|
...errorBlock
|
|
4580
|
-
}
|
|
6198
|
+
},
|
|
6199
|
+
...netDiff !== null ? { networkDiff: netDiff } : {}
|
|
4581
6200
|
};
|
|
4582
6201
|
const result = await deps.skillRunner.run(classifySkill, deps.skillCtx, classifyInput);
|
|
4583
6202
|
deps.testResultsRepo.setClassification(tr.id, result.output);
|
|
@@ -5321,6 +6940,8 @@ var Orchestrator = class {
|
|
|
5321
6940
|
executionAdapter: this.executionAdapter,
|
|
5322
6941
|
testResultsRepo: this.testResultsRepo,
|
|
5323
6942
|
gatesRepo: this.gatesRepo,
|
|
6943
|
+
// Storage handle for the classifying state's xray diff lookup (v0.4).
|
|
6944
|
+
storage: this.deps.storage,
|
|
5324
6945
|
// F3 pre-flight: hand the cost tracker + cap to runSkill so
|
|
5325
6946
|
// it can abort before launching a skill that would tip the
|
|
5326
6947
|
// cap. costCap=null (opt-out) is propagated as-is.
|
|
@@ -5474,11 +7095,11 @@ var AxeRunError = class extends Error {
|
|
|
5474
7095
|
}
|
|
5475
7096
|
};
|
|
5476
7097
|
function defaultAxeScriptPath() {
|
|
5477
|
-
const
|
|
7098
|
+
const here22 = path15.dirname(fileURLToPath(import.meta.url));
|
|
5478
7099
|
const candidates = [
|
|
5479
|
-
|
|
5480
|
-
|
|
5481
|
-
|
|
7100
|
+
path15.resolve(here22, "a11y", "scripts", "axe-runner.mjs"),
|
|
7101
|
+
path15.resolve(here22, "scripts", "axe-runner.mjs"),
|
|
7102
|
+
path15.resolve(here22, "..", "a11y", "scripts", "axe-runner.mjs")
|
|
5482
7103
|
];
|
|
5483
7104
|
for (const c of candidates) {
|
|
5484
7105
|
if (existsSync(c)) return c;
|
|
@@ -5486,8 +7107,8 @@ function defaultAxeScriptPath() {
|
|
|
5486
7107
|
return candidates[0];
|
|
5487
7108
|
}
|
|
5488
7109
|
async function runAxe(opts) {
|
|
5489
|
-
const tmp = mkdtempSync(
|
|
5490
|
-
const out =
|
|
7110
|
+
const tmp = mkdtempSync(path15.join(tmpdir(), "qaios-axe-"));
|
|
7111
|
+
const out = path15.join(tmp, "findings.json");
|
|
5491
7112
|
mkdirSync(tmp, { recursive: true });
|
|
5492
7113
|
const timeoutMs = opts.timeoutMs ?? 45e3;
|
|
5493
7114
|
const command = opts.command ?? ["node", defaultAxeScriptPath()];
|
|
@@ -5612,14 +7233,14 @@ var SnapshotCaptureError = class extends Error {
|
|
|
5612
7233
|
}
|
|
5613
7234
|
};
|
|
5614
7235
|
function defaultCaptureScriptPath2() {
|
|
5615
|
-
const
|
|
7236
|
+
const here22 = path15.dirname(fileURLToPath(import.meta.url));
|
|
5616
7237
|
const candidates = [
|
|
5617
7238
|
// Bundled (tsup flattens src/visual/snapshot-capture.ts → dist/index.js).
|
|
5618
|
-
|
|
7239
|
+
path15.resolve(here22, "visual", "scripts", "capture-screenshot.mjs"),
|
|
5619
7240
|
// Co-located fallback.
|
|
5620
|
-
|
|
7241
|
+
path15.resolve(here22, "scripts", "capture-screenshot.mjs"),
|
|
5621
7242
|
// Source layout under vitest.
|
|
5622
|
-
|
|
7243
|
+
path15.resolve(here22, "..", "visual", "scripts", "capture-screenshot.mjs")
|
|
5623
7244
|
];
|
|
5624
7245
|
for (const c of candidates) {
|
|
5625
7246
|
if (existsSync(c)) return c;
|
|
@@ -5629,7 +7250,7 @@ function defaultCaptureScriptPath2() {
|
|
|
5629
7250
|
function safeName(name) {
|
|
5630
7251
|
return name.toLowerCase().replace(/[^a-z0-9._-]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 120) || "snapshot";
|
|
5631
7252
|
}
|
|
5632
|
-
function
|
|
7253
|
+
function sha256Hex4(buffer) {
|
|
5633
7254
|
return createHash("sha256").update(buffer).digest("hex");
|
|
5634
7255
|
}
|
|
5635
7256
|
async function captureVisualBaselines(opts) {
|
|
@@ -5644,7 +7265,7 @@ async function captureVisualBaselines(opts) {
|
|
|
5644
7265
|
);
|
|
5645
7266
|
}
|
|
5646
7267
|
const [bin, ...args] = command;
|
|
5647
|
-
const baselinesDir = opts.baselinesDir ??
|
|
7268
|
+
const baselinesDir = opts.baselinesDir ?? path15.join(opts.cwd, ".qaios", "baselines");
|
|
5648
7269
|
const repo = new VisualBaselinesRepository(opts.storage.db);
|
|
5649
7270
|
const viewportByName = new Map(opts.viewports.map((v) => [v.name, v]));
|
|
5650
7271
|
const baselines = [];
|
|
@@ -5667,8 +7288,8 @@ async function captureVisualBaselines(opts) {
|
|
|
5667
7288
|
const relImagePath = [".qaios", "baselines", dim.name, `${safeName(snapshot.name)}.png`].join(
|
|
5668
7289
|
"/"
|
|
5669
7290
|
);
|
|
5670
|
-
const absImagePath =
|
|
5671
|
-
mkdirSync(
|
|
7291
|
+
const absImagePath = path15.join(baselinesDir, dim.name, `${safeName(snapshot.name)}.png`);
|
|
7292
|
+
mkdirSync(path15.dirname(absImagePath), { recursive: true });
|
|
5672
7293
|
const env = {
|
|
5673
7294
|
...process.env,
|
|
5674
7295
|
QAIOS_SCREENSHOT_URL: joinUrl(opts.baseUrl, snapshot.route),
|
|
@@ -5705,7 +7326,7 @@ async function captureVisualBaselines(opts) {
|
|
|
5705
7326
|
);
|
|
5706
7327
|
}
|
|
5707
7328
|
const bytes = readFileSync(absImagePath);
|
|
5708
|
-
const sha =
|
|
7329
|
+
const sha = sha256Hex4(bytes);
|
|
5709
7330
|
const baselineId = ulid();
|
|
5710
7331
|
const approvedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
5711
7332
|
const baselineInput = {
|
|
@@ -5936,7 +7557,7 @@ function diffImages(baselinePath, currentPath, diffOutPath, opts = {}) {
|
|
|
5936
7557
|
diffMask[i] = 1;
|
|
5937
7558
|
}
|
|
5938
7559
|
}
|
|
5939
|
-
mkdirSync(
|
|
7560
|
+
mkdirSync(path15.dirname(diffOutPath), { recursive: true });
|
|
5940
7561
|
writeFileSync(diffOutPath, PNG.sync.write(diffPng));
|
|
5941
7562
|
const totalPixels = width * height;
|
|
5942
7563
|
const percentageChanged = Number((pixelsChanged / totalPixels * 100).toFixed(4));
|
|
@@ -6002,18 +7623,18 @@ var SnapshotCheckError = class extends Error {
|
|
|
6002
7623
|
}
|
|
6003
7624
|
};
|
|
6004
7625
|
function defaultCaptureScriptPath3() {
|
|
6005
|
-
const
|
|
7626
|
+
const here22 = path15.dirname(fileURLToPath(import.meta.url));
|
|
6006
7627
|
const candidates = [
|
|
6007
|
-
|
|
6008
|
-
|
|
6009
|
-
|
|
7628
|
+
path15.resolve(here22, "visual", "scripts", "capture-screenshot.mjs"),
|
|
7629
|
+
path15.resolve(here22, "scripts", "capture-screenshot.mjs"),
|
|
7630
|
+
path15.resolve(here22, "..", "visual", "scripts", "capture-screenshot.mjs")
|
|
6010
7631
|
];
|
|
6011
7632
|
for (const c of candidates) {
|
|
6012
7633
|
if (existsSync(c)) return c;
|
|
6013
7634
|
}
|
|
6014
7635
|
return candidates[0];
|
|
6015
7636
|
}
|
|
6016
|
-
function
|
|
7637
|
+
function sha256Hex5(buf) {
|
|
6017
7638
|
return createHash("sha256").update(buf).digest("hex");
|
|
6018
7639
|
}
|
|
6019
7640
|
function spawnCapture3(bin, args, cwd, env, opts) {
|
|
@@ -6083,8 +7704,8 @@ async function runVisualCheck(opts) {
|
|
|
6083
7704
|
}
|
|
6084
7705
|
const [bin, ...args] = command;
|
|
6085
7706
|
const thresholdPct = opts.thresholdPct ?? 0.1;
|
|
6086
|
-
const runsDir = opts.runsDir ??
|
|
6087
|
-
const diffsDir = opts.diffsDir ??
|
|
7707
|
+
const runsDir = opts.runsDir ?? path15.join(opts.cwd, ".qaios", "runs", opts.workflowId);
|
|
7708
|
+
const diffsDir = opts.diffsDir ?? path15.join(opts.cwd, ".qaios", "diffs", opts.workflowId);
|
|
6088
7709
|
new VisualBaselinesRepository(opts.storage.db);
|
|
6089
7710
|
const diffRepo = new VisualDiffsRepository(opts.storage.db);
|
|
6090
7711
|
const skillRunner = opts.skillRunner ?? new SkillRunner();
|
|
@@ -6109,7 +7730,7 @@ async function runVisualCheck(opts) {
|
|
|
6109
7730
|
`baseline ${baseline.id} references unknown viewport "${baseline.viewport}"`
|
|
6110
7731
|
);
|
|
6111
7732
|
}
|
|
6112
|
-
const baselineAbsPath =
|
|
7733
|
+
const baselineAbsPath = path15.isAbsolute(baseline.imagePath) ? baseline.imagePath : path15.join(opts.cwd, baseline.imagePath);
|
|
6113
7734
|
if (!existsSync(baselineAbsPath)) {
|
|
6114
7735
|
perBaseline.push({
|
|
6115
7736
|
baselineId: baseline.id,
|
|
@@ -6125,9 +7746,9 @@ async function runVisualCheck(opts) {
|
|
|
6125
7746
|
summary.pendingReview++;
|
|
6126
7747
|
continue;
|
|
6127
7748
|
}
|
|
6128
|
-
const safe =
|
|
6129
|
-
const currentAbsPath =
|
|
6130
|
-
mkdirSync(
|
|
7749
|
+
const safe = path15.basename(baseline.imagePath);
|
|
7750
|
+
const currentAbsPath = path15.join(runsDir, baseline.viewport, safe);
|
|
7751
|
+
mkdirSync(path15.dirname(currentAbsPath), { recursive: true });
|
|
6131
7752
|
const env = {
|
|
6132
7753
|
...process.env,
|
|
6133
7754
|
QAIOS_SCREENSHOT_URL: joinUrl(opts.baseUrl, baseline.route),
|
|
@@ -6156,11 +7777,11 @@ async function runVisualCheck(opts) {
|
|
|
6156
7777
|
}
|
|
6157
7778
|
const baselineBytes = readFileSync(baselineAbsPath);
|
|
6158
7779
|
const currentBytes = readFileSync(currentAbsPath);
|
|
6159
|
-
const baselineSha =
|
|
6160
|
-
const currentSha =
|
|
6161
|
-
const currentImagePath =
|
|
6162
|
-
const diffAbsPath =
|
|
6163
|
-
const diffImagePath =
|
|
7780
|
+
const baselineSha = sha256Hex5(baselineBytes);
|
|
7781
|
+
const currentSha = sha256Hex5(currentBytes);
|
|
7782
|
+
const currentImagePath = path15.relative(opts.cwd, currentAbsPath).split(path15.sep).join("/");
|
|
7783
|
+
const diffAbsPath = path15.join(diffsDir, baseline.viewport, safe);
|
|
7784
|
+
const diffImagePath = path15.relative(opts.cwd, diffAbsPath).split(path15.sep).join("/");
|
|
6164
7785
|
if (baselineSha === currentSha) {
|
|
6165
7786
|
perBaseline.push({
|
|
6166
7787
|
baselineId: baseline.id,
|
|
@@ -6176,7 +7797,7 @@ async function runVisualCheck(opts) {
|
|
|
6176
7797
|
summary.noDiff++;
|
|
6177
7798
|
continue;
|
|
6178
7799
|
}
|
|
6179
|
-
mkdirSync(
|
|
7800
|
+
mkdirSync(path15.dirname(diffAbsPath), { recursive: true });
|
|
6180
7801
|
const diff = diffImages(baselineAbsPath, currentAbsPath, diffAbsPath);
|
|
6181
7802
|
if (diff.sizeMismatch) {
|
|
6182
7803
|
const diffId2 = ulid();
|
|
@@ -6361,7 +7982,7 @@ var OpenApiParseError = class extends Error {
|
|
|
6361
7982
|
};
|
|
6362
7983
|
var HTTP_METHODS = /* @__PURE__ */ new Set(["get", "post", "put", "patch", "delete", "head", "options"]);
|
|
6363
7984
|
function parseOpenApi(specPath) {
|
|
6364
|
-
const abs =
|
|
7985
|
+
const abs = path15.resolve(specPath);
|
|
6365
7986
|
if (!existsSync(abs)) {
|
|
6366
7987
|
throw new OpenApiParseError("qaios.openapi.spec_not_found", `OpenAPI spec not found at ${abs}`);
|
|
6367
7988
|
}
|
|
@@ -6858,6 +8479,10 @@ var ExitCode = {
|
|
|
6858
8479
|
// rate limit, timeout, key invalid
|
|
6859
8480
|
INTERNAL: 5,
|
|
6860
8481
|
// QAIOS bug — should be reported
|
|
8482
|
+
// The UI tests passed, but xray detected a backend divergence (a 4xx/5xx or
|
|
8483
|
+
// status change on a call the test made). Only returned under `--xray-strict`;
|
|
8484
|
+
// without it, such a run is exit 0 with a warning. (v0.4)
|
|
8485
|
+
PASS_WITH_NETWORK_DIVERGENCE: 6,
|
|
6861
8486
|
CANCELLED: 130
|
|
6862
8487
|
// user pressed Ctrl+C (SIGINT)
|
|
6863
8488
|
};
|
|
@@ -6867,7 +8492,7 @@ function detectProject(projectRoot) {
|
|
|
6867
8492
|
const playwrightVersion = readPlaywrightVersion(packageJson);
|
|
6868
8493
|
const hasPlaywright = playwrightVersion !== null;
|
|
6869
8494
|
const testDir = detectTestDir(projectRoot);
|
|
6870
|
-
const projectName = packageJson?.name ??
|
|
8495
|
+
const projectName = packageJson?.name ?? path15.basename(path15.resolve(projectRoot));
|
|
6871
8496
|
const isEmpty = directoryIsEmpty(projectRoot);
|
|
6872
8497
|
const looksLikeProject = packageJson !== null;
|
|
6873
8498
|
return {
|
|
@@ -6882,7 +8507,7 @@ function detectProject(projectRoot) {
|
|
|
6882
8507
|
};
|
|
6883
8508
|
}
|
|
6884
8509
|
function readPackageJson(projectRoot) {
|
|
6885
|
-
const p =
|
|
8510
|
+
const p = path15.join(projectRoot, "package.json");
|
|
6886
8511
|
if (!existsSync(p)) return null;
|
|
6887
8512
|
try {
|
|
6888
8513
|
return JSON.parse(readFileSync(p, "utf-8"));
|
|
@@ -6891,10 +8516,10 @@ function readPackageJson(projectRoot) {
|
|
|
6891
8516
|
}
|
|
6892
8517
|
}
|
|
6893
8518
|
function detectPackageManager(projectRoot) {
|
|
6894
|
-
if (existsSync(
|
|
6895
|
-
if (existsSync(
|
|
6896
|
-
if (existsSync(
|
|
6897
|
-
if (existsSync(
|
|
8519
|
+
if (existsSync(path15.join(projectRoot, "pnpm-lock.yaml"))) return "pnpm";
|
|
8520
|
+
if (existsSync(path15.join(projectRoot, "bun.lockb"))) return "bun";
|
|
8521
|
+
if (existsSync(path15.join(projectRoot, "yarn.lock"))) return "yarn";
|
|
8522
|
+
if (existsSync(path15.join(projectRoot, "package-lock.json"))) return "npm";
|
|
6898
8523
|
return "unknown";
|
|
6899
8524
|
}
|
|
6900
8525
|
function readPlaywrightVersion(pkg) {
|
|
@@ -6903,7 +8528,7 @@ function readPlaywrightVersion(pkg) {
|
|
|
6903
8528
|
}
|
|
6904
8529
|
function detectTestDir(projectRoot) {
|
|
6905
8530
|
for (const candidate of ["tests", "e2e", "playwright"]) {
|
|
6906
|
-
const full =
|
|
8531
|
+
const full = path15.join(projectRoot, candidate);
|
|
6907
8532
|
if (existsSync(full)) {
|
|
6908
8533
|
try {
|
|
6909
8534
|
if (statSync(full).isDirectory()) return candidate;
|
|
@@ -6929,12 +8554,12 @@ function runDoctorEnv() {
|
|
|
6929
8554
|
return { exitCode: ExitCode.SUCCESS, envSnapshot: snapshotEnv() };
|
|
6930
8555
|
}
|
|
6931
8556
|
function runDoctor(opts = {}) {
|
|
6932
|
-
const cwd =
|
|
8557
|
+
const cwd = path15.resolve(opts.cwd ?? process.cwd());
|
|
6933
8558
|
const checks = [];
|
|
6934
8559
|
checks.push(checkNode());
|
|
6935
8560
|
const apiKeyCheck = checkProviderApiKey(cwd);
|
|
6936
8561
|
checks.push(apiKeyCheck);
|
|
6937
|
-
const qaiosDir =
|
|
8562
|
+
const qaiosDir = path15.join(cwd, ".qaios");
|
|
6938
8563
|
const qaiosExists = existsSync(qaiosDir) && safeIsDir(qaiosDir);
|
|
6939
8564
|
checks.push({
|
|
6940
8565
|
name: ".qaios/ directory",
|
|
@@ -6942,7 +8567,7 @@ function runDoctor(opts = {}) {
|
|
|
6942
8567
|
detail: qaiosExists ? `present at ${qaiosDir}` : `missing at ${qaiosDir}. Run \`qaios init\` first.`
|
|
6943
8568
|
});
|
|
6944
8569
|
if (qaiosExists) {
|
|
6945
|
-
const dbPath =
|
|
8570
|
+
const dbPath = path15.join(qaiosDir, "workflows.db");
|
|
6946
8571
|
if (!existsSync(dbPath)) {
|
|
6947
8572
|
checks.push({
|
|
6948
8573
|
name: "SQLite DB",
|
|
@@ -6971,7 +8596,7 @@ function runDoctor(opts = {}) {
|
|
|
6971
8596
|
function checkConfig(qaiosDir, qaiosExists) {
|
|
6972
8597
|
const name = "config.yaml";
|
|
6973
8598
|
if (!qaiosExists) return { name, status: "skip", detail: "skipped (no .qaios/)" };
|
|
6974
|
-
const configPath =
|
|
8599
|
+
const configPath = path15.join(qaiosDir, "config.yaml");
|
|
6975
8600
|
if (!existsSync(configPath)) {
|
|
6976
8601
|
return { name, status: "skip", detail: "not present (defaults will be used)" };
|
|
6977
8602
|
}
|
|
@@ -7002,7 +8627,7 @@ function checkNode() {
|
|
|
7002
8627
|
};
|
|
7003
8628
|
}
|
|
7004
8629
|
function resolveProviderFromConfig(cwd) {
|
|
7005
|
-
const candidate =
|
|
8630
|
+
const candidate = path15.join(cwd, ".qaios", "config.yaml");
|
|
7006
8631
|
if (!existsSync(candidate)) return { provider: "anthropic" };
|
|
7007
8632
|
try {
|
|
7008
8633
|
const raw = parse(readFileSync(candidate, "utf-8"));
|
|
@@ -7103,7 +8728,7 @@ function checkPlaywright(cwd) {
|
|
|
7103
8728
|
}
|
|
7104
8729
|
function checkAxeCore(cwd) {
|
|
7105
8730
|
try {
|
|
7106
|
-
const pkgPath =
|
|
8731
|
+
const pkgPath = path15.join(cwd, "package.json");
|
|
7107
8732
|
if (!existsSync(pkgPath)) {
|
|
7108
8733
|
return { name: "axe-core (a11y)", status: "skip", detail: "no package.json" };
|
|
7109
8734
|
}
|
|
@@ -7391,8 +9016,8 @@ function scopeFooterApi(scope) {
|
|
|
7391
9016
|
}
|
|
7392
9017
|
var STATUS_OR_RESPONSE_PATTERN = /\b[1-5]\d\d\b|response\./i;
|
|
7393
9018
|
var MIN_SCENARIOS_PER_ENDPOINT = 3;
|
|
7394
|
-
function endpointStepPattern(
|
|
7395
|
-
const escaped =
|
|
9019
|
+
function endpointStepPattern(path20, method) {
|
|
9020
|
+
const escaped = path20.split("/").map(
|
|
7396
9021
|
(seg) => /^\{.*\}$/.test(seg) ? "[^/\\s?#]+" : seg.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
|
|
7397
9022
|
).join("/");
|
|
7398
9023
|
return new RegExp(`\\b${method}\\b[\\s\\S]*?${escaped}(?=$|[\\s?#])`, "i");
|
|
@@ -7702,6 +9327,15 @@ var ClassifyResultInput = z.object({
|
|
|
7702
9327
|
designSpec: z.object({
|
|
7703
9328
|
scenarioId: z.string().optional(),
|
|
7704
9329
|
oracle: z.string().optional()
|
|
9330
|
+
}).optional(),
|
|
9331
|
+
// xray (v0.4): a compact, deterministic summary of how this test's network
|
|
9332
|
+
// traffic diverged from its baseline contract. Present only when xray ran.
|
|
9333
|
+
// Lets the classifier reach `backend_divergence` when the UI error (or even a
|
|
9334
|
+
// UI pass) is explained by a backend 4xx/5xx / status / shape change.
|
|
9335
|
+
networkDiff: z.object({
|
|
9336
|
+
summary: z.string(),
|
|
9337
|
+
// already truncated ≤~1,500 tokens by the diff engine
|
|
9338
|
+
hasFailSignal: z.boolean()
|
|
7705
9339
|
}).optional()
|
|
7706
9340
|
});
|
|
7707
9341
|
function buildSystemPrompt5(projectMemory) {
|
|
@@ -7722,6 +9356,11 @@ Classification rubric:
|
|
|
7722
9356
|
- expectation_outdated: app intentionally changed but test wasn't updated. Cues:
|
|
7723
9357
|
app screenshot looks correct/intentional but assertion checks old text/structure.
|
|
7724
9358
|
Difficult to detect without app context \u2014 be cautious here.
|
|
9359
|
+
- backend_divergence: the UI may look fine but the BACKEND diverged from its known-good
|
|
9360
|
+
baseline. Cues: the "Network divergence" section below shows an ERROR_RESPONSE (a
|
|
9361
|
+
4xx/5xx or failed request) or STATUS_CHANGED on a call this test made \u2014 especially
|
|
9362
|
+
when the UI assertions passed. This is the class for "the page rendered but the API
|
|
9363
|
+
broke". Prefer this over real_failure when a network fail-signal explains the result.
|
|
7725
9364
|
- unknown: cannot classify with confidence. Default if multiple categories plausible.
|
|
7726
9365
|
|
|
7727
9366
|
Suggested action mapping:
|
|
@@ -7730,6 +9369,7 @@ Suggested action mapping:
|
|
|
7730
9369
|
- environmental \u2192 investigate_env (do not file as defect against the app)
|
|
7731
9370
|
- locator_broken \u2192 heal_locator
|
|
7732
9371
|
- expectation_outdated \u2192 update_test (but flag for human review)
|
|
9372
|
+
- backend_divergence \u2192 create_defect (cite the offending identity key + diff class)
|
|
7733
9373
|
- unknown \u2192 needs_human_review
|
|
7734
9374
|
|
|
7735
9375
|
Project context:
|
|
@@ -7744,6 +9384,10 @@ function buildUserPrompt5(input) {
|
|
|
7744
9384
|
const attachments = tr.attachments.length === 0 ? "(none)" : tr.attachments.join(", ");
|
|
7745
9385
|
const historicalJson = JSON.stringify(tr.historicalRuns, null, 2);
|
|
7746
9386
|
const oracle = input.designSpec?.oracle ?? "(unspecified)";
|
|
9387
|
+
const networkSection = input.networkDiff !== void 0 ? `
|
|
9388
|
+
|
|
9389
|
+
Network divergence (vs baseline contract${input.networkDiff.hasFailSignal ? ", FAIL-SIGNAL present" : ""}):
|
|
9390
|
+
${input.networkDiff.summary}` : "";
|
|
7747
9391
|
return `Test that failed:
|
|
7748
9392
|
File: ${tr.file}
|
|
7749
9393
|
Test: ${tr.name}
|
|
@@ -7761,7 +9405,7 @@ Attachments: ${attachments}
|
|
|
7761
9405
|
Historical runs (most recent first):
|
|
7762
9406
|
${historicalJson}
|
|
7763
9407
|
|
|
7764
|
-
Oracle (if available): ${oracle}
|
|
9408
|
+
Oracle (if available): ${oracle}${networkSection}
|
|
7765
9409
|
|
|
7766
9410
|
Classify this failure.`;
|
|
7767
9411
|
}
|
|
@@ -8681,7 +10325,7 @@ function resolveLlmClient(injected, llmConfig) {
|
|
|
8681
10325
|
|
|
8682
10326
|
// src/commands/a11y.ts
|
|
8683
10327
|
function loadConfig(cwd) {
|
|
8684
|
-
const candidate =
|
|
10328
|
+
const candidate = path15.join(cwd, ".qaios", "config.yaml");
|
|
8685
10329
|
if (!existsSync(candidate)) return null;
|
|
8686
10330
|
try {
|
|
8687
10331
|
return parse(readFileSync(candidate, "utf-8"));
|
|
@@ -8691,7 +10335,7 @@ function loadConfig(cwd) {
|
|
|
8691
10335
|
}
|
|
8692
10336
|
var GLYPH = { ok: "\u2713", failed: "\u2717", warn: "\u26A0", issue: "\u2691" };
|
|
8693
10337
|
async function runA11y(opts) {
|
|
8694
|
-
const cwd =
|
|
10338
|
+
const cwd = path15.resolve(opts.cwd ?? process.cwd());
|
|
8695
10339
|
if (!opts.url || opts.url.trim().length === 0) {
|
|
8696
10340
|
return {
|
|
8697
10341
|
exitCode: ExitCode.USER_ERROR,
|
|
@@ -8701,7 +10345,7 @@ async function runA11y(opts) {
|
|
|
8701
10345
|
}
|
|
8702
10346
|
};
|
|
8703
10347
|
}
|
|
8704
|
-
const qaiosDir =
|
|
10348
|
+
const qaiosDir = path15.join(cwd, ".qaios");
|
|
8705
10349
|
if (!existsSync(qaiosDir) && opts.storage === void 0) {
|
|
8706
10350
|
return {
|
|
8707
10351
|
exitCode: ExitCode.USER_ERROR,
|
|
@@ -8716,7 +10360,7 @@ async function runA11y(opts) {
|
|
|
8716
10360
|
const mode = opts.mode ?? config?.mode ?? "LITE";
|
|
8717
10361
|
const wcagLevel = opts.wcagLevel ?? "AA";
|
|
8718
10362
|
const ownsStorage = opts.storage === void 0;
|
|
8719
|
-
const storage = opts.storage ?? Storage.open(
|
|
10363
|
+
const storage = opts.storage ?? Storage.open(path15.join(qaiosDir, "workflows.db"), { skipMigrations: false });
|
|
8720
10364
|
const auditLogger = new AuditLogger(storage.db);
|
|
8721
10365
|
const workflowsRepo = new WorkflowsRepository(storage.db);
|
|
8722
10366
|
const llm = resolveLlmClient(opts.llm, config?.llm);
|
|
@@ -8783,11 +10427,11 @@ async function runA11y(opts) {
|
|
|
8783
10427
|
` ${GLYPH.ok} scan complete \u2014 ${violationCount} violation(s), ${incompleteCount} needs-review, ${bestPracticeCount} best-practice`
|
|
8784
10428
|
);
|
|
8785
10429
|
writeOut("");
|
|
8786
|
-
const specsDir =
|
|
10430
|
+
const specsDir = path15.join(qaiosDir, "specs", workflowId);
|
|
8787
10431
|
if (findings.length === 0) {
|
|
8788
10432
|
mkdirSync(specsDir, { recursive: true });
|
|
8789
10433
|
writeFileSync(
|
|
8790
|
-
|
|
10434
|
+
path15.join(specsDir, "A11yReport.json"),
|
|
8791
10435
|
JSON.stringify({ url: opts.url, wcagLevel, findings: [], issues: [] }, null, 2) + "\n"
|
|
8792
10436
|
);
|
|
8793
10437
|
writeOut("No accessibility issues found.");
|
|
@@ -8841,7 +10485,7 @@ async function runA11y(opts) {
|
|
|
8841
10485
|
const issues = triaged?.issues ?? [];
|
|
8842
10486
|
mkdirSync(specsDir, { recursive: true });
|
|
8843
10487
|
writeFileSync(
|
|
8844
|
-
|
|
10488
|
+
path15.join(specsDir, "A11yReport.json"),
|
|
8845
10489
|
JSON.stringify({ url: opts.url, wcagLevel, findings, issues }, null, 2) + "\n"
|
|
8846
10490
|
);
|
|
8847
10491
|
writeOut("");
|
|
@@ -8986,7 +10630,7 @@ async function fileIssueAsDefect(args) {
|
|
|
8986
10630
|
};
|
|
8987
10631
|
}
|
|
8988
10632
|
function loadConfig2(cwd) {
|
|
8989
|
-
const candidate =
|
|
10633
|
+
const candidate = path15.join(cwd, ".qaios", "config.yaml");
|
|
8990
10634
|
if (!existsSync(candidate)) return null;
|
|
8991
10635
|
try {
|
|
8992
10636
|
return parse(readFileSync(candidate, "utf-8"));
|
|
@@ -9004,7 +10648,7 @@ function renderTextLine(line) {
|
|
|
9004
10648
|
return line + "\n";
|
|
9005
10649
|
}
|
|
9006
10650
|
async function runExplore(opts) {
|
|
9007
|
-
const cwd =
|
|
10651
|
+
const cwd = path15.resolve(opts.cwd ?? process.cwd());
|
|
9008
10652
|
if (!opts.url || opts.url.trim().length === 0) {
|
|
9009
10653
|
return {
|
|
9010
10654
|
exitCode: ExitCode.USER_ERROR,
|
|
@@ -9014,7 +10658,7 @@ async function runExplore(opts) {
|
|
|
9014
10658
|
}
|
|
9015
10659
|
};
|
|
9016
10660
|
}
|
|
9017
|
-
const qaiosDir =
|
|
10661
|
+
const qaiosDir = path15.join(cwd, ".qaios");
|
|
9018
10662
|
if (!existsSync(qaiosDir) && opts.storage === void 0) {
|
|
9019
10663
|
return {
|
|
9020
10664
|
exitCode: ExitCode.USER_ERROR,
|
|
@@ -9040,7 +10684,7 @@ async function runExplore(opts) {
|
|
|
9040
10684
|
const memory = loadProjectMemory(cwd);
|
|
9041
10685
|
const mode = opts.mode ?? config?.mode ?? "LITE";
|
|
9042
10686
|
const ownsStorage = opts.storage === void 0;
|
|
9043
|
-
const storage = opts.storage ?? Storage.open(
|
|
10687
|
+
const storage = opts.storage ?? Storage.open(path15.join(qaiosDir, "workflows.db"), { skipMigrations: false });
|
|
9044
10688
|
const auditLogger = new AuditLogger(storage.db);
|
|
9045
10689
|
const workflowsRepo = new WorkflowsRepository(storage.db);
|
|
9046
10690
|
const llm = resolveLlmClient(opts.llm, config?.llm);
|
|
@@ -9182,14 +10826,14 @@ async function runExplore(opts) {
|
|
|
9182
10826
|
writeOut(` Areas: ${exploreOutput.charter.areasToProbe.join("; ")}`);
|
|
9183
10827
|
writeOut(` Techniques: ${exploreOutput.charter.techniques.join(", ")}`);
|
|
9184
10828
|
writeOut("");
|
|
9185
|
-
const specsDir =
|
|
10829
|
+
const specsDir = path15.join(qaiosDir, "specs", workflowId);
|
|
9186
10830
|
mkdirSync(specsDir, { recursive: true });
|
|
9187
|
-
const charterPath =
|
|
10831
|
+
const charterPath = path15.join(specsDir, "Charter.json");
|
|
9188
10832
|
writeFileSync(charterPath, JSON.stringify(exploreOutput.charter, null, 2) + "\n");
|
|
9189
|
-
const notesPath =
|
|
10833
|
+
const notesPath = path15.join(specsDir, "SessionNotes.md");
|
|
9190
10834
|
writeFileSync(notesPath, renderSessionNotesMarkdown(exploreOutput));
|
|
9191
|
-
writeOut(`Persisted ${
|
|
9192
|
-
writeOut(`Persisted ${
|
|
10835
|
+
writeOut(`Persisted ${path15.relative(cwd, charterPath)}`);
|
|
10836
|
+
writeOut(`Persisted ${path15.relative(cwd, notesPath)}`);
|
|
9193
10837
|
writeOut("");
|
|
9194
10838
|
if (opts.charterOnly === true) {
|
|
9195
10839
|
finalize2("succeeded");
|
|
@@ -9383,7 +11027,7 @@ async function fileFindingAsDefect(args) {
|
|
|
9383
11027
|
};
|
|
9384
11028
|
}
|
|
9385
11029
|
function loadConfig3(cwd) {
|
|
9386
|
-
const candidate =
|
|
11030
|
+
const candidate = path15.join(cwd, ".qaios", "config.yaml");
|
|
9387
11031
|
if (!existsSync(candidate)) return null;
|
|
9388
11032
|
try {
|
|
9389
11033
|
return parse(readFileSync(candidate, "utf-8"));
|
|
@@ -9418,8 +11062,8 @@ function pickTarget(testResultsRepo, opts) {
|
|
|
9418
11062
|
reason: "must pass a test file path or --last-failure."
|
|
9419
11063
|
};
|
|
9420
11064
|
}
|
|
9421
|
-
const abs =
|
|
9422
|
-
const rel =
|
|
11065
|
+
const abs = path15.isAbsolute(opts.target) ? opts.target : path15.resolve(opts.cwd, opts.target);
|
|
11066
|
+
const rel = path15.relative(opts.cwd, abs).split(path15.sep).join("/");
|
|
9423
11067
|
if (opts.scenarioId !== void 0 && opts.scenarioId.length > 0) {
|
|
9424
11068
|
const row2 = testResultsRepo.mostRecentFailedForFileScenario(rel, opts.scenarioId);
|
|
9425
11069
|
return row2 !== null ? { row: row2, reason: null } : {
|
|
@@ -9437,8 +11081,8 @@ function renderTextLine2(line) {
|
|
|
9437
11081
|
return line + "\n";
|
|
9438
11082
|
}
|
|
9439
11083
|
async function runFix(opts) {
|
|
9440
|
-
const cwd =
|
|
9441
|
-
const qaiosDir =
|
|
11084
|
+
const cwd = path15.resolve(opts.cwd ?? process.cwd());
|
|
11085
|
+
const qaiosDir = path15.join(cwd, ".qaios");
|
|
9442
11086
|
if (!existsSync(qaiosDir) && opts.storage === void 0) {
|
|
9443
11087
|
return {
|
|
9444
11088
|
exitCode: ExitCode.USER_ERROR,
|
|
@@ -9449,7 +11093,7 @@ async function runFix(opts) {
|
|
|
9449
11093
|
};
|
|
9450
11094
|
}
|
|
9451
11095
|
const ownsStorage = opts.storage === void 0;
|
|
9452
|
-
const storage = opts.storage ?? Storage.open(
|
|
11096
|
+
const storage = opts.storage ?? Storage.open(path15.join(qaiosDir, "workflows.db"), { skipMigrations: false });
|
|
9453
11097
|
const auditLogger = new AuditLogger(storage.db);
|
|
9454
11098
|
const testResultsRepo = new TestResultsRepository(storage.db);
|
|
9455
11099
|
const workflowsRepo = new WorkflowsRepository(storage.db);
|
|
@@ -9492,9 +11136,9 @@ async function runFix(opts) {
|
|
|
9492
11136
|
];
|
|
9493
11137
|
let resolvedTestFile = tr.testFile;
|
|
9494
11138
|
for (const dir of candidateDirs) {
|
|
9495
|
-
const candidate =
|
|
11139
|
+
const candidate = path15.isAbsolute(tr.testFile) ? tr.testFile : path15.join(cwd, dir, tr.testFile);
|
|
9496
11140
|
if (existsSync(candidate)) {
|
|
9497
|
-
resolvedTestFile =
|
|
11141
|
+
resolvedTestFile = path15.isAbsolute(tr.testFile) ? tr.testFile : path15.join(dir, tr.testFile);
|
|
9498
11142
|
break;
|
|
9499
11143
|
}
|
|
9500
11144
|
}
|
|
@@ -9748,7 +11392,7 @@ async function runFix(opts) {
|
|
|
9748
11392
|
} else {
|
|
9749
11393
|
writeOut(`${GLYPH3.failed} test still failing after patch`);
|
|
9750
11394
|
if (opts.keepFailedPatch !== true && apply.oldContents !== null) {
|
|
9751
|
-
const absPath =
|
|
11395
|
+
const absPath = path15.isAbsolute(resolvedTestFile) ? resolvedTestFile : path15.resolve(cwd, resolvedTestFile);
|
|
9752
11396
|
try {
|
|
9753
11397
|
writeFileSync(absPath, apply.oldContents, "utf-8");
|
|
9754
11398
|
rolledBack = true;
|
|
@@ -9893,8 +11537,8 @@ function renderVerify(result, genesis, latest) {
|
|
|
9893
11537
|
].join("\n");
|
|
9894
11538
|
}
|
|
9895
11539
|
async function runHistory(opts) {
|
|
9896
|
-
const cwd =
|
|
9897
|
-
const qaiosDir =
|
|
11540
|
+
const cwd = path15.resolve(opts.cwd ?? process.cwd());
|
|
11541
|
+
const qaiosDir = path15.join(cwd, ".qaios");
|
|
9898
11542
|
if (!existsSync(qaiosDir) && opts.storage === void 0) {
|
|
9899
11543
|
return {
|
|
9900
11544
|
exitCode: ExitCode.USER_ERROR,
|
|
@@ -9905,7 +11549,7 @@ async function runHistory(opts) {
|
|
|
9905
11549
|
};
|
|
9906
11550
|
}
|
|
9907
11551
|
const ownsStorage = opts.storage === void 0;
|
|
9908
|
-
const storage = opts.storage ?? Storage.open(
|
|
11552
|
+
const storage = opts.storage ?? Storage.open(path15.join(qaiosDir, "workflows.db"), { skipMigrations: false });
|
|
9909
11553
|
const auditLogger = new AuditLogger(storage.db);
|
|
9910
11554
|
const workflowsRepo = new WorkflowsRepository(storage.db);
|
|
9911
11555
|
const runsRepo = new RunsRepository(storage.db);
|
|
@@ -9939,13 +11583,13 @@ async function runHistory(opts) {
|
|
|
9939
11583
|
}
|
|
9940
11584
|
if (opts.exportPath !== void 0) {
|
|
9941
11585
|
const entries = auditLogger.listEntries();
|
|
9942
|
-
const absPath =
|
|
9943
|
-
mkdirSync(
|
|
11586
|
+
const absPath = path15.resolve(cwd, opts.exportPath);
|
|
11587
|
+
mkdirSync(path15.dirname(absPath), { recursive: true });
|
|
9944
11588
|
const lines = entries.map((e) => JSON.stringify(e)).join("\n");
|
|
9945
11589
|
writeFileSync(absPath, lines + (entries.length > 0 ? "\n" : ""));
|
|
9946
11590
|
if (!opts.quiet) {
|
|
9947
11591
|
process.stdout.write(
|
|
9948
|
-
`Exported ${entries.length} audit entries to ${
|
|
11592
|
+
`Exported ${entries.length} audit entries to ${path15.relative(cwd, absPath)}
|
|
9949
11593
|
`
|
|
9950
11594
|
);
|
|
9951
11595
|
}
|
|
@@ -10012,8 +11656,8 @@ async function runHistory(opts) {
|
|
|
10012
11656
|
if (ownsStorage) storage.close();
|
|
10013
11657
|
}
|
|
10014
11658
|
}
|
|
10015
|
-
var __dirname$2 =
|
|
10016
|
-
var DEFAULT_TEMPLATES_DIR =
|
|
11659
|
+
var __dirname$2 = path15.dirname(fileURLToPath(import.meta.url));
|
|
11660
|
+
var DEFAULT_TEMPLATES_DIR = path15.resolve(__dirname$2);
|
|
10017
11661
|
function renderTemplate(template, vars) {
|
|
10018
11662
|
let out = template;
|
|
10019
11663
|
for (const [key, value] of Object.entries(vars)) {
|
|
@@ -10029,7 +11673,7 @@ function renderTemplate(template, vars) {
|
|
|
10029
11673
|
return out;
|
|
10030
11674
|
}
|
|
10031
11675
|
function renderQaiosMd(vars, templatesDir = DEFAULT_TEMPLATES_DIR) {
|
|
10032
|
-
const templatePath =
|
|
11676
|
+
const templatePath = path15.join(templatesDir, "QAIOS.md.template");
|
|
10033
11677
|
const template = readFileSync(templatePath, "utf-8");
|
|
10034
11678
|
return renderTemplate(template, vars);
|
|
10035
11679
|
}
|
|
@@ -10044,11 +11688,12 @@ var QAIOS_GITIGNORE = `# QAIOS managed directory.
|
|
|
10044
11688
|
cache/
|
|
10045
11689
|
runs/
|
|
10046
11690
|
diffs/
|
|
11691
|
+
xray/
|
|
10047
11692
|
workflows.db-wal
|
|
10048
11693
|
workflows.db-shm
|
|
10049
11694
|
`;
|
|
10050
11695
|
function runInit(opts = {}) {
|
|
10051
|
-
const cwd =
|
|
11696
|
+
const cwd = path15.resolve(opts.cwd ?? process.cwd());
|
|
10052
11697
|
const detection = detectProject(cwd);
|
|
10053
11698
|
if (!detection.looksLikeProject && !detection.isEmpty && !opts.force) {
|
|
10054
11699
|
return {
|
|
@@ -10060,7 +11705,7 @@ function runInit(opts = {}) {
|
|
|
10060
11705
|
detection
|
|
10061
11706
|
};
|
|
10062
11707
|
}
|
|
10063
|
-
const qaiosDir =
|
|
11708
|
+
const qaiosDir = path15.join(cwd, ".qaios");
|
|
10064
11709
|
if (existsSync(qaiosDir) && !opts.force) {
|
|
10065
11710
|
return {
|
|
10066
11711
|
exitCode: ExitCode.USER_ERROR,
|
|
@@ -10103,7 +11748,7 @@ function runInit(opts = {}) {
|
|
|
10103
11748
|
}
|
|
10104
11749
|
});
|
|
10105
11750
|
mkdirSync(qaiosDir, { recursive: true });
|
|
10106
|
-
const dbPath =
|
|
11751
|
+
const dbPath = path15.join(qaiosDir, "workflows.db");
|
|
10107
11752
|
let migrations;
|
|
10108
11753
|
try {
|
|
10109
11754
|
const storage = Storage.open(dbPath, { skipMigrations: true });
|
|
@@ -10129,14 +11774,14 @@ Original error: ${raw}` : `Failed to initialise workflows.db: ${raw}`;
|
|
|
10129
11774
|
};
|
|
10130
11775
|
}
|
|
10131
11776
|
const filesWritten = [];
|
|
10132
|
-
filesWritten.push(
|
|
10133
|
-
const configPath =
|
|
11777
|
+
filesWritten.push(path15.relative(cwd, dbPath));
|
|
11778
|
+
const configPath = path15.join(qaiosDir, "config.yaml");
|
|
10134
11779
|
writeFileSync(configPath, stringify(config), "utf-8");
|
|
10135
|
-
filesWritten.push(
|
|
10136
|
-
const gitignorePath =
|
|
11780
|
+
filesWritten.push(path15.relative(cwd, configPath));
|
|
11781
|
+
const gitignorePath = path15.join(qaiosDir, ".gitignore");
|
|
10137
11782
|
writeFileSync(gitignorePath, QAIOS_GITIGNORE, "utf-8");
|
|
10138
|
-
filesWritten.push(
|
|
10139
|
-
const qaiosMdPath =
|
|
11783
|
+
filesWritten.push(path15.relative(cwd, gitignorePath));
|
|
11784
|
+
const qaiosMdPath = path15.join(cwd, "QAIOS.md");
|
|
10140
11785
|
const qaiosMd = renderQaiosMd({
|
|
10141
11786
|
projectName: detection.projectName,
|
|
10142
11787
|
stack: stackHint(detection),
|
|
@@ -10145,7 +11790,7 @@ Original error: ${raw}` : `Failed to initialise workflows.db: ${raw}`;
|
|
|
10145
11790
|
});
|
|
10146
11791
|
if (!existsSync(qaiosMdPath) || opts.force) {
|
|
10147
11792
|
writeFileSync(qaiosMdPath, qaiosMd, "utf-8");
|
|
10148
|
-
filesWritten.push(
|
|
11793
|
+
filesWritten.push(path15.relative(cwd, qaiosMdPath));
|
|
10149
11794
|
}
|
|
10150
11795
|
return {
|
|
10151
11796
|
exitCode: ExitCode.SUCCESS,
|
|
@@ -10179,9 +11824,9 @@ function isWritable(dir) {
|
|
|
10179
11824
|
}
|
|
10180
11825
|
}
|
|
10181
11826
|
async function runConfig(opts) {
|
|
10182
|
-
const cwd =
|
|
10183
|
-
const qaiosDir =
|
|
10184
|
-
const configPath =
|
|
11827
|
+
const cwd = path15.resolve(opts.cwd ?? process.cwd());
|
|
11828
|
+
const qaiosDir = path15.join(cwd, ".qaios");
|
|
11829
|
+
const configPath = path15.join(qaiosDir, "config.yaml");
|
|
10185
11830
|
if (!existsSync(qaiosDir)) {
|
|
10186
11831
|
return {
|
|
10187
11832
|
exitCode: ExitCode.USER_ERROR,
|
|
@@ -10501,8 +12146,8 @@ function validateAndWrite(configPath, text, writeOut) {
|
|
|
10501
12146
|
return { exitCode: ExitCode.SUCCESS };
|
|
10502
12147
|
}
|
|
10503
12148
|
async function runMcp(opts) {
|
|
10504
|
-
const cwd =
|
|
10505
|
-
const qaiosDir =
|
|
12149
|
+
const cwd = path15.resolve(opts.cwd ?? process.cwd());
|
|
12150
|
+
const qaiosDir = path15.join(cwd, ".qaios");
|
|
10506
12151
|
if (!existsSync(qaiosDir) && opts.storage === void 0) {
|
|
10507
12152
|
return {
|
|
10508
12153
|
exitCode: ExitCode.USER_ERROR,
|
|
@@ -10513,7 +12158,7 @@ async function runMcp(opts) {
|
|
|
10513
12158
|
};
|
|
10514
12159
|
}
|
|
10515
12160
|
const ownsStorage = opts.storage === void 0;
|
|
10516
|
-
const storage = opts.storage ?? Storage.open(
|
|
12161
|
+
const storage = opts.storage ?? Storage.open(path15.join(qaiosDir, "workflows.db"), { skipMigrations: false });
|
|
10517
12162
|
const repo = new McpServersRepository(storage.db);
|
|
10518
12163
|
const writeOut = (line) => {
|
|
10519
12164
|
if (opts.quiet === true) return;
|
|
@@ -10726,7 +12371,7 @@ async function testServer(repo, opts, writeOut) {
|
|
|
10726
12371
|
}
|
|
10727
12372
|
}
|
|
10728
12373
|
function loadLlmConfig(cwd) {
|
|
10729
|
-
const candidate =
|
|
12374
|
+
const candidate = path15.join(cwd, ".qaios", "config.yaml");
|
|
10730
12375
|
if (!existsSync(candidate)) return void 0;
|
|
10731
12376
|
try {
|
|
10732
12377
|
const parsed = parse(readFileSync(candidate, "utf-8"));
|
|
@@ -10777,8 +12422,8 @@ async function applyDecision(args) {
|
|
|
10777
12422
|
return { resumed: true };
|
|
10778
12423
|
}
|
|
10779
12424
|
async function runReview(opts) {
|
|
10780
|
-
const cwd =
|
|
10781
|
-
const qaiosDir =
|
|
12425
|
+
const cwd = path15.resolve(opts.cwd ?? process.cwd());
|
|
12426
|
+
const qaiosDir = path15.join(cwd, ".qaios");
|
|
10782
12427
|
if (!existsSync(qaiosDir) && opts.storage === void 0) {
|
|
10783
12428
|
return {
|
|
10784
12429
|
exitCode: ExitCode.USER_ERROR,
|
|
@@ -10792,7 +12437,7 @@ async function runReview(opts) {
|
|
|
10792
12437
|
};
|
|
10793
12438
|
}
|
|
10794
12439
|
const ownsStorage = opts.storage === void 0;
|
|
10795
|
-
const storage = opts.storage ?? Storage.open(
|
|
12440
|
+
const storage = opts.storage ?? Storage.open(path15.join(qaiosDir, "workflows.db"), { skipMigrations: false });
|
|
10796
12441
|
const auditLogger = new AuditLogger(storage.db);
|
|
10797
12442
|
const gatesRepo = new GatesRepository(storage.db);
|
|
10798
12443
|
const llm = resolveLlmClient(opts.llm, loadLlmConfig(cwd));
|
|
@@ -10897,7 +12542,7 @@ async function runReview(opts) {
|
|
|
10897
12542
|
}
|
|
10898
12543
|
}
|
|
10899
12544
|
function loadRunConfig(cwd) {
|
|
10900
|
-
const candidate =
|
|
12545
|
+
const candidate = path15.join(cwd, ".qaios", "config.yaml");
|
|
10901
12546
|
if (!existsSync(candidate)) return null;
|
|
10902
12547
|
try {
|
|
10903
12548
|
return parse(readFileSync(candidate, "utf-8"));
|
|
@@ -11006,9 +12651,32 @@ function exitCodeForOutcome(workflowStatus, runStatus, reason) {
|
|
|
11006
12651
|
return ExitCode.INTERNAL;
|
|
11007
12652
|
}
|
|
11008
12653
|
}
|
|
12654
|
+
function readPassWithDivergence(db, workflowId) {
|
|
12655
|
+
let row;
|
|
12656
|
+
try {
|
|
12657
|
+
row = db.prepare(
|
|
12658
|
+
`SELECT payload_json FROM audit_log
|
|
12659
|
+
WHERE workflow_id = ? AND event = 'xray.diff'
|
|
12660
|
+
ORDER BY timestamp DESC LIMIT 1`
|
|
12661
|
+
).get(workflowId);
|
|
12662
|
+
} catch {
|
|
12663
|
+
return null;
|
|
12664
|
+
}
|
|
12665
|
+
if (row?.payload_json === void 0) return null;
|
|
12666
|
+
try {
|
|
12667
|
+
const payload = JSON.parse(row.payload_json);
|
|
12668
|
+
if (payload.passWithDivergence !== true) return null;
|
|
12669
|
+
return { count: payload.count ?? 0, flagged: payload.flagged ?? [] };
|
|
12670
|
+
} catch {
|
|
12671
|
+
return null;
|
|
12672
|
+
}
|
|
12673
|
+
}
|
|
12674
|
+
function indent(s) {
|
|
12675
|
+
return s.split("\n").map((l) => ` ${l}`).join("\n");
|
|
12676
|
+
}
|
|
11009
12677
|
async function runRun(opts) {
|
|
11010
|
-
const cwd =
|
|
11011
|
-
const qaiosDir =
|
|
12678
|
+
const cwd = path15.resolve(opts.cwd ?? process.cwd());
|
|
12679
|
+
const qaiosDir = path15.join(cwd, ".qaios");
|
|
11012
12680
|
if (!existsSync(qaiosDir) && opts.storage === void 0) {
|
|
11013
12681
|
return {
|
|
11014
12682
|
exitCode: ExitCode.USER_ERROR,
|
|
@@ -11019,7 +12687,7 @@ async function runRun(opts) {
|
|
|
11019
12687
|
};
|
|
11020
12688
|
}
|
|
11021
12689
|
const ownsStorage = opts.storage === void 0;
|
|
11022
|
-
const storage = opts.storage ?? Storage.open(
|
|
12690
|
+
const storage = opts.storage ?? Storage.open(path15.join(qaiosDir, "workflows.db"), { skipMigrations: false });
|
|
11023
12691
|
const auditLogger = new AuditLogger(storage.db);
|
|
11024
12692
|
const config = loadRunConfig(cwd);
|
|
11025
12693
|
const llm = resolveLlmClient(opts.llm, config?.llm);
|
|
@@ -11044,6 +12712,19 @@ async function runRun(opts) {
|
|
|
11044
12712
|
extraArgs.push(`--retries=${opts.retry}`);
|
|
11045
12713
|
}
|
|
11046
12714
|
if (extraArgs.length > 0) args["executionExtraArgs"] = extraArgs;
|
|
12715
|
+
const xrayCfg = config?.xray;
|
|
12716
|
+
const flagOn = opts.xray !== void 0 && opts.xray !== false;
|
|
12717
|
+
const enabled = flagOn || opts.xrayStrict === true || xrayCfg?.enabled === true;
|
|
12718
|
+
if (enabled) {
|
|
12719
|
+
const cfgLevel = xrayCfg?.level && xrayCfg.level !== "off" ? xrayCfg.level : "summary";
|
|
12720
|
+
const level = typeof opts.xray === "string" ? opts.xray : cfgLevel;
|
|
12721
|
+
const xrayArg = { level };
|
|
12722
|
+
if (typeof xrayCfg?.quiescenceMs === "number") xrayArg["quiescenceMs"] = xrayCfg.quiescenceMs;
|
|
12723
|
+
if (Array.isArray(xrayCfg?.volatileParams)) xrayArg["volatileParams"] = xrayCfg.volatileParams;
|
|
12724
|
+
if (Array.isArray(xrayCfg?.ignoreHosts)) xrayArg["ignoreHosts"] = xrayCfg.ignoreHosts;
|
|
12725
|
+
if (Array.isArray(xrayCfg?.redact)) xrayArg["redact"] = xrayCfg.redact;
|
|
12726
|
+
args["xray"] = xrayArg;
|
|
12727
|
+
}
|
|
11047
12728
|
const capturePageSnapshot2 = opts.noHeal === true ? void 0 : opts.capturePageSnapshot ?? ((o) => capturePageSnapshot({ url: o.url, cwd: o.cwd }));
|
|
11048
12729
|
const orchestrator = new Orchestrator({
|
|
11049
12730
|
storage,
|
|
@@ -11096,7 +12777,22 @@ async function runRun(opts) {
|
|
|
11096
12777
|
}
|
|
11097
12778
|
const exec = result.outputs["execute.playwright"];
|
|
11098
12779
|
const runStatus = exec?.runStatus;
|
|
11099
|
-
|
|
12780
|
+
let exitCode = exitCodeForOutcome(result.status, runStatus, result.blockedReason);
|
|
12781
|
+
if (exitCode === ExitCode.SUCCESS) {
|
|
12782
|
+
const divergence = readPassWithDivergence(storage.db, result.workflowId);
|
|
12783
|
+
if (divergence !== null) {
|
|
12784
|
+
if (opts.quiet !== true) {
|
|
12785
|
+
process.stderr.write(
|
|
12786
|
+
`\u26A0 xray: ${divergence.count} test(s) passed but the backend diverged from baseline:
|
|
12787
|
+
` + divergence.flagged.map((f) => ` \xB7 ${f.test}
|
|
12788
|
+
${indent(f.summary)}`).join("\n") + "\n"
|
|
12789
|
+
);
|
|
12790
|
+
}
|
|
12791
|
+
if (opts.xrayStrict === true) {
|
|
12792
|
+
exitCode = ExitCode.PASS_WITH_NETWORK_DIVERGENCE;
|
|
12793
|
+
}
|
|
12794
|
+
}
|
|
12795
|
+
}
|
|
11100
12796
|
return {
|
|
11101
12797
|
exitCode,
|
|
11102
12798
|
workflowId: result.workflowId,
|
|
@@ -11113,8 +12809,574 @@ async function runRun(opts) {
|
|
|
11113
12809
|
if (ownsStorage) storage.close();
|
|
11114
12810
|
}
|
|
11115
12811
|
}
|
|
12812
|
+
path15.dirname(fileURLToPath(import.meta.url));
|
|
12813
|
+
function rowToNetRun2(row) {
|
|
12814
|
+
return NetRunRow.parse({
|
|
12815
|
+
runId: row.run_id,
|
|
12816
|
+
workflowId: row.workflow_id,
|
|
12817
|
+
testId: row.test_id,
|
|
12818
|
+
browser: row.browser,
|
|
12819
|
+
tier: row.tier,
|
|
12820
|
+
captureLevel: row.capture_level,
|
|
12821
|
+
startedAt: row.started_at,
|
|
12822
|
+
isGreen: row.is_green === 1,
|
|
12823
|
+
isBaseline: row.is_baseline === 1
|
|
12824
|
+
});
|
|
12825
|
+
}
|
|
12826
|
+
var NetRunsRepository2 = class {
|
|
12827
|
+
constructor(db) {
|
|
12828
|
+
this.db = db;
|
|
12829
|
+
}
|
|
12830
|
+
db;
|
|
12831
|
+
create(input) {
|
|
12832
|
+
const r = NetRunRow.parse(input);
|
|
12833
|
+
this.db.prepare(
|
|
12834
|
+
`INSERT INTO net_runs
|
|
12835
|
+
(run_id, workflow_id, test_id, browser, tier, capture_level,
|
|
12836
|
+
started_at, is_green, is_baseline)
|
|
12837
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
|
12838
|
+
).run(
|
|
12839
|
+
r.runId,
|
|
12840
|
+
r.workflowId,
|
|
12841
|
+
r.testId,
|
|
12842
|
+
r.browser,
|
|
12843
|
+
r.tier,
|
|
12844
|
+
r.captureLevel,
|
|
12845
|
+
r.startedAt,
|
|
12846
|
+
r.isGreen ? 1 : 0,
|
|
12847
|
+
r.isBaseline ? 1 : 0
|
|
12848
|
+
);
|
|
12849
|
+
return r;
|
|
12850
|
+
}
|
|
12851
|
+
findById(runId) {
|
|
12852
|
+
const row = this.db.prepare("SELECT * FROM net_runs WHERE run_id = ?").get(runId);
|
|
12853
|
+
return row ? rowToNetRun2(row) : null;
|
|
12854
|
+
}
|
|
12855
|
+
listByWorkflow(workflowId) {
|
|
12856
|
+
const rows = this.db.prepare("SELECT * FROM net_runs WHERE workflow_id = ? ORDER BY started_at DESC").all(workflowId);
|
|
12857
|
+
return rows.map(rowToNetRun2);
|
|
12858
|
+
}
|
|
12859
|
+
/**
|
|
12860
|
+
* Runs for a test, newest first. Used by baseline selection (last green per
|
|
12861
|
+
* test+browser) and volatility learning (compare consecutive green runs).
|
|
12862
|
+
*/
|
|
12863
|
+
listByTest(testId, browser) {
|
|
12864
|
+
const sql = browser === void 0 ? "SELECT * FROM net_runs WHERE test_id = ? ORDER BY started_at DESC" : "SELECT * FROM net_runs WHERE test_id = ? AND browser = ? ORDER BY started_at DESC";
|
|
12865
|
+
const rows = browser === void 0 ? this.db.prepare(sql).all(testId) : this.db.prepare(sql).all(testId, browser);
|
|
12866
|
+
return rows.map(rowToNetRun2);
|
|
12867
|
+
}
|
|
12868
|
+
/** Flip green/baseline flags as the run is finalized / a baseline is pinned. */
|
|
12869
|
+
update(runId, patch) {
|
|
12870
|
+
const fields = [];
|
|
12871
|
+
const params = [];
|
|
12872
|
+
if (patch.isGreen !== void 0) {
|
|
12873
|
+
fields.push("is_green = ?");
|
|
12874
|
+
params.push(patch.isGreen ? 1 : 0);
|
|
12875
|
+
}
|
|
12876
|
+
if (patch.isBaseline !== void 0) {
|
|
12877
|
+
fields.push("is_baseline = ?");
|
|
12878
|
+
params.push(patch.isBaseline ? 1 : 0);
|
|
12879
|
+
}
|
|
12880
|
+
if (fields.length === 0) return this.findById(runId);
|
|
12881
|
+
params.push(runId);
|
|
12882
|
+
this.db.prepare(`UPDATE net_runs SET ${fields.join(", ")} WHERE run_id = ?`).run(...params);
|
|
12883
|
+
return this.findById(runId);
|
|
12884
|
+
}
|
|
12885
|
+
/** Clear the baseline pin for every run of a (test, browser). */
|
|
12886
|
+
clearBaseline(testId, browser) {
|
|
12887
|
+
this.db.prepare("UPDATE net_runs SET is_baseline = 0 WHERE test_id = ? AND browser = ?").run(testId, browser);
|
|
12888
|
+
}
|
|
12889
|
+
delete(runId) {
|
|
12890
|
+
this.db.prepare("DELETE FROM net_runs WHERE run_id = ?").run(runId);
|
|
12891
|
+
}
|
|
12892
|
+
};
|
|
12893
|
+
function rowToNetRequest2(row) {
|
|
12894
|
+
return NetRequestRow.parse({
|
|
12895
|
+
id: row.id,
|
|
12896
|
+
runId: row.run_id,
|
|
12897
|
+
stepId: row.step_id,
|
|
12898
|
+
attribution: row.attribution,
|
|
12899
|
+
targetType: row.target_type,
|
|
12900
|
+
method: row.method,
|
|
12901
|
+
urlRaw: row.url_raw,
|
|
12902
|
+
urlTemplate: row.url_template,
|
|
12903
|
+
gqlOperation: row.gql_operation,
|
|
12904
|
+
identityKey: row.identity_key,
|
|
12905
|
+
status: row.status,
|
|
12906
|
+
errorText: row.error_text,
|
|
12907
|
+
mime: row.mime,
|
|
12908
|
+
reqHash: row.req_hash,
|
|
12909
|
+
respHash: row.resp_hash,
|
|
12910
|
+
shapeHash: row.shape_hash,
|
|
12911
|
+
reqBodyRef: row.req_body_ref,
|
|
12912
|
+
respBodyRef: row.resp_body_ref,
|
|
12913
|
+
bodyStatus: row.body_status,
|
|
12914
|
+
redirectsJson: row.redirects_json,
|
|
12915
|
+
timingsJson: row.timings_json,
|
|
12916
|
+
initiatorJson: row.initiator_json,
|
|
12917
|
+
wallStart: row.wall_start
|
|
12918
|
+
});
|
|
12919
|
+
}
|
|
12920
|
+
var NetRequestsRepository2 = class {
|
|
12921
|
+
constructor(db) {
|
|
12922
|
+
this.db = db;
|
|
12923
|
+
}
|
|
12924
|
+
db;
|
|
12925
|
+
create(input) {
|
|
12926
|
+
const r = NetRequestRow.parse(input);
|
|
12927
|
+
this.db.prepare(
|
|
12928
|
+
`INSERT INTO net_requests
|
|
12929
|
+
(id, run_id, step_id, attribution, target_type, method, url_raw,
|
|
12930
|
+
url_template, gql_operation, identity_key, status, error_text, mime,
|
|
12931
|
+
req_hash, resp_hash, shape_hash, req_body_ref, resp_body_ref,
|
|
12932
|
+
body_status, redirects_json, timings_json, initiator_json, wall_start)
|
|
12933
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
|
12934
|
+
).run(
|
|
12935
|
+
r.id,
|
|
12936
|
+
r.runId,
|
|
12937
|
+
r.stepId,
|
|
12938
|
+
r.attribution,
|
|
12939
|
+
r.targetType,
|
|
12940
|
+
r.method,
|
|
12941
|
+
r.urlRaw,
|
|
12942
|
+
r.urlTemplate,
|
|
12943
|
+
r.gqlOperation,
|
|
12944
|
+
r.identityKey,
|
|
12945
|
+
r.status,
|
|
12946
|
+
r.errorText,
|
|
12947
|
+
r.mime,
|
|
12948
|
+
r.reqHash,
|
|
12949
|
+
r.respHash,
|
|
12950
|
+
r.shapeHash,
|
|
12951
|
+
r.reqBodyRef,
|
|
12952
|
+
r.respBodyRef,
|
|
12953
|
+
r.bodyStatus,
|
|
12954
|
+
r.redirectsJson,
|
|
12955
|
+
r.timingsJson,
|
|
12956
|
+
r.initiatorJson,
|
|
12957
|
+
r.wallStart
|
|
12958
|
+
);
|
|
12959
|
+
return r;
|
|
12960
|
+
}
|
|
12961
|
+
/** Bulk insert in one transaction — a run can produce hundreds of requests. */
|
|
12962
|
+
createMany(rows) {
|
|
12963
|
+
const insert = this.db.transaction((items) => {
|
|
12964
|
+
for (const item of items) this.create(item);
|
|
12965
|
+
});
|
|
12966
|
+
insert(rows);
|
|
12967
|
+
}
|
|
12968
|
+
listByRun(runId) {
|
|
12969
|
+
const rows = this.db.prepare("SELECT * FROM net_requests WHERE run_id = ? ORDER BY wall_start ASC").all(runId);
|
|
12970
|
+
return rows.map(rowToNetRequest2);
|
|
12971
|
+
}
|
|
12972
|
+
listByRunStep(runId, stepId) {
|
|
12973
|
+
const sql = stepId === null ? "SELECT * FROM net_requests WHERE run_id = ? AND step_id IS NULL ORDER BY wall_start ASC" : "SELECT * FROM net_requests WHERE run_id = ? AND step_id = ? ORDER BY wall_start ASC";
|
|
12974
|
+
const rows = stepId === null ? this.db.prepare(sql).all(runId) : this.db.prepare(sql).all(runId, stepId);
|
|
12975
|
+
return rows.map(rowToNetRequest2);
|
|
12976
|
+
}
|
|
12977
|
+
deleteByRun(runId) {
|
|
12978
|
+
this.db.prepare("DELETE FROM net_requests WHERE run_id = ?").run(runId);
|
|
12979
|
+
}
|
|
12980
|
+
};
|
|
12981
|
+
function buildRunGraph2(requests) {
|
|
12982
|
+
const graph = /* @__PURE__ */ new Map();
|
|
12983
|
+
for (const r of requests) {
|
|
12984
|
+
const key = r.identityKey;
|
|
12985
|
+
const existing = graph.get(key);
|
|
12986
|
+
if (existing === void 0) {
|
|
12987
|
+
graph.set(key, {
|
|
12988
|
+
identityKey: r.identityKey,
|
|
12989
|
+
stepId: r.stepId,
|
|
12990
|
+
attribution: r.attribution,
|
|
12991
|
+
method: r.method,
|
|
12992
|
+
status: r.status,
|
|
12993
|
+
errorText: r.errorText,
|
|
12994
|
+
shapeHash: r.shapeHash,
|
|
12995
|
+
respHash: r.respHash,
|
|
12996
|
+
count: 1
|
|
12997
|
+
});
|
|
12998
|
+
} else {
|
|
12999
|
+
existing.count += 1;
|
|
13000
|
+
if (isErrorStatus3(r.status) && !isErrorStatus3(existing.status)) {
|
|
13001
|
+
existing.status = r.status;
|
|
13002
|
+
existing.errorText = r.errorText;
|
|
13003
|
+
}
|
|
13004
|
+
}
|
|
13005
|
+
}
|
|
13006
|
+
return graph;
|
|
13007
|
+
}
|
|
13008
|
+
function isErrorStatus3(status) {
|
|
13009
|
+
return status === null || status >= 400 || status < 0;
|
|
13010
|
+
}
|
|
13011
|
+
var BaselineSelector2 = class {
|
|
13012
|
+
netRuns;
|
|
13013
|
+
netRequests;
|
|
13014
|
+
constructor(db) {
|
|
13015
|
+
this.netRuns = new NetRunsRepository2(db);
|
|
13016
|
+
this.netRequests = new NetRequestsRepository2(db);
|
|
13017
|
+
}
|
|
13018
|
+
/**
|
|
13019
|
+
* The baseline run for a (test, browser): the pinned baseline if one exists,
|
|
13020
|
+
* else the most recent green run. Returns null if there's no green run yet.
|
|
13021
|
+
* `excludeRunId` skips a candidate run (so a run never baselines against itself).
|
|
13022
|
+
*/
|
|
13023
|
+
select(testId, browser, excludeRunId) {
|
|
13024
|
+
const runs = this.netRuns.listByTest(testId, browser);
|
|
13025
|
+
const pinned = runs.find((r) => r.isBaseline && r.runId !== excludeRunId);
|
|
13026
|
+
if (pinned !== void 0) return pinned;
|
|
13027
|
+
return runs.find((r) => r.isGreen && r.runId !== excludeRunId) ?? null;
|
|
13028
|
+
}
|
|
13029
|
+
/** Build the causal graph for a run by id (or null if the run is unknown). */
|
|
13030
|
+
graphFor(runId) {
|
|
13031
|
+
if (this.netRuns.findById(runId) === null) return null;
|
|
13032
|
+
return buildRunGraph2(this.netRequests.listByRun(runId));
|
|
13033
|
+
}
|
|
13034
|
+
/** Pin a run as the baseline for its (test, browser), clearing any prior pin. */
|
|
13035
|
+
pin(runId) {
|
|
13036
|
+
const run = this.netRuns.findById(runId);
|
|
13037
|
+
if (run === null) return null;
|
|
13038
|
+
this.netRuns.clearBaseline(run.testId, run.browser);
|
|
13039
|
+
return this.netRuns.update(runId, { isBaseline: true });
|
|
13040
|
+
}
|
|
13041
|
+
/** Clear the baseline pin for a (test, browser). */
|
|
13042
|
+
clear(testId, browser) {
|
|
13043
|
+
this.netRuns.clearBaseline(testId, browser);
|
|
13044
|
+
}
|
|
13045
|
+
};
|
|
13046
|
+
var FAIL_SIGNAL_CLASSES2 = /* @__PURE__ */ new Set([
|
|
13047
|
+
"ERROR_RESPONSE",
|
|
13048
|
+
"STATUS_CHANGED"
|
|
13049
|
+
]);
|
|
13050
|
+
var SEVERITY2 = {
|
|
13051
|
+
ERROR_RESPONSE: 6,
|
|
13052
|
+
STATUS_CHANGED: 5,
|
|
13053
|
+
MISSING_CALL: 4,
|
|
13054
|
+
NEW_CALL: 3,
|
|
13055
|
+
SCHEMA_CHANGED: 2,
|
|
13056
|
+
VALUE_CHANGED: 1
|
|
13057
|
+
};
|
|
13058
|
+
function statusClass2(status) {
|
|
13059
|
+
if (status === null || status <= 0) return "unknown";
|
|
13060
|
+
if (status >= 500) return "5xx";
|
|
13061
|
+
if (status >= 400) return "4xx";
|
|
13062
|
+
if (status >= 300) return "3xx";
|
|
13063
|
+
if (status >= 200) return "2xx";
|
|
13064
|
+
return "other";
|
|
13065
|
+
}
|
|
13066
|
+
function isErrorStatus22(status) {
|
|
13067
|
+
return status !== null && status >= 400;
|
|
13068
|
+
}
|
|
13069
|
+
function isUnknownStatus2(status, errorText) {
|
|
13070
|
+
return errorText === null && (status === null || status <= 0);
|
|
13071
|
+
}
|
|
13072
|
+
function diffGraphs2(run, baseline, opts = {}) {
|
|
13073
|
+
const presenceUnstable = new Set(
|
|
13074
|
+
(opts.volatility ?? []).filter((v) => v.kind === "presence").map((v) => v.identityKey)
|
|
13075
|
+
);
|
|
13076
|
+
const valueVolatile = new Set(
|
|
13077
|
+
(opts.volatility ?? []).filter((v) => v.kind === "value").map((v) => v.identityKey)
|
|
13078
|
+
);
|
|
13079
|
+
const backgroundFailSignal = opts.backgroundIsFailSignal === true;
|
|
13080
|
+
const divergences = [];
|
|
13081
|
+
const emit2 = (diffClass, e, detail) => {
|
|
13082
|
+
const isFail = FAIL_SIGNAL_CLASSES2.has(diffClass) && (backgroundFailSignal || e.attribution !== "background");
|
|
13083
|
+
divergences.push({
|
|
13084
|
+
diffClass,
|
|
13085
|
+
identityKey: e.identityKey,
|
|
13086
|
+
stepId: e.stepId,
|
|
13087
|
+
attribution: e.attribution,
|
|
13088
|
+
detail,
|
|
13089
|
+
failSignal: isFail
|
|
13090
|
+
});
|
|
13091
|
+
};
|
|
13092
|
+
for (const [key, entry] of run) {
|
|
13093
|
+
const base = baseline.get(key);
|
|
13094
|
+
if (isUnknownStatus2(entry.status, entry.errorText)) continue;
|
|
13095
|
+
if (isErrorStatus22(entry.status) || entry.errorText !== null) {
|
|
13096
|
+
const ui = opts.uiPassed === true ? " (UI assertions passed)" : "";
|
|
13097
|
+
emit2(
|
|
13098
|
+
"ERROR_RESPONSE",
|
|
13099
|
+
entry,
|
|
13100
|
+
`${describe2(entry)} returned ${entry.errorText ?? entry.status ?? "error"}${ui}`
|
|
13101
|
+
);
|
|
13102
|
+
continue;
|
|
13103
|
+
}
|
|
13104
|
+
if (base !== void 0) {
|
|
13105
|
+
const runClass = statusClass2(entry.status);
|
|
13106
|
+
const baseClass = statusClass2(base.status);
|
|
13107
|
+
if (runClass !== "unknown" && baseClass !== "unknown" && runClass !== baseClass) {
|
|
13108
|
+
emit2(
|
|
13109
|
+
"STATUS_CHANGED",
|
|
13110
|
+
entry,
|
|
13111
|
+
`${describe2(entry)} status ${base.status ?? "\u2014"} \u2192 ${entry.status ?? "\u2014"}`
|
|
13112
|
+
);
|
|
13113
|
+
} else if (entry.shapeHash !== null && base.shapeHash !== null && entry.shapeHash !== base.shapeHash) {
|
|
13114
|
+
emit2("SCHEMA_CHANGED", entry, `${describe2(entry)} response shape changed`);
|
|
13115
|
+
} else if (entry.respHash !== null && base.respHash !== null && entry.respHash !== base.respHash && !valueVolatile.has(key)) {
|
|
13116
|
+
emit2("VALUE_CHANGED", entry, `${describe2(entry)} response value changed`);
|
|
13117
|
+
}
|
|
13118
|
+
} else if (!presenceUnstable.has(key)) {
|
|
13119
|
+
emit2("NEW_CALL", entry, `${describe2(entry)} is new vs baseline`);
|
|
13120
|
+
}
|
|
13121
|
+
}
|
|
13122
|
+
for (const [key, base] of baseline) {
|
|
13123
|
+
if (!run.has(key) && !presenceUnstable.has(key)) {
|
|
13124
|
+
emit2("MISSING_CALL", base, `${describe2(base)} was expected but did not occur`);
|
|
13125
|
+
}
|
|
13126
|
+
}
|
|
13127
|
+
divergences.sort(
|
|
13128
|
+
(a, b) => SEVERITY2[b.diffClass] - SEVERITY2[a.diffClass] || a.identityKey.localeCompare(b.identityKey)
|
|
13129
|
+
);
|
|
13130
|
+
return {
|
|
13131
|
+
divergences,
|
|
13132
|
+
hasFailSignal: divergences.some((d) => d.failSignal)
|
|
13133
|
+
};
|
|
13134
|
+
}
|
|
13135
|
+
function describe2(e) {
|
|
13136
|
+
const parts = e.identityKey.split("|");
|
|
13137
|
+
return parts.length >= 3 ? `${parts[0]} ${parts[1]}${parts[2]}` : e.identityKey;
|
|
13138
|
+
}
|
|
13139
|
+
|
|
13140
|
+
// src/commands/xray.ts
|
|
13141
|
+
function write(s) {
|
|
13142
|
+
process.stdout.write(s.endsWith("\n") ? s : s + "\n");
|
|
13143
|
+
}
|
|
13144
|
+
function userError(code, message) {
|
|
13145
|
+
return { exitCode: ExitCode.USER_ERROR, error: { code, message } };
|
|
13146
|
+
}
|
|
13147
|
+
function renderRun(run, requests) {
|
|
13148
|
+
const lines = [];
|
|
13149
|
+
const greenMark = run.isGreen ? "\u2713 green" : "\xB7 not-green";
|
|
13150
|
+
const baseMark = run.isBaseline ? " \u2605 baseline" : "";
|
|
13151
|
+
lines.push("");
|
|
13152
|
+
lines.push(`\u25B8 ${run.testId}`);
|
|
13153
|
+
lines.push(
|
|
13154
|
+
` run ${run.runId} \xB7 ${run.browser} (tier ${run.tier}) \xB7 ${run.captureLevel} \xB7 ${greenMark}${baseMark} \xB7 ${requests.length} requests`
|
|
13155
|
+
);
|
|
13156
|
+
const byStep = /* @__PURE__ */ new Map();
|
|
13157
|
+
for (const r of requests) {
|
|
13158
|
+
const key = r.stepId ?? "(background)";
|
|
13159
|
+
const list = byStep.get(key) ?? [];
|
|
13160
|
+
list.push(r);
|
|
13161
|
+
byStep.set(key, list);
|
|
13162
|
+
}
|
|
13163
|
+
for (const [stepKey, reqs] of byStep) {
|
|
13164
|
+
const stepLabel = stepKey === "(background)" ? "background / setup" : `step ${stepKey.slice(-6)}`;
|
|
13165
|
+
lines.push(` ${stepLabel}`);
|
|
13166
|
+
for (const r of reqs) {
|
|
13167
|
+
const status = r.status === null ? r.errorText ? `ERR ${r.errorText}` : "\u2014" : String(r.status);
|
|
13168
|
+
const flag = statusFlag(r);
|
|
13169
|
+
const gql = r.gqlOperation ? ` {gql:${r.gqlOperation}}` : "";
|
|
13170
|
+
lines.push(
|
|
13171
|
+
` ${flag}${r.method.padEnd(5)} ${status.padEnd(14)} ${r.identityKey.split("|").slice(1).join(" ")}${gql} [${r.attribution}]`
|
|
13172
|
+
);
|
|
13173
|
+
}
|
|
13174
|
+
}
|
|
13175
|
+
return lines.join("\n");
|
|
13176
|
+
}
|
|
13177
|
+
function statusFlag(r) {
|
|
13178
|
+
if (r.errorText) return "\u2717 ";
|
|
13179
|
+
if (typeof r.status === "number" && r.status >= 400) return "\u2717 ";
|
|
13180
|
+
return " ";
|
|
13181
|
+
}
|
|
13182
|
+
async function runXray(opts) {
|
|
13183
|
+
const cwd = path15.resolve(opts.cwd ?? process.cwd());
|
|
13184
|
+
const qaiosDir = path15.join(cwd, ".qaios");
|
|
13185
|
+
if (!existsSync(qaiosDir) && opts.storage === void 0) {
|
|
13186
|
+
return userError(
|
|
13187
|
+
"qaios.xray.not_initialized",
|
|
13188
|
+
`${cwd} is not a QAIOS project \u2014 run \`qaios init\` first.`
|
|
13189
|
+
);
|
|
13190
|
+
}
|
|
13191
|
+
const ownsStorage = opts.storage === void 0;
|
|
13192
|
+
const storage = opts.storage ?? Storage.open(path15.join(qaiosDir, "workflows.db"), { skipMigrations: false });
|
|
13193
|
+
try {
|
|
13194
|
+
switch (opts.verb) {
|
|
13195
|
+
case "show":
|
|
13196
|
+
return showVerb(storage, opts);
|
|
13197
|
+
case "diff":
|
|
13198
|
+
return diffVerb(storage, opts);
|
|
13199
|
+
case "baseline":
|
|
13200
|
+
return baselineVerb(storage, opts);
|
|
13201
|
+
case "volatility":
|
|
13202
|
+
return volatilityVerb(storage, opts);
|
|
13203
|
+
default:
|
|
13204
|
+
return userError("qaios.xray.unknown_verb", `Unknown xray verb: ${String(opts.verb)}`);
|
|
13205
|
+
}
|
|
13206
|
+
} finally {
|
|
13207
|
+
if (ownsStorage) storage.close();
|
|
13208
|
+
}
|
|
13209
|
+
}
|
|
13210
|
+
function showVerb(storage, opts) {
|
|
13211
|
+
const netRuns = new NetRunsRepository(storage.db);
|
|
13212
|
+
const netRequests = new NetRequestsRepository(storage.db);
|
|
13213
|
+
let runs = [];
|
|
13214
|
+
if (opts.target !== void 0) {
|
|
13215
|
+
const single = netRuns.findById(opts.target);
|
|
13216
|
+
runs = single ? [single] : netRuns.listByWorkflow(opts.target);
|
|
13217
|
+
}
|
|
13218
|
+
if (runs.length === 0) {
|
|
13219
|
+
const hint = opts.target ? ` for "${opts.target}"` : "";
|
|
13220
|
+
return userError(
|
|
13221
|
+
"qaios.xray.no_capture",
|
|
13222
|
+
`No xray capture found${hint}. Run \`qaios run --xray\` first, then \`qaios xray show <runId|workflowId>\`.`
|
|
13223
|
+
);
|
|
13224
|
+
}
|
|
13225
|
+
if (opts.json === true) {
|
|
13226
|
+
write(
|
|
13227
|
+
JSON.stringify(
|
|
13228
|
+
runs.map((run) => ({ run, requests: netRequests.listByRun(run.runId) })),
|
|
13229
|
+
null,
|
|
13230
|
+
2
|
|
13231
|
+
)
|
|
13232
|
+
);
|
|
13233
|
+
return { exitCode: ExitCode.SUCCESS };
|
|
13234
|
+
}
|
|
13235
|
+
if (opts.quiet !== true) {
|
|
13236
|
+
write(`xray capture \u2014 ${runs.length} run(s)`);
|
|
13237
|
+
for (const run of runs) write(renderRun(run, netRequests.listByRun(run.runId)));
|
|
13238
|
+
write("");
|
|
13239
|
+
}
|
|
13240
|
+
return { exitCode: ExitCode.SUCCESS };
|
|
13241
|
+
}
|
|
13242
|
+
function diffVerb(storage, opts) {
|
|
13243
|
+
const netRuns = new NetRunsRepository(storage.db);
|
|
13244
|
+
const netRequests = new NetRequestsRepository(storage.db);
|
|
13245
|
+
const volatility = new NetVolatilityRepository(storage.db);
|
|
13246
|
+
const baselines = new BaselineSelector2(storage.db);
|
|
13247
|
+
if (opts.target === void 0) {
|
|
13248
|
+
return userError(
|
|
13249
|
+
"qaios.xray.diff_usage",
|
|
13250
|
+
"Usage: qaios xray diff <runA> [runB]. Omit runB to diff against the baseline."
|
|
13251
|
+
);
|
|
13252
|
+
}
|
|
13253
|
+
const runA = netRuns.findById(opts.target);
|
|
13254
|
+
if (runA === null) {
|
|
13255
|
+
return userError("qaios.xray.run_not_found", `No captured run with id "${opts.target}".`);
|
|
13256
|
+
}
|
|
13257
|
+
let runB;
|
|
13258
|
+
if (opts.target2 !== void 0) {
|
|
13259
|
+
runB = netRuns.findById(opts.target2);
|
|
13260
|
+
if (runB === null) {
|
|
13261
|
+
return userError("qaios.xray.run_not_found", `No captured run with id "${opts.target2}".`);
|
|
13262
|
+
}
|
|
13263
|
+
} else {
|
|
13264
|
+
runB = baselines.select(runA.testId, runA.browser, runA.runId);
|
|
13265
|
+
if (runB === null) {
|
|
13266
|
+
return userError(
|
|
13267
|
+
"qaios.xray.no_baseline",
|
|
13268
|
+
`No baseline (green run) for "${runA.testId}" yet \u2014 pass an explicit second run, or capture a green run first.`
|
|
13269
|
+
);
|
|
13270
|
+
}
|
|
13271
|
+
}
|
|
13272
|
+
const runGraph = buildRunGraph2(netRequests.listByRun(runA.runId));
|
|
13273
|
+
const baseGraph = buildRunGraph2(netRequests.listByRun(runB.runId));
|
|
13274
|
+
const result = diffGraphs2(runGraph, baseGraph, {
|
|
13275
|
+
volatility: volatility.listByTest(runA.testId),
|
|
13276
|
+
uiPassed: runA.isGreen
|
|
13277
|
+
});
|
|
13278
|
+
if (opts.json === true) {
|
|
13279
|
+
write(JSON.stringify({ runA: runA.runId, runB: runB.runId, ...result }, null, 2));
|
|
13280
|
+
return { exitCode: ExitCode.SUCCESS };
|
|
13281
|
+
}
|
|
13282
|
+
if (opts.quiet !== true) {
|
|
13283
|
+
write(`xray diff \u2014 ${runA.runId} vs ${runB.runId} (${runA.testId})`);
|
|
13284
|
+
if (result.divergences.length === 0) {
|
|
13285
|
+
write(" \u2713 no divergences \u2014 backend contract matches the baseline.");
|
|
13286
|
+
} else {
|
|
13287
|
+
for (const d of result.divergences) {
|
|
13288
|
+
const glyph = d.failSignal ? "\u2717" : "\u26A0";
|
|
13289
|
+
write(` ${glyph} [${d.diffClass}] ${d.detail} {${d.attribution}}`);
|
|
13290
|
+
}
|
|
13291
|
+
}
|
|
13292
|
+
write("");
|
|
13293
|
+
}
|
|
13294
|
+
return { exitCode: ExitCode.SUCCESS };
|
|
13295
|
+
}
|
|
13296
|
+
function baselineVerb(storage, opts) {
|
|
13297
|
+
const baselines = new BaselineSelector2(storage.db);
|
|
13298
|
+
const netRuns = new NetRunsRepository(storage.db);
|
|
13299
|
+
const sub = opts.sub ?? "set";
|
|
13300
|
+
if (sub === "set") {
|
|
13301
|
+
if (opts.target === void 0) {
|
|
13302
|
+
return userError("qaios.xray.baseline_usage", "Usage: qaios xray baseline set <runId>.");
|
|
13303
|
+
}
|
|
13304
|
+
const pinned = baselines.pin(opts.target);
|
|
13305
|
+
if (pinned === null) {
|
|
13306
|
+
return userError("qaios.xray.run_not_found", `No captured run with id "${opts.target}".`);
|
|
13307
|
+
}
|
|
13308
|
+
if (opts.quiet !== true) {
|
|
13309
|
+
write(`\u2713 baseline pinned: ${pinned.testId} (${pinned.browser}) \u2192 run ${pinned.runId}`);
|
|
13310
|
+
}
|
|
13311
|
+
return { exitCode: ExitCode.SUCCESS };
|
|
13312
|
+
}
|
|
13313
|
+
if (sub === "clear") {
|
|
13314
|
+
if (opts.target === void 0) {
|
|
13315
|
+
return userError(
|
|
13316
|
+
"qaios.xray.baseline_usage",
|
|
13317
|
+
"Usage: qaios xray baseline clear <runId|testId>."
|
|
13318
|
+
);
|
|
13319
|
+
}
|
|
13320
|
+
const run = netRuns.findById(opts.target);
|
|
13321
|
+
if (run === null) {
|
|
13322
|
+
return userError(
|
|
13323
|
+
"qaios.xray.run_not_found",
|
|
13324
|
+
`Pass a run id to clear its (test, browser) baseline.`
|
|
13325
|
+
);
|
|
13326
|
+
}
|
|
13327
|
+
baselines.clear(run.testId, run.browser);
|
|
13328
|
+
if (opts.quiet !== true) write(`\u2713 baseline cleared for ${run.testId} (${run.browser}).`);
|
|
13329
|
+
return { exitCode: ExitCode.SUCCESS };
|
|
13330
|
+
}
|
|
13331
|
+
return userError("qaios.xray.baseline_usage", `Unknown baseline action "${sub}" (set|clear).`);
|
|
13332
|
+
}
|
|
13333
|
+
function volatilityVerb(storage, opts) {
|
|
13334
|
+
const volatility = new NetVolatilityRepository(storage.db);
|
|
13335
|
+
const sub = opts.sub ?? "show";
|
|
13336
|
+
if (sub === "reset") {
|
|
13337
|
+
const n = volatility.reset(opts.target);
|
|
13338
|
+
if (opts.quiet !== true) {
|
|
13339
|
+
write(
|
|
13340
|
+
`\u2713 cleared ${n} learned volatility entr${n === 1 ? "y" : "ies"}${opts.target ? ` for ${opts.target}` : ""}.`
|
|
13341
|
+
);
|
|
13342
|
+
}
|
|
13343
|
+
return { exitCode: ExitCode.SUCCESS };
|
|
13344
|
+
}
|
|
13345
|
+
if (sub === "show") {
|
|
13346
|
+
if (opts.target === void 0) {
|
|
13347
|
+
return userError(
|
|
13348
|
+
"qaios.xray.volatility_usage",
|
|
13349
|
+
"Usage: qaios xray volatility show <testId> | reset [testId]."
|
|
13350
|
+
);
|
|
13351
|
+
}
|
|
13352
|
+
const rows = volatility.listByTest(opts.target);
|
|
13353
|
+
if (opts.json === true) {
|
|
13354
|
+
write(JSON.stringify(rows, null, 2));
|
|
13355
|
+
return { exitCode: ExitCode.SUCCESS };
|
|
13356
|
+
}
|
|
13357
|
+
if (opts.quiet !== true) {
|
|
13358
|
+
if (rows.length === 0) {
|
|
13359
|
+
const greenRuns = new NetRunsRepository(storage.db).listByTest(opts.target).filter((r) => r.isGreen).length;
|
|
13360
|
+
write(
|
|
13361
|
+
greenRuns >= 2 ? `No volatility learned for "${opts.target}" \u2014 its ${greenRuns} green runs were identical (a stable contract, nothing to relax).` : `No learned volatility for "${opts.target}" yet \u2014 needs \u22652 green runs (have ${greenRuns}).`
|
|
13362
|
+
);
|
|
13363
|
+
} else {
|
|
13364
|
+
write(`xray volatility \u2014 ${opts.target} (${rows.length} learned)`);
|
|
13365
|
+
for (const r of rows) {
|
|
13366
|
+
write(` \xB7 [${r.kind}] ${r.identityKey.split("|").slice(0, 3).join(" ")}`);
|
|
13367
|
+
}
|
|
13368
|
+
}
|
|
13369
|
+
write("");
|
|
13370
|
+
}
|
|
13371
|
+
return { exitCode: ExitCode.SUCCESS };
|
|
13372
|
+
}
|
|
13373
|
+
return userError(
|
|
13374
|
+
"qaios.xray.volatility_usage",
|
|
13375
|
+
`Unknown volatility action "${sub}" (show|reset).`
|
|
13376
|
+
);
|
|
13377
|
+
}
|
|
11116
13378
|
function loadConfig4(cwd) {
|
|
11117
|
-
const candidate =
|
|
13379
|
+
const candidate = path15.join(cwd, ".qaios", "config.yaml");
|
|
11118
13380
|
if (!existsSync(candidate)) return null;
|
|
11119
13381
|
try {
|
|
11120
13382
|
return parse(readFileSync(candidate, "utf-8"));
|
|
@@ -11160,8 +13422,8 @@ function loadSnapshotSpec(specPath) {
|
|
|
11160
13422
|
};
|
|
11161
13423
|
}
|
|
11162
13424
|
async function runSnapshotCapture(opts) {
|
|
11163
|
-
const cwd =
|
|
11164
|
-
const qaiosDir =
|
|
13425
|
+
const cwd = path15.resolve(opts.cwd ?? process.cwd());
|
|
13426
|
+
const qaiosDir = path15.join(cwd, ".qaios");
|
|
11165
13427
|
if (!existsSync(qaiosDir) && opts.storage === void 0) {
|
|
11166
13428
|
return {
|
|
11167
13429
|
exitCode: ExitCode.USER_ERROR,
|
|
@@ -11180,7 +13442,7 @@ async function runSnapshotCapture(opts) {
|
|
|
11180
13442
|
}
|
|
11181
13443
|
};
|
|
11182
13444
|
}
|
|
11183
|
-
const specAbs =
|
|
13445
|
+
const specAbs = path15.isAbsolute(opts.spec) ? opts.spec : path15.resolve(cwd, opts.spec);
|
|
11184
13446
|
if (!existsSync(specAbs)) {
|
|
11185
13447
|
return {
|
|
11186
13448
|
exitCode: ExitCode.USER_ERROR,
|
|
@@ -11241,7 +13503,7 @@ async function runSnapshotCapture(opts) {
|
|
|
11241
13503
|
};
|
|
11242
13504
|
}
|
|
11243
13505
|
const ownsStorage = opts.storage === void 0;
|
|
11244
|
-
const storage = opts.storage ?? Storage.open(
|
|
13506
|
+
const storage = opts.storage ?? Storage.open(path15.join(qaiosDir, "workflows.db"), { skipMigrations: false });
|
|
11245
13507
|
const auditLogger = new AuditLogger(storage.db);
|
|
11246
13508
|
const baselineRepo = new VisualBaselinesRepository(storage.db);
|
|
11247
13509
|
if (opts.update !== true) {
|
|
@@ -11329,8 +13591,8 @@ function exitCodeForCheck(summary) {
|
|
|
11329
13591
|
return ExitCode.SUCCESS;
|
|
11330
13592
|
}
|
|
11331
13593
|
async function runSnapshotCheck(opts) {
|
|
11332
|
-
const cwd =
|
|
11333
|
-
const qaiosDir =
|
|
13594
|
+
const cwd = path15.resolve(opts.cwd ?? process.cwd());
|
|
13595
|
+
const qaiosDir = path15.join(cwd, ".qaios");
|
|
11334
13596
|
if (!existsSync(qaiosDir) && opts.storage === void 0) {
|
|
11335
13597
|
return {
|
|
11336
13598
|
exitCode: ExitCode.USER_ERROR,
|
|
@@ -11352,7 +13614,7 @@ async function runSnapshotCheck(opts) {
|
|
|
11352
13614
|
};
|
|
11353
13615
|
}
|
|
11354
13616
|
const ownsStorage = opts.storage === void 0;
|
|
11355
|
-
const storage = opts.storage ?? Storage.open(
|
|
13617
|
+
const storage = opts.storage ?? Storage.open(path15.join(qaiosDir, "workflows.db"), { skipMigrations: false });
|
|
11356
13618
|
const auditLogger = new AuditLogger(storage.db);
|
|
11357
13619
|
const baselineRepo = new VisualBaselinesRepository(storage.db);
|
|
11358
13620
|
const llm = resolveLlmClient(opts.llm, config?.llm);
|
|
@@ -11500,15 +13762,15 @@ function autoDecide(item, threshold) {
|
|
|
11500
13762
|
function applyReviewDecision(args) {
|
|
11501
13763
|
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
11502
13764
|
if (args.action === "approve") {
|
|
11503
|
-
const currentAbs =
|
|
13765
|
+
const currentAbs = path15.isAbsolute(args.diff.currentImagePath) ? args.diff.currentImagePath : path15.join(args.cwd, args.diff.currentImagePath);
|
|
11504
13766
|
if (!existsSync(currentAbs)) {
|
|
11505
13767
|
return {
|
|
11506
13768
|
applied: false,
|
|
11507
13769
|
reason: `current image not found at ${currentAbs} \u2014 cannot copy to baseline.`
|
|
11508
13770
|
};
|
|
11509
13771
|
}
|
|
11510
|
-
const baselineAbs =
|
|
11511
|
-
mkdirSync(
|
|
13772
|
+
const baselineAbs = path15.isAbsolute(args.baseline.imagePath) ? args.baseline.imagePath : path15.join(args.cwd, args.baseline.imagePath);
|
|
13773
|
+
mkdirSync(path15.dirname(baselineAbs), { recursive: true });
|
|
11512
13774
|
copyFileSync(currentAbs, baselineAbs);
|
|
11513
13775
|
args.storage.db.transaction(() => {
|
|
11514
13776
|
args.baselineRepo.updateImage(args.baseline.id, {
|
|
@@ -11558,8 +13820,8 @@ function applyReviewDecision(args) {
|
|
|
11558
13820
|
return { applied: true };
|
|
11559
13821
|
}
|
|
11560
13822
|
async function runSnapshotReview(opts) {
|
|
11561
|
-
const cwd =
|
|
11562
|
-
const qaiosDir =
|
|
13823
|
+
const cwd = path15.resolve(opts.cwd ?? process.cwd());
|
|
13824
|
+
const qaiosDir = path15.join(cwd, ".qaios");
|
|
11563
13825
|
if (!existsSync(qaiosDir) && opts.storage === void 0) {
|
|
11564
13826
|
return {
|
|
11565
13827
|
exitCode: ExitCode.USER_ERROR,
|
|
@@ -11572,7 +13834,7 @@ async function runSnapshotReview(opts) {
|
|
|
11572
13834
|
};
|
|
11573
13835
|
}
|
|
11574
13836
|
const ownsStorage = opts.storage === void 0;
|
|
11575
|
-
const storage = opts.storage ?? Storage.open(
|
|
13837
|
+
const storage = opts.storage ?? Storage.open(path15.join(qaiosDir, "workflows.db"), { skipMigrations: false });
|
|
11576
13838
|
const auditLogger = new AuditLogger(storage.db);
|
|
11577
13839
|
const baselineRepo = new VisualBaselinesRepository(storage.db);
|
|
11578
13840
|
const diffRepo = new VisualDiffsRepository(storage.db);
|
|
@@ -11689,7 +13951,7 @@ async function runSnapshotReview(opts) {
|
|
|
11689
13951
|
}
|
|
11690
13952
|
}
|
|
11691
13953
|
function loadConfig5(cwd) {
|
|
11692
|
-
const candidate =
|
|
13954
|
+
const candidate = path15.join(cwd, ".qaios", "config.yaml");
|
|
11693
13955
|
if (!existsSync(candidate)) return null;
|
|
11694
13956
|
try {
|
|
11695
13957
|
return parse(readFileSync(candidate, "utf-8"));
|
|
@@ -11702,14 +13964,14 @@ function loadConfig5(cwd) {
|
|
|
11702
13964
|
}
|
|
11703
13965
|
}
|
|
11704
13966
|
function detectInstallCommand(cwd) {
|
|
11705
|
-
if (existsSync(
|
|
11706
|
-
if (existsSync(
|
|
11707
|
-
if (existsSync(
|
|
13967
|
+
if (existsSync(path15.join(cwd, "pnpm-lock.yaml"))) return "pnpm add -D";
|
|
13968
|
+
if (existsSync(path15.join(cwd, "bun.lockb"))) return "bun add -d";
|
|
13969
|
+
if (existsSync(path15.join(cwd, "yarn.lock"))) return "yarn add -D";
|
|
11708
13970
|
return "npm install --save-dev";
|
|
11709
13971
|
}
|
|
11710
13972
|
function filterUninstalledDeps(cwd, imports) {
|
|
11711
13973
|
if (imports.length === 0) return [];
|
|
11712
|
-
const pkgPath =
|
|
13974
|
+
const pkgPath = path15.join(cwd, "package.json");
|
|
11713
13975
|
if (!existsSync(pkgPath)) return [];
|
|
11714
13976
|
let installed;
|
|
11715
13977
|
try {
|
|
@@ -11819,7 +14081,7 @@ function exitCodeForStatus(status, reason, runSummary) {
|
|
|
11819
14081
|
}
|
|
11820
14082
|
}
|
|
11821
14083
|
async function runTest(opts) {
|
|
11822
|
-
const cwd =
|
|
14084
|
+
const cwd = path15.resolve(opts.cwd ?? process.cwd());
|
|
11823
14085
|
if (!opts.description || opts.description.trim().length === 0) {
|
|
11824
14086
|
return {
|
|
11825
14087
|
exitCode: ExitCode.USER_ERROR,
|
|
@@ -11829,7 +14091,7 @@ async function runTest(opts) {
|
|
|
11829
14091
|
}
|
|
11830
14092
|
};
|
|
11831
14093
|
}
|
|
11832
|
-
const qaiosDir =
|
|
14094
|
+
const qaiosDir = path15.join(cwd, ".qaios");
|
|
11833
14095
|
if (!existsSync(qaiosDir) && opts.storage === void 0) {
|
|
11834
14096
|
return {
|
|
11835
14097
|
exitCode: ExitCode.USER_ERROR,
|
|
@@ -11851,11 +14113,11 @@ async function runTest(opts) {
|
|
|
11851
14113
|
const candidates = [];
|
|
11852
14114
|
if (opts.apiSpec !== void 0 && opts.apiSpec.trim().length > 0) {
|
|
11853
14115
|
candidates.push(
|
|
11854
|
-
|
|
14116
|
+
path15.isAbsolute(opts.apiSpec) ? opts.apiSpec : path15.resolve(cwd, opts.apiSpec)
|
|
11855
14117
|
);
|
|
11856
14118
|
}
|
|
11857
14119
|
if (/\.(ya?ml|json)$/i.test(opts.description.trim())) {
|
|
11858
|
-
const guess =
|
|
14120
|
+
const guess = path15.isAbsolute(opts.description) ? opts.description.trim() : path15.resolve(cwd, opts.description.trim());
|
|
11859
14121
|
candidates.push(guess);
|
|
11860
14122
|
}
|
|
11861
14123
|
const found = candidates.find((c) => existsSync(c)) ?? null;
|
|
@@ -11879,7 +14141,7 @@ async function runTest(opts) {
|
|
|
11879
14141
|
};
|
|
11880
14142
|
}
|
|
11881
14143
|
const isPathDescription = candidates.includes(
|
|
11882
|
-
|
|
14144
|
+
path15.isAbsolute(opts.description) ? opts.description : path15.resolve(cwd, opts.description)
|
|
11883
14145
|
);
|
|
11884
14146
|
if (isPathDescription || opts.description.trim().length < 3) {
|
|
11885
14147
|
const epSummary = parsedSpec.endpoints.slice(0, 8).map((e) => `${e.method} ${e.path}${e.summary ? ` \u2014 ${e.summary}` : ""}`).join("\n");
|
|
@@ -11929,7 +14191,7 @@ ${epSummary}`;
|
|
|
11929
14191
|
if (opts.featureName !== void 0) args["featureName"] = opts.featureName;
|
|
11930
14192
|
const mode = opts.mode ?? config?.mode ?? "LITE";
|
|
11931
14193
|
const ownsStorage = opts.storage === void 0;
|
|
11932
|
-
const storage = opts.storage ?? Storage.open(
|
|
14194
|
+
const storage = opts.storage ?? Storage.open(path15.join(qaiosDir, "workflows.db"), { skipMigrations: false });
|
|
11933
14195
|
const auditLogger = new AuditLogger(storage.db);
|
|
11934
14196
|
const llm = resolveLlmClient(opts.llm, config?.llm);
|
|
11935
14197
|
const gateConfig = {};
|
|
@@ -11997,27 +14259,27 @@ ${epSummary}`;
|
|
|
11997
14259
|
}
|
|
11998
14260
|
const filesWritten = [];
|
|
11999
14261
|
if (result.status === "succeeded") {
|
|
12000
|
-
const specsDir =
|
|
14262
|
+
const specsDir = path15.join(qaiosDir, "specs", result.workflowId);
|
|
12001
14263
|
mkdirSync(specsDir, { recursive: true });
|
|
12002
14264
|
const designKey = Object.keys(result.outputs).find((k) => k.startsWith("design."));
|
|
12003
14265
|
if (designKey !== void 0) {
|
|
12004
14266
|
const design2 = result.outputs[designKey];
|
|
12005
14267
|
if (design2 !== void 0 && design2["designSpec"] !== void 0) {
|
|
12006
|
-
const designSpecPath =
|
|
14268
|
+
const designSpecPath = path15.join(specsDir, "DesignSpec.json");
|
|
12007
14269
|
writeFileSync(designSpecPath, JSON.stringify(design2["designSpec"], null, 2) + "\n");
|
|
12008
|
-
filesWritten.push(
|
|
14270
|
+
filesWritten.push(path15.relative(cwd, designSpecPath));
|
|
12009
14271
|
}
|
|
12010
14272
|
}
|
|
12011
14273
|
let importsRequired = [];
|
|
12012
14274
|
let qaiosMdSuggestions = [];
|
|
12013
14275
|
const writeKey2 = Object.keys(result.outputs).find((k) => k.startsWith("write."));
|
|
12014
14276
|
if (writeKey2 !== void 0) {
|
|
12015
|
-
const
|
|
14277
|
+
const write2 = result.outputs[writeKey2];
|
|
12016
14278
|
const generatedAtIso = (/* @__PURE__ */ new Date()).toISOString();
|
|
12017
14279
|
const disallowedImportsByFile = /* @__PURE__ */ new Map();
|
|
12018
|
-
for (const file of
|
|
12019
|
-
const absPath =
|
|
12020
|
-
mkdirSync(
|
|
14280
|
+
for (const file of write2.files ?? []) {
|
|
14281
|
+
const absPath = path15.join(cwd, file.path);
|
|
14282
|
+
mkdirSync(path15.dirname(absPath), { recursive: true });
|
|
12021
14283
|
const injected = injectGeneratedFileHeader(
|
|
12022
14284
|
file.contents,
|
|
12023
14285
|
result.workflowId,
|
|
@@ -12045,8 +14307,8 @@ ${epSummary}`;
|
|
|
12045
14307
|
`
|
|
12046
14308
|
);
|
|
12047
14309
|
}
|
|
12048
|
-
if (Array.isArray(
|
|
12049
|
-
if (Array.isArray(
|
|
14310
|
+
if (Array.isArray(write2.importsRequired)) importsRequired = write2.importsRequired;
|
|
14311
|
+
if (Array.isArray(write2.qaiosMdSuggestions)) qaiosMdSuggestions = write2.qaiosMdSuggestions;
|
|
12050
14312
|
}
|
|
12051
14313
|
if (!opts.quiet && opts.json !== true) {
|
|
12052
14314
|
process.stdout.write(`
|
|
@@ -12119,9 +14381,9 @@ Ran generated tests: ${runSummary.passedCount} passed, ${runSummary.failedCount}
|
|
|
12119
14381
|
}
|
|
12120
14382
|
|
|
12121
14383
|
// src/index.ts
|
|
12122
|
-
var __dirname2 =
|
|
14384
|
+
var __dirname2 = path15.dirname(fileURLToPath(import.meta.url));
|
|
12123
14385
|
function readPackageVersion() {
|
|
12124
|
-
for (const candidate of [
|
|
14386
|
+
for (const candidate of [path15.resolve(__dirname2, "..", "package.json")]) {
|
|
12125
14387
|
try {
|
|
12126
14388
|
const pkg = JSON.parse(readFileSync(candidate, "utf-8"));
|
|
12127
14389
|
if (pkg.version) return pkg.version;
|
|
@@ -12152,7 +14414,7 @@ function buildProgram() {
|
|
|
12152
14414
|
}
|
|
12153
14415
|
const detected = result.detection;
|
|
12154
14416
|
const lines = [];
|
|
12155
|
-
lines.push(`\u2713 Created ${
|
|
14417
|
+
lines.push(`\u2713 Created ${path15.relative(process.cwd(), result.qaiosDir)}/`);
|
|
12156
14418
|
if (detected.hasPlaywright) {
|
|
12157
14419
|
lines.push(`\u2713 Detected: Playwright ${detected.playwrightVersion}`);
|
|
12158
14420
|
} else {
|
|
@@ -12197,7 +14459,9 @@ function buildProgram() {
|
|
|
12197
14459
|
}
|
|
12198
14460
|
process.exit(result.exitCode);
|
|
12199
14461
|
});
|
|
12200
|
-
program.command("test <description>").description(
|
|
14462
|
+
program.command("test <description>").description(
|
|
14463
|
+
"GENERATE test files from a feature description (design \u2192 write); --run also executes them"
|
|
14464
|
+
).option("--type <type>", "testing type: web | api", "web").option("--api-spec <path>", "path to an OpenAPI spec (required when --type=api)").option("--base-url <url>", "override app.baseUrl from .qaios/config.yaml for this run").option(
|
|
12201
14465
|
"--scope <scope>",
|
|
12202
14466
|
"design scope: minimal (~3 scenarios) | standard (default) | exhaustive (~15-25 scenarios)",
|
|
12203
14467
|
"standard"
|
|
@@ -12234,7 +14498,12 @@ function buildProgram() {
|
|
|
12234
14498
|
}
|
|
12235
14499
|
process.exit(result.exitCode);
|
|
12236
14500
|
});
|
|
12237
|
-
program.command("run [pattern]").description(
|
|
14501
|
+
program.command("run [pattern]").description(
|
|
14502
|
+
"EXECUTE existing Playwright tests, classify failures, auto-heal the first broken locator inline, file defects"
|
|
14503
|
+
).option("--no-classify", "skip LLM classification of failures").option("--no-heal", "skip auto-healing on locator failures").option("--no-heal-verify", "skip the re-run that confirms an applied heal").option("--no-defect", "skip defect creation for real failures").option("--retry <n>", "retry failed tests N times before classifying", (v) => parseInt(v, 10)).option("--workers <n>", "Playwright workers count", (v) => parseInt(v, 10)).option("--headed", "run browser headed").option("--xray [level]", "capture network traffic (headers|summary|full; default summary)").option(
|
|
14504
|
+
"--xray-strict",
|
|
14505
|
+
"fail (exit 6) when UI-green tests have a backend divergence (implies --xray)"
|
|
14506
|
+
).option("--workflow <id>", "attach to an existing workflow id").action(async (pattern, cmdOpts, command) => {
|
|
12238
14507
|
const globalOpts = command.parent?.opts() ?? {};
|
|
12239
14508
|
const opts = {
|
|
12240
14509
|
// Commander turns --no-X flags into `X: false` (default true);
|
|
@@ -12252,6 +14521,18 @@ function buildProgram() {
|
|
|
12252
14521
|
if (typeof cmdOpts["retry"] === "number") opts.retry = cmdOpts["retry"];
|
|
12253
14522
|
if (typeof cmdOpts["workers"] === "number") opts.workers = cmdOpts["workers"];
|
|
12254
14523
|
if (typeof cmdOpts["workflow"] === "string") opts.workflowId = cmdOpts["workflow"];
|
|
14524
|
+
const xrayVal = cmdOpts["xray"];
|
|
14525
|
+
if (xrayVal === true) opts.xray = true;
|
|
14526
|
+
else if (xrayVal === "headers" || xrayVal === "summary" || xrayVal === "full") {
|
|
14527
|
+
opts.xray = xrayVal;
|
|
14528
|
+
} else if (typeof xrayVal === "string") {
|
|
14529
|
+
process.stderr.write(
|
|
14530
|
+
`\u2717 qaios.run.bad_xray_level: --xray must be headers|summary|full (got "${xrayVal}")
|
|
14531
|
+
`
|
|
14532
|
+
);
|
|
14533
|
+
process.exit(1);
|
|
14534
|
+
}
|
|
14535
|
+
if (cmdOpts["xrayStrict"] === true) opts.xrayStrict = true;
|
|
12255
14536
|
if (globalOpts["mode"] === "LITE" || globalOpts["mode"] === "FULL" || globalOpts["mode"] === "TRUST") {
|
|
12256
14537
|
opts.mode = globalOpts["mode"];
|
|
12257
14538
|
}
|
|
@@ -12262,7 +14543,9 @@ function buildProgram() {
|
|
|
12262
14543
|
}
|
|
12263
14544
|
process.exit(result.exitCode);
|
|
12264
14545
|
});
|
|
12265
|
-
program.command("fix [target]").description(
|
|
14546
|
+
program.command("fix [target]").description(
|
|
14547
|
+
"Repair ONE specific broken test you select (targeted heal + re-verify) \u2014 vs `run` which auto-heals inline"
|
|
14548
|
+
).option("--auto", "auto-apply fixes regardless of mode (TRUST behavior)").option("--dry-run", "propose fix, show patch, don't apply").option("--last-failure", "fix the test from the most recent failed run").option("--scenario <id>", "fix a specific scenario by ID").option(
|
|
12266
14549
|
"--keep-failed-patch",
|
|
12267
14550
|
"keep the patch on disk even if the re-run still fails (default: roll back)"
|
|
12268
14551
|
).action(async (target, cmdOpts, command) => {
|
|
@@ -12305,7 +14588,9 @@ function buildProgram() {
|
|
|
12305
14588
|
}
|
|
12306
14589
|
process.exit(result.exitCode);
|
|
12307
14590
|
});
|
|
12308
|
-
snapshot.command("review").description(
|
|
14591
|
+
snapshot.command("review").description(
|
|
14592
|
+
"Approve/reject pending VISUAL DIFFs (screenshot regressions) \u2014 not workflow gates (see top-level `review`)"
|
|
14593
|
+
).option("--workflow <id>", "restrict to one workflow id").option(
|
|
12309
14594
|
"--auto-apply",
|
|
12310
14595
|
"non-interactive: apply high-confidence classifier suggestions; leave uncertain diffs pending"
|
|
12311
14596
|
).option(
|
|
@@ -12497,7 +14782,9 @@ function buildProgram() {
|
|
|
12497
14782
|
process.exit(result.exitCode);
|
|
12498
14783
|
});
|
|
12499
14784
|
}
|
|
12500
|
-
configGroup.command("get [key]").description(
|
|
14785
|
+
configGroup.command("get [key]").description(
|
|
14786
|
+
"Print the RAW on-disk value (no defaults; one key or whole file) \u2014 vs `show` which applies defaults"
|
|
14787
|
+
).action((key, cmdOpts, command) => {
|
|
12501
14788
|
void runConfigVerb("get", cmdOpts, command, key !== void 0 ? { key } : {});
|
|
12502
14789
|
});
|
|
12503
14790
|
configGroup.command("set <key> <value>").description("Set a value (validates against schema before writing)").action((key, value, cmdOpts, command) => {
|
|
@@ -12509,7 +14796,9 @@ function buildProgram() {
|
|
|
12509
14796
|
configGroup.command("show").description("Print resolved config (defaults applied)").action((cmdOpts, command) => {
|
|
12510
14797
|
void runConfigVerb("show", cmdOpts, command, {});
|
|
12511
14798
|
});
|
|
12512
|
-
program.command("review").description(
|
|
14799
|
+
program.command("review").description(
|
|
14800
|
+
"Approve/reject pending workflow GATES (design/writing review pauses), then resume the workflow \u2014 not visual diffs (see `snapshot review`)"
|
|
14801
|
+
).option("--workflow <id>", "restrict to one workflow id").option("--auto-approve", "approve all pending gates without prompting (CI-friendly)").action(async (cmdOpts, command) => {
|
|
12513
14802
|
const globalOpts = command.parent?.opts() ?? {};
|
|
12514
14803
|
const workflowFlag = typeof cmdOpts["workflow"] === "string" ? cmdOpts["workflow"] : typeof globalOpts["workflow"] === "string" ? globalOpts["workflow"] : void 0;
|
|
12515
14804
|
const opts = {
|
|
@@ -12526,7 +14815,9 @@ function buildProgram() {
|
|
|
12526
14815
|
}
|
|
12527
14816
|
process.exit(result.exitCode);
|
|
12528
14817
|
});
|
|
12529
|
-
program.command("history").description(
|
|
14818
|
+
program.command("history").description(
|
|
14819
|
+
"View PAST workflows + audit log (read-only; list / detail / --verify chain / --export)"
|
|
14820
|
+
).option("--workflow <id>", "show single workflow detail").option("--last", "shortcut for --workflow <most-recent-id>").option("--limit <n>", "limit row count (default 20)", (v) => parseInt(v, 10)).option("--since <duration>", "relative time (e.g. 2h, 1d, 30m)").option(
|
|
12530
14821
|
"--status <status>",
|
|
12531
14822
|
"filter by workflow status: succeeded | failed | blocked | cancelled | running"
|
|
12532
14823
|
).option("--verify", "verify audit log hash chain integrity").option("--export <path>", "export full audit log as JSONL").option("--suggestions", "show pending QAIOS.md suggestions").action(async (cmdOpts, command) => {
|
|
@@ -12553,6 +14844,53 @@ function buildProgram() {
|
|
|
12553
14844
|
}
|
|
12554
14845
|
process.exit(result.exitCode);
|
|
12555
14846
|
});
|
|
14847
|
+
const xrayGroup = program.command("xray").description(
|
|
14848
|
+
"Inspect network captured by `run --xray` (read-only: show / diff / baseline / volatility)"
|
|
14849
|
+
);
|
|
14850
|
+
function dispatchXray(verb, command, extras = {}) {
|
|
14851
|
+
const globalOpts = command.parent?.parent?.opts?.() ?? command.parent?.opts?.() ?? {};
|
|
14852
|
+
const opts = {
|
|
14853
|
+
verb,
|
|
14854
|
+
json: Boolean(globalOpts["json"]),
|
|
14855
|
+
quiet: Boolean(globalOpts["quiet"])
|
|
14856
|
+
};
|
|
14857
|
+
if (extras.target !== void 0) opts.target = extras.target;
|
|
14858
|
+
if (extras.target2 !== void 0) opts.target2 = extras.target2;
|
|
14859
|
+
if (extras.sub !== void 0) opts.sub = extras.sub;
|
|
14860
|
+
return runXray(opts).then((result) => {
|
|
14861
|
+
if (result.error) {
|
|
14862
|
+
process.stderr.write(`\u2717 ${result.error.code}: ${result.error.message}
|
|
14863
|
+
`);
|
|
14864
|
+
}
|
|
14865
|
+
process.exit(result.exitCode);
|
|
14866
|
+
});
|
|
14867
|
+
}
|
|
14868
|
+
xrayGroup.command("show [target]").description("Show the per-step causal graph of a run or workflow").action((target, _cmdOpts, command) => {
|
|
14869
|
+
void dispatchXray("show", command, target !== void 0 ? { target } : {});
|
|
14870
|
+
});
|
|
14871
|
+
xrayGroup.command("diff <runA> [runB]").description("Diff a run against another run, or against its baseline").action((runA, runB, _cmdOpts, command) => {
|
|
14872
|
+
void dispatchXray("diff", command, {
|
|
14873
|
+
target: runA,
|
|
14874
|
+
...runB !== void 0 ? { target2: runB } : {}
|
|
14875
|
+
});
|
|
14876
|
+
});
|
|
14877
|
+
const baselineGroup = xrayGroup.command("baseline").description("Pin or clear a capture baseline");
|
|
14878
|
+
baselineGroup.command("set <runId>").description("Pin a run as the baseline for its (test, browser)").action((runId, _cmdOpts, command) => {
|
|
14879
|
+
void dispatchXray("baseline", command, { sub: "set", target: runId });
|
|
14880
|
+
});
|
|
14881
|
+
baselineGroup.command("clear <runId>").description("Clear a (test, browser)'s pinned baseline").action((runId, _cmdOpts, command) => {
|
|
14882
|
+
void dispatchXray("baseline", command, { sub: "clear", target: runId });
|
|
14883
|
+
});
|
|
14884
|
+
const volatilityGroup = xrayGroup.command("volatility").description("Show or reset learned field volatility");
|
|
14885
|
+
volatilityGroup.command("show <testId>").description("Show the learned volatility contract for a test").action((testId, _cmdOpts, command) => {
|
|
14886
|
+
void dispatchXray("volatility", command, { sub: "show", target: testId });
|
|
14887
|
+
});
|
|
14888
|
+
volatilityGroup.command("reset [testId]").description("Reset learned volatility (all tests, or one)").action((testId, _cmdOpts, command) => {
|
|
14889
|
+
void dispatchXray("volatility", command, {
|
|
14890
|
+
sub: "reset",
|
|
14891
|
+
...testId !== void 0 ? { target: testId } : {}
|
|
14892
|
+
});
|
|
14893
|
+
});
|
|
12556
14894
|
return program;
|
|
12557
14895
|
}
|
|
12558
14896
|
function formatDoctor(checks) {
|
|
@@ -12583,11 +14921,11 @@ var invokedAsScript = (() => {
|
|
|
12583
14921
|
try {
|
|
12584
14922
|
return realpathSync(p);
|
|
12585
14923
|
} catch {
|
|
12586
|
-
return
|
|
14924
|
+
return path15.resolve(p);
|
|
12587
14925
|
}
|
|
12588
14926
|
};
|
|
12589
14927
|
if (realOf(argv1) === realOf(thisFile)) return true;
|
|
12590
|
-
const invokedName =
|
|
14928
|
+
const invokedName = path15.basename(argv1).replace(/\.(js|mjs|cjs)$/, "");
|
|
12591
14929
|
return invokedName === "qaios";
|
|
12592
14930
|
})();
|
|
12593
14931
|
if (invokedAsScript) {
|