@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 CHANGED
@@ -11,7 +11,7 @@ Devflow Pi 是一个面向项目研发交付的独立 OMP 产品入口,内置
11
11
  安装与启动:
12
12
 
13
13
  ```bash
14
- npm install -g @guanlin4924/devflow-pi@0.1.97
14
+ npm install -g @guanlin4924/devflow-pi@0.1.98
15
15
  cd /path/to/your-project
16
16
  devflow-pi init
17
17
  devflow-pi doctor
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@guanlin4924/devflow-pi",
3
- "version": "0.1.97",
3
+ "version": "0.1.98",
4
4
  "type": "module",
5
5
  "description": "Devflow 专项研发 Pi,基于固定版本 OMP Runtime 组装研发状态机、代码智能、设计、实现与质量门禁。",
6
6
  "license": "UNLICENSED",
@@ -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 Error(String(response?.error?.message ?? "explore-host request failed")));
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, secrets: Record<string, string> = {}) => ({
4325
- content: [{ type: "text" as const, text: JSON.stringify(redactExploreValue(result, secrets), null, 2) }],
4326
- details: redactExploreValue(result, secrets),
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, childSecrets);
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
- const operation = params.operation ?? params.action_name;
4367
- if (!params.ref || !operation) return fail("act requires ref and operation");
4368
- if (!["click", "fill", "check", "uncheck", "select", "press"].includes(operation)) return fail("act operation is invalid");
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
- return fail(error instanceof Error ? error.message : String(error), "explore_failed");
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
- if (/^aria-ref=e\d+$/.test(rawScope) || /^e\d+$/.test(rawScope))
418
- scope = this.page.locator(rawScope.startsWith("aria-ref=") ? rawScope : `aria-ref=${rawScope}`);
419
- else if (rawScope.startsWith("role="))
420
- scope = this.page.getByRole(rawScope.slice(5));
421
- else if (rawScope.startsWith("testid="))
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 = this.page.locator(rawScope);
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
- this.steps.push({ action: "goto", url: new URL(startUrl).pathname });
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.unique(inner))
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 this.unique(this.page.getByTestId(testid)))
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
- if (await this.unique(roleLocator))
600
- return { locator: `role:${role}:${name}` };
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 this.unique(this.page.getByLabel(ariaLabel, { exact: true })))
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 this.unique(this.page.getByPlaceholder(placeholder, { exact: true })))
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
- return { needs_locator: true, candidates: candidates.map((candidate) => this.redactor.value(candidate)) };
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 manual = metadata.filter((item) => item.needs_locator).map((item) => item.id);
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
- const profile = JSON.parse(fs2.readFileSync(environmentFile, "utf8"));
719
- const policy = buildPolicy(profile);
720
- redactor = new Redactor;
721
- for (const envName of Object.values(profile.secret_env ?? {})) {
722
- const value = process.env[envName];
723
- if (value)
724
- redactor.add(value);
725
- }
726
- browser = await chromium.launch();
727
- context = await browser.newContext();
728
- await new NetworkRecorder(redactor).install(context, policy);
729
- page = await context.newPage();
730
- const startUrl = absoluteUrl(String(request.params?.url ?? "/"), profile);
731
- await page.goto(startUrl, { waitUntil: "domcontentloaded" });
732
- view = new ExplorerView(page, redactor);
733
- recorder = new Recorder(page, view, redactor, String(request.params?.feature ?? "explore"), profile.profile ?? path2.basename(environmentFile, path2.extname(environmentFile)), startUrl);
734
- totalTimer = setTimeout(() => void shutdown(), 60 * 60 * 1000);
735
- reply(request.id, await view.observe({ full: true }));
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
- value = secretValue(secretKey, profile);
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.waitForLoadState("domcontentloaded").catch(() => {});
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
- reply(request.id, { actual: current.redactor.value(kind === "each_text" ? await locator.allInnerTexts() : expected), view: await current.view.observe() });
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
- handle(JSON.parse(line));
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": "0973ff6be3b4a3cdd332b90cc6c09ebe96b4ea98bc1e1273be6c09b9bf79efd9",
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
@@ -1,5 +1,5 @@
1
1
  export const PRODUCT_NAME = "Devflow Pi";
2
- export const PRODUCT_VERSION = "0.1.97";
2
+ export const PRODUCT_VERSION = "0.1.98";
3
3
  export const PROJECT_SCHEMA_VERSION = 1;
4
4
  export const OMP_RUNTIME_VERSION = "17.2.7";
5
5