@guanlin4924/devflow-pi 0.1.96 → 0.1.97

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.
Files changed (38) hide show
  1. package/README.md +11 -1
  2. package/package.json +1 -1
  3. package/plugins/devflow-core/extensions/devflow-core.ts +678 -91
  4. package/plugins/devflow-core/skills/wireframe-render/SKILL.md +1 -1
  5. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/acceptance_test_runner.py +18 -3
  6. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/acceptance_test_suite.rb +6 -1
  7. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/advance_state.rb +64 -8
  8. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/dsl_coverage.py +3 -0
  9. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/dsl_v1.py +52 -2
  10. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/execution_contract.rb +2 -0
  11. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/explore_preflight.py +58 -0
  12. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/init_workflow.rb +1 -0
  13. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/locator_map.py +156 -0
  14. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/machine_evidence.rb +1 -0
  15. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/playwright_adapter.py +1 -1
  16. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/requirement_graph.rb +50 -15
  17. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/session_rollover.rb +60 -0
  18. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/startup_preflight.py +167 -0
  19. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/validate_state.rb +5 -0
  20. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/wireframe_render.py +262 -56
  21. package/plugins/devflow-core/skills/workflow-orchestrator/templates/regression-acceptance.yaml +126 -0
  22. package/plugins/devflow-core/skills/workflow-pipeline/SKILL.md +2 -0
  23. package/plugins/devflow-quality/runtime/playwright/devflow-test-dsl-v1.schema.json +103 -88
  24. package/plugins/devflow-quality/runtime/playwright/explore-host.mjs +879 -0
  25. package/plugins/devflow-quality/runtime/playwright/runner.spec.mjs +297 -15
  26. package/plugins/devflow-quality/runtime/playwright/runtime-manifest.json +3 -2
  27. package/plugins/devflow-quality/skills/workflow-acceptance-test-design/SKILL.md +4 -0
  28. package/plugins/devflow-quality/skills/workflow-generate-mainflow-cases/SKILL.md +10 -2
  29. package/plugins/devflow-quality/skills/workflow-generate-mainflow-cases/references/intermediate-format.md +6 -2
  30. package/plugins/devflow-quality/skills/workflow-generate-mainflow-cases/references/project-dsl-contract.md +3 -1
  31. package/plugins/devflow-quality/skills/workflow-generate-mainflow-cases/references/ui-mainflow-rules.md +4 -2
  32. package/plugins/devflow-quality/skills/workflow-generate-mainflow-cases/scripts/compile_dsl.py +98 -15
  33. package/plugins/devflow-quality/skills/workflow-generate-mainflow-cases/scripts/locator_report.py +73 -0
  34. package/plugins/devflow-quality/skills/workflow-regression-test-design/SKILL.md +69 -0
  35. package/plugins/devflow-quality/skills/workflow-release-closeout/SKILL.md +1 -1
  36. package/plugins/devflow-quality/skills/workflow-run-acceptance-tests/SKILL.md +1 -1
  37. package/src/constants.ts +1 -1
  38. package/src/startup-briefing.ts +109 -0
@@ -218,6 +218,8 @@ var UI_ACTIONS = new Set([
218
218
  "wait_for",
219
219
  "assert_visible",
220
220
  "assert_text",
221
+ "assert_each_text",
222
+ "assert_value",
221
223
  "assert_url",
222
224
  "screenshot"
223
225
  ]);
@@ -225,6 +227,13 @@ var API_ACTIONS = new Set(["request"]);
225
227
  var BUILTIN_VARIABLES = new Set(["execution_id", "namespace"]);
226
228
  var VAR_REF = /\$\{([^}]*)\}/g;
227
229
  var VAR_NAME = /^[A-Za-z_][A-Za-z0-9_]{0,63}$/;
230
+ var FRAGILE_CSS = [
231
+ /el-table_\d+(?:_column_\d+)?/,
232
+ /:nth-(?:last-)?(?:child|of-type)\(/,
233
+ /\[data-v-[0-9a-fA-F]{8}\]/,
234
+ /[A-Za-z]+[-_][0-9a-fA-F]{6,}/,
235
+ /_[A-Za-z0-9]{5,}_/
236
+ ];
228
237
  function collectRefs(value, out = new Set) {
229
238
  if (typeof value === "string") {
230
239
  for (const m of value.matchAll(VAR_REF))
@@ -305,11 +314,54 @@ function semanticIssues(dsl) {
305
314
  }
306
315
  defined.add(name);
307
316
  }
317
+ } else if (step.target && typeof step.target === "object") {
318
+ semanticTargetIssues(step.target, `${at}/target`, issues, step.action);
308
319
  }
309
320
  });
310
321
  }
311
322
  return issues;
312
323
  }
324
+ function semanticTargetIssues(target, at, issues, action, depth = 0) {
325
+ const locatorKeys = ["testid", "role", "label", "placeholder", "text", "css"].filter((key) => (key in target));
326
+ if (locatorKeys.length > 1)
327
+ issues.push({ path: at, message: "target must contain exactly one locator form" });
328
+ if ("role" in target && !("name" in target))
329
+ issues.push({ path: at, message: "role target requires name" });
330
+ if ("name" in target && !("role" in target))
331
+ issues.push({ path: at, message: "name is only valid with role" });
332
+ if (typeof target.css === "string" && FRAGILE_CSS.some((pattern) => pattern.test(target.css))) {
333
+ issues.push({ path: `${at}/css`, message: "fragile generated/structural CSS is not allowed; use table/column, within, row, or a mapping anchor" });
334
+ }
335
+ if ("table" in target) {
336
+ const table = target.table;
337
+ const choices = table && typeof table === "object" ? ["near_heading", "testid", "only"].filter((key) => (key in table)) : [];
338
+ if (choices.length !== 1)
339
+ issues.push({ path: `${at}/table`, message: "table requires exactly one of near_heading, testid, or only" });
340
+ if (!("column" in target) && !("row" in target))
341
+ issues.push({ path: at, message: "table target requires column or row" });
342
+ if (target.table_kind !== undefined && target.table_kind !== "html" && target.table_kind !== "element-plus") {
343
+ issues.push({ path: `${at}/table_kind`, message: "table_kind must be html or element-plus" });
344
+ }
345
+ }
346
+ if ("column" in target && !("table" in target))
347
+ issues.push({ path: `${at}/column`, message: "column requires table" });
348
+ if (target.row && typeof target.row === "object") {
349
+ const row = target.row;
350
+ if ("equals" in row === "contains" in row)
351
+ issues.push({ path: `${at}/row`, message: "row requires exactly one of equals or contains" });
352
+ if (!("column" in row))
353
+ issues.push({ path: `${at}/row`, message: "row requires column" });
354
+ }
355
+ if ("table" in target && !("row" in target) && action !== "assert_each_text") {
356
+ issues.push({ path: at, message: "table/column targets require assert_each_text or a row restriction" });
357
+ }
358
+ if (target.within !== undefined) {
359
+ if (depth >= 2)
360
+ issues.push({ path: `${at}/within`, message: "within nesting exceeds maximum depth 2" });
361
+ else if (target.within && typeof target.within === "object")
362
+ semanticTargetIssues(target.within, `${at}/within`, issues, action, depth + 1);
363
+ }
364
+ }
313
365
 
314
366
  class UnsetVariableError extends Error {
315
367
  variable;
@@ -697,13 +749,22 @@ async function executeApi(step, ctx) {
697
749
  }
698
750
  }
699
751
  for (const [name, spec] of Object.entries(step.extract ?? {})) {
700
- const found = parsed ? jsonPointer(json, spec.pointer) : { found: false };
701
- if (!found.found) {
702
- throw new StepFailure("failed", "extract_missing", `extract ${name}: pointer ${spec.pointer} not found`, details);
752
+ let value;
753
+ if (spec.header !== undefined) {
754
+ const headers2 = response.headers();
755
+ const header = Object.entries(headers2).find(([key]) => key.toLowerCase() === spec.header.toLowerCase());
756
+ if (!header)
757
+ throw new StepFailure("failed", "extract_missing", `extract ${name}: header ${spec.header} not found`, details);
758
+ value = header[1];
759
+ } else {
760
+ const found = parsed ? jsonPointer(json, spec.pointer ?? "") : { found: false };
761
+ if (!found.found)
762
+ throw new StepFailure("failed", "extract_missing", `extract ${name}: pointer ${spec.pointer} not found`, details);
763
+ value = found.value;
703
764
  }
704
- if (spec.sensitive)
705
- ctx.redactor.add(found.value);
706
- ctx.vars.set(name, found.value);
765
+ if (spec.header !== undefined ? spec.sensitive !== false : spec.sensitive === true)
766
+ ctx.redactor.add(value);
767
+ ctx.vars.set(name, value);
707
768
  }
708
769
  return details;
709
770
  }
@@ -712,31 +773,198 @@ async function executeApi(step, ctx) {
712
773
  import path from "node:path";
713
774
  import fs from "node:fs";
714
775
  var POLL_MS = 100;
715
- function buildLocator(page, target, ctx) {
776
+ var normalized = (value) => value.replace(/\s+/g, " ").trim();
777
+ var rootLocator = (root, selector) => root.locator(selector);
778
+ function baseLocator(root, target, ctx) {
716
779
  const text = (key) => interpolate(String(target[key]), ctx.vars);
717
780
  const exact = target.exact === true;
718
781
  const q = (s) => `'${ctx.redactor.text(s).replace(/'/g, "\\'")}'`;
719
782
  const exactSuffix = exact ? ", { exact: true }" : "";
720
783
  if ("testid" in target)
721
- return { locator: page.getByTestId(text("testid")), description: `getByTestId(${q(text("testid"))})` };
784
+ return { locator: root.getByTestId(text("testid")), description: `getByTestId(${q(text("testid"))})`, strategy: "testid" };
722
785
  if ("role" in target) {
723
786
  const name = text("name");
724
787
  return {
725
- locator: page.getByRole(target.role, { name, exact }),
726
- description: `getByRole('${String(target.role)}', { name: ${q(name)}${exact ? ", exact: true" : ""} })`
788
+ locator: root.getByRole(target.role, { name, exact }),
789
+ description: `getByRole('${String(target.role)}', { name: ${q(name)}${exact ? ", exact: true" : ""} })`,
790
+ strategy: "role"
727
791
  };
728
792
  }
729
793
  if ("label" in target)
730
- return { locator: page.getByLabel(text("label"), { exact }), description: `getByLabel(${q(text("label"))}${exactSuffix})` };
794
+ return { locator: root.getByLabel(text("label"), { exact }), description: `getByLabel(${q(text("label"))}${exactSuffix})`, strategy: "label" };
731
795
  if ("placeholder" in target) {
732
- return { locator: page.getByPlaceholder(text("placeholder"), { exact }), description: `getByPlaceholder(${q(text("placeholder"))}${exactSuffix})` };
796
+ return { locator: root.getByPlaceholder(text("placeholder"), { exact }), description: `getByPlaceholder(${q(text("placeholder"))}${exactSuffix})`, strategy: "placeholder" };
733
797
  }
734
798
  if ("text" in target)
735
- return { locator: page.getByText(text("text"), { exact }), description: `getByText(${q(text("text"))}${exactSuffix})` };
799
+ return { locator: root.getByText(text("text"), { exact }), description: `getByText(${q(text("text"))}${exactSuffix})`, strategy: "text" };
736
800
  if ("css" in target)
737
- return { locator: page.locator(text("css")), description: `locator(${q(text("css"))})` };
801
+ return { locator: root.locator(text("css")), description: `locator(${q(text("css"))})`, strategy: "css" };
738
802
  throw new StepFailure("blocked", "invalid_target", "target has no supported locator form");
739
803
  }
804
+ async function visibleLogicalTables(root, kind) {
805
+ const selector = kind === "element-plus" ? ".el-table:visible" : "table:visible";
806
+ const tables = rootLocator(root, selector);
807
+ return Array.from({ length: await tables.count() }, (_, i) => tables.nth(i));
808
+ }
809
+ async function chooseTable(root, target, ctx) {
810
+ const kind = target.table_kind === "element-plus" ? "element-plus" : "html";
811
+ let tables = await visibleLogicalTables(root, kind);
812
+ const ref = target.table ?? { only: true };
813
+ if (ref.testid !== undefined) {
814
+ const candidate = root.getByTestId(interpolate(String(ref.testid), ctx.vars));
815
+ const count = await candidate.count();
816
+ if (count === 0)
817
+ throw new StepFailure("failed", "table_not_found", `no table matches testid '${ref.testid}'`, { locator_strategy: "table_column", locator_details: { table: "testid", match_count: 0 } });
818
+ if (count > 1)
819
+ throw new StepFailure("failed", "table_ambiguous", `multiple tables match testid '${ref.testid}'`, { locator_strategy: "table_column", locator_details: { table: "testid", match_count: count } });
820
+ const candidateTag = await candidate.evaluate((el) => el.tagName.toLowerCase());
821
+ if (kind === "html" && candidateTag === "table")
822
+ return { table: candidate, index: 0, kind };
823
+ const table = candidate.locator(kind === "element-plus" ? ".el-table__header-wrapper" : "table").first();
824
+ if (await table.count() === 0)
825
+ throw new StepFailure("blocked", "table_adapter_mismatch", "table testid does not contain the expected table structure");
826
+ return { table: candidate, index: 0, kind };
827
+ }
828
+ if (ref.near_heading !== undefined) {
829
+ const heading = interpolate(String(ref.near_heading), ctx.vars);
830
+ const headings = root.getByRole("heading", { name: heading, exact: true });
831
+ if (await headings.count() === 0)
832
+ throw new StepFailure("failed", "table_not_found", `no visible heading '${heading}' identifies a table`, { locator_strategy: "table_column", locator_details: { near_heading: heading } });
833
+ if (await headings.count() > 1)
834
+ throw new StepFailure("failed", "table_ambiguous", `multiple headings '${heading}' identify tables`, { locator_strategy: "table_column", locator_details: { near_heading: heading, heading_matches: await headings.count() } });
835
+ const headingHandle = await headings.elementHandle();
836
+ const index = await rootLocator(root, kind === "element-plus" ? ".el-table:visible" : "table:visible").evaluateAll((els, h) => {
837
+ if (!h)
838
+ return -1;
839
+ const after = els.map((el, i) => ({ el, i })).filter(({ el }) => Boolean(h.compareDocumentPosition(el) & Node.DOCUMENT_POSITION_FOLLOWING));
840
+ return after.length ? after[0].i : -1;
841
+ }, headingHandle);
842
+ await headingHandle?.dispose();
843
+ if (index < 0)
844
+ throw new StepFailure("failed", "table_not_found", `no visible table follows heading '${heading}'`, { locator_strategy: "table_column", locator_details: { near_heading: heading } });
845
+ return { table: tables[index], index, kind };
846
+ }
847
+ if (tables.length === 0)
848
+ throw new StepFailure("failed", "table_not_found", "no visible table found", { locator_strategy: "table_column", locator_details: { table: "only", match_count: 0 } });
849
+ if (tables.length > 1)
850
+ throw new StepFailure("failed", "table_ambiguous", `expected one visible table, found ${tables.length}`, { locator_strategy: "table_column", locator_details: { table: "only", match_count: tables.length } });
851
+ return { table: tables[0], index: 0, kind };
852
+ }
853
+ async function tableLocator(root, target, ctx) {
854
+ const selected = await chooseTable(root, target, ctx);
855
+ const column = target.column ?? target.row?.column;
856
+ if (typeof column !== "string")
857
+ throw new StepFailure("blocked", "invalid_target", "table target requires a column");
858
+ const headingText = interpolate(column, ctx.vars);
859
+ let cells;
860
+ let bodyRows;
861
+ if (selected.kind === "element-plus") {
862
+ const headerWrappers = selected.table.locator(":scope > .el-table__header-wrapper, :scope > .el-table__inner-wrapper > .el-table__header-wrapper");
863
+ const bodyWrappers = selected.table.locator(":scope > .el-table__body-wrapper, :scope > .el-table__inner-wrapper > .el-table__body-wrapper");
864
+ if (await headerWrappers.count() !== 1 || await bodyWrappers.count() !== 1) {
865
+ throw new StepFailure("blocked", "table_adapter_mismatch", "Element Plus table must have exactly one header and body wrapper", { locator_strategy: "table_column", locator_details: { table_kind: selected.kind } });
866
+ }
867
+ const headers = headerWrappers.locator("th:visible");
868
+ const matches = [];
869
+ for (let i = 0;i < await headers.count(); i++) {
870
+ if (normalized(await headers.nth(i).innerText()) === normalized(headingText))
871
+ matches.push(i);
872
+ }
873
+ if (matches.length === 0)
874
+ throw new StepFailure("failed", "column_header_not_found", `no visible table header equals '${headingText}'`, { locator_strategy: "table_column", locator_details: { column: headingText, header_matches: 0 } });
875
+ if (matches.length > 1)
876
+ throw new StepFailure("failed", "column_header_ambiguous", `multiple visible table headers equal '${headingText}'`, { locator_strategy: "table_column", locator_details: { column: headingText, header_matches: matches.length } });
877
+ const th = headers.nth(matches[0]);
878
+ const classes = (await th.getAttribute("class") ?? "").split(/\s+/).filter((name) => /^el-table_\d+_column_\d+$/.test(name));
879
+ if (classes.length !== 1)
880
+ throw new StepFailure("blocked", "table_adapter_mismatch", `header '${headingText}' does not have one Element Plus column class`);
881
+ const className = classes[0];
882
+ cells = bodyWrappers.locator(`td.${className}:visible`);
883
+ bodyRows = bodyWrappers.locator("tbody tr:visible");
884
+ } else {
885
+ const headers = selected.table.locator("thead th:visible");
886
+ if (await headers.count() === 0)
887
+ throw new StepFailure("blocked", "table_adapter_mismatch", "HTML table has no visible thead headers");
888
+ let columnIndex = -1;
889
+ let offset = 0;
890
+ for (let i = 0;i < await headers.count(); i++) {
891
+ const th = headers.nth(i);
892
+ const span = Number(await th.getAttribute("colspan") ?? "1");
893
+ if (normalized(await th.innerText()) === normalized(headingText)) {
894
+ if (columnIndex >= 0)
895
+ throw new StepFailure("failed", "column_header_ambiguous", `multiple table headers equal '${headingText}'`);
896
+ columnIndex = offset;
897
+ }
898
+ offset += Number.isFinite(span) && span > 0 ? span : 1;
899
+ }
900
+ if (columnIndex < 0)
901
+ throw new StepFailure("failed", "column_header_not_found", `no table header equals '${headingText}'`, { locator_strategy: "table_column", locator_details: { column: headingText, header_matches: 0 } });
902
+ bodyRows = selected.table.locator("tbody tr:visible");
903
+ cells = selected.table.locator(`tbody tr:visible > td:visible:nth-child(${columnIndex + 1})`);
904
+ }
905
+ const rowSpec = target.row;
906
+ if (rowSpec) {
907
+ const matchingRows = [];
908
+ for (let i = 0;i < await bodyRows.count(); i++) {
909
+ const row2 = bodyRows.nth(i);
910
+ const cell = selected.kind === "element-plus" ? row2.locator(`td.${(await cells.first().getAttribute("class") ?? "").split(/\s+/).find((x) => /^el-table_\d+_column_\d+$/.test(x)) ?? "__missing__"}`).first() : row2.locator("td:visible").nth(await cells.first().evaluate((el) => Array.from(el.parentElement?.children ?? []).indexOf(el)));
911
+ const value = normalized(await cell.innerText().catch(() => ""));
912
+ const expected = normalized(interpolate(String(rowSpec.equals ?? rowSpec.contains), ctx.vars));
913
+ if (rowSpec.equals !== undefined ? value === expected : value.includes(expected))
914
+ matchingRows.push(i);
915
+ }
916
+ if (matchingRows.length === 0)
917
+ throw new StepFailure("failed", "row_not_found", `no row has ${rowSpec.equals !== undefined ? "value" : "text"} '${rowSpec.equals ?? rowSpec.contains}'`, { locator_strategy: "row", locator_details: { row_matches: 0, column: rowSpec.column } });
918
+ if (matchingRows.length > 1)
919
+ throw new StepFailure("failed", "row_ambiguous", `multiple rows match '${rowSpec.equals ?? rowSpec.contains}'`, { locator_strategy: "row", locator_details: { row_matches: matchingRows.length, column: rowSpec.column } });
920
+ const row = bodyRows.nth(matchingRows[0]);
921
+ if (target.role || target.testid || target.label || target.placeholder || target.text || target.css) {
922
+ const inner = baseLocator(row, target, ctx);
923
+ return { ...inner, strategy: "row", details: { row_matches: 1, column: rowSpec.column } };
924
+ }
925
+ cells = selected.kind === "element-plus" ? row.locator(`td.${(await cells.first().getAttribute("class") ?? "").split(/\s+/).find((x) => /^el-table_\d+_column_\d+$/.test(x)) ?? "__missing__"}`).first() : row.locator("td:visible").nth(await cells.first().evaluate((el) => Array.from(el.parentElement?.children ?? []).indexOf(el)));
926
+ }
927
+ const quoted = `'${ctx.redactor.text(headingText).replace(/'/g, "\\'")}'`;
928
+ return { locator: cells, description: `table column ${quoted}`, strategy: "table_column", details: { table_kind: selected.kind, column: headingText, table_index: selected.index, data_dependent: Boolean(rowSpec) } };
929
+ }
930
+ var STRUCTURED_RETRY_CODES = new Set([
931
+ "element_not_found",
932
+ "strict_locator_violation",
933
+ "table_not_found",
934
+ "table_ambiguous",
935
+ "column_header_not_found",
936
+ "column_header_ambiguous",
937
+ "row_not_found",
938
+ "row_ambiguous",
939
+ "table_adapter_mismatch"
940
+ ]);
941
+ async function buildLocator(page, target, ctx, deadline = Date.now() + DEFAULT_UI_TIMEOUT_MS) {
942
+ if (!target.within && !target.table)
943
+ return buildLocatorOnce(page, target, ctx, deadline);
944
+ for (;; ) {
945
+ try {
946
+ return await buildLocatorOnce(page, target, ctx, deadline);
947
+ } catch (error) {
948
+ const retryable = error instanceof StepFailure && STRUCTURED_RETRY_CODES.has(error.code);
949
+ if (!retryable || Date.now() >= deadline)
950
+ throw error;
951
+ await sleep(POLL_MS);
952
+ }
953
+ }
954
+ }
955
+ async function buildLocatorOnce(page, target, ctx, deadline) {
956
+ if (target.within) {
957
+ const scope = await buildLocator(page, target.within, ctx, deadline);
958
+ const count = await scope.locator.count();
959
+ if (count !== 1)
960
+ throw new StepFailure("failed", count === 0 ? "element_not_found" : "strict_locator_violation", `within target must match exactly one element`, { locator: scope.description, match_count: count });
961
+ const inner = target.table ? await tableLocator(scope.locator, target, ctx) : baseLocator(scope.locator, target, ctx);
962
+ return { ...inner, strategy: "within", details: { within: scope.description, ...inner.details ?? {} } };
963
+ }
964
+ if (target.table)
965
+ return tableLocator(page, target, ctx);
966
+ return baseLocator(page, target, ctx);
967
+ }
740
968
  async function resolveStrict(locator, description, deadline) {
741
969
  let count = 0;
742
970
  for (;; ) {
@@ -784,9 +1012,11 @@ async function executeUi(step, ctx) {
784
1012
  const details = {};
785
1013
  let locator = null;
786
1014
  if (step.target) {
787
- const built = buildLocator(page, step.target, ctx);
1015
+ const built = await buildLocator(page, step.target, ctx, deadline);
788
1016
  locator = built.locator;
789
1017
  details.locator = built.description;
1018
+ details.locator_strategy = built.strategy;
1019
+ details.locator_details = built.details;
790
1020
  }
791
1021
  const withDetails = (err) => {
792
1022
  if (err instanceof StepFailure)
@@ -877,6 +1107,58 @@ async function executeUi(step, ctx) {
877
1107
  details.assertion = { match, expected: ctx.redactor.text(expectedRaw), actual: ctx.redactor.text(actualRaw) };
878
1108
  break;
879
1109
  }
1110
+ case "assert_each_text": {
1111
+ const minCount = Number(step.min_count ?? 1);
1112
+ const normalizeWhitespace = step.normalize_whitespace !== false;
1113
+ const caseSensitive = step.case_sensitive !== false;
1114
+ const match = String(step.match);
1115
+ const expectedRaw = interpolate(String(step.expected), ctx.vars);
1116
+ const expected = normalizeText(expectedRaw, normalizeWhitespace, caseSensitive);
1117
+ let count = 0;
1118
+ let actualRaw = "";
1119
+ for (;; ) {
1120
+ count = await locator.count();
1121
+ if (count >= minCount) {
1122
+ const texts = await locator.allInnerTexts();
1123
+ const firstBad = texts.find((value) => !textMatches(normalizeText(value, normalizeWhitespace, caseSensitive), expected, match));
1124
+ if (!firstBad) {
1125
+ details.match_count = count;
1126
+ actualRaw = texts[0] ?? "";
1127
+ break;
1128
+ }
1129
+ actualRaw = firstBad;
1130
+ }
1131
+ if (Date.now() >= deadline) {
1132
+ details.match_count = count;
1133
+ details.assertion = { match, expected: ctx.redactor.text(expectedRaw), actual: ctx.redactor.text(actualRaw) };
1134
+ throw new StepFailure("failed", "assertion_failed", `each text ${match} assertion failed`);
1135
+ }
1136
+ await sleep(POLL_MS);
1137
+ }
1138
+ details.assertion = { match, expected: ctx.redactor.text(expectedRaw), actual: ctx.redactor.text(actualRaw) };
1139
+ break;
1140
+ }
1141
+ case "assert_value": {
1142
+ details.match_count = await resolveStrict(locator, details.locator, deadline);
1143
+ const normalizeWhitespace = step.normalize_whitespace !== false;
1144
+ const caseSensitive = step.case_sensitive !== false;
1145
+ const match = String(step.match);
1146
+ const expectedRaw = interpolate(String(step.expected), ctx.vars);
1147
+ const expected = normalizeText(expectedRaw, normalizeWhitespace, caseSensitive);
1148
+ let actualRaw = "";
1149
+ for (;; ) {
1150
+ actualRaw = await locator.inputValue({ timeout: Math.max(remaining(deadline), 1000) });
1151
+ if (textMatches(normalizeText(actualRaw, normalizeWhitespace, caseSensitive), expected, match))
1152
+ break;
1153
+ if (Date.now() >= deadline) {
1154
+ details.assertion = { match, expected: ctx.redactor.text(expectedRaw), actual: ctx.redactor.text(actualRaw) };
1155
+ throw new StepFailure("failed", "assertion_failed", `value ${match} assertion failed`);
1156
+ }
1157
+ await sleep(POLL_MS);
1158
+ }
1159
+ details.assertion = { match, expected: ctx.redactor.text(expectedRaw), actual: ctx.redactor.text(actualRaw) };
1160
+ break;
1161
+ }
880
1162
  case "assert_url": {
881
1163
  const match = String(step.match);
882
1164
  const raw = interpolate(String(step.expected), ctx.vars);
@@ -3,9 +3,10 @@
3
3
  "runtime_version": "0.1.0",
4
4
  "playwright_version": "1.63.0",
5
5
  "files": {
6
- "runner.spec.mjs": "5612cde9ddef5a203829d86ab45788d65cbe83891ff7dbb73c308aa9345ead83",
6
+ "runner.spec.mjs": "abf0c5c9aff43b4f233de4c44db0946001c970758ef63ab861fad1d125c1a6fc",
7
7
  "playwright.config.mjs": "301c0b97e229f83e6934994718b11b93a1da5d46ec606cfd11a8292e9d622b02",
8
8
  "reporter.mjs": "5c91d68c50a93603f583cf82345d427bcd9ce9a7cfb78fd6d0989abddbd55275",
9
- "devflow-test-dsl-v1.schema.json": "21ce608adcaf60ac138b532b7731f3b74c3e2749cba26dfeb12fc12ce0b6e5f3"
9
+ "explore-host.mjs": "0973ff6be3b4a3cdd332b90cc6c09ebe96b4ea98bc1e1273be6c09b9bf79efd9",
10
+ "devflow-test-dsl-v1.schema.json": "df07e55507697a70df214ae820403b530b04aeb04a12d0aa85715bf5e528e194"
10
11
  }
11
12
  }
@@ -48,11 +48,15 @@ description: "Use after integration verification and before change planning to d
48
48
  "preconditions": ["身份、数据和环境前置条件"],
49
49
  "steps": ["测试操作"],
50
50
  "oracle": "可观察的预期结果",
51
+ "oracle_source": "requirement|contract|invariant|characterization",
52
+ "oracle_evidence": "非 requirement 来源的约定、不变量说明或观察记录",
51
53
  "automation_mode": "automated|manual|blocked",
52
54
  "evidence_strategy": "replay|causal|observation"
53
55
  }
54
56
  ```
55
57
 
58
+ `oracle_source` 是冻结 TC 的判定标准来源。功能开发工作流缺省为 `requirement`;回归验收工作流必须显式填写。`contract`、`invariant`、`characterization` 必须同时填写 `oracle_evidence`,且回归用例可以省略 `acceptance_refs`。`characterization` 只证明行为未变化,不证明行为正确;不得把依赖环境数据的具体值写成判定标准。
59
+
56
60
  - `execution_surface` 必须显式填写:单个 Core HTTP 请求且断言来自 `execution` 用 `http`;真实页面交互用 `ui`;受限 DSL 中的多步骤 API 或 UI/API 组合用 `api_flow`;项目自有测试命令、权限、边界、韧性、迁移和性能专项用 `project_test`。`page` 必须对应 `ui`,带 `execution.protocol: http` 的 Core API TC 必须对应 `http`;新冻结 TC 缺失或冲突会被 CP-3 拒绝。
57
61
  - `automated` 的 `ui` / `api_flow` TC 在 `run-acceptance-tests` 绑定阶段必须有 v1 DSL(`workflow-generate-mainflow-cases` 生成,`covers` 逐条映射 `preconditions[i]`、`steps[i]` 和 `oracle`)并经用户批准 `execution_plan`,否则只会 blocked。因此每个前置条件和步骤要写成可单独映射的原子条目,oracle 要写成确定性的可观察结果(具体文案、URL、响应字段);需要纯视觉判断、缺少稳定 locator 或无法准备/隔离数据的场景,现在就标 `manual` 或 `blocked`,不要标 `automated`。
58
62
  - `manual` 额外写 `manual_reason` 和 `owner`;`blocked` 额外写 `blocked_by` 和 `owner`。
@@ -9,23 +9,31 @@ description: "Use after code review, as the binding-phase prerequisite of run-ac
9
9
 
10
10
  ## Hard Rules
11
11
 
12
+ - `devflow_explore` 只能辅助回归设计或本步骤的 DSL 绑定;它产生的视图、断言和 draft 都不是验收证据。只能消费经人工检查的草稿,仍须运行本技能的编译、目录校验、独立审查和 `execution_plan` 审批流程。
13
+ - 探索草稿中的 secret 变量只能保持 `${KEY}` 引用,禁止读取、回填或输出凭据值;页面中的个人信息也不能被当作通用 oracle。
14
+
12
15
  - MUST NOT hardcode `platform-backend` or `platform-frontend`.
13
16
  - MUST derive code roots from `.devflow/planning/navigation` and `.devflow/context/spec`.
14
17
  - MUST use `.devflow/planning/code-index/` for fine-grained entrypoint, route, component, API, call, import, and data-flow evidence when present.
15
18
  - MUST read the frozen `.devflow/planning/requirements/{feature}/tests.json` before selecting scope. Only frozen, `confirmed`, `automation_mode: automated` TCs whose `execution_surface` is `ui` or `api_flow` get a DSL (legacy `page` TCs derive to `ui`). `http` TCs (including Core-generated API TCs with `execution.expect.error_code`) and `project_test` TCs are executed by their own adapters, not here.
16
19
  - MUST NOT read `SUMMARY.md`, delivery self-reports, or diffs to decide what to test. Current code is allowed only to bind an already frozen TC to an entrypoint, `data-testid`/locator, request path, or test data; it must never change the TC's steps or oracle.
17
20
  - MUST NOT hand-write or hand-edit `TC-xxxx.json`. Write `case-descriptions.json` and run `compile_dsl.py`; the compiler owns `tc_sha256`, target/driver generation, and hard rejections (`ai_only`, Midscene, xpath, scripts, token injection, fixed waits).
21
+ - MUST resolve `anchor:<page-key>/<anchor-name>` locators from `.devflow/planning/test-locators/<page-key>.json` during compilation. Missing anchors and cycles block generation; generated steps retain `source_anchor` for the internal locator-stability report. Runtime never reads mapping files.
22
+ - MUST prefer an existing `data-testid`, then mapping anchor, then role/name, label/placeholder, structured table/within/row target, text, and finally restricted CSS. Never require a frontend change to make generation pass.
18
23
  - MUST run `compile_dsl.py`, then `validate_case_payloads.py` on the whole `generated-cases/{feature}` directory, before reporting success. Block if either exits non-zero.
19
24
  - MUST block if codebase discovery fails.
20
25
  - MUST get an independent AI review of every DSL against its TC oracle (advisory) before proposing approvals. The reviewer must not be the agent that wrote the descriptions.
21
26
  - MUST propose one `execution_plan` approval per automated TC with `devflow_approval`; only the user can approve it, in `/devflow` → “待审批”. The agent cannot confirm, must not write approval records, and must not treat an unapproved DSL as accepted.
27
+ - MUST inspect `.devflow/planning/features/{feature}/locator-stability.md` after compilation; it is an internal record of locator strategy, risk, reason, and optional frontend improvements.
22
28
  - MUST NOT create `case-import-payloads.json`. That name belongs only to the explicit legacy TestAgent export and is never a Runtime DSL artifact (see Legacy export).
23
29
 
24
30
  ## Workflow Position
25
31
 
26
- This skill is not a workflow step and must not appear in `pending_steps` or participate in main-chain ordering. It runs inside the binding phase of `run-acceptance-tests`:
32
+ 功能开发工作流中,本技能的前置条件是代码审查已完成;回归验收工作流中,它只在 `regression-dsl` 步骤运行。本技能不得把功能开发的代码审查加入回归主链:
33
+
34
+ `feature-development: execute -> code-review -> [human acceptance] -> generate DSL -> bind + user approves execution plans -> run-acceptance-tests`
27
35
 
28
- `execute -> code-review -> [human acceptance] -> generate DSL (this skill) -> bind + user approves execution plans -> run-acceptance-tests -> release closeout`
36
+ `regression-acceptance: regression-test-design -> regression-dsl (this skill) -> bind + user approves execution plans -> run-acceptance-tests`
29
37
 
30
38
  Before running, verify that the current feature's `code-review` is completed and the frozen TC suite is fresh (`devflow_acceptance_tests(feature)`). A generation, validation, or approval failure means the affected TCs cannot run as `devflow_playwright`; report it and either fix it or downgrade the TC (`manual` with a `mode_override` approval, or `blocked` with owner/reason). It never mutates workflow state itself.
31
39
 
@@ -1,6 +1,8 @@
1
1
  # Case Descriptions Format (`case-descriptions.json`)
2
2
 
3
- `case-descriptions.json` 是 AI 写给 `scripts/compile_dsl.py` 的输入,编译产物是每个冻结 TC 一个 `devflow-test-dsl/v1` 文件 `.devflow/planning/generated-cases/{feature}/TC-xxxx.json`(形状见 `project-dsl-contract.md`)。**不要手写 DSL**:locator 写成 `"type:value"` 字符串,其余键与 DSL 同名,编译器负责生成 `target`、`driver`、`tc_sha256`、步骤默认 ID 并校验。
3
+ `case-descriptions.json` 是 AI 写给 `scripts/compile_dsl.py` 的输入,编译产物是每个冻结 TC 一个 `devflow-test-dsl/v1` 文件 `.devflow/planning/generated-cases/{feature}/TC-xxxx.json`(形状见 `project-dsl-contract.md`)。**不要手写 DSL**:普通 locator 写成 `"type:value"` 字符串;稳定定位映射可写成 `"anchor:<page-key>/<anchor-name>"`,表格、区域和行目标可直接写结构化 locator 对象。编译器负责生成 `target`、`driver`、`tc_sha256`、步骤默认 ID 并校验。
4
+
5
+ 映射文件放在 `.devflow/planning/test-locators/<page-key>.json`。锚点引用会在编译时严格展开,循环、缺失锚点、非法目标都会拒绝;生成步骤保留 `source_anchor` 供内部报告追溯。映射修改后必须重新编译并重新审批 DSL。
4
6
 
5
7
  本文件描述的是 `compile_dsl.py` 实际接受的格式;出现任何不在此列的键都会被拒绝(不会静默丢弃)。
6
8
 
@@ -85,6 +87,8 @@
85
87
  | `select` | `locator`、`value`(字符串或字符串数组,多选) | `exact` |
86
88
  | `wait_for` | `locator` | `state`(`visible`\|`hidden`\|`attached`\|`detached`,缺省 `visible`)、`exact` |
87
89
  | `assert_text` | `locator`、`expected` | `match`(`exact`\|`contains`,缺省 `exact`)、`normalize_whitespace`、`case_sensitive`、`exact` |
90
+ | `assert_each_text` | `locator`、`expected` | `match`、`min_count`(最小 1)、`normalize_whitespace`、`case_sensitive`、`exact` |
91
+ | `assert_value` | `locator`、`expected` | `match`、`normalize_whitespace`、`case_sensitive`、`exact`;expected 可为空 |
88
92
  | `assert_url` | `expected` | `match`(`exact`\|`contains`,缺省 `contains`) |
89
93
  | `screenshot` | — | `full_page` |
90
94
 
@@ -100,7 +104,7 @@
100
104
  | `headers`、`query`、`json` | 请求头、查询参数(标量)、JSON 请求体;字符串叶子可用 `${var}` |
101
105
  | `expect_status` | 期望 HTTP 状态码 |
102
106
  | `assertions` | `[ { "pointer": "/data/status", "op": "equals", "value": "created" } ]`;`op` 为 `equals`/`not_equals`/`contains`/`exists`/`not_exists`(后两者不带 `value`);`pointer` 是 JSON Pointer |
103
- | `extract` | `{ "order_id": "/data/id" }` 或 `{ "token": { "pointer": "/t", "sensitive": true } }`;只支持 JSON Pointer |
107
+ | `extract` | `{ "order_id": "/data/id" }` 或 `{ "token": { "pointer": "/t", "sensitive": true } }`;也支持 `{ "jwt": { "header": "x-jwt-token" } }`,header 默认敏感且大小写不敏感 |
104
108
  | `retry`、`idempotent` | `retry: { "max_attempts": 2-5, "on": ["connection_error"\|"timeout"\|"rate_limited"] }`;POST/PATCH 重试必须 `idempotent: true`;默认不重试 |
105
109
 
106
110
  API 步骤通过页面同一个 BrowserContext 发送,与 UI 登录态共享 cookie。`HEAD`/`OPTIONS` 不支持。响应体断言只能用 `assertions` 里的 JSON Pointer,不能用 JSONPath、`status_code`/`json_path_exists` 之类旧断言类型。
@@ -53,6 +53,8 @@ DSL 是纯数据,由 Devflow Playwright Runtime 解释执行,Core 用同一
53
53
  | `select` | `target`、`value`(字符串或字符串数组) |
54
54
  | `wait_for` | `target`、`state`(`visible`\|`hidden`\|`attached`\|`detached`);只做条件等待,无固定 sleep |
55
55
  | `assert_text` | `target`、`match`(`exact`\|`contains`)、`expected`,可选 `normalize_whitespace`(缺省 true)、`case_sensitive`(缺省 true) |
56
+ | `assert_each_text` | 多元素 `target`,每个元素都满足 `match`/`expected`;可选 `min_count`(缺省 1) |
57
+ | `assert_value` | 唯一 input/textarea/select 的当前值;`expected` 可为空字符串 |
56
58
  | `assert_url` | `match`、`expected` |
57
59
  | `screenshot` | 可选 `name`(`[A-Za-z0-9_-]{1,64}`)、`full_page` |
58
60
 
@@ -87,7 +89,7 @@ DSL 是纯数据,由 Devflow Playwright Runtime 解释执行,Core 用同一
87
89
  - `method`:`GET`/`POST`/`PUT`/`PATCH`/`DELETE`;`url` 以 `/` 开头相对 profile 的 `api_base_url`(缺省 `base_url`),绝对 URL 的 origin 须在 profile `api_origins` 中;不自动跟随重定向;
88
90
  - `headers`、`query`(标量)、`json`(任意 JSON,字符串叶子可引用变量);
89
91
  - `expect_status`(100–599);`assertions[]` 的 `op` 为 `equals`/`not_equals`/`contains`/`exists`/`not_exists`,`pointer` 为 JSON Pointer,`exists`/`not_exists` 不带 `value`,其余必须带;
90
- - `extract`:`{ name: { pointer, sensitive? } }`,之后的步骤可用 `${name}`,不得重定义已存在变量;
92
+ - `extract`:`{ name: { pointer, sensitive? } }` 或 `{ name: { header, sensitive? } }`,二选一;header 名大小写不敏感且缺失失败,header 默认 `sensitive: true`,之后的步骤可用 `${name}`,不得重定义已存在变量;
91
93
  - `retry: { max_attempts: 2–5, on: [connection_error|timeout|rate_limited] }`,只重试传输失败;POST/PATCH 需 `idempotent: true`;默认不重试;
92
94
  - 通过页面同一 BrowserContext 发送,与 UI 登录态共享 cookie;
93
95
  - `Authorization`、`Cookie`、`X-Api-Key` 等敏感请求头只能由 secret/sensitive 变量拼成,如 `"Bearer ${auth_token}"`。
@@ -22,7 +22,7 @@ UI 用例把冻结的、自动化的 `execution_surface: ui` TC(含存量推
22
22
 
23
23
  ## Step Rules
24
24
 
25
- - 只用 v1 UI 动作:`goto`、`click`、`fill`、`select`、`check`、`uncheck`、`wait_for`、`assert_visible`、`assert_text`、`assert_url`、`screenshot`。没有 `hover`、`wait`(固定/加载等待)、`verify`、`act`、`assert_url_contains`、`inject_token`、`evaluate_js`。
25
+ - 只用 v1 UI 动作:`goto`、`click`、`fill`、`select`、`check`、`uncheck`、`wait_for`、`assert_visible`、`assert_text`、`assert_each_text`、`assert_value`、`assert_url`、`screenshot`。没有 `hover`、`wait`(固定/加载等待)、`verify`、`act`、`assert_url_contains`、`inject_token`、`evaluate_js`。
26
26
  - 动作后需要等待时用 `wait_for`(locator + `state`),不要用固定 sleep;“URL 包含 X”用 `assert_url` + `match: "contains"`。
27
27
  - 需要预置数据或登录态时,用 `data.setup` 的 API 请求步骤,或显式 UI 登录步骤(`goto` + `fill` + `click`);没有 `auth` 快捷方式。凭据写成 `variables` 里的 `{ "secret": "ENV_KEY" }`,绝不写字面量。
28
28
  - 需要清理时写 `data.cleanup` 的 API 步骤。测试数据使用 `${namespace}`/`${execution_id}` 命名,避免多次运行互相污染。
@@ -30,7 +30,9 @@ UI 用例把冻结的、自动化的 `execution_surface: ui` TC(含存量推
30
30
 
31
31
  ## Locator 决策
32
32
 
33
- 顺序:`testid` → `role` → `label` / `placeholder` → `text` → `css`(最后手段)。
33
+ 顺序:已有 `testid` → 映射锚点 → `role` → `label` / `placeholder` → `table`+`column` / `within` / `row` → `text` → `css`(最后手段)。
34
+
35
+ 映射锚点写为 `anchor:<page-key>/<anchor-name>`,由编译器展开为完整目标;运行时不读取映射文件。表格、区域和行目标可直接写结构化 locator,例如 `{ "table": { "near_heading": "店铺列表" }, "column": "店铺名称", "table_kind": "element-plus" }`。`row` 是数据依赖定位,`within` 和表格列都必须满足严格唯一性。
34
36
 
35
37
  1. 源码里有 `data-testid` 就用 `testid:`。没有而该元素是主流程的关键控件时,在 `source-map.md` 里记录“缺少 testid”,并建议开发者补上,不要靠猜。
36
38
  2. 有明确 ARIA 角色和可访问名称:`role:button:确定`。