@piwitests/reporter 0.22.1 → 0.24.0
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/README.md +20 -0
- package/dist/cli/index.js +469 -83
- package/dist/global-setup-module.js +9 -1
- package/dist/index.d.ts +110 -2
- package/dist/index.js +885 -6
- package/dist/internal/capture/attachments.d.ts +2 -0
- package/dist/internal/capture/attachments.js +3 -1
- package/dist/internal/capture/capture-fixtures.js +3 -1
- package/package.json +3 -1
package/dist/index.js
CHANGED
|
@@ -33,7 +33,9 @@ __export(index_exports, {
|
|
|
33
33
|
PiwiDashboardReporter: () => PiwiDashboardReporter,
|
|
34
34
|
createGlobalSetup: () => createGlobalSetup,
|
|
35
35
|
default: () => index_default,
|
|
36
|
+
extendPiwiAi: () => extendPiwiAi,
|
|
36
37
|
extendPiwiFixtures: () => extendPiwiFixtures,
|
|
38
|
+
piwiAiFixtures: () => piwiAiFixtures,
|
|
37
39
|
piwiFixtures: () => piwiFixtures,
|
|
38
40
|
wrapConfig: () => wrapConfig
|
|
39
41
|
});
|
|
@@ -103,7 +105,15 @@ var PIWI_ENV_KEYS = {
|
|
|
103
105
|
captureServerTraces: "PIWI_CAPTURE_SERVER_TRACES",
|
|
104
106
|
inspectOnFailure: "PIWI_INSPECT_ON_FAIL",
|
|
105
107
|
pickLocatorOnFailure: "PIWI_PICK_LOCATOR_ON_FAIL",
|
|
106
|
-
outputFile: "PIWI_OUTPUT_FILE"
|
|
108
|
+
outputFile: "PIWI_OUTPUT_FILE",
|
|
109
|
+
aiMode: "PIWI_AI",
|
|
110
|
+
aiDir: "PIWI_AI_DIR",
|
|
111
|
+
aiOnMiss: "PIWI_AI_ON_MISS",
|
|
112
|
+
aiMaxSteps: "PIWI_AI_MAX_FLOW_STEPS",
|
|
113
|
+
aiMaxSnapshotChars: "PIWI_AI_MAX_SNAPSHOT_CHARS",
|
|
114
|
+
aiOptionalProbeTimeout: "PIWI_AI_OPTIONAL_PROBE_TIMEOUT",
|
|
115
|
+
aiResponseWaitTimeout: "PIWI_AI_RESPONSE_WAIT_TIMEOUT",
|
|
116
|
+
aiScreenshotFallback: "PIWI_AI_SCREENSHOT_FALLBACK"
|
|
107
117
|
};
|
|
108
118
|
var PIWI_DESKTOP_CONFIG_ENV = "PIWI_DESKTOP_CONFIG";
|
|
109
119
|
function readBool(val) {
|
|
@@ -186,6 +196,18 @@ function applyOptionsToEnv(options) {
|
|
|
186
196
|
if (options.inspectOnFailure !== void 0) env[PIWI_ENV_KEYS.inspectOnFailure] = String(options.inspectOnFailure);
|
|
187
197
|
if (options.pickLocatorOnFailure !== void 0)
|
|
188
198
|
env[PIWI_ENV_KEYS.pickLocatorOnFailure] = String(options.pickLocatorOnFailure);
|
|
199
|
+
if (options.ai?.mode !== void 0) env[PIWI_ENV_KEYS.aiMode] = options.ai.mode;
|
|
200
|
+
if (options.ai?.dir !== void 0) env[PIWI_ENV_KEYS.aiDir] = options.ai.dir;
|
|
201
|
+
if (options.ai?.onMiss !== void 0) env[PIWI_ENV_KEYS.aiOnMiss] = options.ai.onMiss;
|
|
202
|
+
if (options.ai?.maxSteps !== void 0) env[PIWI_ENV_KEYS.aiMaxSteps] = String(options.ai.maxSteps);
|
|
203
|
+
if (options.ai?.maxSnapshotChars !== void 0)
|
|
204
|
+
env[PIWI_ENV_KEYS.aiMaxSnapshotChars] = String(options.ai.maxSnapshotChars);
|
|
205
|
+
if (options.ai?.optionalProbeTimeout !== void 0)
|
|
206
|
+
env[PIWI_ENV_KEYS.aiOptionalProbeTimeout] = String(options.ai.optionalProbeTimeout);
|
|
207
|
+
if (options.ai?.responseWaitTimeout !== void 0)
|
|
208
|
+
env[PIWI_ENV_KEYS.aiResponseWaitTimeout] = String(options.ai.responseWaitTimeout);
|
|
209
|
+
if (options.ai?.screenshotFallback !== void 0)
|
|
210
|
+
env[PIWI_ENV_KEYS.aiScreenshotFallback] = String(options.ai.screenshotFallback);
|
|
189
211
|
}
|
|
190
212
|
|
|
191
213
|
// src/internal/transport/http-client.ts
|
|
@@ -437,6 +459,7 @@ function toWireTestCase(tc) {
|
|
|
437
459
|
networkRequests: rest.networkRequests || null,
|
|
438
460
|
webVitals: rest.webVitals || null,
|
|
439
461
|
pageState: rest.pageState || null,
|
|
462
|
+
aiUsage: rest.aiUsage || null,
|
|
440
463
|
consoleLogs: rest.consoleLogs || null,
|
|
441
464
|
ariaSnapshot: rest.ariaSnapshot || null,
|
|
442
465
|
testSource: rest.testSource || null,
|
|
@@ -836,7 +859,9 @@ var ATTACHMENT_NAMES = {
|
|
|
836
859
|
webVitals: "piwi-web-vitals",
|
|
837
860
|
locatorSuggestion: "piwi-locator-suggestion",
|
|
838
861
|
pageState: "piwi-page-state",
|
|
839
|
-
userPick: "piwi-user-pick"
|
|
862
|
+
userPick: "piwi-user-pick",
|
|
863
|
+
aiUsage: "piwi-ai-usage",
|
|
864
|
+
aiMeta: "piwi-ai-meta"
|
|
840
865
|
};
|
|
841
866
|
var INTERNAL_ATTACHMENT_NAMES = new Set(Object.values(ATTACHMENT_NAMES));
|
|
842
867
|
var LOCATOR_SUGGESTION_ANNOTATION = ATTACHMENT_NAMES.locatorSuggestion;
|
|
@@ -945,6 +970,13 @@ var FileHandler = class {
|
|
|
945
970
|
} catch {
|
|
946
971
|
}
|
|
947
972
|
}
|
|
973
|
+
const aiUsage = find(ATTACHMENT_NAMES.aiUsage);
|
|
974
|
+
if (aiUsage?.body) {
|
|
975
|
+
try {
|
|
976
|
+
testCase.aiUsage = JSON.parse(aiUsage.body.toString());
|
|
977
|
+
} catch {
|
|
978
|
+
}
|
|
979
|
+
}
|
|
948
980
|
}
|
|
949
981
|
/** Compute SHA-256 hash and size for a single test case's trace file. Returns `null` when the case has no trace on disk. */
|
|
950
982
|
async computeSingleTraceHash(testCase) {
|
|
@@ -1772,10 +1804,10 @@ function readSourceSnippet(file, declLine, context, failingLine) {
|
|
|
1772
1804
|
const hasFailingLine = failingLine != null;
|
|
1773
1805
|
const isFailing = hasFailingLine && lineNum === failingLine;
|
|
1774
1806
|
const isDecl = lineNum === declLine && !isFailing;
|
|
1775
|
-
let
|
|
1776
|
-
if (isFailing)
|
|
1777
|
-
else if (isDecl)
|
|
1778
|
-
return `${
|
|
1807
|
+
let marker2 = " ";
|
|
1808
|
+
if (isFailing) marker2 = "> ";
|
|
1809
|
+
else if (isDecl) marker2 = hasFailingLine ? "* " : "> ";
|
|
1810
|
+
return `${marker2}${String(lineNum).padStart(4)} | ${l}`;
|
|
1779
1811
|
}).join("\n");
|
|
1780
1812
|
} catch {
|
|
1781
1813
|
return null;
|
|
@@ -4132,8 +4164,30 @@ function mergeCandidates(base, extra) {
|
|
|
4132
4164
|
var path15 = __toESM(require("path"));
|
|
4133
4165
|
|
|
4134
4166
|
// ../core/src/locator-fingerprint.ts
|
|
4167
|
+
var ELEMENT_MATCH_SCORES = { role: 60, text: 55, label: 50 };
|
|
4135
4168
|
var PRESENT_SIMILARITY = 0.8;
|
|
4136
4169
|
var MATCH_SIMILARITY = 0.2;
|
|
4170
|
+
var TEXT_CONTENT_ROLES = /* @__PURE__ */ new Set([
|
|
4171
|
+
"button",
|
|
4172
|
+
"link",
|
|
4173
|
+
"heading",
|
|
4174
|
+
"menuitem",
|
|
4175
|
+
"menuitemcheckbox",
|
|
4176
|
+
"menuitemradio",
|
|
4177
|
+
"tab",
|
|
4178
|
+
"option",
|
|
4179
|
+
"cell",
|
|
4180
|
+
"columnheader",
|
|
4181
|
+
"rowheader",
|
|
4182
|
+
"gridcell",
|
|
4183
|
+
"treeitem",
|
|
4184
|
+
"listitem",
|
|
4185
|
+
"checkbox",
|
|
4186
|
+
"radio",
|
|
4187
|
+
"switch"
|
|
4188
|
+
]);
|
|
4189
|
+
var FORM_FIELD_ROLES = /* @__PURE__ */ new Set(["textbox", "combobox", "searchbox", "spinbutton", "slider"]);
|
|
4190
|
+
var escapeQuote = (s) => s.replace(/\\/g, "\\\\").replace(/'/g, "\\'");
|
|
4137
4191
|
function parseAriaCandidates(ariaSnapshot) {
|
|
4138
4192
|
if (!ariaSnapshot) return [];
|
|
4139
4193
|
const out = [];
|
|
@@ -4196,6 +4250,40 @@ function matchRenamedElement(fp, candidates) {
|
|
|
4196
4250
|
}
|
|
4197
4251
|
return null;
|
|
4198
4252
|
}
|
|
4253
|
+
function freshLocatorsFromCandidate(c) {
|
|
4254
|
+
const out = [];
|
|
4255
|
+
const name = c.name;
|
|
4256
|
+
if (!name) return out;
|
|
4257
|
+
out.push(
|
|
4258
|
+
c.level != null ? {
|
|
4259
|
+
locator: `getByRole('${escapeQuote(c.role)}', { name: '${escapeQuote(name)}', level: ${c.level} })`,
|
|
4260
|
+
method: "getByRole",
|
|
4261
|
+
args: { role: c.role, name, level: c.level },
|
|
4262
|
+
score: ELEMENT_MATCH_SCORES.role
|
|
4263
|
+
} : {
|
|
4264
|
+
locator: `getByRole('${escapeQuote(c.role)}', { name: '${escapeQuote(name)}' })`,
|
|
4265
|
+
method: "getByRole",
|
|
4266
|
+
args: { role: c.role, name },
|
|
4267
|
+
score: ELEMENT_MATCH_SCORES.role
|
|
4268
|
+
}
|
|
4269
|
+
);
|
|
4270
|
+
if (TEXT_CONTENT_ROLES.has(c.role)) {
|
|
4271
|
+
out.push({
|
|
4272
|
+
locator: `getByText('${escapeQuote(name)}')`,
|
|
4273
|
+
method: "getByText",
|
|
4274
|
+
args: { text: name },
|
|
4275
|
+
score: ELEMENT_MATCH_SCORES.text
|
|
4276
|
+
});
|
|
4277
|
+
} else if (FORM_FIELD_ROLES.has(c.role)) {
|
|
4278
|
+
out.push({
|
|
4279
|
+
locator: `getByLabel('${escapeQuote(name)}')`,
|
|
4280
|
+
method: "getByLabel",
|
|
4281
|
+
args: { label: name },
|
|
4282
|
+
score: ELEMENT_MATCH_SCORES.label
|
|
4283
|
+
});
|
|
4284
|
+
}
|
|
4285
|
+
return out;
|
|
4286
|
+
}
|
|
4199
4287
|
|
|
4200
4288
|
// ../core/src/locator-methods.ts
|
|
4201
4289
|
var LOCATOR_BUILDER_METHODS = [
|
|
@@ -5392,13 +5480,804 @@ function extendPiwiFixtures(test) {
|
|
|
5392
5480
|
return test.extend(piwiFixtures);
|
|
5393
5481
|
}
|
|
5394
5482
|
|
|
5483
|
+
// src/internal/ai/ai-fixtures.ts
|
|
5484
|
+
var fs15 = __toESM(require("fs"));
|
|
5485
|
+
var path19 = __toESM(require("path"));
|
|
5486
|
+
var import_test = require("@playwright/test");
|
|
5487
|
+
|
|
5488
|
+
// src/internal/ai/artifact.ts
|
|
5489
|
+
var fs14 = __toESM(require("fs"));
|
|
5490
|
+
var path17 = __toESM(require("path"));
|
|
5491
|
+
var ARTIFACT_VERSION = 1;
|
|
5492
|
+
var LOCATOR_METHOD_SET = new Set(LOCATOR_METHODS);
|
|
5493
|
+
var ACTION_METHOD_SET2 = new Set(ACTION_METHODS);
|
|
5494
|
+
var POSTCONDITION_ASSERTS = /* @__PURE__ */ new Set([
|
|
5495
|
+
"visible",
|
|
5496
|
+
"hidden",
|
|
5497
|
+
"attached",
|
|
5498
|
+
"url"
|
|
5499
|
+
]);
|
|
5500
|
+
function sortDeep(value) {
|
|
5501
|
+
if (Array.isArray(value)) return value.map(sortDeep);
|
|
5502
|
+
if (value !== null && typeof value === "object") {
|
|
5503
|
+
const out = {};
|
|
5504
|
+
for (const key of Object.keys(value).sort()) {
|
|
5505
|
+
const v = value[key];
|
|
5506
|
+
if (v !== void 0) out[key] = sortDeep(v);
|
|
5507
|
+
}
|
|
5508
|
+
return out;
|
|
5509
|
+
}
|
|
5510
|
+
return value;
|
|
5511
|
+
}
|
|
5512
|
+
function serializeEntry(entry) {
|
|
5513
|
+
return `${JSON.stringify(sortDeep(entry), null, 2)}
|
|
5514
|
+
`;
|
|
5515
|
+
}
|
|
5516
|
+
var ArtifactError = class extends Error {
|
|
5517
|
+
};
|
|
5518
|
+
function assert(condition, message) {
|
|
5519
|
+
if (!condition) throw new ArtifactError(message);
|
|
5520
|
+
}
|
|
5521
|
+
function validateStructuredLocator(value, where) {
|
|
5522
|
+
assert(value !== null && typeof value === "object", `${where}: locator must be an object`);
|
|
5523
|
+
const loc = value;
|
|
5524
|
+
assert(typeof loc.method === "string", `${where}: locator.method must be a string`);
|
|
5525
|
+
assert(LOCATOR_METHOD_SET.has(loc.method), `${where}: locator method "${String(loc.method)}" is not allowlisted`);
|
|
5526
|
+
assert(Array.isArray(loc.args), `${where}: locator.args must be an array`);
|
|
5527
|
+
if (loc.chain !== void 0) {
|
|
5528
|
+
assert(Array.isArray(loc.chain), `${where}: locator.chain must be an array`);
|
|
5529
|
+
loc.chain.forEach((child, i) => validateStructuredLocator(child, `${where}.chain[${i}]`));
|
|
5530
|
+
}
|
|
5531
|
+
}
|
|
5532
|
+
function validateRunStep(value, where) {
|
|
5533
|
+
assert(value !== null && typeof value === "object", `${where}: step must be an object`);
|
|
5534
|
+
const step = value;
|
|
5535
|
+
validateStructuredLocator(step.locator, `${where}.locator`);
|
|
5536
|
+
assert(typeof step.action === "string", `${where}: action must be a string`);
|
|
5537
|
+
assert(ACTION_METHOD_SET2.has(step.action), `${where}: action "${String(step.action)}" is not allowlisted`);
|
|
5538
|
+
if (step.value !== void 0) assert(typeof step.value === "string", `${where}: value must be a string`);
|
|
5539
|
+
if (step.optional !== void 0) assert(typeof step.optional === "boolean", `${where}: optional must be a boolean`);
|
|
5540
|
+
if (step.waitForResponse !== void 0) {
|
|
5541
|
+
assert(typeof step.waitForResponse === "string", `${where}: waitForResponse must be a string`);
|
|
5542
|
+
}
|
|
5543
|
+
}
|
|
5544
|
+
function validatePostcondition(value) {
|
|
5545
|
+
assert(value !== null && typeof value === "object", "postcondition must be an object");
|
|
5546
|
+
const post = value;
|
|
5547
|
+
assert(
|
|
5548
|
+
POSTCONDITION_ASSERTS.has(post.assert),
|
|
5549
|
+
`postcondition.assert "${String(post.assert)}" is not supported`
|
|
5550
|
+
);
|
|
5551
|
+
if (post.assert === "url") {
|
|
5552
|
+
assert(typeof post.url === "string", "postcondition.url must be a string for a url assert");
|
|
5553
|
+
} else {
|
|
5554
|
+
validateStructuredLocator(post.locator, "postcondition.locator");
|
|
5555
|
+
}
|
|
5556
|
+
}
|
|
5557
|
+
function parseEntry(text) {
|
|
5558
|
+
let raw;
|
|
5559
|
+
try {
|
|
5560
|
+
raw = JSON.parse(text);
|
|
5561
|
+
} catch (error) {
|
|
5562
|
+
throw new ArtifactError(`entry is not valid JSON: ${error.message}`);
|
|
5563
|
+
}
|
|
5564
|
+
assert(raw !== null && typeof raw === "object", "entry must be an object");
|
|
5565
|
+
const obj = raw;
|
|
5566
|
+
assert(obj.version === ARTIFACT_VERSION, `unsupported entry version ${String(obj.version)}`);
|
|
5567
|
+
assert(typeof obj.template === "string", "entry.template must be a string");
|
|
5568
|
+
if (obj.kind === "locator") {
|
|
5569
|
+
validateStructuredLocator(obj.locator, "entry.locator");
|
|
5570
|
+
return obj;
|
|
5571
|
+
}
|
|
5572
|
+
if (obj.kind === "run") {
|
|
5573
|
+
assert(Array.isArray(obj.steps), "entry.steps must be an array");
|
|
5574
|
+
obj.steps.forEach((step, i) => validateRunStep(step, `entry.steps[${i}]`));
|
|
5575
|
+
validatePostcondition(obj.postcondition);
|
|
5576
|
+
return obj;
|
|
5577
|
+
}
|
|
5578
|
+
throw new ArtifactError(`unsupported entry kind "${String(obj.kind)}"`);
|
|
5579
|
+
}
|
|
5580
|
+
function readEntry(file) {
|
|
5581
|
+
let text;
|
|
5582
|
+
try {
|
|
5583
|
+
text = fs14.readFileSync(file, "utf8");
|
|
5584
|
+
} catch (error) {
|
|
5585
|
+
if (error.code === "ENOENT") return null;
|
|
5586
|
+
throw error;
|
|
5587
|
+
}
|
|
5588
|
+
return parseEntry(text);
|
|
5589
|
+
}
|
|
5590
|
+
function writeEntry(file, entry) {
|
|
5591
|
+
const canonical = serializeEntry(entry);
|
|
5592
|
+
return withEntryLock(file, () => {
|
|
5593
|
+
if (fs14.existsSync(file) && fs14.readFileSync(file, "utf8") === canonical) return { written: false };
|
|
5594
|
+
fs14.mkdirSync(path17.dirname(file), { recursive: true });
|
|
5595
|
+
const tmp = `${file}.${process.pid}.tmp`;
|
|
5596
|
+
fs14.writeFileSync(tmp, canonical);
|
|
5597
|
+
fs14.renameSync(tmp, file);
|
|
5598
|
+
return { written: true };
|
|
5599
|
+
});
|
|
5600
|
+
}
|
|
5601
|
+
function withEntryLock(file, fn) {
|
|
5602
|
+
const lock = `${file}.lock`;
|
|
5603
|
+
fs14.mkdirSync(path17.dirname(file), { recursive: true });
|
|
5604
|
+
const deadline = Date.now() + 5e3;
|
|
5605
|
+
for (; ; ) {
|
|
5606
|
+
try {
|
|
5607
|
+
const fd = fs14.openSync(lock, "wx");
|
|
5608
|
+
fs14.closeSync(fd);
|
|
5609
|
+
break;
|
|
5610
|
+
} catch (error) {
|
|
5611
|
+
if (error.code !== "EEXIST") throw error;
|
|
5612
|
+
if (Date.now() > deadline) {
|
|
5613
|
+
try {
|
|
5614
|
+
fs14.unlinkSync(lock);
|
|
5615
|
+
} catch {
|
|
5616
|
+
}
|
|
5617
|
+
}
|
|
5618
|
+
}
|
|
5619
|
+
}
|
|
5620
|
+
try {
|
|
5621
|
+
return fn();
|
|
5622
|
+
} finally {
|
|
5623
|
+
try {
|
|
5624
|
+
fs14.unlinkSync(lock);
|
|
5625
|
+
} catch {
|
|
5626
|
+
}
|
|
5627
|
+
}
|
|
5628
|
+
}
|
|
5629
|
+
|
|
5630
|
+
// src/internal/ai/keys.ts
|
|
5631
|
+
var crypto3 = __toESM(require("crypto"));
|
|
5632
|
+
var path18 = __toESM(require("path"));
|
|
5633
|
+
var DEFAULT_AI_DIR = "__piwi__";
|
|
5634
|
+
function normalizeTemplate(template) {
|
|
5635
|
+
return template.trim().replace(/\s+/g, " ").toLowerCase();
|
|
5636
|
+
}
|
|
5637
|
+
function hashTemplate(normalized, ordinal = 0) {
|
|
5638
|
+
const input = ordinal > 0 ? `${normalized}#${ordinal}` : normalized;
|
|
5639
|
+
return crypto3.createHash("sha256").update(input).digest("hex").slice(0, 8);
|
|
5640
|
+
}
|
|
5641
|
+
function slug(text, maxLength = 40) {
|
|
5642
|
+
const base = text.trim().toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
|
|
5643
|
+
return (base || "x").slice(0, maxLength).replace(/-+$/g, "") || "x";
|
|
5644
|
+
}
|
|
5645
|
+
function entryDir(specFile, dir = DEFAULT_AI_DIR) {
|
|
5646
|
+
return path18.join(path18.dirname(specFile), dir, path18.basename(specFile));
|
|
5647
|
+
}
|
|
5648
|
+
function entryPath(params) {
|
|
5649
|
+
const basis = `${normalizeTemplate(params.testTitle)}::${normalizeTemplate(params.template)}`;
|
|
5650
|
+
const hash = hashTemplate(basis, params.ordinal ?? 0);
|
|
5651
|
+
const name = `${slug(params.testTitle)}.${slug(params.template)}.${hash}.json`;
|
|
5652
|
+
return path18.join(entryDir(params.specFile, params.dir), name);
|
|
5653
|
+
}
|
|
5654
|
+
function parseLocation(location) {
|
|
5655
|
+
const match = /:(\d+):(\d+)$/.exec(location);
|
|
5656
|
+
if (!match) return null;
|
|
5657
|
+
return { line: Number(match[1]), col: Number(match[2]) };
|
|
5658
|
+
}
|
|
5659
|
+
function orderByPosition(locations) {
|
|
5660
|
+
return [...new Set(locations)].sort((a, b) => {
|
|
5661
|
+
const pa = parseLocation(a);
|
|
5662
|
+
const pb = parseLocation(b);
|
|
5663
|
+
if (!pa || !pb) return a < b ? -1 : a > b ? 1 : 0;
|
|
5664
|
+
return pa.line - pb.line || pa.col - pb.col;
|
|
5665
|
+
});
|
|
5666
|
+
}
|
|
5667
|
+
function ordinalForLocation(sites, location) {
|
|
5668
|
+
const ordered = orderByPosition(sites);
|
|
5669
|
+
const index = ordered.indexOf(location);
|
|
5670
|
+
return index < 0 ? 0 : index;
|
|
5671
|
+
}
|
|
5672
|
+
function findTemplateSites(source, template) {
|
|
5673
|
+
const needle = template.trim();
|
|
5674
|
+
if (!needle) return [];
|
|
5675
|
+
const sites = [];
|
|
5676
|
+
const lines = source.split("\n");
|
|
5677
|
+
for (let i = 0; i < lines.length; i++) {
|
|
5678
|
+
let from = 0;
|
|
5679
|
+
for (; ; ) {
|
|
5680
|
+
const col = lines[i].indexOf(needle, from);
|
|
5681
|
+
if (col < 0) break;
|
|
5682
|
+
sites.push(`${i + 1}:${col + 1}`);
|
|
5683
|
+
from = col + needle.length;
|
|
5684
|
+
}
|
|
5685
|
+
}
|
|
5686
|
+
return sites;
|
|
5687
|
+
}
|
|
5688
|
+
|
|
5689
|
+
// src/internal/ai/params.ts
|
|
5690
|
+
var PLACEHOLDER = /\{([^{}]+)\}/g;
|
|
5691
|
+
var MARKER = /\{\{([^{}]+)\}\}/g;
|
|
5692
|
+
function extractPlaceholders(template) {
|
|
5693
|
+
const names = [];
|
|
5694
|
+
for (const match of template.matchAll(PLACEHOLDER)) {
|
|
5695
|
+
const name = match[1].trim();
|
|
5696
|
+
if (name && !names.includes(name)) names.push(name);
|
|
5697
|
+
}
|
|
5698
|
+
return names;
|
|
5699
|
+
}
|
|
5700
|
+
function marker(name) {
|
|
5701
|
+
return `{{${name}}}`;
|
|
5702
|
+
}
|
|
5703
|
+
function validateParams(template, params) {
|
|
5704
|
+
const placeholders = extractPlaceholders(template);
|
|
5705
|
+
if (placeholders.length === 0) return;
|
|
5706
|
+
const missing = placeholders.filter((name) => params?.[name] === void 0);
|
|
5707
|
+
if (missing.length > 0) {
|
|
5708
|
+
throw new Error(`piwi AI: template "${template}" is missing parameter(s): ${missing.join(", ")}`);
|
|
5709
|
+
}
|
|
5710
|
+
}
|
|
5711
|
+
function substituteMarkers(text, params) {
|
|
5712
|
+
return text.replace(MARKER, (whole, name) => {
|
|
5713
|
+
const value = params[name.trim()];
|
|
5714
|
+
return value === void 0 ? whole : value;
|
|
5715
|
+
});
|
|
5716
|
+
}
|
|
5717
|
+
function substituteArgs(args, params) {
|
|
5718
|
+
return args.map((arg) => substituteValue(arg, params));
|
|
5719
|
+
}
|
|
5720
|
+
function substituteValue(value, params) {
|
|
5721
|
+
if (typeof value === "string") return substituteMarkers(value, params);
|
|
5722
|
+
if (Array.isArray(value)) return value.map((v) => substituteValue(v, params));
|
|
5723
|
+
if (value !== null && typeof value === "object") {
|
|
5724
|
+
const out = {};
|
|
5725
|
+
for (const [key, v] of Object.entries(value)) out[key] = substituteValue(v, params);
|
|
5726
|
+
return out;
|
|
5727
|
+
}
|
|
5728
|
+
return value;
|
|
5729
|
+
}
|
|
5730
|
+
function maskValues(text, params) {
|
|
5731
|
+
const entries = Object.entries(params).filter(([, value]) => value.length > 0).sort((a, b) => b[1].length - a[1].length);
|
|
5732
|
+
let masked = text;
|
|
5733
|
+
for (const [name, value] of entries) {
|
|
5734
|
+
masked = masked.split(value).join(marker(name));
|
|
5735
|
+
}
|
|
5736
|
+
return masked;
|
|
5737
|
+
}
|
|
5738
|
+
function isParametric(template, compiledText) {
|
|
5739
|
+
return extractPlaceholders(template).every((name) => compiledText.includes(marker(name)));
|
|
5740
|
+
}
|
|
5741
|
+
|
|
5742
|
+
// src/internal/ai/interpreter.ts
|
|
5743
|
+
var LOCATOR_METHOD_SET2 = new Set(LOCATOR_METHODS);
|
|
5744
|
+
var ACTION_METHOD_SET3 = new Set(ACTION_METHODS);
|
|
5745
|
+
var StepDriftError = class extends Error {
|
|
5746
|
+
constructor(step, fingerprint) {
|
|
5747
|
+
super(
|
|
5748
|
+
`piwi AI: element drifted from its recorded identity (role="${fingerprint.role ?? ""}" name="${fingerprint.name ?? ""}") before ${describeLocator(step.locator)}.${step.action}() \u2014 the flow was not run to avoid corrupting page state`
|
|
5749
|
+
);
|
|
5750
|
+
this.step = step;
|
|
5751
|
+
this.fingerprint = fingerprint;
|
|
5752
|
+
this.name = "StepDriftError";
|
|
5753
|
+
}
|
|
5754
|
+
};
|
|
5755
|
+
var PostconditionError = class extends Error {
|
|
5756
|
+
constructor(post, cause) {
|
|
5757
|
+
super(`piwi AI: postcondition (${describePostcondition(post)}) not satisfied \u2014 ${cause}`);
|
|
5758
|
+
this.name = "PostconditionError";
|
|
5759
|
+
}
|
|
5760
|
+
};
|
|
5761
|
+
function describeLocator(loc) {
|
|
5762
|
+
const head = `${loc.method}(${loc.args.map((a) => JSON.stringify(a)).join(", ")})`;
|
|
5763
|
+
const tail = (loc.chain ?? []).map(describeLocator).join(".");
|
|
5764
|
+
return tail ? `${head}.${tail}` : head;
|
|
5765
|
+
}
|
|
5766
|
+
function renderSourceArg(arg) {
|
|
5767
|
+
if (typeof arg === "string") return `'${arg.replace(/\\/g, "\\\\").replace(/'/g, "\\'")}'`;
|
|
5768
|
+
if (arg === null || typeof arg === "number" || typeof arg === "boolean") return String(arg);
|
|
5769
|
+
if (Array.isArray(arg)) return `[${arg.map(renderSourceArg).join(", ")}]`;
|
|
5770
|
+
return `{ ${Object.entries(arg).map(([key, value]) => `${key}: ${renderSourceArg(value)}`).join(", ")} }`;
|
|
5771
|
+
}
|
|
5772
|
+
function locatorSource(loc) {
|
|
5773
|
+
const head = `${loc.method}(${loc.args.map(renderSourceArg).join(", ")})`;
|
|
5774
|
+
const tail = (loc.chain ?? []).map(locatorSource).join(".");
|
|
5775
|
+
return tail ? `${head}.${tail}` : head;
|
|
5776
|
+
}
|
|
5777
|
+
function describePostcondition(post) {
|
|
5778
|
+
return post.assert === "url" ? `url \u2192 ${post.url ?? ""}` : `${describeLocator(post.locator)} ${post.assert}`;
|
|
5779
|
+
}
|
|
5780
|
+
function buildLocator(root, structured, params = {}) {
|
|
5781
|
+
if (!LOCATOR_METHOD_SET2.has(structured.method)) {
|
|
5782
|
+
throw new Error(`piwi AI: locator method "${structured.method}" is not allowlisted`);
|
|
5783
|
+
}
|
|
5784
|
+
const args = substituteArgs(structured.args, params);
|
|
5785
|
+
let current = root[structured.method](...args);
|
|
5786
|
+
for (const child of structured.chain ?? []) {
|
|
5787
|
+
current = buildLocator(current, child, params);
|
|
5788
|
+
}
|
|
5789
|
+
return current;
|
|
5790
|
+
}
|
|
5791
|
+
async function probePresent(locator, timeout) {
|
|
5792
|
+
try {
|
|
5793
|
+
await locator.first().waitFor({ state: "visible", timeout });
|
|
5794
|
+
return true;
|
|
5795
|
+
} catch {
|
|
5796
|
+
return false;
|
|
5797
|
+
}
|
|
5798
|
+
}
|
|
5799
|
+
function checkFingerprintDrift(ariaSnapshot, fingerprint) {
|
|
5800
|
+
if (!ariaSnapshot) return "unknown";
|
|
5801
|
+
const candidates = parseAriaCandidates(ariaSnapshot);
|
|
5802
|
+
if (candidates.length === 0) return "unknown";
|
|
5803
|
+
return fingerprintPresent(fingerprint, candidates) ? "present" : "drifted";
|
|
5804
|
+
}
|
|
5805
|
+
async function performAction(page, locator, step, ctx) {
|
|
5806
|
+
if (!ACTION_METHOD_SET3.has(step.action)) throw new Error(`piwi AI: action "${step.action}" is not allowlisted`);
|
|
5807
|
+
const args = step.value === void 0 ? [] : [substituteMarkers(step.value, ctx.params)];
|
|
5808
|
+
const act = () => locator[step.action](...args);
|
|
5809
|
+
if (!step.waitForResponse) {
|
|
5810
|
+
await act();
|
|
5811
|
+
return;
|
|
5812
|
+
}
|
|
5813
|
+
const pattern = substituteMarkers(step.waitForResponse, ctx.params);
|
|
5814
|
+
const waitForResponse = ctx.responseWaitTimeout === void 0 ? page.waitForResponse(pattern) : page.waitForResponse(pattern, { timeout: ctx.responseWaitTimeout });
|
|
5815
|
+
await Promise.all([waitForResponse, act()]);
|
|
5816
|
+
}
|
|
5817
|
+
async function executeStep(step, ctx) {
|
|
5818
|
+
const body = async () => {
|
|
5819
|
+
const locator = buildLocator(ctx.page, step.locator, ctx.params);
|
|
5820
|
+
if (step.optional) {
|
|
5821
|
+
const present = await probePresent(locator, ctx.optionalProbeTimeout ?? 2e3);
|
|
5822
|
+
if (!present) return "skipped";
|
|
5823
|
+
}
|
|
5824
|
+
if (step.fingerprint && ctx.readAria) {
|
|
5825
|
+
const aria = await ctx.readAria(ctx.page);
|
|
5826
|
+
if (checkFingerprintDrift(aria, step.fingerprint) === "drifted") {
|
|
5827
|
+
throw new StepDriftError(step, step.fingerprint);
|
|
5828
|
+
}
|
|
5829
|
+
}
|
|
5830
|
+
await performAction(ctx.page, locator, step, ctx);
|
|
5831
|
+
return "ran";
|
|
5832
|
+
};
|
|
5833
|
+
const title = `${step.action}: ${describeLocator(step.locator)}${step.optional ? " (optional)" : ""}`;
|
|
5834
|
+
return ctx.step ? ctx.step(title, body) : body();
|
|
5835
|
+
}
|
|
5836
|
+
async function assertPostcondition(post, ctx) {
|
|
5837
|
+
const body = async () => {
|
|
5838
|
+
try {
|
|
5839
|
+
if (post.assert === "url") {
|
|
5840
|
+
await ctx.page.waitForURL(substituteMarkers(post.url ?? "", ctx.params));
|
|
5841
|
+
return;
|
|
5842
|
+
}
|
|
5843
|
+
const locator = buildLocator(ctx.page, post.locator, ctx.params);
|
|
5844
|
+
const state = post.assert === "hidden" ? "hidden" : post.assert === "attached" ? "attached" : "visible";
|
|
5845
|
+
await locator.first().waitFor({ state });
|
|
5846
|
+
} catch (error) {
|
|
5847
|
+
throw new PostconditionError(post, error.message);
|
|
5848
|
+
}
|
|
5849
|
+
};
|
|
5850
|
+
await (ctx.step ? ctx.step(`postcondition: ${describePostcondition(post)}`, body) : body());
|
|
5851
|
+
}
|
|
5852
|
+
async function executeRun(entry, ctx) {
|
|
5853
|
+
for (const step of entry.steps) {
|
|
5854
|
+
await executeStep(step, ctx);
|
|
5855
|
+
}
|
|
5856
|
+
await assertPostcondition(entry.postcondition, ctx);
|
|
5857
|
+
}
|
|
5858
|
+
|
|
5859
|
+
// src/internal/ai/compile.ts
|
|
5860
|
+
function hasAnchor(args) {
|
|
5861
|
+
return Object.keys(args).some((key) => key.startsWith("anchor"));
|
|
5862
|
+
}
|
|
5863
|
+
function positionalArgs(method, args) {
|
|
5864
|
+
switch (method) {
|
|
5865
|
+
case "getByTestId":
|
|
5866
|
+
return typeof args.testId === "string" ? [args.testId] : null;
|
|
5867
|
+
case "getByRole": {
|
|
5868
|
+
if (typeof args.role !== "string") return null;
|
|
5869
|
+
const options = {};
|
|
5870
|
+
if (typeof args.name === "string") options.name = args.name;
|
|
5871
|
+
if (typeof args.level === "number") options.level = args.level;
|
|
5872
|
+
return Object.keys(options).length > 0 ? [args.role, options] : [args.role];
|
|
5873
|
+
}
|
|
5874
|
+
case "getByLabel":
|
|
5875
|
+
return typeof args.label === "string" ? [args.label] : null;
|
|
5876
|
+
case "getByPlaceholder":
|
|
5877
|
+
return typeof args.placeholder === "string" ? [args.placeholder] : null;
|
|
5878
|
+
case "getByText":
|
|
5879
|
+
case "getByAltText":
|
|
5880
|
+
return typeof args.text === "string" ? [args.text] : null;
|
|
5881
|
+
case "getByTitle":
|
|
5882
|
+
return typeof args.title === "string" ? [args.title] : null;
|
|
5883
|
+
case "locator":
|
|
5884
|
+
return typeof args.selector === "string" ? [args.selector] : null;
|
|
5885
|
+
default:
|
|
5886
|
+
return null;
|
|
5887
|
+
}
|
|
5888
|
+
}
|
|
5889
|
+
function rankedToStructured(ranked) {
|
|
5890
|
+
if (hasAnchor(ranked.args)) return null;
|
|
5891
|
+
const args = positionalArgs(ranked.method, ranked.args);
|
|
5892
|
+
if (!args) return null;
|
|
5893
|
+
return { method: ranked.method, args };
|
|
5894
|
+
}
|
|
5895
|
+
function compileFromCandidate(candidate) {
|
|
5896
|
+
const fingerprint = { role: candidate.role, name: candidate.name };
|
|
5897
|
+
if (candidate.level != null) fingerprint.level = candidate.level;
|
|
5898
|
+
for (const ranked of freshLocatorsFromCandidate(candidate)) {
|
|
5899
|
+
const structured = rankedToStructured(ranked);
|
|
5900
|
+
if (structured) return { locator: structured, fingerprint, score: ranked.score };
|
|
5901
|
+
}
|
|
5902
|
+
return null;
|
|
5903
|
+
}
|
|
5904
|
+
|
|
5905
|
+
// src/internal/ai/resolution.ts
|
|
5906
|
+
var MAX_SNAPSHOT_CHARS = 24e3;
|
|
5907
|
+
var DEFAULT_MAX_STEPS = 20;
|
|
5908
|
+
var ServerStepResolver = class {
|
|
5909
|
+
constructor(serverUrl, apiKey) {
|
|
5910
|
+
this.serverUrl = serverUrl;
|
|
5911
|
+
this.apiKey = apiKey;
|
|
5912
|
+
}
|
|
5913
|
+
async resolveStep(request) {
|
|
5914
|
+
const headers = { "Content-Type": "application/json" };
|
|
5915
|
+
if (this.apiKey) headers["X-API-Key"] = this.apiKey;
|
|
5916
|
+
const url = `${this.serverUrl.replace(/\/+$/, "")}/api/ai/step-resolution`;
|
|
5917
|
+
const res = await fetch(url, { method: "POST", headers, body: JSON.stringify(request) });
|
|
5918
|
+
if (!res.ok) {
|
|
5919
|
+
const body = await res.json().catch(() => ({}));
|
|
5920
|
+
throw new Error(body.message || `piwi AI: step resolution failed (${res.status})`);
|
|
5921
|
+
}
|
|
5922
|
+
return await res.json();
|
|
5923
|
+
}
|
|
5924
|
+
};
|
|
5925
|
+
function candidateOf(element) {
|
|
5926
|
+
return { role: element.role, name: element.name ?? null, level: element.level ?? null };
|
|
5927
|
+
}
|
|
5928
|
+
function locatorFromElement(element) {
|
|
5929
|
+
return compileFromCandidate(candidateOf(element));
|
|
5930
|
+
}
|
|
5931
|
+
function lazyLocator(resolveOnce) {
|
|
5932
|
+
let pending = null;
|
|
5933
|
+
const ensure = () => pending ??= resolveOnce();
|
|
5934
|
+
const handler = {
|
|
5935
|
+
get(_t, prop) {
|
|
5936
|
+
if (typeof prop !== "string" || prop === "then" || prop === "catch" || prop === "finally") return void 0;
|
|
5937
|
+
return (...args) => ensure().then((loc) => loc[prop](...args));
|
|
5938
|
+
}
|
|
5939
|
+
};
|
|
5940
|
+
return new Proxy({}, handler);
|
|
5941
|
+
}
|
|
5942
|
+
function buildPostcondition(resolved, template) {
|
|
5943
|
+
if (!resolved)
|
|
5944
|
+
throw new Error(`piwi AI: flow "${template}" resolved without a postcondition (the oracle is mandatory)`);
|
|
5945
|
+
if (resolved.assert === "url") {
|
|
5946
|
+
if (!resolved.url) throw new Error(`piwi AI: flow "${template}" postcondition is a url assert without a url`);
|
|
5947
|
+
return { assert: "url", url: resolved.url };
|
|
5948
|
+
}
|
|
5949
|
+
const compiled = resolved.element ? locatorFromElement(resolved.element) : null;
|
|
5950
|
+
if (!compiled) throw new Error(`piwi AI: flow "${template}" postcondition element could not be compiled`);
|
|
5951
|
+
return { assert: resolved.assert, locator: compiled.locator };
|
|
5952
|
+
}
|
|
5953
|
+
var readMaskedSnapshot = async (page, params) => {
|
|
5954
|
+
const raw = await ariaSnapshotBestEffort(page.locator("body"));
|
|
5955
|
+
return raw ? maskValues(raw, params) : "";
|
|
5956
|
+
};
|
|
5957
|
+
var observeXhrResponses = async (page, action, settleMs) => {
|
|
5958
|
+
const urls = [];
|
|
5959
|
+
const onResponse = (res) => {
|
|
5960
|
+
const type = res.request().resourceType();
|
|
5961
|
+
if (type === "xhr" || type === "fetch") urls.push(res.url());
|
|
5962
|
+
};
|
|
5963
|
+
page.on("response", onResponse);
|
|
5964
|
+
try {
|
|
5965
|
+
await action();
|
|
5966
|
+
await page.waitForLoadState("networkidle", settleMs === void 0 ? void 0 : { timeout: settleMs }).catch(() => {
|
|
5967
|
+
});
|
|
5968
|
+
} finally {
|
|
5969
|
+
page.off("response", onResponse);
|
|
5970
|
+
}
|
|
5971
|
+
return urls;
|
|
5972
|
+
};
|
|
5973
|
+
var captureViewportScreenshot = async (page) => {
|
|
5974
|
+
try {
|
|
5975
|
+
const buffer = await page.screenshot({ type: "jpeg", quality: 60 });
|
|
5976
|
+
return { mediaType: "image/jpeg", data: buffer.toString("base64") };
|
|
5977
|
+
} catch {
|
|
5978
|
+
return null;
|
|
5979
|
+
}
|
|
5980
|
+
};
|
|
5981
|
+
async function maybeScreenshot(ctx, ariaSnapshot) {
|
|
5982
|
+
if (!ctx.screenshotFallback || ariaSnapshot.length > 0) return void 0;
|
|
5983
|
+
const capture = ctx.captureScreenshot ?? captureViewportScreenshot;
|
|
5984
|
+
return await capture(ctx.page) ?? void 0;
|
|
5985
|
+
}
|
|
5986
|
+
function assertParametric(template, locatorJson) {
|
|
5987
|
+
if (extractPlaceholders(template).length > 0 && !isParametric(template, locatorJson)) {
|
|
5988
|
+
throw new Error(
|
|
5989
|
+
`piwi AI: resolved locator for "${template}" is not parametric \u2014 the grounding pinned a concrete value instead of a placeholder`
|
|
5990
|
+
);
|
|
5991
|
+
}
|
|
5992
|
+
}
|
|
5993
|
+
async function resolveLocator(template, ctx) {
|
|
5994
|
+
const readSnapshot = ctx.readSnapshot ?? readMaskedSnapshot;
|
|
5995
|
+
const cap = ctx.maxSnapshotChars ?? MAX_SNAPSHOT_CHARS;
|
|
5996
|
+
const ariaSnapshot = (await readSnapshot(ctx.page, ctx.params)).slice(0, cap);
|
|
5997
|
+
const screenshot = await maybeScreenshot(ctx, ariaSnapshot);
|
|
5998
|
+
const decision = await ctx.resolver.resolveStep({
|
|
5999
|
+
kind: "locator",
|
|
6000
|
+
template,
|
|
6001
|
+
paramNames: Object.keys(ctx.params),
|
|
6002
|
+
ariaSnapshot,
|
|
6003
|
+
history: [],
|
|
6004
|
+
...screenshot ? { screenshot } : {}
|
|
6005
|
+
});
|
|
6006
|
+
if (!decision.element) throw new Error(`piwi AI: resolver returned no element for "${template}"`);
|
|
6007
|
+
const compiled = locatorFromElement(decision.element);
|
|
6008
|
+
if (!compiled) throw new Error(`piwi AI: element for "${template}" could not be compiled to a stable locator`);
|
|
6009
|
+
const entry = {
|
|
6010
|
+
version: ARTIFACT_VERSION,
|
|
6011
|
+
kind: "locator",
|
|
6012
|
+
template,
|
|
6013
|
+
locator: compiled.locator,
|
|
6014
|
+
fingerprint: compiled.fingerprint
|
|
6015
|
+
};
|
|
6016
|
+
assertParametric(template, JSON.stringify(compiled.locator));
|
|
6017
|
+
const built = buildLocator(ctx.page, compiled.locator, ctx.params);
|
|
6018
|
+
await built.first().waitFor({ state: "visible" });
|
|
6019
|
+
return entry;
|
|
6020
|
+
}
|
|
6021
|
+
async function resolveRun(template, ctx) {
|
|
6022
|
+
const readSnapshot = ctx.readSnapshot ?? readMaskedSnapshot;
|
|
6023
|
+
const maxSteps = ctx.maxSteps ?? DEFAULT_MAX_STEPS;
|
|
6024
|
+
const cap = ctx.maxSnapshotChars ?? MAX_SNAPSHOT_CHARS;
|
|
6025
|
+
const paramNames = Object.keys(ctx.params);
|
|
6026
|
+
const steps = [];
|
|
6027
|
+
const history = [];
|
|
6028
|
+
for (let i = 0; i < maxSteps; i++) {
|
|
6029
|
+
const ariaSnapshot = (await readSnapshot(ctx.page, ctx.params)).slice(0, cap);
|
|
6030
|
+
const screenshot = await maybeScreenshot(ctx, ariaSnapshot);
|
|
6031
|
+
const decision = await ctx.resolver.resolveStep({
|
|
6032
|
+
kind: "run",
|
|
6033
|
+
template,
|
|
6034
|
+
paramNames,
|
|
6035
|
+
ariaSnapshot,
|
|
6036
|
+
history,
|
|
6037
|
+
...screenshot ? { screenshot } : {}
|
|
6038
|
+
});
|
|
6039
|
+
if (decision.done) {
|
|
6040
|
+
const postcondition = buildPostcondition(decision.postcondition, template);
|
|
6041
|
+
await assertPostcondition(postcondition, { page: ctx.page, params: ctx.params });
|
|
6042
|
+
return { version: ARTIFACT_VERSION, kind: "run", template, steps, postcondition };
|
|
6043
|
+
}
|
|
6044
|
+
if (!decision.element || !decision.action) {
|
|
6045
|
+
throw new Error(`piwi AI: resolver returned neither a step nor done for "${template}"`);
|
|
6046
|
+
}
|
|
6047
|
+
const compiled = locatorFromElement(decision.element);
|
|
6048
|
+
if (!compiled) throw new Error(`piwi AI: a step element for "${template}" could not be compiled`);
|
|
6049
|
+
const step = { locator: compiled.locator, action: decision.action, fingerprint: compiled.fingerprint };
|
|
6050
|
+
if (decision.value !== void 0) step.value = decision.value;
|
|
6051
|
+
if (decision.optional) step.optional = true;
|
|
6052
|
+
steps.push(step);
|
|
6053
|
+
const observe = ctx.observeResponses ?? observeXhrResponses;
|
|
6054
|
+
const observed = await observe(
|
|
6055
|
+
ctx.page,
|
|
6056
|
+
() => executeStep(step, {
|
|
6057
|
+
page: ctx.page,
|
|
6058
|
+
params: ctx.params,
|
|
6059
|
+
optionalProbeTimeout: ctx.optionalProbeTimeout
|
|
6060
|
+
}).then(() => void 0),
|
|
6061
|
+
ctx.responseWaitTimeout
|
|
6062
|
+
);
|
|
6063
|
+
history.push({ action: decision.action, element: decision.element, value: decision.value });
|
|
6064
|
+
if (observed.length > 0) {
|
|
6065
|
+
const waitDecision = await ctx.resolver.resolveStep({
|
|
6066
|
+
kind: "wait",
|
|
6067
|
+
template,
|
|
6068
|
+
paramNames,
|
|
6069
|
+
ariaSnapshot: "",
|
|
6070
|
+
history,
|
|
6071
|
+
observedResponses: observed.map((url) => maskValues(url, ctx.params))
|
|
6072
|
+
});
|
|
6073
|
+
if (waitDecision.waitForResponse) step.waitForResponse = waitDecision.waitForResponse;
|
|
6074
|
+
}
|
|
6075
|
+
}
|
|
6076
|
+
throw new Error(`piwi AI: exceeded the ${maxSteps}-step budget resolving "${template}"`);
|
|
6077
|
+
}
|
|
6078
|
+
|
|
6079
|
+
// src/internal/ai/ai-fixtures.ts
|
|
6080
|
+
function parseAiMode(value) {
|
|
6081
|
+
return value === "resolve" || value === "heal" ? value : "replay";
|
|
6082
|
+
}
|
|
6083
|
+
function readPositiveInt(value) {
|
|
6084
|
+
if (value === void 0 || value.trim() === "") return void 0;
|
|
6085
|
+
const n = Number(value);
|
|
6086
|
+
return Number.isFinite(n) && n >= 0 ? Math.floor(n) : void 0;
|
|
6087
|
+
}
|
|
6088
|
+
function readAiConfig(env) {
|
|
6089
|
+
return {
|
|
6090
|
+
mode: parseAiMode(env.PIWI_AI),
|
|
6091
|
+
dir: env.PIWI_AI_DIR || DEFAULT_AI_DIR,
|
|
6092
|
+
onMiss: env.PIWI_AI_ON_MISS === "fixme" ? "fixme" : "fail",
|
|
6093
|
+
update: env.PIWI_AI_UPDATE === "true",
|
|
6094
|
+
maxSteps: readPositiveInt(env.PIWI_AI_MAX_FLOW_STEPS),
|
|
6095
|
+
maxSnapshotChars: readPositiveInt(env.PIWI_AI_MAX_SNAPSHOT_CHARS),
|
|
6096
|
+
optionalProbeTimeout: readPositiveInt(env.PIWI_AI_OPTIONAL_PROBE_TIMEOUT),
|
|
6097
|
+
responseWaitTimeout: readPositiveInt(env.PIWI_AI_RESPONSE_WAIT_TIMEOUT),
|
|
6098
|
+
screenshotFallback: env.PIWI_AI_SCREENSHOT_FALLBACK === "true"
|
|
6099
|
+
};
|
|
6100
|
+
}
|
|
6101
|
+
function testIdentity(testInfo) {
|
|
6102
|
+
const titles = testInfo.titlePath.slice(1);
|
|
6103
|
+
return titles.length > 0 ? titles.join(" \u203A ") : testInfo.title;
|
|
6104
|
+
}
|
|
6105
|
+
function relativeToCwd(file) {
|
|
6106
|
+
return path19.relative(process.cwd(), file).split(path19.sep).join("/");
|
|
6107
|
+
}
|
|
6108
|
+
function recordIntents(intents, entry) {
|
|
6109
|
+
const add = (locator, kind) => {
|
|
6110
|
+
intents.set(`${entry.template}\0${locator}`, { template: entry.template, locator, kind });
|
|
6111
|
+
};
|
|
6112
|
+
if (entry.kind === "locator") {
|
|
6113
|
+
add(locatorSource(entry.locator), "locator");
|
|
6114
|
+
return;
|
|
6115
|
+
}
|
|
6116
|
+
for (const step of entry.steps) add(locatorSource(step.locator), "run");
|
|
6117
|
+
if (entry.postcondition.locator) add(locatorSource(entry.postcondition.locator), "run");
|
|
6118
|
+
}
|
|
6119
|
+
function missMessage(template, testTitle, relPath) {
|
|
6120
|
+
return [
|
|
6121
|
+
`piwi AI: no committed entry for "${template}" in test "${testTitle}".`,
|
|
6122
|
+
` expected file: ${relPath}`,
|
|
6123
|
+
` run: piwi ai resolve --grep ${JSON.stringify(testTitle)}`,
|
|
6124
|
+
` (requires a configured authoring model; commit the generated file afterwards)`
|
|
6125
|
+
].join("\n");
|
|
6126
|
+
}
|
|
6127
|
+
function createAiApi(page, testInfo, config, used, intents) {
|
|
6128
|
+
const testTitle = testIdentity(testInfo);
|
|
6129
|
+
let source = null;
|
|
6130
|
+
const readSource = () => {
|
|
6131
|
+
if (source === null) {
|
|
6132
|
+
try {
|
|
6133
|
+
source = fs15.readFileSync(testInfo.file, "utf8");
|
|
6134
|
+
} catch {
|
|
6135
|
+
source = "";
|
|
6136
|
+
}
|
|
6137
|
+
}
|
|
6138
|
+
return source;
|
|
6139
|
+
};
|
|
6140
|
+
const ordinalFor = (template, location) => {
|
|
6141
|
+
if (!location) return 0;
|
|
6142
|
+
const sites = findTemplateSites(readSource(), template);
|
|
6143
|
+
if (sites.length <= 1) return 0;
|
|
6144
|
+
const callLine = /:(\d+):\d+$/.exec(location)?.[1];
|
|
6145
|
+
const perFileSites = sites.map((s) => `${testInfo.file}:${s}`);
|
|
6146
|
+
const match = perFileSites.find((s) => /:(\d+):\d+$/.exec(s)?.[1] === callLine);
|
|
6147
|
+
return match ? ordinalForLocation(perFileSites, match) : 0;
|
|
6148
|
+
};
|
|
6149
|
+
const resolveFile = (template) => {
|
|
6150
|
+
const location = captureCallerLocation();
|
|
6151
|
+
const ordinal = ordinalFor(template, location);
|
|
6152
|
+
return entryPath({ specFile: testInfo.file, testTitle, template, ordinal, dir: config.dir });
|
|
6153
|
+
};
|
|
6154
|
+
const missInReplay = (template, file) => {
|
|
6155
|
+
const message = missMessage(template, testTitle, relativeToCwd(file));
|
|
6156
|
+
if (config.onMiss === "fixme") {
|
|
6157
|
+
testInfo.annotations.push({ type: "fixme", description: message });
|
|
6158
|
+
testInfo.fixme();
|
|
6159
|
+
}
|
|
6160
|
+
throw new Error(message);
|
|
6161
|
+
};
|
|
6162
|
+
const requireResolver = () => {
|
|
6163
|
+
const serverUrl = process.env.PIWI_DASHBOARD_URL;
|
|
6164
|
+
if (!serverUrl) {
|
|
6165
|
+
throw new Error(`piwi AI: ${config.mode} mode needs PIWI_DASHBOARD_URL (the authoring server) to be set`);
|
|
6166
|
+
}
|
|
6167
|
+
return new ServerStepResolver(serverUrl, process.env.PIWI_API_KEY ?? null);
|
|
6168
|
+
};
|
|
6169
|
+
const readAria = (p) => ariaSnapshotBestEffort(p.locator("body"));
|
|
6170
|
+
const step = (title, body) => import_test.test.step(title, body);
|
|
6171
|
+
const canReuse = (entry, kind) => entry?.kind === kind && !(config.mode !== "replay" && config.update);
|
|
6172
|
+
return {
|
|
6173
|
+
piwiLocator(template, ...params) {
|
|
6174
|
+
const values = params[0] ?? {};
|
|
6175
|
+
validateParams(template, params[0]);
|
|
6176
|
+
const file = resolveFile(template);
|
|
6177
|
+
const existing = readEntry(file);
|
|
6178
|
+
if (canReuse(existing, "locator")) {
|
|
6179
|
+
used.add(file);
|
|
6180
|
+
recordIntents(intents, existing);
|
|
6181
|
+
return buildLocator(page, existing.locator, values);
|
|
6182
|
+
}
|
|
6183
|
+
if (config.mode === "replay") return missInReplay(template, file);
|
|
6184
|
+
const resolver = requireResolver();
|
|
6185
|
+
return lazyLocator(async () => {
|
|
6186
|
+
const entry = await resolveLocator(template, {
|
|
6187
|
+
page,
|
|
6188
|
+
params: values,
|
|
6189
|
+
resolver,
|
|
6190
|
+
maxSnapshotChars: config.maxSnapshotChars,
|
|
6191
|
+
screenshotFallback: config.screenshotFallback
|
|
6192
|
+
});
|
|
6193
|
+
writeEntry(file, entry);
|
|
6194
|
+
used.add(file);
|
|
6195
|
+
recordIntents(intents, entry);
|
|
6196
|
+
return buildLocator(page, entry.locator, values);
|
|
6197
|
+
});
|
|
6198
|
+
},
|
|
6199
|
+
async piwiRun(template, ...params) {
|
|
6200
|
+
const values = params[0] ?? {};
|
|
6201
|
+
validateParams(template, params[0]);
|
|
6202
|
+
const file = resolveFile(template);
|
|
6203
|
+
const existing = readEntry(file);
|
|
6204
|
+
if (canReuse(existing, "run")) {
|
|
6205
|
+
used.add(file);
|
|
6206
|
+
recordIntents(intents, existing);
|
|
6207
|
+
await step(
|
|
6208
|
+
`piwiRun: ${template}`,
|
|
6209
|
+
() => executeRun(existing, {
|
|
6210
|
+
page,
|
|
6211
|
+
params: values,
|
|
6212
|
+
readAria,
|
|
6213
|
+
step,
|
|
6214
|
+
optionalProbeTimeout: config.optionalProbeTimeout,
|
|
6215
|
+
responseWaitTimeout: config.responseWaitTimeout
|
|
6216
|
+
})
|
|
6217
|
+
);
|
|
6218
|
+
return;
|
|
6219
|
+
}
|
|
6220
|
+
if (config.mode === "replay") {
|
|
6221
|
+
missInReplay(template, file);
|
|
6222
|
+
return;
|
|
6223
|
+
}
|
|
6224
|
+
const resolver = requireResolver();
|
|
6225
|
+
await step(`piwiRun (resolve): ${template}`, async () => {
|
|
6226
|
+
const entry = await resolveRun(template, {
|
|
6227
|
+
page,
|
|
6228
|
+
params: values,
|
|
6229
|
+
resolver,
|
|
6230
|
+
maxSteps: config.maxSteps,
|
|
6231
|
+
maxSnapshotChars: config.maxSnapshotChars,
|
|
6232
|
+
optionalProbeTimeout: config.optionalProbeTimeout,
|
|
6233
|
+
responseWaitTimeout: config.responseWaitTimeout,
|
|
6234
|
+
screenshotFallback: config.screenshotFallback
|
|
6235
|
+
});
|
|
6236
|
+
writeEntry(file, entry);
|
|
6237
|
+
used.add(file);
|
|
6238
|
+
recordIntents(intents, entry);
|
|
6239
|
+
});
|
|
6240
|
+
}
|
|
6241
|
+
};
|
|
6242
|
+
}
|
|
6243
|
+
async function attachUsage(testInfo, used, intents) {
|
|
6244
|
+
if (used.size === 0) return;
|
|
6245
|
+
const entries = [...used].map(relativeToCwd).sort();
|
|
6246
|
+
const intentList = [...intents.values()].sort(
|
|
6247
|
+
(a, b) => a.template.localeCompare(b.template) || a.locator.localeCompare(b.locator)
|
|
6248
|
+
);
|
|
6249
|
+
await testInfo.attach(ATTACHMENT_NAMES.aiUsage, {
|
|
6250
|
+
contentType: "application/json",
|
|
6251
|
+
body: Buffer.from(JSON.stringify({ entries, ...intentList.length > 0 ? { intents: intentList } : {} }))
|
|
6252
|
+
});
|
|
6253
|
+
}
|
|
6254
|
+
var piwiAiFixtures = {
|
|
6255
|
+
page: async ({ page }, use, testInfo) => {
|
|
6256
|
+
const config = readAiConfig(process.env);
|
|
6257
|
+
const used = /* @__PURE__ */ new Set();
|
|
6258
|
+
const intents = /* @__PURE__ */ new Map();
|
|
6259
|
+
const ai = createAiApi(page, testInfo, config, used, intents);
|
|
6260
|
+
Object.assign(page, ai);
|
|
6261
|
+
try {
|
|
6262
|
+
await use(page);
|
|
6263
|
+
} finally {
|
|
6264
|
+
await attachUsage(testInfo, used, intents);
|
|
6265
|
+
}
|
|
6266
|
+
}
|
|
6267
|
+
};
|
|
6268
|
+
function extendPiwiAi(test) {
|
|
6269
|
+
return test.extend(piwiAiFixtures);
|
|
6270
|
+
}
|
|
6271
|
+
|
|
5395
6272
|
// src/index.ts
|
|
5396
6273
|
var index_default = PiwiDashboardReporter;
|
|
5397
6274
|
// Annotate the CommonJS export names for ESM import in node:
|
|
5398
6275
|
0 && (module.exports = {
|
|
5399
6276
|
PiwiDashboardReporter,
|
|
5400
6277
|
createGlobalSetup,
|
|
6278
|
+
extendPiwiAi,
|
|
5401
6279
|
extendPiwiFixtures,
|
|
6280
|
+
piwiAiFixtures,
|
|
5402
6281
|
piwiFixtures,
|
|
5403
6282
|
wrapConfig
|
|
5404
6283
|
});
|