@sun-asterisk/sungen 3.2.24 → 3.2.26
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/capabilities/context-router.d.ts.map +1 -1
- package/dist/capabilities/context-router.js +15 -7
- package/dist/capabilities/context-router.js.map +1 -1
- package/dist/cli/commands/audit.d.ts.map +1 -1
- package/dist/cli/commands/audit.js +23 -4
- package/dist/cli/commands/audit.js.map +1 -1
- package/dist/cli/commands/capability.d.ts.map +1 -1
- package/dist/cli/commands/capability.js +6 -48
- package/dist/cli/commands/capability.js.map +1 -1
- package/dist/cli/commands/changed.d.ts +14 -0
- package/dist/cli/commands/changed.d.ts.map +1 -0
- package/dist/cli/commands/changed.js +128 -0
- package/dist/cli/commands/changed.js.map +1 -0
- package/dist/cli/commands/delivery.d.ts.map +1 -1
- package/dist/cli/commands/delivery.js +25 -1
- package/dist/cli/commands/delivery.js.map +1 -1
- package/dist/cli/commands/generate.js +1 -1
- package/dist/cli/commands/generate.js.map +1 -1
- package/dist/cli/commands/results-merge.d.ts +19 -0
- package/dist/cli/commands/results-merge.d.ts.map +1 -0
- package/dist/cli/commands/results-merge.js +191 -0
- package/dist/cli/commands/results-merge.js.map +1 -0
- package/dist/cli/commands/update.d.ts.map +1 -1
- package/dist/cli/commands/update.js +100 -12
- package/dist/cli/commands/update.js.map +1 -1
- package/dist/cli/index.js +4 -0
- package/dist/cli/index.js.map +1 -1
- package/dist/exporters/api-catalog-loader.d.ts.map +1 -1
- package/dist/exporters/api-catalog-loader.js +10 -3
- package/dist/exporters/api-catalog-loader.js.map +1 -1
- package/dist/exporters/api-testcase-formatter.d.ts +9 -0
- package/dist/exporters/api-testcase-formatter.d.ts.map +1 -1
- package/dist/exporters/api-testcase-formatter.js +55 -3
- package/dist/exporters/api-testcase-formatter.js.map +1 -1
- package/dist/exporters/csv-exporter.d.ts.map +1 -1
- package/dist/exporters/csv-exporter.js +10 -1
- package/dist/exporters/csv-exporter.js.map +1 -1
- package/dist/exporters/json-exporter.d.ts.map +1 -1
- package/dist/exporters/json-exporter.js +6 -1
- package/dist/exporters/json-exporter.js.map +1 -1
- package/dist/exporters/matrix/build.d.ts.map +1 -1
- package/dist/exporters/matrix/build.js +14 -1
- package/dist/exporters/matrix/build.js.map +1 -1
- package/dist/exporters/matrix/export.js +1 -1
- package/dist/exporters/matrix/export.js.map +1 -1
- package/dist/exporters/matrix/map-loader.d.ts.map +1 -1
- package/dist/exporters/matrix/map-loader.js +5 -0
- package/dist/exporters/matrix/map-loader.js.map +1 -1
- package/dist/exporters/matrix/render-csv.d.ts.map +1 -1
- package/dist/exporters/matrix/render-csv.js +5 -1
- package/dist/exporters/matrix/render-csv.js.map +1 -1
- package/dist/exporters/matrix/render-xlsx.d.ts.map +1 -1
- package/dist/exporters/matrix/render-xlsx.js +4 -1
- package/dist/exporters/matrix/render-xlsx.js.map +1 -1
- package/dist/exporters/matrix/types.d.ts +18 -0
- package/dist/exporters/matrix/types.d.ts.map +1 -1
- package/dist/exporters/matrix/types.js.map +1 -1
- package/dist/exporters/playwright-report-parser.d.ts +17 -2
- package/dist/exporters/playwright-report-parser.d.ts.map +1 -1
- package/dist/exporters/playwright-report-parser.js +68 -8
- package/dist/exporters/playwright-report-parser.js.map +1 -1
- package/dist/exporters/results-merge.d.ts +46 -0
- package/dist/exporters/results-merge.d.ts.map +1 -0
- package/dist/exporters/results-merge.js +148 -0
- package/dist/exporters/results-merge.js.map +1 -0
- package/dist/exporters/selector-key-resolver.d.ts +4 -0
- package/dist/exporters/selector-key-resolver.d.ts.map +1 -1
- package/dist/exporters/selector-key-resolver.js +101 -1
- package/dist/exporters/selector-key-resolver.js.map +1 -1
- package/dist/exporters/types.d.ts +8 -1
- package/dist/exporters/types.d.ts.map +1 -1
- package/dist/exporters/xlsx-report-builder.d.ts.map +1 -1
- package/dist/exporters/xlsx-report-builder.js +5 -4
- package/dist/exporters/xlsx-report-builder.js.map +1 -1
- package/dist/generators/gherkin-parser/index.d.ts +2 -0
- package/dist/generators/gherkin-parser/index.d.ts.map +1 -1
- package/dist/generators/gherkin-parser/index.js +52 -1
- package/dist/generators/gherkin-parser/index.js.map +1 -1
- package/dist/generators/test-generator/adapters/adapter-interface.d.ts +20 -10
- package/dist/generators/test-generator/adapters/adapter-interface.d.ts.map +1 -1
- package/dist/generators/test-generator/adapters/appium/appium-adapter.d.ts +2 -11
- package/dist/generators/test-generator/adapters/appium/appium-adapter.d.ts.map +1 -1
- package/dist/generators/test-generator/adapters/appium/appium-adapter.js.map +1 -1
- package/dist/generators/test-generator/adapters/appium/templates/imports.hbs +3 -0
- package/dist/generators/test-generator/adapters/appium/templates/steps/actions/date-picker-unsupported.hbs +4 -0
- package/dist/generators/test-generator/adapters/appium/templates/steps/actions/lifecycle-missing-app-id.hbs +5 -0
- package/dist/generators/test-generator/adapters/appium/templates/steps/assertions/matches-format-assertion.hbs +15 -0
- package/dist/generators/test-generator/adapters/appium/templates/steps/assertions/visible-filtered-assertion.hbs +5 -2
- package/dist/generators/test-generator/adapters/appium/templates/test-file.hbs +185 -26
- package/dist/generators/test-generator/adapters/playwright/playwright-adapter.d.ts +2 -11
- package/dist/generators/test-generator/adapters/playwright/playwright-adapter.d.ts.map +1 -1
- package/dist/generators/test-generator/adapters/playwright/playwright-adapter.js.map +1 -1
- package/dist/generators/test-generator/adapters/playwright/templates/imports.hbs +3 -0
- package/dist/generators/test-generator/adapters/playwright/templates/steps/actions/select-action.hbs +17 -1
- package/dist/generators/test-generator/adapters/playwright/templates/steps/assertions/attribute-assertion.hbs +6 -1
- package/dist/generators/test-generator/adapters/playwright/templates/steps/assertions/have-value-assertion.hbs +18 -1
- package/dist/generators/test-generator/adapters/playwright/templates/steps/assertions/hidden-with-filter-assertion.hbs +5 -1
- package/dist/generators/test-generator/adapters/playwright/templates/steps/assertions/matches-format-assertion.hbs +5 -0
- package/dist/generators/test-generator/adapters/playwright/templates/steps/assertions/state-with-filter-assertion.hbs +4 -2
- package/dist/generators/test-generator/adapters/playwright/templates/steps/navigation/wait-for-element-with-text.hbs +4 -1
- package/dist/generators/test-generator/adapters/playwright/templates/steps/partials/recipe-expect-text.hbs +1 -1
- package/dist/generators/test-generator/code-generator.d.ts +6 -0
- package/dist/generators/test-generator/code-generator.d.ts.map +1 -1
- package/dist/generators/test-generator/code-generator.js +80 -7
- package/dist/generators/test-generator/code-generator.js.map +1 -1
- package/dist/generators/test-generator/diagnostics.d.ts +60 -6
- package/dist/generators/test-generator/diagnostics.d.ts.map +1 -1
- package/dist/generators/test-generator/diagnostics.js +59 -5
- package/dist/generators/test-generator/diagnostics.js.map +1 -1
- package/dist/generators/test-generator/patterns/expect-patterns.d.ts +7 -0
- package/dist/generators/test-generator/patterns/expect-patterns.d.ts.map +1 -1
- package/dist/generators/test-generator/patterns/expect-patterns.js +36 -2
- package/dist/generators/test-generator/patterns/expect-patterns.js.map +1 -1
- package/dist/generators/test-generator/patterns/index.d.ts +19 -12
- package/dist/generators/test-generator/patterns/index.d.ts.map +1 -1
- package/dist/generators/test-generator/patterns/index.js +58 -38
- package/dist/generators/test-generator/patterns/index.js.map +1 -1
- package/dist/generators/test-generator/patterns/types.d.ts +23 -0
- package/dist/generators/test-generator/patterns/types.d.ts.map +1 -1
- package/dist/generators/test-generator/patterns/unsupported-step-code.d.ts +18 -0
- package/dist/generators/test-generator/patterns/unsupported-step-code.d.ts.map +1 -0
- package/dist/generators/test-generator/patterns/unsupported-step-code.js +23 -0
- package/dist/generators/test-generator/patterns/unsupported-step-code.js.map +1 -0
- package/dist/generators/test-generator/recipe-expander.js +9 -1
- package/dist/generators/test-generator/recipe-expander.js.map +1 -1
- package/dist/generators/test-generator/reset-tier.d.ts +68 -0
- package/dist/generators/test-generator/reset-tier.d.ts.map +1 -0
- package/dist/generators/test-generator/reset-tier.js +94 -0
- package/dist/generators/test-generator/reset-tier.js.map +1 -0
- package/dist/generators/test-generator/step-mapper.d.ts +39 -0
- package/dist/generators/test-generator/step-mapper.d.ts.map +1 -1
- package/dist/generators/test-generator/step-mapper.js +144 -5
- package/dist/generators/test-generator/step-mapper.js.map +1 -1
- package/dist/generators/test-generator/template-engine.d.ts +2 -10
- package/dist/generators/test-generator/template-engine.d.ts.map +1 -1
- package/dist/generators/test-generator/template-engine.js +16 -2
- package/dist/generators/test-generator/template-engine.js.map +1 -1
- package/dist/generators/test-generator/utils/format-pattern.d.ts +35 -0
- package/dist/generators/test-generator/utils/format-pattern.d.ts.map +1 -0
- package/dist/generators/test-generator/utils/format-pattern.js +82 -0
- package/dist/generators/test-generator/utils/format-pattern.js.map +1 -0
- package/dist/generators/test-generator/utils/js-literal-scanner.d.ts +42 -0
- package/dist/generators/test-generator/utils/js-literal-scanner.d.ts.map +1 -0
- package/dist/generators/test-generator/utils/js-literal-scanner.js +142 -0
- package/dist/generators/test-generator/utils/js-literal-scanner.js.map +1 -0
- package/dist/generators/test-generator/utils/mask-refs-for-matching.d.ts +18 -0
- package/dist/generators/test-generator/utils/mask-refs-for-matching.d.ts.map +1 -0
- package/dist/generators/test-generator/utils/mask-refs-for-matching.js +25 -0
- package/dist/generators/test-generator/utils/mask-refs-for-matching.js.map +1 -0
- package/dist/generators/test-generator/utils/runtime-data-transformer.d.ts +9 -1
- package/dist/generators/test-generator/utils/runtime-data-transformer.d.ts.map +1 -1
- package/dist/generators/test-generator/utils/runtime-data-transformer.js +124 -63
- package/dist/generators/test-generator/utils/runtime-data-transformer.js.map +1 -1
- package/dist/generators/test-generator/utils/selector-resolver.d.ts.map +1 -1
- package/dist/generators/test-generator/utils/selector-resolver.js +20 -1
- package/dist/generators/test-generator/utils/selector-resolver.js.map +1 -1
- package/dist/generators/test-generator/utils/spec-syntax-check.d.ts +14 -0
- package/dist/generators/test-generator/utils/spec-syntax-check.d.ts.map +1 -0
- package/dist/generators/test-generator/utils/spec-syntax-check.js +26 -0
- package/dist/generators/test-generator/utils/spec-syntax-check.js.map +1 -0
- package/dist/harness/annotation-overrides.d.ts +0 -12
- package/dist/harness/annotation-overrides.d.ts.map +1 -1
- package/dist/harness/annotation-overrides.js +9 -5
- package/dist/harness/annotation-overrides.js.map +1 -1
- package/dist/harness/audit.d.ts +7 -0
- package/dist/harness/audit.d.ts.map +1 -1
- package/dist/harness/audit.js +78 -10
- package/dist/harness/audit.js.map +1 -1
- package/dist/harness/capability-plan.d.ts +2 -1
- package/dist/harness/capability-plan.d.ts.map +1 -1
- package/dist/harness/capability-plan.js +8 -3
- package/dist/harness/capability-plan.js.map +1 -1
- package/dist/harness/capability.d.ts +11 -0
- package/dist/harness/capability.d.ts.map +1 -1
- package/dist/harness/capability.js +8 -0
- package/dist/harness/capability.js.map +1 -1
- package/dist/harness/catalog/drivers.yaml +5 -0
- package/dist/harness/flow-contract.d.ts +18 -1
- package/dist/harness/flow-contract.d.ts.map +1 -1
- package/dist/harness/flow-contract.js +72 -9
- package/dist/harness/flow-contract.js.map +1 -1
- package/dist/harness/parse.d.ts.map +1 -1
- package/dist/harness/parse.js +5 -1
- package/dist/harness/parse.js.map +1 -1
- package/dist/harness/quality-gates.d.ts +12 -1
- package/dist/harness/quality-gates.d.ts.map +1 -1
- package/dist/harness/quality-gates.js +62 -7
- package/dist/harness/quality-gates.js.map +1 -1
- package/dist/harness/run-manifest.d.ts +58 -0
- package/dist/harness/run-manifest.d.ts.map +1 -0
- package/dist/harness/run-manifest.js +264 -0
- package/dist/harness/run-manifest.js.map +1 -0
- package/dist/harness/spec-branches.d.ts +88 -0
- package/dist/harness/spec-branches.d.ts.map +1 -0
- package/dist/harness/spec-branches.js +280 -0
- package/dist/harness/spec-branches.js.map +1 -0
- package/dist/harness/spec-coverage.d.ts +1 -1
- package/dist/harness/spec-coverage.js +4 -4
- package/dist/harness/spec-coverage.js.map +1 -1
- package/dist/harness/viewpoint-baseline.d.ts +9 -0
- package/dist/harness/viewpoint-baseline.d.ts.map +1 -1
- package/dist/harness/viewpoint-baseline.js +33 -3
- package/dist/harness/viewpoint-baseline.js.map +1 -1
- package/dist/harness/viewpoint-ledger.d.ts +1 -1
- package/dist/harness/viewpoint-ledger.d.ts.map +1 -1
- package/dist/harness/viewpoint-ledger.js +88 -9
- package/dist/harness/viewpoint-ledger.js.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +5 -1
- package/dist/index.js.map +1 -1
- package/dist/orchestrator/lockstep-version.d.ts +22 -0
- package/dist/orchestrator/lockstep-version.d.ts.map +1 -0
- package/dist/orchestrator/lockstep-version.js +58 -0
- package/dist/orchestrator/lockstep-version.js.map +1 -0
- package/dist/orchestrator/project-deps-sync.d.ts +39 -0
- package/dist/orchestrator/project-deps-sync.d.ts.map +1 -0
- package/dist/orchestrator/project-deps-sync.js +175 -0
- package/dist/orchestrator/project-deps-sync.js.map +1 -0
- package/dist/orchestrator/project-initializer.d.ts.map +1 -1
- package/dist/orchestrator/project-initializer.js +5 -2
- package/dist/orchestrator/project-initializer.js.map +1 -1
- package/dist/orchestrator/templates/ai-src/commands/add-flow.md +16 -0
- package/dist/orchestrator/templates/ai-src/commands/create-test.md +9 -0
- package/dist/orchestrator/templates/ai-src/commands/delivery.md +9 -2
- package/dist/orchestrator/templates/ai-src/commands/run-test.md +109 -0
- package/dist/orchestrator/templates/ai-src/config/claude.md +1 -1
- package/dist/orchestrator/templates/ai-src/config/copilot.md +1 -1
- package/dist/orchestrator/templates/ai-src/skills/sungen-api-design/SKILL.md +49 -0
- package/dist/orchestrator/templates/ai-src/skills/sungen-error-mapping/SKILL.md +14 -3
- package/dist/orchestrator/templates/ai-src/skills/sungen-gherkin-syntax/SKILL.md +110 -6
- package/dist/orchestrator/templates/ai-src/skills/sungen-locale/SKILL.md +1 -1
- package/dist/orchestrator/templates/ai-src/skills/sungen-mobile-gestures/SKILL.md +8 -0
- package/dist/orchestrator/templates/ai-src/skills/sungen-selector-fix/SKILL.md +1 -1
- package/dist/orchestrator/templates/ai-src/skills/sungen-tc-generation/SKILL.md +40 -1
- package/dist/orchestrator/templates/ai-src/skills/sungen-tc-review/SKILL.md +15 -0
- package/dist/orchestrator/templates/env.appium.example +10 -0
- package/dist/orchestrator/templates/specs-api.d.ts +4 -2
- package/dist/orchestrator/templates/specs-api.d.ts.map +1 -1
- package/dist/orchestrator/templates/specs-api.js +233 -10
- package/dist/orchestrator/templates/specs-api.js.map +1 -1
- package/dist/orchestrator/templates/specs-api.ts +214 -11
- package/dist/orchestrator/templates/specs-regex-escape.d.ts +13 -0
- package/dist/orchestrator/templates/specs-regex-escape.d.ts.map +1 -0
- package/dist/orchestrator/templates/specs-regex-escape.js +19 -0
- package/dist/orchestrator/templates/specs-regex-escape.js.map +1 -0
- package/dist/orchestrator/templates/specs-regex-escape.ts +16 -0
- package/dist/orchestrator/templates/specs-test-data.ts +53 -6
- package/dist/orchestrator/templates/wdio.conf.ts +28 -9
- package/package.json +8 -6
- package/src/capabilities/context-router.ts +13 -5
- package/src/cli/commands/audit.ts +22 -3
- package/src/cli/commands/capability.ts +4 -38
- package/src/cli/commands/changed.ts +104 -0
- package/src/cli/commands/delivery.ts +27 -2
- package/src/cli/commands/generate.ts +1 -1
- package/src/cli/commands/results-merge.ts +153 -0
- package/src/cli/commands/update.ts +72 -13
- package/src/cli/index.ts +4 -0
- package/src/exporters/api-catalog-loader.ts +10 -2
- package/src/exporters/api-testcase-formatter.ts +59 -3
- package/src/exporters/csv-exporter.ts +11 -2
- package/src/exporters/json-exporter.ts +7 -2
- package/src/exporters/matrix/build.ts +13 -1
- package/src/exporters/matrix/export.ts +1 -1
- package/src/exporters/matrix/map-loader.ts +5 -0
- package/src/exporters/matrix/render-csv.ts +5 -1
- package/src/exporters/matrix/render-xlsx.ts +4 -1
- package/src/exporters/matrix/types.ts +18 -0
- package/src/exporters/playwright-report-parser.ts +68 -9
- package/src/exporters/results-merge.ts +150 -0
- package/src/exporters/selector-key-resolver.ts +81 -1
- package/src/exporters/types.ts +8 -1
- package/src/exporters/xlsx-report-builder.ts +5 -4
- package/src/generators/gherkin-parser/index.ts +55 -1
- package/src/generators/test-generator/adapters/adapter-interface.ts +23 -1
- package/src/generators/test-generator/adapters/appium/appium-adapter.ts +2 -2
- package/src/generators/test-generator/adapters/appium/templates/imports.hbs +3 -0
- package/src/generators/test-generator/adapters/appium/templates/steps/actions/date-picker-unsupported.hbs +4 -0
- package/src/generators/test-generator/adapters/appium/templates/steps/actions/lifecycle-missing-app-id.hbs +5 -0
- package/src/generators/test-generator/adapters/appium/templates/steps/assertions/matches-format-assertion.hbs +15 -0
- package/src/generators/test-generator/adapters/appium/templates/steps/assertions/visible-filtered-assertion.hbs +5 -2
- package/src/generators/test-generator/adapters/appium/templates/test-file.hbs +185 -26
- package/src/generators/test-generator/adapters/playwright/playwright-adapter.ts +2 -2
- package/src/generators/test-generator/adapters/playwright/templates/imports.hbs +3 -0
- package/src/generators/test-generator/adapters/playwright/templates/steps/actions/select-action.hbs +17 -1
- package/src/generators/test-generator/adapters/playwright/templates/steps/assertions/attribute-assertion.hbs +6 -1
- package/src/generators/test-generator/adapters/playwright/templates/steps/assertions/have-value-assertion.hbs +18 -1
- package/src/generators/test-generator/adapters/playwright/templates/steps/assertions/hidden-with-filter-assertion.hbs +5 -1
- package/src/generators/test-generator/adapters/playwright/templates/steps/assertions/matches-format-assertion.hbs +5 -0
- package/src/generators/test-generator/adapters/playwright/templates/steps/assertions/state-with-filter-assertion.hbs +4 -2
- package/src/generators/test-generator/adapters/playwright/templates/steps/navigation/wait-for-element-with-text.hbs +4 -1
- package/src/generators/test-generator/adapters/playwright/templates/steps/partials/recipe-expect-text.hbs +1 -1
- package/src/generators/test-generator/code-generator.ts +86 -7
- package/src/generators/test-generator/diagnostics.ts +60 -6
- package/src/generators/test-generator/patterns/expect-patterns.ts +37 -2
- package/src/generators/test-generator/patterns/index.ts +58 -33
- package/src/generators/test-generator/patterns/types.ts +36 -0
- package/src/generators/test-generator/patterns/unsupported-step-code.ts +19 -0
- package/src/generators/test-generator/recipe-expander.ts +9 -1
- package/src/generators/test-generator/reset-tier.ts +93 -0
- package/src/generators/test-generator/step-mapper.ts +149 -6
- package/src/generators/test-generator/template-engine.ts +18 -3
- package/src/generators/test-generator/utils/format-pattern.ts +79 -0
- package/src/generators/test-generator/utils/js-literal-scanner.ts +132 -0
- package/src/generators/test-generator/utils/mask-refs-for-matching.ts +21 -0
- package/src/generators/test-generator/utils/runtime-data-transformer.ts +126 -76
- package/src/generators/test-generator/utils/selector-resolver.ts +22 -1
- package/src/generators/test-generator/utils/spec-syntax-check.ts +22 -0
- package/src/harness/annotation-overrides.ts +10 -5
- package/src/harness/audit.ts +82 -12
- package/src/harness/capability-plan.ts +10 -5
- package/src/harness/capability.ts +16 -0
- package/src/harness/catalog/drivers.yaml +5 -0
- package/src/harness/flow-contract.ts +87 -9
- package/src/harness/parse.ts +5 -1
- package/src/harness/quality-gates.ts +64 -6
- package/src/harness/run-manifest.ts +254 -0
- package/src/harness/spec-branches.ts +346 -0
- package/src/harness/spec-coverage.ts +4 -4
- package/src/harness/viewpoint-baseline.ts +41 -6
- package/src/harness/viewpoint-ledger.ts +82 -9
- package/src/index.ts +1 -0
- package/src/orchestrator/lockstep-version.ts +46 -0
- package/src/orchestrator/project-deps-sync.ts +175 -0
- package/src/orchestrator/project-initializer.ts +5 -2
- package/src/orchestrator/templates/ai-src/commands/add-flow.md +16 -0
- package/src/orchestrator/templates/ai-src/commands/create-test.md +9 -0
- package/src/orchestrator/templates/ai-src/commands/delivery.md +9 -2
- package/src/orchestrator/templates/ai-src/commands/run-test.md +109 -0
- package/src/orchestrator/templates/ai-src/config/claude.md +1 -1
- package/src/orchestrator/templates/ai-src/config/copilot.md +1 -1
- package/src/orchestrator/templates/ai-src/skills/sungen-api-design/SKILL.md +49 -0
- package/src/orchestrator/templates/ai-src/skills/sungen-error-mapping/SKILL.md +14 -3
- package/src/orchestrator/templates/ai-src/skills/sungen-gherkin-syntax/SKILL.md +110 -6
- package/src/orchestrator/templates/ai-src/skills/sungen-locale/SKILL.md +1 -1
- package/src/orchestrator/templates/ai-src/skills/sungen-mobile-gestures/SKILL.md +8 -0
- package/src/orchestrator/templates/ai-src/skills/sungen-selector-fix/SKILL.md +1 -1
- package/src/orchestrator/templates/ai-src/skills/sungen-tc-generation/SKILL.md +40 -1
- package/src/orchestrator/templates/ai-src/skills/sungen-tc-review/SKILL.md +15 -0
- package/src/orchestrator/templates/env.appium.example +10 -0
- package/src/orchestrator/templates/specs-api.ts +214 -11
- package/src/orchestrator/templates/specs-regex-escape.ts +16 -0
- package/src/orchestrator/templates/specs-test-data.ts +53 -6
- package/src/orchestrator/templates/wdio.conf.ts +28 -9
|
@@ -36,7 +36,7 @@ AND → inherits from preceding keyword
|
|
|
36
36
|
|
|
37
37
|
```
|
|
38
38
|
User is on [T] page | page with {{v}} | dialog
|
|
39
|
-
User fill [T] field | textarea | search | slider | date-picker with {{v}}
|
|
39
|
+
User fill [T] field | textarea | search | slider | date-picker with {{v}} # date-picker: web only unless qa/app.yaml declares widgets.datepicker — appium refuses it
|
|
40
40
|
User fill [T] uploader with {{f}} [web]
|
|
41
41
|
User clear [T] field
|
|
42
42
|
User check [T] checkbox | toggle | radio
|
|
@@ -104,13 +104,23 @@ two content-filtered queries can hit different rows if the table re-renders in b
|
|
|
104
104
|
# 1. Visibility: User see [T] type (NEVER add "is visible") | is hidden
|
|
105
105
|
# 2. Text (toHaveText): User see [T] message | header | label with {{v}}
|
|
106
106
|
# 3. Partial (toContainText): User see [T] text contains {{v}}
|
|
107
|
-
#
|
|
107
|
+
# 3b. Format (shape, not value): User see [T] label matches "yyyy/mm/dd ~ yyyy/mm/dd"
|
|
108
|
+
# 4. Input (toHaveValue): User see [T] field | date-picker | search | slider with {{v}}
|
|
109
|
+
# dropdown | select | combobox → tag probed at runtime (toHaveValue native, toHaveText custom trigger)
|
|
108
110
|
# 5. State: User see [T] button is disabled | checkbox is checked | dialog with {{v}} is hidden
|
|
109
111
|
# 6. Attribute (toHaveAttribute): User see [T] image | link with {{v}}
|
|
110
112
|
# 7. Count: User see [T] row with {{count}}
|
|
111
113
|
# 8. Page: User see [T] page | page with {{url}} ← `with` also asserts the query string
|
|
114
|
+
# NEVER a state: `page is hidden` / `page is empty` … are NOT step forms (see Pattern 8 below)
|
|
112
115
|
```
|
|
113
116
|
|
|
117
|
+
> **`page` takes NO state clause.** The only two forms are `see [T] page` and `see [T] page with
|
|
118
|
+
> {{url}}`. `see [X] page is hidden` is not a supported step: `page` compiles to a URL assertion, so
|
|
119
|
+
> the clause is dropped and the step asserts the page IS displayed — the opposite of what it reads
|
|
120
|
+
> as. The compiler now rejects it (`SG-E021`) instead of generating that. To say "we left page X",
|
|
121
|
+
> assert the page you expect (`Then User see [Other] page`); to say "this went away", assert a marker
|
|
122
|
+
> element (`Then User see [Some Element] is hidden`).
|
|
123
|
+
|
|
114
124
|
**Pattern 8 — the page assertion judges pathname AND query together.** Both `see [T] page` and
|
|
115
125
|
`is on [T] page` (Then-side) compile to ONE web-first predicate — `toHaveURL(urlMatches({…}))`,
|
|
116
126
|
which retries like any other assertion — comparing the **exact** pathname (anchored, trailing slash
|
|
@@ -197,7 +207,31 @@ Then User see key matching "PATTERN" in local storage # regex over key name
|
|
|
197
207
|
# same five forms with `session storage`
|
|
198
208
|
```
|
|
199
209
|
|
|
200
|
-
`[KEY]` is a **storage key, not a selector** — never add it to selectors.yaml. It may embed `{{vars}}`: `[{{exclusive_code}}_ACCESS_TOKEN]`. The check runs inside the browser and returns only a boolean, so a failure message never contains the stored value (safe for tokens). There is deliberately no `equals {{expected}}` form. ⚠️ `expect [KEY] in local storage …` is NOT valid (`expect` reads `{{response}}` refs only) — the compiler warns SG-W011.
|
|
210
|
+
`[KEY]` is a **storage key, not a selector** — never add it to selectors.yaml. It may embed `{{vars}}`: `[{{exclusive_code}}_ACCESS_TOKEN]`. **This is the ONLY bracket that accepts a `{{var}}`.** An element `[Reference]` is a selectors.yaml key matched **literally** — it is never interpolated, so `[{{var}}]` for an element is invalid and the compiler rejects it. To target text that varies (locale, `@cases` column), keep the reference static and put the variable in the selector entry's `name:`/`value:` field, or pick by text with `with {{var}}` (`User click [Answer List] item with {{confirm_value}}`). The check runs inside the browser and returns only a boolean, so a failure message never contains the stored value (safe for tokens). There is deliberately no `equals {{expected}}` form. ⚠️ `expect [KEY] in local storage …` is NOT valid (`expect` reads `{{response}}` refs only) — the compiler warns SG-W011.
|
|
211
|
+
|
|
212
|
+
### Format assertion `see [T] type matches "<format>"`
|
|
213
|
+
|
|
214
|
+
A spec often states a label's contract as a **shape**, not a value — "Format: yyyy/mm/dd ~ yyyy/mm/dd",
|
|
215
|
+
"Format: 残りX個". Write it verbatim; no regex belongs in a `.feature`:
|
|
216
|
+
|
|
217
|
+
```gherkin
|
|
218
|
+
Then User see [Application Period] label matches "yyyy/mm/dd ~ yyyy/mm/dd"
|
|
219
|
+
Then User see [Remaining Stock] label matches "残りX個"
|
|
220
|
+
Then User see [Total Plans] label matches "*X件"
|
|
221
|
+
```
|
|
222
|
+
|
|
223
|
+
| Token | Matches | | Token | Matches |
|
|
224
|
+
|---|---|---|---|---|
|
|
225
|
+
| `yyyy` | a 4-digit year | | `X` | an integer, `1,280` allowed |
|
|
226
|
+
| `mm` `dd` `hh` `ss` | a zero-padded 2-digit part | | `*` | any run of characters |
|
|
227
|
+
|
|
228
|
+
Everything else is a **literal**. The match is **anchored** — `"X件"` is not satisfied by `12件です`;
|
|
229
|
+
use `*` where the label legitimately carries more (a mobile node that fuses a prefix and its count,
|
|
230
|
+
`開催中の企画\n2件` → `"*X件"`). A token glued to an ASCII letter stays literal, so `"Comment: X件"`
|
|
231
|
+
keeps its `mm`.
|
|
232
|
+
|
|
233
|
+
Use this instead of `with {{v}}` when the VALUE varies but the SHAPE is the requirement: an exact
|
|
234
|
+
value pins data that changes daily (red while the app is right), and `contains` passes on anything.
|
|
201
235
|
|
|
202
236
|
### Tab order `[web]`
|
|
203
237
|
|
|
@@ -307,8 +341,13 @@ Full-shape contract check: `expect {{name.body}} matches schema [Ref]` validates
|
|
|
307
341
|
### Platform Support — web-only / mobile-only / divergences
|
|
308
342
|
|
|
309
343
|
Every claim below is checked against a shipped `.hbs` under
|
|
310
|
-
`adapters/{playwright,appium}/templates/steps/` or a shipped diagnostic
|
|
311
|
-
|
|
344
|
+
`adapters/{playwright,appium}/templates/steps/` or a shipped diagnostic. The authoritative
|
|
345
|
+
per-template answer is **generated from the adapters themselves** — `docs/spec/step-support-matrix.md`
|
|
346
|
+
(regenerate: `npm run gen:step-matrix`; `DIVERGENCES` lives in `tools/step-support-matrix/sources.ts`). Read
|
|
347
|
+
it when this prose and the compiler seem to disagree: the matrix is derived, this section is authored.
|
|
348
|
+
|
|
349
|
+
The matrix maps **templates**, not phrasings — a phrasing no pattern requests is absent from it
|
|
350
|
+
entirely (a *pattern* gap, not a template gap), which is what the catalogue below is for.
|
|
312
351
|
|
|
313
352
|
**Web-only `[web]`** — the Appium template throws, naming the reason: `fill [T] uploader with
|
|
314
353
|
{{f}}`, Positional table rows, the whole Table section, Browser storage, Tab order, `@mock`,
|
|
@@ -355,6 +394,10 @@ zero-match assertion failure, not a platform gap.
|
|
|
355
394
|
| Group | Types |
|
|
356
395
|
|---|---|
|
|
357
396
|
| **Context** | `page` `dialog` `modal` `drawer` `tab` `alert` `overlay` `step` |
|
|
397
|
+
|
|
398
|
+
> `page` is the one Context type with **no state**: `dialog`/`modal`/`drawer`/`tab` etc. all take
|
|
399
|
+
> `is hidden` / `is visible`, `page` never does. It asserts a URL. See Pattern 8.
|
|
400
|
+
|
|
358
401
|
| **Input** | `field` `textarea` `search` `dropdown` `option` `checkbox` `radio` `toggle` `uploader` `slider` `date-picker` |
|
|
359
402
|
| **Trigger** | `button` `link` `icon` `menuitem` `tag` |
|
|
360
403
|
| **Data** | `table` `row` `column` `cell` `list` `item` `card` `section` |
|
|
@@ -443,12 +486,68 @@ on mobile a dialog-scoped ref still resolves against the whole screen (`SG-W021`
|
|
|
443
486
|
| `@afterEach` | Hook: runs after each test → `test.afterEach()` (custom cleanup) |
|
|
444
487
|
| `@afterAll` | Hook: runs once after all tests → `test.afterAll()` |
|
|
445
488
|
| `@flow` | Mark feature as E2E flow (cross-screen testing) |
|
|
489
|
+
| `@reset:none\|soft\|data\|full` | **`[mobile]`** How much state each scenario starts from. Default `soft`. Feature-level, or on a Scenario to RAISE (never lower) that one scenario's tier. See "Reset tiers" below |
|
|
446
490
|
| `@cases:dataset` | Data-driven: run the scenario once per row of the `dataset` LIST in test-data → one `test()` per row |
|
|
447
491
|
| `@query:name` | Database: run the named query from `database/queries.yaml` (precondition) and bind its rows to `{{name}}`; assert with `expect {{name.count}} …` + path access. Override params `@query:name(p={{v}})`. Repeatable. (Optional Data Driver — see Database verification above) |
|
|
448
492
|
| `@api:name` | API: run the named request from `api/apis.yaml` (precondition) and bind the response to `{{name}}`; assert with `expect {{name.status}} …` + path access (`{{name.body.<path>}}`). Override params `@api:name(p={{v}})`. Repeatable. (Optional API Driver) |
|
|
449
493
|
| `@concurrent:N` | API idempotency: fire the bound `@api` request N times in parallel, then bind aggregates on the `@api` name — `{{name.ok_count}}` (2xx count) and `{{name.status_counts}}` (status→count map). Assert the exactly-once invariant (`expect {{name.ok_count}} is 1`); pair with `@query` as a DB oracle. Tag order = run order: `@api` (mutate) before `@query` (verify). (Optional API Driver) |
|
|
450
494
|
| `@hybrid` | One unit, two capabilities: a signed-in browser session (UI) authorizes the `@api` call — the API request reuses the UI `storageState`. (Optional API + UI Drivers) |
|
|
451
495
|
|
|
496
|
+
### Reset tiers (`@reset`) `[mobile]`
|
|
497
|
+
|
|
498
|
+
Web gets a clean slate for free — a URL reload is effectively fresh state. Mobile does not: to save
|
|
499
|
+
time the app stays alive across the whole session, so the end state of one scenario becomes the start
|
|
500
|
+
state of the next. Left uncontrolled that breaks test independence (scenarios can't run in isolation
|
|
501
|
+
or in parallel, a leftover "nearly right" state makes the FOLLOWING scenario fail, and a scenario that
|
|
502
|
+
dies mid-flow poisons every one after it). Nobody just reinstalls between scenarios because a cold
|
|
503
|
+
start is 5–30s — hence a ladder rather than one all-or-nothing switch.
|
|
504
|
+
|
|
505
|
+
| Tier | Mechanism | Cost | Clears |
|
|
506
|
+
|---|---|---|---|
|
|
507
|
+
| `none` | nothing — only the NATIVE-context + portrait guards | 0s | — state carries over BY DESIGN |
|
|
508
|
+
| `soft` | `terminateApp` + `activateApp` — **the default** | ~1–3s | in-memory state, navigation stack |
|
|
509
|
+
| `data` | `soft` + wipe the app's data layer | ~2–5s | + local DB, cache, prefs, keychain |
|
|
510
|
+
| `full` | `removeApp` + `installApp` | ~5–30s | + permissions, install-time state |
|
|
511
|
+
|
|
512
|
+
```gherkin
|
|
513
|
+
@platform:mobile @reset:data
|
|
514
|
+
Feature: Onboarding
|
|
515
|
+
Path: com.example.app/.MainActivity | com.example.app
|
|
516
|
+
|
|
517
|
+
Scenario: VP-001 First-run tutorial shows for a brand-new user
|
|
518
|
+
Then User see [Tutorial Slide 1] section
|
|
519
|
+
|
|
520
|
+
@reset:full
|
|
521
|
+
Scenario: VP-002 Needs a clean install, unlike its neighbours
|
|
522
|
+
Then User see [Permission Prompt] dialog
|
|
523
|
+
```
|
|
524
|
+
|
|
525
|
+
Rules that matter when generating:
|
|
526
|
+
|
|
527
|
+
- **`soft` is the default** — omit the tag unless the scenario actually needs more. An untagged
|
|
528
|
+
feature behaves exactly as it always did.
|
|
529
|
+
- **Pick `data` for first-run / onboarding / empty-state oracles.** A plain relaunch leaves a
|
|
530
|
+
completed tutorial completed, so the scenario passes on run 1 and fails on run 2.
|
|
531
|
+
- **Pick `full` only for permissions or install-time state.** It costs a cold start every scenario.
|
|
532
|
+
- **Pick `none` only for a deliberate carryover chain** (a cart accumulating across scenarios). Order
|
|
533
|
+
becomes load-bearing, and the scenarios can no longer run independently — that is the trade.
|
|
534
|
+
- **A scenario tier RAISES the feature tier, never lowers it.** A `@reset:full` feature is not weakened
|
|
535
|
+
by a `@reset:soft` scenario; use a separate feature if you need a weaker one.
|
|
536
|
+
- **A failed scenario escalates the NEXT one by one rung** (`soft`→`data`→`full`), because a process
|
|
537
|
+
relaunch does not undo a half-written local DB. One rung, one scenario, and never from `none`.
|
|
538
|
+
A **retry** of the failed scenario is NOT escalated — it re-runs at its declared tier, so a
|
|
539
|
+
state-dependent failure fails both attempts and is reported honestly instead of passing on attempt 2.
|
|
540
|
+
- **`SUNGEN_RESET_TIER=data` raises the floor for a whole run** without regenerating specs — CI can run
|
|
541
|
+
stricter than local dev. It never lowers a declared tier.
|
|
542
|
+
- **`data`/`full` on iOS reinstall the app**, because `mobile: clearApp` is Android-only (UiAutomator2).
|
|
543
|
+
That needs `IOS_APP` (or `APP_APK` on Android) so the `appium:app` capability is present, or the
|
|
544
|
+
reset throws naming it.
|
|
545
|
+
- **`data` and `full` wipe app data on the target device.** Emulator / dedicated test device only.
|
|
546
|
+
|
|
547
|
+
Mobile app-state preconditions have no per-step equivalent: `Given the user is logged out` compiles to
|
|
548
|
+
a COMMENT on Appium (auth lives in the keychain, not in cookies) and reports **SG-W023**. Declare
|
|
549
|
+
`@reset:data` on the Feature instead of writing a step that establishes nothing.
|
|
550
|
+
|
|
452
551
|
### Data-driven scenarios (`@cases`)
|
|
453
552
|
|
|
454
553
|
For one test case × many inputs (email/format/boundary validation, decision tables), tag the
|
|
@@ -575,7 +674,7 @@ dashboard:
|
|
|
575
674
|
|
|
576
675
|
- Tool executes **only Given→When** of `@steps` scenario (skips Then)
|
|
577
676
|
- The `Given` in `@extend` scenario is the **entry assertion** (confirms state after base steps)
|
|
578
|
-
- **Entry assertion MUST use `Given User is on [X]
|
|
677
|
+
- **Entry assertion MUST use `Given User is on [X] page`** (or `[X] dialog`) — NEVER `Given User see [X] type`. `is on` takes ONLY `page` (`screen` on mobile) and `dialog`: it names a navigation target. For a state inside the current page (a wizard step, a panel) assert it with `Then User see [X] section` and reach it via the flow's When steps or an `@steps`/`@extend` base — the compiler rejects `is on [X] <other type>`.
|
|
579
678
|
- `Given` keyword ONLY allows `is on` action. `see` = `Then` only.
|
|
580
679
|
- If `@steps` scenario fails, `@extend` scenario is **skipped**
|
|
581
680
|
- Name format: `snake_case` or `kebab-case` with module prefix: `@steps:kudos__open_modal`
|
|
@@ -593,6 +692,11 @@ dashboard:
|
|
|
593
692
|
| Hardcode data | `with {{admin@mail.com}}` | `with {{invalid_email}}` |
|
|
594
693
|
| Missing `is` for state | `with {{text}} hidden` | `with {{text}} is hidden` |
|
|
595
694
|
| State as value | `with {{disabled}}` | `is disabled` |
|
|
695
|
+
| State on a `page` | `Then User see [Home] page is hidden` | `Then User see [Other] page` — or assert a marker element: `Then User see [Home Banner] is hidden`. `page` asserts a URL; the clause is dropped and the assertion INVERTS. Rejected by `SG-E021` |
|
|
696
|
+
| State the step form cannot assert | `wait for [X] spinner is checked` · `see [R] row in [T] table with {{v}} is disabled` | Assert the state on the element itself: `Then User see [X] checkbox is checked`. A `wait` only honours `hidden`/`visible`/`disabled`/`enabled`; a row/column step only honours `is hidden`. Anything else is dropped → `SG-E021` |
|
|
697
|
+
| Invented step form | any sentence with no matching pattern | Use a form from this reference. There is no silent fallback any more — an unmatched step compiles to a runtime `throw` (`SG-W001`). If the check cannot be automated, tag the scenario `@manual` |
|
|
698
|
+
| Assertion clause the pattern drops | `Then User see [Code] text matches "<format>"` compiling to a bare `toBeVisible()` | Use the exact supported phrasing for the clause (see the `matches` / `contains` / `has text` forms above). A clause that gets swallowed leaves an assertion that passes whatever the element says, so the step is rejected with `SG-E023` instead. If the step still compiles bare, you are on an older global sungen — run `npx sungen` |
|
|
699
|
+
| Mobile lifecycle step with no app id | `When User restart the app` in a feature whose `Path:` carries only a route | Declare the app id on the Feature: `Path: com.example.app/.MainActivity \| com.example.app`. The lifecycle steps resolve the app from `Path`; without it the whole spec would die at runtime, so the step is rejected with `SG-E024`. For a mobile unit `--path` carries the APP ID, `--reach` carries the navigation recipe |
|
|
596
700
|
| Missing target type | `fill [email] with {{v}}` | `fill [email] field with {{v}}` |
|
|
597
701
|
| Background with scope | `Background: ... And User is on [X] dialog` | Use `@steps` + `@extend` for scope-dependent flows |
|
|
598
702
|
| `is on` after When | `When ... And User is on [X] dialog` | `And User see [X] dialog` or separate Given |
|
|
@@ -575,7 +575,7 @@ If the screen has multiple `.feature` files (e.g. `home.feature` + `home-modal.f
|
|
|
575
575
|
|
|
576
576
|
## What NOT to do
|
|
577
577
|
|
|
578
|
-
- Do not edit `.feature` files.
|
|
578
|
+
- Do not edit `.feature` files. Their `[Target]` labels are locale-stable keys — they are matched literally and must NOT be turned into `[{{var}}]`. Localised text lives in the selector entry's `name:`/`value:`, so only selectors + test-data need surgery.
|
|
579
579
|
{{#cap parallel-subagents}}
|
|
580
580
|
- Do not write a separate selectors file per locale (`home.en.yaml`). One selectors file with `{{var}}` works across all locales — that's the whole point.
|
|
581
581
|
- Do not delete keys from the base `test-data/<feature>.yaml`. Always append. Existing tests rely on base values being complete.
|
|
@@ -43,6 +43,14 @@ patterns (`click`, `hover`, `fill`) don't cover.
|
|
|
43
43
|
> - **`reinstall the app`** → remove → install from the session's build file → launch. **Requires
|
|
44
44
|
> the session to have installed via `APP_APK` / `IOS_APP`** (the `appium:app` cap) — attaching by
|
|
45
45
|
> package only makes the step fail loud with that exact fix in the message.
|
|
46
|
+
>
|
|
47
|
+
> **These two are IN-scenario steps. Reset BETWEEN scenarios is a tag, not a step** — `@reset:` on the
|
|
48
|
+
> Feature (`none` · `soft` = default · `data` · `full`), which runs in the `beforeEach` hook. Reach for
|
|
49
|
+
> the tag when the whole suite needs a cleaner starting state, and for these steps when one scenario's
|
|
50
|
+
> ORACLE is "does this survive a restart / a reinstall". Writing a step to establish app state does not
|
|
51
|
+
> work on mobile: `Given the user is logged out` compiles to a comment (auth lives in the keychain, not
|
|
52
|
+
> in cookies) and reports **SG-W023** — declare `@reset:data` instead. Full tier table + trade-offs:
|
|
53
|
+
> `sungen-gherkin-syntax` → "Reset tiers".
|
|
46
54
|
> - **`turn airplane mode|wifi|mobile data on|off`** → `mobile: setConnectivity`. **Android-only** —
|
|
47
55
|
> XCUITest cannot toggle connectivity; on iOS the step throws loud. Keep such scenarios
|
|
48
56
|
> `@platform:android`. Do NOT author cleanup steps to restore connectivity "in case the test
|
|
@@ -57,7 +57,7 @@ When running Phase 0 for a **flow** (`qa/flows/<name>/`), check existing screen
|
|
|
57
57
|
2. **Collect references**: parse the `.feature` file for every `[Reference]` element + its type (e.g. `[Submit] button`, `[Email] field`). Deduplicate.
|
|
58
58
|
|
|
59
59
|
⛔ **Non-selector bracket refs — never hunt the DOM for these, never write a selectors.yaml entry:**
|
|
60
|
-
- `[KEY] in local storage …` / `[KEY] in session storage …` — a **storage key**, checked via `page.evaluate`, not an element (the key may embed `{{vars}}`, e.g. `[{{code}}_ACCESS_TOKEN]`).
|
|
60
|
+
- `[KEY] in local storage …` / `[KEY] in session storage …` — a **storage key**, checked via `page.evaluate`, not an element (the key may embed `{{vars}}`, e.g. `[{{code}}_ACCESS_TOKEN]` — this is the ONLY bracket that accepts one; an element `[Reference]` is matched literally and `[{{var}}]` is rejected by the compiler).
|
|
61
61
|
- Bracket refs consumed by other non-DOM step families (`@mock` route refs) follow the same rule: if the step's grammar does not target a UI element, the bracket content is NOT a selector.
|
|
62
62
|
Writing a fabricated entry for these breaks the real step (the pattern parses the bracket itself) and pollutes selectors.yaml.
|
|
63
63
|
✅ **Tab-order table cells ARE selector refs** — each `| [Ref] |` row under `Then User see tab order:` resolves through selectors.yaml like any element (verify them once; they are the same refs the scenario's other steps use — don't double-count them as new elements).
|
|
@@ -449,6 +449,15 @@ Cross-pattern: `@high` by default.
|
|
|
449
449
|
|
|
450
450
|
---
|
|
451
451
|
|
|
452
|
+
## Format over value
|
|
453
|
+
|
|
454
|
+
When the spec states a label's contract as a **shape** ("Format: yyyy/mm/dd ~ yyyy/mm/dd", "Format: X件", "Format: 残りX個"), generate `matches "<format>"` — never `with {{v}}`. An exact value pins data that changes daily (red while the app is right); `contains` proves nothing. Tokens: `yyyy mm dd hh ss X *`. Anchored — use `*` when a node fuses a prefix with the value. See `sungen-gherkin-syntax` → Format assertion.
|
|
455
|
+
|
|
456
|
+
```gherkin
|
|
457
|
+
Then User see [Application Period] label matches "yyyy/mm/dd ~ yyyy/mm/dd"
|
|
458
|
+
Then User see [Total Plans] label matches "*X件"
|
|
459
|
+
```
|
|
460
|
+
|
|
452
461
|
## Priority Tags
|
|
453
462
|
|
|
454
463
|
Every scenario **MUST** have exactly one priority tag. Assign by user impact per the **"Assign priority by user impact"** table in `sungen-gherkin-syntax`; override only when context differs.
|
|
@@ -709,7 +718,7 @@ flows:
|
|
|
709
718
|
branchFrom: "BF step 1, the control tapped twice"
|
|
710
719
|
outcome: "One request, one record"
|
|
711
720
|
status: pending-clarification # the spec is silent — ASK, never assume a guard
|
|
712
|
-
reason: "The guard lives in
|
|
721
|
+
reason: "The guard lives in SCREEN_A_001's spec, which this project does not hold."
|
|
713
722
|
```
|
|
714
723
|
|
|
715
724
|
Four statuses, and **silence is not one of them**: `covered` · `deferred` · `pending-clarification`
|
|
@@ -719,6 +728,36 @@ missing flow. `sungen audit` then measures `flowCoverage` per DECLARED FLOW and
|
|
|
719
728
|
`FLOW-UNCOVERED` (declared, nobody wrote it), `FLOW-UNDECLARED` (a scenario claiming a flow id the
|
|
720
729
|
inventory never declares), `FLOW-STATUS-UNREASONED` and `FLOW-INVENTORY-MISSING`.
|
|
721
730
|
|
|
731
|
+
**Enumerate every branch the spec declares — do not rely on reading carefully.** Before writing
|
|
732
|
+
scenarios, list each mutually-exclusive branch the spec makes explicit, one line per arm:
|
|
733
|
+
|
|
734
|
+
- every `### Exception Flow X` / `### Alternative Flow X` heading;
|
|
735
|
+
- every row of a decision table (`DEC-*`, `BR-*`, any table of conditions → outcomes);
|
|
736
|
+
- every arm of an if/elif/else cascade in pseudocode;
|
|
737
|
+
- every state transition a state machine declares.
|
|
738
|
+
|
|
739
|
+
Each arm needs its OWN scenario, or a written exclusion. **Covering a sibling arm proves nothing
|
|
740
|
+
about this one** — and two scenarios that assert the same thing under different names are one test
|
|
741
|
+
counted twice, which is worse than a gap because the coverage sheet reports it as two.
|
|
742
|
+
`sungen audit` reports `SPEC-BRANCH-UNCOVERED` and `BRANCH-OVERCLAIMED`. This rule existed as
|
|
743
|
+
prose before and was missed three times in three flows; it is enumerated now for that reason.
|
|
744
|
+
|
|
745
|
+
**Two viewpoints per UI region, not one.** A region that has both conditional rendering (`DEC-*`)
|
|
746
|
+
and an acceptance scenario describing a click needs a case for EACH: "render — DEC-xxx" and
|
|
747
|
+
"interaction — click → destination". Finishing the render half is the easy half, and stopping there
|
|
748
|
+
is the common failure. Every navigation target the spec names needs a click-through case
|
|
749
|
+
(`NAV-TARGET-ABSENT`), even when this flow does not consume the destination — a context param
|
|
750
|
+
handed to another screen is that screen's input, and the handoff is where it goes wrong.
|
|
751
|
+
|
|
752
|
+
**A deny case needs its allow case.** "permission denied → content hidden" passes just as happily
|
|
753
|
+
when the content is hidden for everyone. Pair it with "permission granted → content shown", the
|
|
754
|
+
way a boundary needs `min-1 / min / max / max+1` (`PERMISSION-PAIR-MISSING`).
|
|
755
|
+
|
|
756
|
+
**State that ARRIVES with the data is a different case from state the test creates.** A
|
|
757
|
+
discriminator field you can only observe at load (an item already favourited before the page
|
|
758
|
+
opens) is a different oracle timing from the same field set by an action inside the test. Both are
|
|
759
|
+
needed; the first is usually `@manual:M1` or a data-factory fixture.
|
|
760
|
+
|
|
722
761
|
**One id per atomic CLAIM in the viewpoint.** The flow id groups; the viewpoint id identifies.
|
|
723
762
|
Writing `FL-BF-001` on four different claims makes the ledger count four items while traceability
|
|
724
763
|
sees one, so the coverage arithmetic and "which test proves this?" are both wrong and neither
|
|
@@ -46,7 +46,12 @@ Hard-fail gate. Any ✗ is a syntax error — must be fixed; do not score furthe
|
|
|
46
46
|
- [ ] `Given` → only `is on` · `When` → action (click/fill/select/press/clear/check/uncheck/hover/wait) · `Then` → only `see` · `And` inherits the preceding keyword
|
|
47
47
|
- [ ] NO `Given User see…` (→ `Then`, or entry assertion `Given User is on [X] page`)
|
|
48
48
|
- [ ] NO `When … And User is on [X] dialog` (→ `And User see` or a separate `Given`)
|
|
49
|
+
- [ ] `dismiss [X]` is SETUP, never proof — it is best-effort and never fails, so a scenario that
|
|
50
|
+
claims an overlay closed must assert it: `Then User see [X] is hidden` (mobile: tap the
|
|
51
|
+
close/scrim node explicitly, then assert)
|
|
52
|
+
- [ ] `is on` takes ONLY `[X] page` (`[X] screen` on mobile) / `[X] dialog` — `is on [X] step|section|card` ✗ (it compiles to a real navigation, or to a URL assertion that cannot fail). A state inside the page → `Then User see [X] section`.
|
|
49
53
|
- [ ] NO literal-URL navigation (`navigate to "/x"`) → `User is on [X] page` + declare in `selectors.yaml`
|
|
54
|
+
- [ ] NO `{{var}}` inside a `[Target]` (`[{{confirm_value}}]` ✗) — a target is a selector key matched literally. For text that varies (locale, `@cases` column) keep the target static and move the variable into the selector entry's `name:`/`value:`, or pick by text with `with {{var}}`. Only a browser-storage key may embed one.
|
|
50
55
|
|
|
51
56
|
### A2. Action → Element type
|
|
52
57
|
|
|
@@ -67,6 +72,16 @@ Hard-fail gate. Any ✗ is a syntax error — must be fixed; do not score furthe
|
|
|
67
72
|
### A4. Assertion (8 patterns → determines the Playwright assertion)
|
|
68
73
|
|
|
69
74
|
- [ ] Visibility `see [T] type` → NO redundant `is visible`; hidden uses `is hidden`
|
|
75
|
+
- [ ] **No state clause on `[T] page`** — `page is hidden`/`is empty`/… are not step forms. `page`
|
|
76
|
+
asserts a URL, so the clause is dropped and the assertion inverts (`SG-E021`). Assert the
|
|
77
|
+
expected page, or a marker element
|
|
78
|
+
- [ ] **The state is one the step form can actually assert** — a `wait for` honours only
|
|
79
|
+
`hidden`/`visible`/`disabled`/`enabled`; `row in [T] table` / `column in [T] table` honour
|
|
80
|
+
only `is hidden`. Anything else is dropped (`SG-E021`) — move the state onto the element
|
|
81
|
+
- [ ] **No invented step forms** — every step matches a pattern in `sungen-gherkin-syntax`. An
|
|
82
|
+
unmatched step compiles to a runtime `throw` (`SG-W001`), never a silent pass; park a
|
|
83
|
+
genuinely unautomatable check as `@manual`
|
|
84
|
+
- [ ] A spec `Format:` line → `matches "<format>"`, NOT `with {{v}}` (an exact value pins data that changes daily; `contains` proves nothing)
|
|
70
85
|
- [ ] Correct pattern: text(`message/header/label with {{v}}`) · partial(`text contains {{v}}`) · input(`field/dropdown with {{v}}`) · state(`is …`) · attribute(`image/link with {{v}}`) · count(`row with {{count}}`) · page(`[T] page`)
|
|
71
86
|
- [ ] Table: `[Col] column in [Table] table`, `[Ref] row in [Table] table with {{v}}`, `table with {{count}}`/`is empty`, `table match data:`; row scope used correctly
|
|
72
87
|
|
|
@@ -24,6 +24,16 @@ JAVA_HOME=/opt/homebrew/opt/openjdk@21
|
|
|
24
24
|
# IOS_BUNDLE_ID=<ios bundle id> # REQUIRED — the iOS bundle (⚠ NOT the Android package)
|
|
25
25
|
# IOS_TEAM_ID=<Apple Team ID> # REQUIRED — 10-char id (Xcode → Settings → Accounts)
|
|
26
26
|
|
|
27
|
+
# ════════════ State reset between scenarios ════════════
|
|
28
|
+
# Raises the reset floor for a WHOLE run without regenerating any spec — CI can run stricter than
|
|
29
|
+
# local dev. It never LOWERS a tier a feature declared with @reset:. Tiers, weakest → strongest:
|
|
30
|
+
# none nothing reset (state carries over by design) 0s
|
|
31
|
+
# soft terminate + relaunch — THE DEFAULT ~1–3s
|
|
32
|
+
# data + wipe local DB / cache / prefs / keychain ~2–5s (iOS: reinstalls, needs IOS_APP)
|
|
33
|
+
# full + remove and reinstall (permissions too) ~5–30s (needs APP_APK / IOS_APP)
|
|
34
|
+
# ⚠ data and full WIPE APP DATA on the target device — emulator / dedicated test device only.
|
|
35
|
+
# SUNGEN_RESET_TIER=data
|
|
36
|
+
|
|
27
37
|
# ════════════ Appium server security (opt-in) ════════════
|
|
28
38
|
# Allow `mobile: shell` (raw adb passthrough) for device-level oracles (e.g. pulling an app's
|
|
29
39
|
# SQLite DB via run-as). Starts the server with --allow-insecure=uiautomator2:adb_shell.
|
|
@@ -58,6 +58,124 @@ function substitute(text: string, params: Record<string, any>): string {
|
|
|
58
58
|
function substituteRaw(text: string, params: Record<string, any>): string {
|
|
59
59
|
return text.replace(/:([A-Za-z_][A-Za-z0-9_]*)/g, (_m, p) => String(params[p] ?? ''));
|
|
60
60
|
}
|
|
61
|
+
|
|
62
|
+
/** The five predefined XML entities — a bound value containing a bare `&` or `<` would
|
|
63
|
+
* otherwise re-create exactly the invalid_xml rejection this encoding exists to avoid. */
|
|
64
|
+
function escapeXml(value: string): string {
|
|
65
|
+
return value
|
|
66
|
+
.replace(/&/g, '&')
|
|
67
|
+
.replace(/</g, '<')
|
|
68
|
+
.replace(/>/g, '>')
|
|
69
|
+
.replace(/"/g, '"')
|
|
70
|
+
.replace(/'/g, ''');
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Bind `:param` tokens into an XML string template by WHITELIST of the passed param
|
|
75
|
+
* names — a generic `:word` scan would mangle namespace prefixes (soapenv:Envelope).
|
|
76
|
+
* Longest name first + a word-boundary lookahead so `:id` can never eat the prefix
|
|
77
|
+
* of `:id_2` or the head of `:idx`. Values are XML-escaped by default; a token the
|
|
78
|
+
* template wraps as `<![CDATA[:param]]>` binds raw inside its CDATA section instead.
|
|
79
|
+
* Replacer FUNCTIONS everywhere — a value containing `$&`-style sequences must never
|
|
80
|
+
* be interpreted as a replacement pattern.
|
|
81
|
+
*/
|
|
82
|
+
function bindXmlParams(template: string, params: Record<string, any>): string {
|
|
83
|
+
let out = template;
|
|
84
|
+
for (const name of Object.keys(params).sort((a, b) => b.length - a.length)) {
|
|
85
|
+
const value = String(params[name] ?? '');
|
|
86
|
+
// A value containing "]]>" would terminate the CDATA section early and ship
|
|
87
|
+
// malformed XML — split it across two sections so the document stays valid.
|
|
88
|
+
const cdataSafe = value.split(']]>').join(']]]]><![CDATA[>');
|
|
89
|
+
out = out.replace(new RegExp(`<!\\[CDATA\\[:${name}\\]\\]>`, 'g'), () => `<![CDATA[${cdataSafe}]]>`);
|
|
90
|
+
out = out.replace(new RegExp(`:${name}(?![A-Za-z0-9_])`, 'g'), () => escapeXml(value));
|
|
91
|
+
}
|
|
92
|
+
return out;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Decode an XML-ish response body: Content-Type charset, else the XML declaration's
|
|
96
|
+
* encoding (read from an ASCII-compatible prefix), else UTF-8. Unknown charsets fall
|
|
97
|
+
* back to UTF-8 rather than failing the whole request. */
|
|
98
|
+
function decodeXmlAwareText(bytes: Buffer, contentType: string): string {
|
|
99
|
+
// A UTF-16 BOM means the latin1 declaration scan below would see NUL-laden
|
|
100
|
+
// garbage that still "parses" \u2014 decode by the BOM first.
|
|
101
|
+
if (bytes.length >= 2 && bytes[0] === 0xff && bytes[1] === 0xfe) return new TextDecoder('utf-16le').decode(bytes);
|
|
102
|
+
if (bytes.length >= 2 && bytes[0] === 0xfe && bytes[1] === 0xff) return new TextDecoder('utf-16be').decode(bytes);
|
|
103
|
+
const head = bytes.subarray(0, 200).toString('latin1');
|
|
104
|
+
const declared = (head.match(/^\uFEFF?\s*<\?xml[^>]*\bencoding=["']([^"']+)["']/i) || [])[1];
|
|
105
|
+
// Candidates in precedence order; an unsupported CT charset falls back to the
|
|
106
|
+
// XML declaration's encoding rather than straight to a mojibake UTF-8 decode.
|
|
107
|
+
const candidates = [(contentType.match(/charset=["']?([^;"'\s]+)/i) || [])[1], declared, 'utf-8'];
|
|
108
|
+
for (const charset of candidates) {
|
|
109
|
+
if (!charset) continue;
|
|
110
|
+
try { return new TextDecoder(charset).decode(bytes); } catch { /* next candidate */ }
|
|
111
|
+
}
|
|
112
|
+
return new TextDecoder('utf-8').decode(bytes);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Parse an XML response body to a plain object, or null on a real parse failure (the
|
|
117
|
+
* caller then keeps the raw string, matching the non-JSON behavior).
|
|
118
|
+
*
|
|
119
|
+
* Locked parser posture:
|
|
120
|
+
* - processEntities/htmlEntities OFF — fast-xml-parser's entity processing (ON by
|
|
121
|
+
* default, including DOCTYPE entities) is exactly the XXE-shaped surface a QA tool
|
|
122
|
+
* must not expose to a live server's response. The five predefined entities are
|
|
123
|
+
* decoded AFTERWARDS by our own pass.
|
|
124
|
+
* - parseTagValue/parseAttributeValue OFF — `<error_code>01000001</error_code>` must
|
|
125
|
+
* stay a string; numeric coercion destroys leading zeros.
|
|
126
|
+
* - attributes kept with the `@_` prefix (`{{r.body.tag.@_id}}`).
|
|
127
|
+
* - repeated elements: XML cannot distinguish "one item" from "a list of one" — the
|
|
128
|
+
* catalog declares `arrayPaths:` (dot jpaths INCLUDING the root element) and those
|
|
129
|
+
* paths are ALWAYS arrays, so `[0]` access works for 1 and N occurrences alike
|
|
130
|
+
* (an EMPTY parent has no child key at all — no array to index).
|
|
131
|
+
*/
|
|
132
|
+
function parseXmlBody(text: string, label: string, arrayPaths?: string[]): any | null {
|
|
133
|
+
let XMLParser: any;
|
|
134
|
+
try {
|
|
135
|
+
// Lazy require: generated projects created before this capability existed don't
|
|
136
|
+
// have the dep; they must fail with the install command, not MODULE_NOT_FOUND.
|
|
137
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
138
|
+
({ XMLParser } = require('fast-xml-parser'));
|
|
139
|
+
} catch {
|
|
140
|
+
throw new Error(`API Driver: ${label} — the response is XML but "fast-xml-parser" is not installed in this project. Run: npm install --save-dev fast-xml-parser`);
|
|
141
|
+
}
|
|
142
|
+
const arraySet = new Set(arrayPaths || []);
|
|
143
|
+
const parser = new XMLParser({
|
|
144
|
+
ignoreAttributes: false,
|
|
145
|
+
attributeNamePrefix: '@_',
|
|
146
|
+
processEntities: false,
|
|
147
|
+
htmlEntities: false,
|
|
148
|
+
parseTagValue: false,
|
|
149
|
+
parseAttributeValue: false,
|
|
150
|
+
trimValues: true,
|
|
151
|
+
isArray: (_name: string, jpath: string) => arraySet.has(jpath),
|
|
152
|
+
});
|
|
153
|
+
try {
|
|
154
|
+
return decodeXmlEntitiesDeep(parser.parse(text));
|
|
155
|
+
} catch {
|
|
156
|
+
return null;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** Decode the five predefined XML entities in every string leaf (`&` last, so a
|
|
161
|
+
* literal `&lt;` decodes to `<` and not further). */
|
|
162
|
+
function decodeXmlEntitiesDeep(value: any): any {
|
|
163
|
+
if (typeof value === 'string') {
|
|
164
|
+
return value
|
|
165
|
+
.replace(/</g, '<')
|
|
166
|
+
.replace(/>/g, '>')
|
|
167
|
+
.replace(/"/g, '"')
|
|
168
|
+
.replace(/'/g, "'")
|
|
169
|
+
.replace(/&/g, '&');
|
|
170
|
+
}
|
|
171
|
+
if (Array.isArray(value)) return value.map(decodeXmlEntitiesDeep);
|
|
172
|
+
if (value !== null && typeof value === 'object') {
|
|
173
|
+
const out: Record<string, any> = {};
|
|
174
|
+
for (const [k, v] of Object.entries(value)) out[k] = decodeXmlEntitiesDeep(v);
|
|
175
|
+
return out;
|
|
176
|
+
}
|
|
177
|
+
return value;
|
|
178
|
+
}
|
|
61
179
|
/** Delete any case-insensitive variant of a header (HTTP header names are case-insensitive, but a
|
|
62
180
|
* plain object keys on exact case — so a datasource `Content-Type` and our `content-type` would
|
|
63
181
|
* both be sent). Call before setting, or to let fetch own the header (multipart boundary). */
|
|
@@ -94,7 +212,11 @@ function loadStorageStateCookieHeader(storageStatePath: string, url: string): st
|
|
|
94
212
|
const targetIsSecure = target.protocol === 'https:';
|
|
95
213
|
const matches = cookies.filter((c) => {
|
|
96
214
|
const domain = (c.domain || '').replace(/^\./, '');
|
|
97
|
-
|
|
215
|
+
// A cookie with NO domain must match nothing, not everything — Playwright
|
|
216
|
+
// always records a domain, so a domainless entry only comes from a hand-
|
|
217
|
+
// edited state file, and treating it as a wildcard would attach the session
|
|
218
|
+
// cookie to any origin a response-bound absolute URL points at.
|
|
219
|
+
const domainOk = !!domain && (target.hostname === domain || target.hostname.endsWith(`.${domain}`));
|
|
98
220
|
const pathOk = target.pathname.startsWith(c.path || '/');
|
|
99
221
|
const notExpired = c.expires === undefined || c.expires < 0 || c.expires > nowSec;
|
|
100
222
|
// A Secure cookie must never leave over plaintext. Playwright's own cookie jar enforces this,
|
|
@@ -201,22 +323,72 @@ class ApiClient {
|
|
|
201
323
|
*/
|
|
202
324
|
async call(
|
|
203
325
|
label: string,
|
|
204
|
-
req: { method: string; path: string; body?: unknown; encoding?: 'json' | 'form' | 'multipart'; files?: Record<string, FileSpec | FileSpec[]>; bodyFile?: FileSpec; headers?: Record<string, string>; timeout?: number; datasource?: string },
|
|
326
|
+
req: { method: string; path: string; body?: unknown; encoding?: 'json' | 'form' | 'multipart' | 'xml'; files?: Record<string, FileSpec | FileSpec[]>; bodyFile?: FileSpec; headers?: Record<string, string>; timeout?: number; datasource?: string; arrayPaths?: string[] },
|
|
205
327
|
params: Record<string, any> = {},
|
|
206
328
|
opts: { storageState?: string } = {},
|
|
207
329
|
): Promise<{ status: number; ok: boolean; body: any; headers: Record<string, string> }> {
|
|
208
330
|
const { conf } = this.cfg(req.datasource);
|
|
209
331
|
const base = (conf.base_url || conf.baseUrl || '').replace(/\/$/, '');
|
|
210
332
|
if (!base) throw new Error(`API Driver: ${label} — datasource has no base_url (set it in .env.qa).`);
|
|
211
|
-
const urlPath = substitute(req.path, params); // path params (:id) bind at runtime
|
|
212
333
|
|
|
213
|
-
|
|
334
|
+
// Whole-token path (`path: ":url"`): bind RAW and use an absolute http(s)
|
|
335
|
+
// value directly — an export/download link returned by a prior response
|
|
336
|
+
// (often on another origin, e.g. blob storage). substitute() would percent-
|
|
337
|
+
// encode the whole URL into `https%3A%2F%2F…` and joinApiUrl would glue it
|
|
338
|
+
// onto base_url. Raw binding also keeps presigned query escapes (%2F)
|
|
339
|
+
// untouched; new URL() is used for VALIDATION only (a response value can be
|
|
340
|
+
// anything — file:/data: schemes are refused, never fetched).
|
|
341
|
+
let fullUrl: string;
|
|
342
|
+
let absolute = false;
|
|
343
|
+
const wholeToken = req.path.match(/^:([A-Za-z_][A-Za-z0-9_]*)$/);
|
|
344
|
+
if (wholeToken) {
|
|
345
|
+
// The bound value MUST be an absolute http(s) URL. Anything else — empty
|
|
346
|
+
// string (a broken export returning {"url":""}), a relative path, or a
|
|
347
|
+
// data:/mailto:/file: form — is refused loudly: falling through to the
|
|
348
|
+
// legacy base_url join would silently GET the API root and let the
|
|
349
|
+
// scenario false-pass on exactly the failure it exists to catch. (A
|
|
350
|
+
// whole-token path was never valid before this feature, so there is no
|
|
351
|
+
// legacy behavior to preserve.)
|
|
352
|
+
const wholeVal = String(params[wholeToken[1]] ?? '');
|
|
353
|
+
let parsed: URL | null = null;
|
|
354
|
+
if (/^[A-Za-z][A-Za-z0-9+.-]*:/.test(wholeVal)) {
|
|
355
|
+
try { parsed = new URL(wholeVal); } catch { parsed = null; }
|
|
356
|
+
}
|
|
357
|
+
if (!parsed || (parsed.protocol !== 'http:' && parsed.protocol !== 'https:')) {
|
|
358
|
+
throw new Error(`API Driver: ${label} — :${wholeToken[1]} must be an absolute http(s) URL (the value is bound from a live response); got: ${JSON.stringify(wholeVal)}`);
|
|
359
|
+
}
|
|
360
|
+
if (parsed.username || parsed.password) {
|
|
361
|
+
throw new Error(`API Driver: ${label} — :${wholeToken[1]} must not carry credentials (user:pass@host): fetch refuses such URLs.`);
|
|
362
|
+
}
|
|
363
|
+
// Raw string, not parsed.toString(): URL normalization could rewrite the
|
|
364
|
+
// presigned query escapes (%2F) the storage host signed.
|
|
365
|
+
fullUrl = wholeVal;
|
|
366
|
+
absolute = true;
|
|
367
|
+
} else if (/^https?:\/\//i.test(req.path)) {
|
|
368
|
+
// Literal absolute catalog path — its :params (if any) bind with the normal encoding.
|
|
369
|
+
fullUrl = substitute(req.path, params);
|
|
370
|
+
absolute = true;
|
|
371
|
+
} else {
|
|
372
|
+
fullUrl = joinApiUrl(base, substitute(req.path, params)); // path params (:id) bind at runtime
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
// Cross-origin absolute URL: the datasource's own headers must NOT travel to
|
|
376
|
+
// another origin — auth tokens, cookies and tenant keys live in conf.headers
|
|
377
|
+
// with no metadata separating them from benign defaults, so ALL of them stay
|
|
378
|
+
// home; only headers declared on the entry itself are sent. An unparseable
|
|
379
|
+
// base counts as a different origin (fail closed).
|
|
380
|
+
const sameOrigin = !absolute || (() => {
|
|
381
|
+
try { return new URL(fullUrl).origin === new URL(base).origin; } catch { return false; }
|
|
382
|
+
})();
|
|
383
|
+
const headers: Record<string, string> = sameOrigin ? { ...(conf.headers || {}) } : {};
|
|
214
384
|
// catalog headers; :param tokens bind at runtime — raw (no URL-encoding, unlike the path)
|
|
215
385
|
for (const [k, v] of Object.entries(req.headers || {}))
|
|
216
386
|
headers[k] = substituteRaw(String(v), params);
|
|
217
387
|
// Body: substitute `:param` into the body template (object values), then encode per `encoding`.
|
|
388
|
+
// An XML body is a raw STRING template and binds by param-name WHITELIST below —
|
|
389
|
+
// the generic whole-value substitution here must not touch it.
|
|
218
390
|
let body: any;
|
|
219
|
-
if (req.body !== undefined && req.body !== null) {
|
|
391
|
+
if (req.body !== undefined && req.body !== null && req.encoding !== 'xml') {
|
|
220
392
|
body = JSON.parse(JSON.stringify(req.body).replace(/":([A-Za-z_][A-Za-z0-9_]*)"/g, (_m, p) => JSON.stringify(params[p] ?? null)));
|
|
221
393
|
}
|
|
222
394
|
// Map the wire format to a fetch request body (#345): json → JSON string (application/json,
|
|
@@ -244,6 +416,17 @@ class ApiClient {
|
|
|
244
416
|
// drop any datasource default Content-Type so it doesn't conflict with / duplicate it.
|
|
245
417
|
deleteHeader(headers, 'content-type');
|
|
246
418
|
fetchBody = buildMultipart(req.files, body, params, label);
|
|
419
|
+
} else if (req.encoding === 'xml' && req.body !== undefined && req.body !== null) {
|
|
420
|
+
// XML body: the catalog carries a raw string template (any shape — attributes,
|
|
421
|
+
// namespaces, CDATA, nesting). :params bind by WHITELIST of the passed param
|
|
422
|
+
// names, never by a generic `:word` scan — that scan would mangle namespace
|
|
423
|
+
// prefixes (soapenv:Envelope, xsi:type) into bound values. A BODYLESS xml
|
|
424
|
+
// entry is fine — a GET whose xml RESPONSE needs arrayPaths carries no body.
|
|
425
|
+
if (typeof req.body !== 'string') {
|
|
426
|
+
throw new Error(`API Driver: ${label} — encoding: xml requires body: to be a string template (a YAML block scalar holding the raw XML).`);
|
|
427
|
+
}
|
|
428
|
+
fetchBody = bindXmlParams(req.body, params);
|
|
429
|
+
if (!hasHeader(headers, 'content-type')) headers['content-type'] = 'application/xml';
|
|
247
430
|
} else if (body !== undefined) {
|
|
248
431
|
const enc = req.encoding ?? 'json';
|
|
249
432
|
if (enc === 'form') {
|
|
@@ -266,9 +449,6 @@ class ApiClient {
|
|
|
266
449
|
throw new Error(`API Driver: ${label} — a ${req.method.toUpperCase()} request cannot carry a body. Remove body:/files:/bodyFile: from the catalog entry, or change its method to POST/PUT/PATCH.`);
|
|
267
450
|
}
|
|
268
451
|
|
|
269
|
-
// Full URL (not a baseURL-relative path) so a base path component like /api/v3 is preserved.
|
|
270
|
-
const fullUrl = joinApiUrl(base, urlPath);
|
|
271
|
-
|
|
272
452
|
// @hybrid: `storageState` is the @auth role's saved session (Playwright's `context.storageState()`
|
|
273
453
|
// JSON) — reuse its cookies (scoped to `fullUrl`'s domain/path) so the request shares the
|
|
274
454
|
// browser's authenticated session without a browser/Playwright context.
|
|
@@ -292,9 +472,32 @@ class ApiClient {
|
|
|
292
472
|
...(fetchBody !== undefined ? { body: fetchBody } : {}),
|
|
293
473
|
signal: controller.signal,
|
|
294
474
|
});
|
|
295
|
-
|
|
475
|
+
// Read bytes, not res.text(): the fetch spec hard-decodes text() as UTF-8, so a
|
|
476
|
+
// Shift_JIS/ISO-8859-1 XML response (common from OTA backends) would mojibake
|
|
477
|
+
// silently. XML-ish responses decode by Content-Type charset, then the XML
|
|
478
|
+
// declaration's encoding, then UTF-8; everything else keeps the UTF-8 decode
|
|
479
|
+
// res.text() always performed.
|
|
480
|
+
const bytes = Buffer.from(await res.arrayBuffer());
|
|
481
|
+
const contentType = res.headers.get('content-type') || '';
|
|
482
|
+
// XML detection: an explicit xml Content-Type (text/xml, application/xml,
|
|
483
|
+
// application/soap+xml — never text/html), else — only when the CT is absent or
|
|
484
|
+
// octet-stream — a sniff REQUIRING the `<?xml` declaration. Never sniff on a bare
|
|
485
|
+
// `<`: the parser does not validate, so an HTML 502 page would come back as a
|
|
486
|
+
// junk object instead of the raw string an author can at least read.
|
|
487
|
+
const ctIsXml = /xml/i.test(contentType) && !/html/i.test(contentType);
|
|
488
|
+
const maySniff = !contentType || /octet-stream/i.test(contentType);
|
|
489
|
+
// Non-XML branch uses TextDecoder, NOT Buffer.toString: the fetch spec's
|
|
490
|
+
// "UTF-8 decode" (what res.text() did) STRIPS a leading BOM, and a BOM'd
|
|
491
|
+
// JSON response must keep parsing as an object exactly as before.
|
|
492
|
+
const text = ctIsXml || maySniff ? decodeXmlAwareText(bytes, contentType) : new TextDecoder('utf-8').decode(bytes);
|
|
493
|
+
const isXml = (ctIsXml || (maySniff && /^\uFEFF?\s*<\?xml/.test(text))) && text.length > 0;
|
|
296
494
|
let parsed: any = text;
|
|
297
|
-
|
|
495
|
+
if (isXml) {
|
|
496
|
+
const xml = parseXmlBody(text, label, req.arrayPaths);
|
|
497
|
+
parsed = xml !== null ? xml : text; // real parse failure → keep the raw string
|
|
498
|
+
} else {
|
|
499
|
+
try { parsed = text ? JSON.parse(text) : null; } catch { /* non-JSON → keep text */ }
|
|
500
|
+
}
|
|
298
501
|
return { status: res.status, ok: res.ok, body: parsed, headers: Object.fromEntries(res.headers.entries()) };
|
|
299
502
|
} finally {
|
|
300
503
|
clearTimeout(timer);
|
|
@@ -310,7 +513,7 @@ class ApiClient {
|
|
|
310
513
|
*/
|
|
311
514
|
async callN(
|
|
312
515
|
label: string,
|
|
313
|
-
req: { method: string; path: string; body?: unknown; encoding?: 'json' | 'form' | 'multipart'; files?: Record<string, FileSpec | FileSpec[]>; bodyFile?: FileSpec; headers?: Record<string, string>; timeout?: number; datasource?: string },
|
|
516
|
+
req: { method: string; path: string; body?: unknown; encoding?: 'json' | 'form' | 'multipart' | 'xml'; files?: Record<string, FileSpec | FileSpec[]>; bodyFile?: FileSpec; headers?: Record<string, string>; timeout?: number; datasource?: string; arrayPaths?: string[] },
|
|
314
517
|
params: Record<string, any> = {},
|
|
315
518
|
n = 1,
|
|
316
519
|
opts: { storageState?: string } = {},
|