@guanlin4924/devflow-pi 0.1.97 → 0.1.98
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 +1 -1
- package/package.json +1 -1
- package/plugins/devflow-core/extensions/devflow-core.ts +31 -12
- package/plugins/devflow-quality/runtime/playwright/explore-host.mjs +233 -97
- package/plugins/devflow-quality/runtime/playwright/runtime-manifest.json +1 -1
- package/plugins/devflow-quality/skills/workflow-generate-mainflow-cases/SKILL.md +2 -0
- package/plugins/devflow-quality/skills/workflow-regression-test-design/SKILL.md +3 -0
- package/src/constants.ts +1 -1
package/README.md
CHANGED
package/package.json
CHANGED
|
@@ -612,6 +612,13 @@ export type ExploreHostRequest = {
|
|
|
612
612
|
params?: Record<string, unknown>;
|
|
613
613
|
};
|
|
614
614
|
|
|
615
|
+
class ExploreHostError extends Error {
|
|
616
|
+
constructor(message: string, readonly code?: string) {
|
|
617
|
+
super(message);
|
|
618
|
+
this.name = "ExploreHostError";
|
|
619
|
+
}
|
|
620
|
+
}
|
|
621
|
+
|
|
615
622
|
/** Small JSON-lines client. The host owns Playwright; Core owns its lifetime. */
|
|
616
623
|
export class ExploreHostManager {
|
|
617
624
|
private child: any;
|
|
@@ -621,8 +628,10 @@ export class ExploreHostManager {
|
|
|
621
628
|
private idleTimer?: ReturnType<typeof setTimeout>;
|
|
622
629
|
private totalTimer?: ReturnType<typeof setTimeout>;
|
|
623
630
|
private lastActivity = 0;
|
|
631
|
+
private exploreSecrets: Record<string, string> = {};
|
|
624
632
|
|
|
625
633
|
get running(): boolean { return Boolean(this.child); }
|
|
634
|
+
get secrets(): Record<string, string> { return { ...this.exploreSecrets }; }
|
|
626
635
|
|
|
627
636
|
async start(hostPath: string, cwd: string, environmentFile: string, secrets: Record<string, string>): Promise<void> {
|
|
628
637
|
if (this.child) throw new Error("an explore session is already active");
|
|
@@ -635,6 +644,7 @@ export class ExploreHostManager {
|
|
|
635
644
|
env.DEVFLOW_EXPLORE_ENVIRONMENT_FILE = environmentFile;
|
|
636
645
|
for (const [name, value] of Object.entries(secrets)) env[name] = value;
|
|
637
646
|
this.child = Bun.spawn(["node", hostPath], { cwd, env, stdin: "pipe", stdout: "pipe", stderr: "pipe", detached: true });
|
|
647
|
+
this.exploreSecrets = { ...secrets };
|
|
638
648
|
void new Response(this.child.stderr).text();
|
|
639
649
|
this.lastActivity = Date.now();
|
|
640
650
|
this.readerTask = this.readResponses(this.child.stdout);
|
|
@@ -662,6 +672,7 @@ export class ExploreHostManager {
|
|
|
662
672
|
const child = this.child;
|
|
663
673
|
if (!child) return;
|
|
664
674
|
this.child = undefined;
|
|
675
|
+
this.exploreSecrets = {};
|
|
665
676
|
if (this.idleTimer) clearTimeout(this.idleTimer);
|
|
666
677
|
if (this.totalTimer) clearTimeout(this.totalTimer);
|
|
667
678
|
for (const pending of this.pending.values()) pending.reject(new Error(reason));
|
|
@@ -706,7 +717,10 @@ export class ExploreHostManager {
|
|
|
706
717
|
if (!waiter) continue;
|
|
707
718
|
this.pending.delete(response.id);
|
|
708
719
|
if (response.ok === true) waiter.resolve(response.result);
|
|
709
|
-
else waiter.reject(new
|
|
720
|
+
else waiter.reject(new ExploreHostError(
|
|
721
|
+
String(response?.error?.message ?? "explore-host request failed"),
|
|
722
|
+
typeof response?.error?.code === "string" ? response.error.code : undefined,
|
|
723
|
+
));
|
|
710
724
|
}
|
|
711
725
|
}
|
|
712
726
|
} finally {
|
|
@@ -1795,6 +1809,7 @@ export default function devflowCore(pi: ExtensionAPI) {
|
|
|
1795
1809
|
if (value && typeof value === "object") return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, redactExploreValue(item, secrets)]));
|
|
1796
1810
|
return value;
|
|
1797
1811
|
};
|
|
1812
|
+
const exploreScopePattern = /^(?:e\d+|aria-ref=e\d+|role=[^:\s]+(?::.+)?|testid=.+)$/;
|
|
1798
1813
|
|
|
1799
1814
|
const exploreHostPath = (): string => {
|
|
1800
1815
|
const candidates = [
|
|
@@ -4293,6 +4308,7 @@ export default function devflowCore(pi: ExtensionAPI) {
|
|
|
4293
4308
|
label: "Devflow Explore",
|
|
4294
4309
|
description: [
|
|
4295
4310
|
"在已批准的 environment_profile 内启动结构化页面探索会话;探索结果不是验收结论,draft 仍必须经过编译、校验和用户批准。",
|
|
4311
|
+
"草稿到执行唯一经过:人工检查 review_items → regression-test-design 冻结 TC 并生成 tests.json → regression-dsl 使用 compile_dsl.py <草稿或 case-descriptions 路径>(无 --draft)编译、校验并提交 execution_plan 批准 → run-acceptance-tests 执行;草稿不能直接执行,也不能用内置浏览器重放代替验收执行。冻结 TC 若需激活合同,请通过 /devflow control → 启动当前可执行步骤进入。DSL 的 screenshot 只供用户在本地查看。",
|
|
4296
4312
|
"凭据只按 profile.secret_env 注入 explore-host 子进程,永不写入 process.env、日志或工具输出。页面文本可能包含个人信息,本工具不做页面级个人信息脱敏。",
|
|
4297
4313
|
].join(" "),
|
|
4298
4314
|
parameters: z.object({
|
|
@@ -4300,7 +4316,7 @@ export default function devflowCore(pi: ExtensionAPI) {
|
|
|
4300
4316
|
feature: z.string().min(1).optional(),
|
|
4301
4317
|
environment_profile: z.string().min(1).optional(),
|
|
4302
4318
|
url: z.string().min(1).optional(),
|
|
4303
|
-
scope: z.string().optional(),
|
|
4319
|
+
scope: z.string().regex(exploreScopePattern, "scope must be a ref, role selector, or testid selector").optional(),
|
|
4304
4320
|
full: z.boolean().optional(),
|
|
4305
4321
|
table_text: z.boolean().optional(),
|
|
4306
4322
|
ref: z.string().min(1).optional(),
|
|
@@ -4316,14 +4332,15 @@ export default function devflowCore(pi: ExtensionAPI) {
|
|
|
4316
4332
|
}),
|
|
4317
4333
|
approval: args => args.action === "observe" || args.action === "stop" ? "read" : "write",
|
|
4318
4334
|
async execute(_id, params, _signal, _onUpdate, ctx) {
|
|
4335
|
+
const currentSecrets = () => exploreHost.secrets;
|
|
4319
4336
|
const fail = (message: string, code = "invalid_request") => ({
|
|
4320
|
-
content: [{ type: "text" as const, text: message }],
|
|
4337
|
+
content: [{ type: "text" as const, text: String(redactExploreValue(message, currentSecrets())) }],
|
|
4321
4338
|
details: { code },
|
|
4322
4339
|
isError: true,
|
|
4323
4340
|
});
|
|
4324
|
-
const response = (result: unknown
|
|
4325
|
-
content: [{ type: "text" as const, text: JSON.stringify(redactExploreValue(result,
|
|
4326
|
-
details: redactExploreValue(result,
|
|
4341
|
+
const response = (result: unknown) => ({
|
|
4342
|
+
content: [{ type: "text" as const, text: JSON.stringify(redactExploreValue(result, currentSecrets()), null, 2) }],
|
|
4343
|
+
details: redactExploreValue(result, currentSecrets()),
|
|
4327
4344
|
});
|
|
4328
4345
|
try {
|
|
4329
4346
|
if (activeContract && !["regression-test-design", "regression-dsl", "generate-mainflow-cases"].includes(activeContract.step)) {
|
|
@@ -4357,16 +4374,15 @@ export default function devflowCore(pi: ExtensionAPI) {
|
|
|
4357
4374
|
const result = await exploreHost.request("start", {
|
|
4358
4375
|
feature: params.feature, environment_profile: params.environment_profile, url: params.url,
|
|
4359
4376
|
});
|
|
4360
|
-
return response(result
|
|
4377
|
+
return response(result);
|
|
4361
4378
|
}
|
|
4362
4379
|
if (!exploreHost.running) return fail("no active explore session", "session_missing");
|
|
4363
4380
|
const requestParams: Record<string, unknown> = {};
|
|
4364
4381
|
if (params.action === "observe") Object.assign(requestParams, { scope: params.scope, full: params.full, table_text: params.table_text });
|
|
4365
4382
|
else if (params.action === "act") {
|
|
4366
|
-
|
|
4367
|
-
if (!params.
|
|
4368
|
-
|
|
4369
|
-
Object.assign(requestParams, { ref: params.ref, action: operation, value: params.value, value_from_secret: params.value_from_secret });
|
|
4383
|
+
if (!params.ref || !params.operation) return fail("act requires ref and operation");
|
|
4384
|
+
if (!["click", "fill", "check", "uncheck", "select", "press"].includes(params.operation)) return fail("act operation is invalid");
|
|
4385
|
+
Object.assign(requestParams, { ref: params.ref, action: params.operation, value: params.value, value_from_secret: params.value_from_secret });
|
|
4370
4386
|
} else if (params.action === "assert") {
|
|
4371
4387
|
if (!params.ref || !params.kind || !["text", "visible", "value", "each_text"].includes(params.kind)) return fail("assert requires ref and a supported kind");
|
|
4372
4388
|
Object.assign(requestParams, { ref: params.ref, kind: params.kind, match: params.match, expected: params.expected });
|
|
@@ -4376,7 +4392,10 @@ export default function devflowCore(pi: ExtensionAPI) {
|
|
|
4376
4392
|
}
|
|
4377
4393
|
return response(await exploreHost.request(params.action, requestParams));
|
|
4378
4394
|
} catch (error) {
|
|
4379
|
-
|
|
4395
|
+
if (params.action === "start") await exploreHost.stop("explore start failed");
|
|
4396
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
4397
|
+
const code = error instanceof ExploreHostError && error.code ? error.code : "explore_failed";
|
|
4398
|
+
return fail(`[${code}] ${message}`, code);
|
|
4380
4399
|
}
|
|
4381
4400
|
},
|
|
4382
4401
|
});
|
|
@@ -226,6 +226,7 @@ class Redactor {
|
|
|
226
226
|
|
|
227
227
|
// plugins/devflow-quality/runtime/playwright/src/explore/view.ts
|
|
228
228
|
var INTERACTIVE = new Set(["button", "link", "textbox", "searchbox", "checkbox", "radio", "combobox", "listbox", "option", "menuitem", "menuitemcheckbox", "menuitemradio", "tab", "switch", "slider", "spinbutton", "treeitem"]);
|
|
229
|
+
var NAMEABLE = new Set(["dialog", "alertdialog", "region", "form", "navigation", ...INTERACTIVE]);
|
|
229
230
|
var INFO = new Set(["heading", "alert", "status", "dialog", "alertdialog"]);
|
|
230
231
|
var CONTEXT = new Set(["dialog", "alertdialog", "form", "navigation", "banner", "main", "region", "table", "row", "tabpanel", "menu"]);
|
|
231
232
|
var TEXT_CONTAINERS = new Set(["dialog", "alertdialog", "alert", "status"]);
|
|
@@ -393,6 +394,37 @@ async function nameRows(page, nodes) {
|
|
|
393
394
|
}
|
|
394
395
|
}
|
|
395
396
|
}
|
|
397
|
+
async function nameContainers(page, nodes) {
|
|
398
|
+
for (const node of nodes.filter((item) => item.ref && !item.name && NAMEABLE.has(item.role))) {
|
|
399
|
+
const element = page.locator(`aria-ref=${node.ref}`);
|
|
400
|
+
if (await element.count() !== 1)
|
|
401
|
+
continue;
|
|
402
|
+
const name = await element.evaluate((item) => {
|
|
403
|
+
const ariaLabel = item.getAttribute("aria-label")?.trim();
|
|
404
|
+
if (ariaLabel)
|
|
405
|
+
return ariaLabel.replace(/\s+/g, " ").trim();
|
|
406
|
+
const ids = item.getAttribute("aria-labelledby")?.trim().split(/\s+/).filter(Boolean) ?? [];
|
|
407
|
+
return ids.map((id) => document.getElementById(id)?.textContent ?? "").join(" ").replace(/\s+/g, " ").trim();
|
|
408
|
+
});
|
|
409
|
+
if (!name)
|
|
410
|
+
continue;
|
|
411
|
+
const roleLocator = page.getByRole(node.role, { name, exact: true });
|
|
412
|
+
if (await sameUniqueElement(roleLocator, element))
|
|
413
|
+
node.name = name;
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
async function sameUniqueElement(candidate, element) {
|
|
417
|
+
if (await candidate.count() !== 1)
|
|
418
|
+
return false;
|
|
419
|
+
const candidateHandle = await candidate.elementHandle();
|
|
420
|
+
if (!candidateHandle)
|
|
421
|
+
return false;
|
|
422
|
+
try {
|
|
423
|
+
return await element.evaluate((node, other) => node === other, candidateHandle);
|
|
424
|
+
} finally {
|
|
425
|
+
await candidateHandle.dispose();
|
|
426
|
+
}
|
|
427
|
+
}
|
|
396
428
|
|
|
397
429
|
class ExplorerView {
|
|
398
430
|
page;
|
|
@@ -410,22 +442,35 @@ class ExplorerView {
|
|
|
410
442
|
node(ref) {
|
|
411
443
|
return this.last?.get(ref);
|
|
412
444
|
}
|
|
413
|
-
async observe(options = {}) {
|
|
445
|
+
async observe(options = {}, settled = true) {
|
|
414
446
|
let scope = this.page.locator("body");
|
|
415
447
|
if (options.scope) {
|
|
416
448
|
const rawScope = options.scope;
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
449
|
+
const ref = /^(?:aria-ref=)?(e\d+)$/.exec(rawScope);
|
|
450
|
+
if (ref) {
|
|
451
|
+
if (!this.hasRef(ref[1]))
|
|
452
|
+
throw Object.assign(new Error(`unknown or stale ref ${ref[1]}`), { code: "stale_ref" });
|
|
453
|
+
scope = this.page.locator(`aria-ref=${ref[1]}`);
|
|
454
|
+
} else if (rawScope.startsWith("role=")) {
|
|
455
|
+
const value = rawScope.slice(5);
|
|
456
|
+
const [role, ...nameParts] = value.split(":");
|
|
457
|
+
if (!/^[-a-z]+$/.test(role) || nameParts.length && !nameParts.join(":").length)
|
|
458
|
+
throw Object.assign(new Error("scope must be eN, aria-ref=eN, role=<role>[:<name>], or testid=<id>"), { code: "invalid_params" });
|
|
459
|
+
scope = nameParts.length ? this.page.getByRole(role, { name: nameParts.join(":"), exact: true }) : this.page.getByRole(role);
|
|
460
|
+
} else if (rawScope.startsWith("testid=") && rawScope.slice(7))
|
|
422
461
|
scope = this.page.getByTestId(rawScope.slice(7));
|
|
423
462
|
else
|
|
424
|
-
scope =
|
|
463
|
+
throw Object.assign(new Error("scope must be eN, aria-ref=eN, role=<role>[:<name>], or testid=<id>"), { code: "invalid_params" });
|
|
464
|
+
const scopeCount = await scope.count();
|
|
465
|
+
if (scopeCount === 0)
|
|
466
|
+
throw Object.assign(new Error("scope does not match exactly one element"), { code: "scope_not_found" });
|
|
467
|
+
if (scopeCount > 1)
|
|
468
|
+
throw Object.assign(new Error("scope matches more than one element"), { code: "scope_ambiguous" });
|
|
425
469
|
}
|
|
426
470
|
const raw = await scope.ariaSnapshot({ mode: "ai" });
|
|
427
471
|
const nodes = parseSnapshot(raw);
|
|
428
472
|
await nameRows(this.page, nodes);
|
|
473
|
+
await nameContainers(this.page, nodes);
|
|
429
474
|
const view = prune(nodes, this.redactor, await passwordRefs(this.page, nodes));
|
|
430
475
|
const fullText = [...view.values()].map((node) => ` ${format(node, this.redactor)}`).join(`
|
|
431
476
|
`);
|
|
@@ -451,7 +496,7 @@ class ExplorerView {
|
|
|
451
496
|
${await this.tableText(scope)}`;
|
|
452
497
|
const text = `[page] ${this.redactor.url(url)} · ${this.redactor.text(await this.page.title())} [${mode}]
|
|
453
498
|
${body}`;
|
|
454
|
-
return { mode, text, stats: { raw: raw.length, pruned: fullText.length, sent: body.length } };
|
|
499
|
+
return { mode, text, settled, stats: { raw: raw.length, pruned: fullText.length, sent: body.length } };
|
|
455
500
|
}
|
|
456
501
|
async tableText(scope) {
|
|
457
502
|
const rows = scope.locator("table tbody tr:visible, .el-table__body-wrapper tbody tr:visible");
|
|
@@ -490,6 +535,18 @@ function inferredRole(tag, type) {
|
|
|
490
535
|
return;
|
|
491
536
|
return { checkbox: "checkbox", radio: "radio", range: "slider" }[type ?? ""] ?? "textbox";
|
|
492
537
|
}
|
|
538
|
+
async function sameUnique(candidate, element) {
|
|
539
|
+
if (await candidate.count() !== 1)
|
|
540
|
+
return false;
|
|
541
|
+
const candidateHandle = await candidate.elementHandle();
|
|
542
|
+
if (!candidateHandle)
|
|
543
|
+
return false;
|
|
544
|
+
try {
|
|
545
|
+
return await element.evaluate((node, other) => node === other, candidateHandle);
|
|
546
|
+
} finally {
|
|
547
|
+
await candidateHandle.dispose();
|
|
548
|
+
}
|
|
549
|
+
}
|
|
493
550
|
|
|
494
551
|
class Recorder {
|
|
495
552
|
page;
|
|
@@ -508,11 +565,29 @@ class Recorder {
|
|
|
508
565
|
this.feature = feature;
|
|
509
566
|
this.environmentProfile = environmentProfile;
|
|
510
567
|
this.startUrl = startUrl;
|
|
511
|
-
|
|
568
|
+
const initial = new URL(startUrl);
|
|
569
|
+
this.steps.push({ action: "goto", url: `${initial.pathname}${initial.search}${initial.hash}` });
|
|
512
570
|
}
|
|
513
571
|
async unique(locator) {
|
|
514
572
|
return await locator.count() === 1;
|
|
515
573
|
}
|
|
574
|
+
async rowTarget(element, role, name) {
|
|
575
|
+
const row = element.locator("xpath=ancestor::tr[1]");
|
|
576
|
+
if (await row.count() !== 1)
|
|
577
|
+
return null;
|
|
578
|
+
const cells = row.locator(":scope > td:visible, :scope > th:visible");
|
|
579
|
+
for (let i = 0;i < await cells.count(); i++) {
|
|
580
|
+
const markerText = (await cells.nth(i).innerText()).replace(/\s+/g, " ").trim();
|
|
581
|
+
if (markerText.length < 2)
|
|
582
|
+
continue;
|
|
583
|
+
const rows = this.page.getByRole("row", { name: markerText, exact: false });
|
|
584
|
+
const inner = rows.getByRole(role, { name, exact: true });
|
|
585
|
+
if (await rows.count() === 1 && await sameUnique(inner, element)) {
|
|
586
|
+
return { locator: { within: { role: "row", name: markerText, exact: false }, role, name, exact: true }, data_dependent: true, reviewReasons: ["data_dependent_locator"] };
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
return null;
|
|
590
|
+
}
|
|
516
591
|
async contextTarget(node, role, name) {
|
|
517
592
|
const context = node.ctx.split(" > ").filter((item) => /^(dialog|alertdialog|form|navigation|banner|main|region)\b/.test(item)).pop();
|
|
518
593
|
if (!context)
|
|
@@ -525,60 +600,10 @@ class Recorder {
|
|
|
525
600
|
const within = contextName ? { role: contextRole, name: contextName, exact: true } : { role: contextRole };
|
|
526
601
|
const root = contextName ? this.page.getByRole(contextRole, { name: contextName, exact: true }) : this.page.getByRole(contextRole);
|
|
527
602
|
const inner = root.getByRole(role, { name, exact: true });
|
|
528
|
-
if (await this.unique(root) && await this.
|
|
603
|
+
if (await this.unique(root) && await sameUnique(inner, this.page.locator(`aria-ref=${node.ref}`)))
|
|
529
604
|
return { locator: { within, role, name, exact: true } };
|
|
530
605
|
return null;
|
|
531
606
|
}
|
|
532
|
-
async elementPlusTarget(element, role, name) {
|
|
533
|
-
const row = element.locator("xpath=ancestor::tr[1]");
|
|
534
|
-
if (await row.count() !== 1)
|
|
535
|
-
return null;
|
|
536
|
-
const root = row.locator("xpath=ancestor::*[contains(concat(' ', normalize-space(@class), ' '), ' el-table ')][1]");
|
|
537
|
-
if (await root.count() !== 1)
|
|
538
|
-
return null;
|
|
539
|
-
const headers = root.locator(".el-table__header-wrapper th:visible");
|
|
540
|
-
const headerByClass = new Map;
|
|
541
|
-
for (let i = 0;i < await headers.count(); i++) {
|
|
542
|
-
const header = headers.nth(i);
|
|
543
|
-
const title = (await header.innerText()).replace(/\s+/g, " ").trim();
|
|
544
|
-
for (const className of (await header.getAttribute("class") ?? "").split(/\s+/)) {
|
|
545
|
-
if (/^el-table_\d+_column_\d+$/.test(className) && title)
|
|
546
|
-
headerByClass.set(className, title);
|
|
547
|
-
}
|
|
548
|
-
}
|
|
549
|
-
const cells = row.locator(":scope > td:visible");
|
|
550
|
-
let actionColumn = "";
|
|
551
|
-
let markerColumn = "";
|
|
552
|
-
let markerText = "";
|
|
553
|
-
for (let i = 0;i < await cells.count(); i++) {
|
|
554
|
-
const cell = cells.nth(i);
|
|
555
|
-
const text = (await cell.innerText()).replace(/\s+/g, " ").trim();
|
|
556
|
-
const className = (await cell.getAttribute("class") ?? "").split(/\s+/).find((item) => headerByClass.has(item));
|
|
557
|
-
const column = className ? headerByClass.get(className) ?? "" : "";
|
|
558
|
-
if (text && !markerText) {
|
|
559
|
-
markerText = text;
|
|
560
|
-
markerColumn = column;
|
|
561
|
-
}
|
|
562
|
-
if (column && await cell.getByRole(role, { name, exact: true }).count() > 0)
|
|
563
|
-
actionColumn = column;
|
|
564
|
-
}
|
|
565
|
-
if (!actionColumn || !markerColumn || !markerText)
|
|
566
|
-
return null;
|
|
567
|
-
const target = {
|
|
568
|
-
within: {
|
|
569
|
-
role: "row",
|
|
570
|
-
name: markerText,
|
|
571
|
-
exact: false
|
|
572
|
-
},
|
|
573
|
-
role,
|
|
574
|
-
name,
|
|
575
|
-
exact: true
|
|
576
|
-
};
|
|
577
|
-
const matchingRows = root.locator(".el-table__body-wrapper tbody tr:visible").filter({ hasText: markerText });
|
|
578
|
-
if (await matchingRows.count() !== 1 || !await this.unique(matchingRows.getByRole(role, { name, exact: true })))
|
|
579
|
-
return null;
|
|
580
|
-
return { locator: target, data_dependent: true };
|
|
581
|
-
}
|
|
582
607
|
async targetFor(ref) {
|
|
583
608
|
if (!this.view.hasRef(ref))
|
|
584
609
|
throw Object.assign(new Error(`unknown or stale ref ${ref}`), { code: "stale_ref" });
|
|
@@ -589,27 +614,29 @@ class Recorder {
|
|
|
589
614
|
if (await element.count() !== 1)
|
|
590
615
|
throw Object.assign(new Error(`ref ${ref} is not unique`), { code: "stale_ref" });
|
|
591
616
|
const testid = await element.getAttribute("data-testid");
|
|
592
|
-
if (testid && await
|
|
617
|
+
if (testid && await sameUnique(this.page.getByTestId(testid), element))
|
|
593
618
|
return { locator: `testid:${testid}` };
|
|
594
619
|
const tag = await element.evaluate((item) => item.tagName.toLowerCase());
|
|
595
620
|
const role = node.role || inferredRole(tag, await element.getAttribute("type"));
|
|
596
621
|
const name = node.name;
|
|
597
622
|
if (role && name) {
|
|
598
623
|
const roleLocator = this.page.getByRole(role, { name, exact: true });
|
|
599
|
-
|
|
600
|
-
|
|
624
|
+
const rowTarget = await this.rowTarget(element, role, name);
|
|
625
|
+
if (rowTarget)
|
|
626
|
+
return rowTarget;
|
|
627
|
+
if (await sameUnique(roleLocator, element)) {
|
|
628
|
+
const inRow2 = await element.locator("xpath=ancestor::tr[1]").count() === 1;
|
|
629
|
+
return { locator: `role:${role}:${name}`, ...inRow2 ? { data_dependent: true, reviewReasons: ["row_scope_missing"] } : {} };
|
|
630
|
+
}
|
|
601
631
|
const within = await this.contextTarget(node, role, name);
|
|
602
632
|
if (within)
|
|
603
633
|
return within;
|
|
604
|
-
const tableTarget = await this.elementPlusTarget(element, role, name);
|
|
605
|
-
if (tableTarget)
|
|
606
|
-
return tableTarget;
|
|
607
634
|
}
|
|
608
635
|
const ariaLabel = await element.getAttribute("aria-label");
|
|
609
|
-
if (ariaLabel && await
|
|
636
|
+
if (ariaLabel && await sameUnique(this.page.getByLabel(ariaLabel, { exact: true }), element))
|
|
610
637
|
return { locator: `label:${ariaLabel}` };
|
|
611
638
|
const placeholder = await element.getAttribute("placeholder");
|
|
612
|
-
if (placeholder && await
|
|
639
|
+
if (placeholder && await sameUnique(this.page.getByPlaceholder(placeholder, { exact: true }), element))
|
|
613
640
|
return { locator: `placeholder:${placeholder}` };
|
|
614
641
|
const candidates = [];
|
|
615
642
|
if (testid)
|
|
@@ -618,19 +645,28 @@ class Recorder {
|
|
|
618
645
|
candidates.push(`role:${role}:${name}`);
|
|
619
646
|
if (placeholder)
|
|
620
647
|
candidates.push(`placeholder:${placeholder}`);
|
|
621
|
-
|
|
648
|
+
const inRow = await element.locator("xpath=ancestor::tr[1]").count() === 1;
|
|
649
|
+
return { needs_locator: true, candidates: candidates.map((candidate) => this.redactor.value(candidate)), ...inRow ? { data_dependent: true, reviewReasons: ["row_scope_missing"] } : {} };
|
|
622
650
|
}
|
|
623
651
|
recordPrepared(id, action, target, extra = {}) {
|
|
624
652
|
const step = { action, ...extra, locator: target.locator ?? "" };
|
|
625
653
|
this.steps.push(step);
|
|
626
654
|
this.explorationSteps.push({
|
|
627
655
|
id,
|
|
656
|
+
action,
|
|
657
|
+
role: typeof target.locator === "object" && target.locator ? target.locator.role : undefined,
|
|
628
658
|
...target.needs_locator ? { needs_locator: true } : {},
|
|
629
659
|
...target.candidates ? { candidates: target.candidates } : {},
|
|
630
660
|
...target.data_dependent ? { data_dependent: true } : {},
|
|
661
|
+
...target.reviewReasons ? { reviewReasons: target.reviewReasons } : {},
|
|
631
662
|
...action.startsWith("assert_") ? { oracle_hint: "characterization" } : {}
|
|
632
663
|
});
|
|
633
664
|
}
|
|
665
|
+
recordAssertionReview(id, kind, expectedFromPage, circular, targetName, weak = false) {
|
|
666
|
+
const item = this.explorationSteps.find((step) => step.id === id);
|
|
667
|
+
if (item)
|
|
668
|
+
Object.assign(item, { ...expectedFromPage ? { expected_from_page: true } : {}, ...circular ? { circular: true } : {}, ...weak ? { reviewReasons: [...item.reviewReasons ?? [], "weak_assertion"] } : {} });
|
|
669
|
+
}
|
|
634
670
|
noteSecretVariable(name) {
|
|
635
671
|
this.secretVariables.add(name);
|
|
636
672
|
}
|
|
@@ -640,16 +676,40 @@ class Recorder {
|
|
|
640
676
|
await fs.mkdir(root, { recursive: true, mode: 448 });
|
|
641
677
|
const steps = this.steps.map((step, index) => ({ id: `s${String(index + 1).padStart(2, "0")}`, ...step }));
|
|
642
678
|
const metadata = this.explorationSteps.map((item, index) => ({ ...item, id: `s${String(index + 2).padStart(2, "0")}` }));
|
|
643
|
-
const
|
|
679
|
+
const review_items = [];
|
|
680
|
+
const reviewKeys = new Set;
|
|
681
|
+
const addReview = (item) => {
|
|
682
|
+
const key = `${item.step}\x00${item.reason}`;
|
|
683
|
+
if (!reviewKeys.has(key)) {
|
|
684
|
+
reviewKeys.add(key);
|
|
685
|
+
review_items.push(item);
|
|
686
|
+
}
|
|
687
|
+
};
|
|
688
|
+
for (const item of metadata) {
|
|
689
|
+
for (const reason of item.reviewReasons ?? [])
|
|
690
|
+
addReview({ step: item.id, reason });
|
|
691
|
+
if (item.needs_locator)
|
|
692
|
+
addReview({ step: item.id, reason: "needs_locator" });
|
|
693
|
+
if (item.expected_from_page)
|
|
694
|
+
addReview({ step: item.id, reason: "expected_from_page" });
|
|
695
|
+
if (item.circular)
|
|
696
|
+
addReview({ step: item.id, reason: "circular_assertion" });
|
|
697
|
+
if (item.action === "assert_visible" && ["button", "link"].includes(item.role ?? ""))
|
|
698
|
+
addReview({ step: item.id, reason: "weak_assertion" });
|
|
699
|
+
}
|
|
700
|
+
const business = metadata.some((item) => item.action?.startsWith("assert_") && item.action !== "assert_visible" && !item.circular);
|
|
701
|
+
if (!business)
|
|
702
|
+
addReview({ step: "case", reason: "no_business_assertion" });
|
|
703
|
+
const manual = [...new Set(review_items.filter((item) => item.reason !== "expected_from_page" && item.reason !== "data_dependent_locator").map((item) => item.step))];
|
|
644
704
|
const draft = {
|
|
645
705
|
cases: [{ tc_id: tcId ?? "TC-0000", name: name.replace(/\.json$/, ""), variables: Object.fromEntries([...this.secretVariables].map((key) => [key, { secret: key }])), data: { setup: [], cleanup: [] }, steps }],
|
|
646
|
-
exploration: { environment_profile: this.environmentProfile, start_url: this.redactor.url(this.startUrl), recorded_steps: steps.length, needs_manual: manual, steps: metadata }
|
|
706
|
+
exploration: { environment_profile: this.environmentProfile, start_url: this.redactor.url(this.startUrl), recorded_steps: steps.length, needs_manual: manual, review_items, steps: metadata }
|
|
647
707
|
};
|
|
648
708
|
const output = path.join(root, fileName);
|
|
649
709
|
const serialized = this.redactor.text(JSON.stringify(draft, null, 2));
|
|
650
710
|
await fs.writeFile(output, `${serialized}
|
|
651
711
|
`, { mode: 384 });
|
|
652
|
-
return { path: output, manual };
|
|
712
|
+
return { path: output, manual, review_items };
|
|
653
713
|
}
|
|
654
714
|
}
|
|
655
715
|
|
|
@@ -660,8 +720,35 @@ var page = null;
|
|
|
660
720
|
var view = null;
|
|
661
721
|
var recorder = null;
|
|
662
722
|
var redactor = null;
|
|
723
|
+
var profileMemory = null;
|
|
663
724
|
var idleTimer = null;
|
|
664
725
|
var totalTimer = null;
|
|
726
|
+
var inFlightRequests = 0;
|
|
727
|
+
var requestStarted = null;
|
|
728
|
+
var requestFinished = null;
|
|
729
|
+
function installRequestCounter(currentContext) {
|
|
730
|
+
inFlightRequests = 0;
|
|
731
|
+
requestStarted = (request) => {
|
|
732
|
+
if (["fetch", "xhr", "document"].includes(request.resourceType()))
|
|
733
|
+
inFlightRequests++;
|
|
734
|
+
};
|
|
735
|
+
requestFinished = () => {
|
|
736
|
+
inFlightRequests = Math.max(0, inFlightRequests - 1);
|
|
737
|
+
};
|
|
738
|
+
currentContext.on("request", requestStarted);
|
|
739
|
+
currentContext.on("requestfinished", requestFinished);
|
|
740
|
+
currentContext.on("requestfailed", requestFinished);
|
|
741
|
+
}
|
|
742
|
+
function removeRequestCounter() {
|
|
743
|
+
if (context && requestStarted && requestFinished) {
|
|
744
|
+
context.off("request", requestStarted);
|
|
745
|
+
context.off("requestfinished", requestFinished);
|
|
746
|
+
context.off("requestfailed", requestFinished);
|
|
747
|
+
}
|
|
748
|
+
requestStarted = null;
|
|
749
|
+
requestFinished = null;
|
|
750
|
+
inFlightRequests = 0;
|
|
751
|
+
}
|
|
665
752
|
function reply(id, result) {
|
|
666
753
|
process.stdout.write(`${JSON.stringify({ id, ok: true, result })}
|
|
667
754
|
`);
|
|
@@ -680,12 +767,14 @@ async function shutdown() {
|
|
|
680
767
|
clearTimeout(totalTimer);
|
|
681
768
|
idleTimer = null;
|
|
682
769
|
totalTimer = null;
|
|
770
|
+
removeRequestCounter();
|
|
683
771
|
await browser?.close().catch(() => {});
|
|
684
772
|
browser = null;
|
|
685
773
|
context = null;
|
|
686
774
|
page = null;
|
|
687
775
|
view = null;
|
|
688
776
|
recorder = null;
|
|
777
|
+
profileMemory = null;
|
|
689
778
|
}
|
|
690
779
|
function touch() {
|
|
691
780
|
if (idleTimer)
|
|
@@ -708,6 +797,33 @@ function secretValue(key, profile) {
|
|
|
708
797
|
throw Object.assign(new Error(`secret ${String(key)} is unavailable`), { code: "secret_unavailable" });
|
|
709
798
|
return value;
|
|
710
799
|
}
|
|
800
|
+
async function settle(currentPage) {
|
|
801
|
+
const deadline = Date.now() + 8000;
|
|
802
|
+
await currentPage.waitForLoadState("domcontentloaded").catch(() => {});
|
|
803
|
+
if (!context)
|
|
804
|
+
return true;
|
|
805
|
+
let quietSince = 0;
|
|
806
|
+
let previousSnapshot = null;
|
|
807
|
+
while (Date.now() < deadline) {
|
|
808
|
+
if (inFlightRequests === 0) {
|
|
809
|
+
if (!quietSince)
|
|
810
|
+
quietSince = Date.now();
|
|
811
|
+
if (Date.now() - quietSince >= 500) {
|
|
812
|
+
const snapshot = await currentPage.ariaSnapshot({ mode: "ai" }).catch(() => "");
|
|
813
|
+
if (previousSnapshot !== null && snapshot === previousSnapshot)
|
|
814
|
+
return true;
|
|
815
|
+
previousSnapshot = snapshot;
|
|
816
|
+
await new Promise((resolve) => setTimeout(resolve, 300));
|
|
817
|
+
continue;
|
|
818
|
+
}
|
|
819
|
+
} else {
|
|
820
|
+
quietSince = 0;
|
|
821
|
+
previousSnapshot = null;
|
|
822
|
+
}
|
|
823
|
+
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
824
|
+
}
|
|
825
|
+
return false;
|
|
826
|
+
}
|
|
711
827
|
async function doStart(request) {
|
|
712
828
|
if (page)
|
|
713
829
|
throw Object.assign(new Error("only one explore session is allowed"), { code: "session_exists" });
|
|
@@ -715,24 +831,32 @@ async function doStart(request) {
|
|
|
715
831
|
if (!environmentFile || !path2.isAbsolute(environmentFile)) {
|
|
716
832
|
throw Object.assign(new Error("environment profile path is missing"), { code: "environment_profile_invalid" });
|
|
717
833
|
}
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
834
|
+
try {
|
|
835
|
+
const profile = JSON.parse(fs2.readFileSync(environmentFile, "utf8"));
|
|
836
|
+
profileMemory = profile;
|
|
837
|
+
const policy = buildPolicy(profile);
|
|
838
|
+
redactor = new Redactor;
|
|
839
|
+
for (const envName of Object.values(profile.secret_env ?? {})) {
|
|
840
|
+
const value = process.env[envName];
|
|
841
|
+
if (value)
|
|
842
|
+
redactor.add(value);
|
|
843
|
+
}
|
|
844
|
+
browser = await chromium.launch();
|
|
845
|
+
context = await browser.newContext();
|
|
846
|
+
installRequestCounter(context);
|
|
847
|
+
await new NetworkRecorder(redactor).install(context, policy);
|
|
848
|
+
page = await context.newPage();
|
|
849
|
+
const startUrl = absoluteUrl(String(request.params?.url ?? "/"), profile);
|
|
850
|
+
await page.goto(startUrl, { waitUntil: "domcontentloaded" });
|
|
851
|
+
const settled = await settle(page);
|
|
852
|
+
view = new ExplorerView(page, redactor);
|
|
853
|
+
recorder = new Recorder(page, view, redactor, String(request.params?.feature ?? "explore"), profile.profile ?? path2.basename(environmentFile, path2.extname(environmentFile)), startUrl);
|
|
854
|
+
totalTimer = setTimeout(() => void shutdown(), 60 * 60 * 1000);
|
|
855
|
+
reply(request.id, await view.observe({ full: true }, settled));
|
|
856
|
+
} catch (error) {
|
|
857
|
+
await shutdown();
|
|
858
|
+
throw error;
|
|
859
|
+
}
|
|
736
860
|
}
|
|
737
861
|
async function doAct(request) {
|
|
738
862
|
const current = session();
|
|
@@ -745,10 +869,10 @@ async function doAct(request) {
|
|
|
745
869
|
let value = request.params?.value;
|
|
746
870
|
let secretKey;
|
|
747
871
|
if (request.params?.value_from_secret !== undefined) {
|
|
748
|
-
const environmentFile = process.env.DEVFLOW_EXPLORE_ENVIRONMENT_FILE;
|
|
749
|
-
const profile = JSON.parse(fs2.readFileSync(environmentFile, "utf8"));
|
|
750
872
|
secretKey = request.params.value_from_secret;
|
|
751
|
-
|
|
873
|
+
if (!profileMemory)
|
|
874
|
+
throw Object.assign(new Error("explore profile is unavailable"), { code: "environment_profile_invalid" });
|
|
875
|
+
value = secretValue(secretKey, profileMemory);
|
|
752
876
|
current.redactor.add(value);
|
|
753
877
|
current.recorder.noteSecretVariable(String(secretKey));
|
|
754
878
|
}
|
|
@@ -775,14 +899,14 @@ async function doAct(request) {
|
|
|
775
899
|
default:
|
|
776
900
|
throw Object.assign(new Error(`unknown action ${action}`), { code: "invalid_params" });
|
|
777
901
|
}
|
|
778
|
-
await current.page
|
|
902
|
+
const settled = await settle(current.page);
|
|
779
903
|
const id = `s${String(current.recorder.steps.length + 1).padStart(2, "0")}`;
|
|
780
904
|
const extra = {};
|
|
781
905
|
if (action === "fill" || action === "select" || action === "press") {
|
|
782
906
|
extra.value = secretKey === undefined ? current.redactor.text(String(value ?? "")) : `\${${String(secretKey)}}`;
|
|
783
907
|
}
|
|
784
908
|
current.recorder.recordPrepared(id, action, prepared, extra);
|
|
785
|
-
reply(request.id, await current.view.observe());
|
|
909
|
+
reply(request.id, { ...await current.view.observe({}, settled), settled });
|
|
786
910
|
}
|
|
787
911
|
async function doAssert(request) {
|
|
788
912
|
const current = session();
|
|
@@ -832,7 +956,16 @@ async function doAssert(request) {
|
|
|
832
956
|
extra.case_sensitive = caseSensitive;
|
|
833
957
|
}
|
|
834
958
|
current.recorder.recordPrepared(`s${String(current.recorder.steps.length + 1).padStart(2, "0")}`, action, prepared, extra);
|
|
835
|
-
|
|
959
|
+
const settled = await settle(current.page);
|
|
960
|
+
const targetName = current.view.node(ref)?.name ?? "";
|
|
961
|
+
const normalize = (value) => value.replace(/\s+/g, " ").trim();
|
|
962
|
+
const normalizedExpected = expected === undefined ? "" : normalize(expected);
|
|
963
|
+
const normalizedName = normalize(targetName);
|
|
964
|
+
const withinName = typeof prepared.locator === "object" && prepared.locator !== null ? String(prepared.locator.within?.name ?? "") : "";
|
|
965
|
+
const normalizedWithinName = normalize(withinName);
|
|
966
|
+
const circular = kind !== "visible" && Boolean(normalizedExpected && (normalizedName && (normalizedExpected.includes(normalizedName) || normalizedName.includes(normalizedExpected)) || normalizedWithinName && (normalizedExpected.includes(normalizedWithinName) || normalizedWithinName.includes(normalizedExpected))));
|
|
967
|
+
current.recorder.recordAssertionReview(`s${String(current.recorder.steps.length).padStart(2, "0")}`, kind, requested === undefined, circular, targetName, kind === "visible" && ["button", "link"].includes(current.view.node(ref)?.role ?? ""));
|
|
968
|
+
reply(request.id, { actual: current.redactor.value(kind === "each_text" ? await locator.allInnerTexts() : expected), view: await current.view.observe({}, settled), settled });
|
|
836
969
|
}
|
|
837
970
|
async function handle(request) {
|
|
838
971
|
touch();
|
|
@@ -863,9 +996,12 @@ async function handle(request) {
|
|
|
863
996
|
}
|
|
864
997
|
}
|
|
865
998
|
var input = readline.createInterface({ input: process.stdin, crlfDelay: Infinity });
|
|
999
|
+
var requestQueue = Promise.resolve();
|
|
866
1000
|
input.on("line", (line) => {
|
|
867
1001
|
try {
|
|
868
|
-
|
|
1002
|
+
const request = JSON.parse(line);
|
|
1003
|
+
requestQueue = requestQueue.then(() => handle(request)).catch((error) => process.stderr.write(`${error instanceof Error ? error.message : String(error)}
|
|
1004
|
+
`));
|
|
869
1005
|
} catch (error) {
|
|
870
1006
|
process.stderr.write(`${error instanceof Error ? error.message : String(error)}
|
|
871
1007
|
`);
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
"runner.spec.mjs": "abf0c5c9aff43b4f233de4c44db0946001c970758ef63ab861fad1d125c1a6fc",
|
|
7
7
|
"playwright.config.mjs": "301c0b97e229f83e6934994718b11b93a1da5d46ec606cfd11a8292e9d622b02",
|
|
8
8
|
"reporter.mjs": "5c91d68c50a93603f583cf82345d427bcd9ce9a7cfb78fd6d0989abddbd55275",
|
|
9
|
-
"explore-host.mjs": "
|
|
9
|
+
"explore-host.mjs": "035ea37f40cef7399e7038537f3f03d42baffe9d2db32d9bf8add25b0d81118e",
|
|
10
10
|
"devflow-test-dsl-v1.schema.json": "df07e55507697a70df214ae820403b530b04aeb04a12d0aa85715bf5e528e194"
|
|
11
11
|
}
|
|
12
12
|
}
|
|
@@ -10,6 +10,8 @@ description: "Use after code review, as the binding-phase prerequisite of run-ac
|
|
|
10
10
|
## Hard Rules
|
|
11
11
|
|
|
12
12
|
- `devflow_explore` 只能辅助回归设计或本步骤的 DSL 绑定;它产生的视图、断言和 draft 都不是验收证据。只能消费经人工检查的草稿,仍须运行本技能的编译、目录校验、独立审查和 `execution_plan` 审批流程。
|
|
13
|
+
- 草稿到执行只有一条路径:①人工检查草稿并处理 `review_items`;②回归流程先由 `regression-test-design` 冻结 TC(生成 `tests.json`,将草稿 `tc_id` 替换为冻结编号);③本 `regression-dsl` 步骤运行 `compile_dsl.py <草稿或 case-descriptions 路径>`(没有 `--draft` 参数),通过校验后提交 `execution_plan` 批准;④由 `run-acceptance-tests` 执行。草稿不能直接执行,也不能用内置浏览器重放代替验收执行。冻结 TC 若提示需要激活合同,应通过 `/devflow control` →“启动当前可执行步骤”进入。
|
|
14
|
+
- DSL 的 `screenshot` 步骤可以留存截图,截图只供用户在本地查看。
|
|
13
15
|
- 探索草稿中的 secret 变量只能保持 `${KEY}` 引用,禁止读取、回填或输出凭据值;页面中的个人信息也不能被当作通用 oracle。
|
|
14
16
|
|
|
15
17
|
- MUST NOT hardcode `platform-backend` or `platform-frontend`.
|
|
@@ -15,6 +15,8 @@ description: "Use from an idle project to derive a user-confirmed, frozen regres
|
|
|
15
15
|
|
|
16
16
|
需要补充当前页面行为时,可在已批准的测试环境中调用 `devflow_explore`(start/observe/act/assert/draft)。探索只用于发现和录制草稿,不是验收证据;密码、密钥和凭据值不得写入描述、日志或草稿。草稿必须由后续 `workflow-generate-mainflow-cases` 编译、校验并经用户批准后才能执行。
|
|
17
17
|
|
|
18
|
+
草稿到执行的唯一合法路径是:①人工检查草稿并处理 `review_items`;②在回归流程的 `regression-test-design` 中冻结 TC(生成 `tests.json`,并将草稿中的 `tc_id` 替换为冻结后的编号);③在 `regression-dsl` 步骤使用 `compile_dsl.py <草稿或 case-descriptions 路径>` 编译(没有 `--draft` 参数),通过校验后提交 `execution_plan` 批准;④由 `run-acceptance-tests` 执行。草稿不能直接执行,也不能用内置浏览器重放代替验收执行。冻结 TC 若提示需要激活合同,应通过 `/devflow control` →“启动当前可执行步骤”进入。
|
|
19
|
+
|
|
18
20
|
1. 读取 `devflow_requirement_graph(action="inspect")` 和 `.devflow/planning/code-index/`,确认当前功能、代码索引 revision、入口、接口、数据流和调用关系。
|
|
19
21
|
2. 阅读实际前端与后端源码、路由/控制器、服务、权限规则、数据访问和已有测试;代码只能用于发现真实功能点与可执行入口,不能把实现细节直接当作预期。
|
|
20
22
|
3. 阅读 `.devflow/planning/test-environments/*.json` 中的环境声明;需要登录或测试 token 时,只记录 `secret_env` 的变量名和所需前置,不读取或输出值。
|
|
@@ -67,3 +69,4 @@ description: "Use from an idle project to derive a user-confirmed, frozen regres
|
|
|
67
69
|
- 不读取或使用 agent 交付自述、`SUMMARY.md` 或 git diff 来决定测试范围。
|
|
68
70
|
- 不写入 `acceptance-results.json`,不生成人工通过结果,不确认执行计划或环境批准。
|
|
69
71
|
- 不向模型上下文、文档或日志输出测试凭据值。
|
|
72
|
+
- DSL 的 `screenshot` 步骤可以留存截图,截图只供用户在本地查看。
|
package/src/constants.ts
CHANGED