
Accelint Ac To Playwright
- 110 installs
- 21 repo stars
- Updated August 4, 2026
- gohypergiant/agent-skills
accelint-ac-to-playwright is an agent skill that validates acceptance criteria and translates them into JSON test plans and Playwright specs using Accelint vocabulary and schema checks.
About
accelint-ac-to-playwright converts acceptance criteria in markdown bullets or Gherkin into schema-valid JSON test plans and Playwright specs, with a mandatory assessment pass first. Teams reach for it when product AC need automation-ready targets and measurable outcomes instead of vague steps. Full conversion stops if assessment fails, and output paths must be supplied explicitly.
- Assessment mode checks AC structure before any file generation
- JSON plans validated against plan-schema before translation
- Controlled area/component/intent vocabulary from test-hooks.md
- CLI generate-tests with explicit output directories
Accelint Ac To Playwright by the numbers
- 110 all-time installs (skills.sh)
- Ranked #968 of 2,153 Testing & QA skills by installs in the Skillselion catalog
- Data as of Aug 5, 2026 (Skillselion catalog sync)
npx skills add https://github.com/gohypergiant/agent-skills --skill accelint-ac-to-playwrightAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 110 |
|---|---|
| repo stars | ★ 21 |
| Last updated | August 4, 2026 |
| Repository | gohypergiant/agent-skills ↗ |
How do you know acceptance criteria are structured enough to become reliable Playwright tests without manual rewrite loops?
Assess acceptance-criteria quality and convert markdown or Gherkin AC into validated JSON plans and Playwright specs with controlled vocabulary targets.
Who is it for?
Developers or QA engineers with AC documents who want assessment-first conversion to Playwright automation.
Skip if: Teams without acceptance criteria text or those seeking unit-test-only guidance unrelated to browser E2E flows.
When should I use this skill?
You ask to review, assess, convert, or generate Playwright tests from markdown or Gherkin acceptance criteria files.
What you get
Assessment report plus, when passing, validated JSON plans and generated Playwright test files in user-specified directories.
Files
AC To Playwright
MANDATORY - READ ENTIRE FILE: Before processing ANY acceptance criteria, you MUST read `references/acceptance-criteria.md` (~175 lines) completely from start to finish. NEVER set any range limits when reading this file. It is the authoritative source for AC writing rules and mappings.
Note on test-hooks.md: Load references/test-hooks.md when converting AC → JSON plans or when running Assessment mode — it contains the controlled vocabulary for area/component/intent target naming patterns. Do NOT load when converting plans → tests (translation script handles this automatically).
Intent Detection
The skill supports two modes based on user phrasing:
Assessment mode (triggers on):
- "review these AC"
- "evaluate these AC"
- "check if these AC are ready"
- "can these AC be converted as-is"
- "are these AC automation-ready"
- "assess these acceptance criteria"
Full conversion mode (triggers on):
- "convert these AC"
- "generate tests from AC"
- "turn AC into Playwright tests"
- "create test automation"
Assessment mode analyzes AC text only (no artifact generation). Full conversion mode generates plans and tests.
Assessment Workflow
0. Detect intent: User asks to review/evaluate/assess/check AC readiness. 1. Prepare for the task:
- Read
references/acceptance-criteria.mdandreferences/test-hooks.md. - Work one input file at a time.
2. Analyze AC text against all conversion requirements:
- Structure & Format:
- Bullet format: proper
-markers for each AC - Gherkin format: valid Feature/Scenario/Examples/Given/When/Then/tags structure
- Step ordering: all Givens → all Whens → all Thens (no mixing within a scenario)
- Targets (semantic validation):
- Every action specifies a target
- Target meets the area/component/intent pattern (all three parts present)
- Area matches controlled vocabulary from
test-hooks.md(nav, header, footer, form, drawer, card, toast, modal, table, page, area) - Component matches controlled vocabulary (button, link, input, dropdown, checkbox, radio, text, div, component)
- Intent is present
- Actions:
- Verbs are recognized and mappable to Playwright actions (click, fill, select, drag)
- No vague verbs (interact, use, hover without x/y coordinates)
- Fill/select actions have quoted literal values (not "a valid email" or "any value")
- Expected Outcomes:
- Explicitly stated (not implied or inferred)
- Measurable (specific text content, element, or state)
- Visibility changes use trigger words (appears, shows, hides, visible, see)
3. Report results:
- If issues found: Report "❌ AC are not conversion-ready" with detailed issue list (see output format below)
- If no issues: Report "✓ AC are conversion-ready" with validated checklist
- Do NOT generate any files (no JSON plans, no test files)
- Report results for all input files - do not stop Assessment mode after a single failure to ensure all issues are surfaced to the user at once.
Conversion Workflow
0. Detect intent: User asks to generate/convert/write tests from AC files. 1. Run Assessment mode:
- Run Assessment mode against all input files and report pass/fail result.
- If Assessment mode reported any failures across all files, STOP. Do not proceed with the rest of Conversion mode.
2. Prepare for the task:
- Require the user to explicitly provide output directories for plans, tests, and summaries before writing any files.
- Read
references/acceptance-criteria.md. - Work one input file at a time. Do not parallelize so that errors in one file's workflow do not affect other files' workflows.
- Derive suite name, test names, startUrl, steps, targets, tags, and source metadata per the rules below.
3. JSON test plan:
- Build a JSON test plan that conforms to
references/plan-schema.ts. - Validate the test plan and report results.
- If validation failed, stop. Do not write the plan. Skip the rest of these steps for the current input file and move on to the next input file.
- If validation passed, write the plan to the user-specified output directory:
<plans-output-dir>/<suite-slug>.json.
4. Translate the plan to tests:
- Once the plan file is written, translate the plan with
scripts/translate-plan-to-tests.ts. - Write the test suite file to the user-specified output directory:
<tests-output-dir>/<suite-slug>.spec.ts. - Append a summary entry to the batch JSON file in the user-specified summary directory (one batch file per run).
5. Next steps:
- Work on the next input file, if any remain.
- After all files are processed:
- Copy
skills/accelint-ac-to-playwright/assets/fixtures/directory to<tests-output-dir>/fixtures/. This directory contains shared test utilities (error-handling.tsandconsole-tracking.ts) that generated tests import from. - Ask the user if they would like a Playwright config template. If yes, copy
skills/accelint-ac-to-playwright/assets/templates/playwright.config.tsinto the user‑specified summaries location.
Recognition Patterns
Before processing AC, identify these quality signals:
Good AC (can process directly):
| Check | Question | If NO → Action |
|---|---|---|
| Targets | Does every action specify area.component.intent? | Ask user to clarify which specific element |
| Values | Are all fill/select values quoted literals? | Ask user for exact values to use |
| Outcomes | Are expectations measurable (specific text/element/state)? | Ask user what exactly to verify |
Bad patterns (ask the user questions):
- "interact with" (and other similar language) → too vague, agent can't map to Playwright action
- Dropdown: "select the first option" → fails, needs exact text
- Always quote exact literals:
'test@example.com'not "a valid email"
The above table directs you to ask for clarifications because guessing creates tests that fail unpredictably.
Naming Transformations
Input to output mapping: One AC file → one suite → one plan file (<plans-dir>/<suite-slug>.json) → one test file
.mdbullet-style: each-bullet = one test.featureGherkin: each Scenario = one test; each Examples row in Scenario Outline = one test
Output structure: After conversion completes, the test output directory will contain:
<suite-slug>.spec.tsfiles (one per AC file)fixtures/directory with shared utilities:fixtures/error-handling.ts- failure artifact attachment helperfixtures/console-tracking.ts- console message tracking helper
Important for users: When copying generated tests to your Playwright project, copy both the .spec.ts files AND the fixtures/ directory. Tests import from these fixtures and will fail to compile without them.
| Input | Suite Name | Test Name | Output Slug |
|---|---|---|---|
.feature | Feature: text → lowercase → capitalize first | Scenario text (lowercase, ~64 char limit) + (params) for Scenario Outlines | suite name → lowercase, spaces to dashes |
.md | filename → lowercase → dashes to spaces → capitalize first | Summarize bullet intent (present tense, lowercase, ~64 char) | suite name → lowercase, spaces to dashes |
Scenario Outline parameters: Use shortest left-to-right column combo that uniquely identifies each row, joined with /.
Example:
Examples:
| username | password | message |
| user1 | pass1 | Welcome user1 |
| user2 | pass2 | Welcome user2 |Appends (user1/pass1) and (user2/pass2) respectively.
Tags (Gherkin only)
- Feature-level tags -> suite tags.
- Scenario-level tags -> test tags.
- Do not include suite tags in test tags; drop duplicates at the test level.
- If no test tags remain, omit tags field for that test.
- Tag values include the leading '@'.
Source metadata
- Always include a source object at suite level.
- If AC file is inside a git repo: repo = repo name (folder containing
.git), path = repo-relative path. - If AC file is not inside a git repo: repo =
external, path = file basename only. - Do not store absolute paths.
Output Rules
Suite-level fields
- Top-level field order: suiteName, tags (if any), source, tests.
Test-level fields
- Start URL: always default to '/' unless the user provides an explicit starting page in a given AC per
references/acceptance-criteria.md. - Steps: use only schema actions (but do not use
goto) and preserve the order in the bullet text or in the Gherkin steps. - Keyboard modifier combinations: When AC describes pressing a key combination (e.g., "press Shift+g", "press Control+Enter"), translate it into a three-step sequence:
1. keyDown with the modifier key (e.g., Shift, Control, or app-specific modifier a) 2. press with the non-modifier key (e.g., g, Enter) 3. keyUp with the same modifier key
- Valid modifiers for
keyDown/keyUp:Shift,Control,a(app-specific) - The
pressaction only accepts single unmodified keys and should never receive combination syntax likeShift+g - Assertions:
- If navigation is triggered, add
expectUrlusing the Start URL mapping. - For visibility changes (e.g., visible/appears/shows/hides and similar wording), add
expectNotVisibleimmediately before the action andexpectVisibleimmediately after (or vice versa as appropriate). - Only add
expectText/expectVisible/expectNotVisiblewhen the AC explicitly names text or visibility. - Do not invent assertions. NEVER infer unstated information. Required fields that MUST be explicit (not inferred):
- target: Must include area + component + intent
- value: Must be quoted literal for fills
- expected outcomes: Must include verifiable element/text
Resources
scripts/plan-schema.ts— schema and validation logic to consult when generating plans.scripts/cli/validate-plan.ts— validator script for JSON plans (run vianpx validate-planafter build).scripts/translate-plan-to-tests.ts— converts a validated plan to a Playwright spec.scripts/cli/generate-tests.ts— CLI wrapper for reading, validating, and writing spec files.
Validation and Retry Protocol
Use npx validate-plan path/to/plan.json to validate a plan against references/plan-schema.ts (after build).
Maximum attempts: 2 total (initial + 1 correction)
1. Attempt 1: Generate JSON → validate
- Pass → proceed to write file
- Fail → go to Attempt 2
2. Attempt 2: Read validation error → fix ONE specific issue → re-validate
- Pass → proceed to write file
- Fail → STOP, report error to user
NEVER:
- Make multiple changes at once (fix one thing, validate, repeat)
- Retry by rephrasing same JSON differently
- Guess at schema requirements if error is unclear
Error Recovery
| Error Type | Diagnostic Question | Common Causes | Fix Strategy |
|---|---|---|---|
| Schema validation fails | What field does error message name? | Wrong field order, missing required field, extra field not in schema, incorrect field type | Check schema for exact field names and order; compare your JSON structure to schema requirements |
| Target naming invalid | Does target match area.component.intent? | Wrong pattern structure, invalid keywords from controlled lists, missing dots | Review test-hooks.md for controlled vocabulary (area: nav/header/footer/etc, component: button/link/input/etc); use fallback keywords (last in each list) if AC term doesn't match |
| Tag validation fails | Does error mention "Tags must start with '@'"? | Tags missing @ prefix in generated JSON | Review AC source: Gherkin tags should include @ (e.g., @smoke not smoke). If AC has @ but JSON doesn't, check JSON generation logic |
| Translation script errors | Which action/assertion caused failure? | Unsupported action type, malformed target selector, missing required field in step | Verify action is in allowed list (click/fill/select); check target has all three parts; ensure step has target and any required fields (e.g., fill needs value) |
| Validation passes but tests fail | Do test hooks match actual page elements? | Target selectors don't match DOM, wrong start URL, timing issues | Ask user to verify page structure matches expected targets; check if startUrl needs adjustment; consider if dynamic content needs wait conditions |
| Multiple validation failures after fixes | Did first fix break something else? | Making multiple speculative changes, misunderstanding schema requirements | Stop after 2 attempts; report specific schema violations to user; ask if AC has ambiguities or if schema has changed |
NEVER Do
- NEVER use bare string values with selectOption — Playwright's
selectOption()matches HTMLvalueattributes by default, not visible text. AC writers specify visible option text (e.g., "Premium Plan"), so always use{ label: "text" }syntax:.selectOption({ label: "Premium Plan" }). Using bare strings (.selectOption("Premium Plan")) causes silent mismatches where tests pass locally but fail in production because the value attribute differs from display text. - NEVER generate artifacts in assessment mode — when the user asks to review/evaluate/assess AC, analyze the AC text only and provide the formatted report. Do not generate JSON plans or test files. Do not assume they want full conversion.
- NEVER skip controlled vocabulary checks in assessment — verify that area and component keywords in targets match the lists in
test-hooks.md. - NEVER use `goto` action in steps — tests start at
startUrl, navigation happens via clicks or fills that trigger page changes. Using goto mid-test breaks Playwright's navigation lifecycle and causes race conditions where assertions run before the page is ready, leading to flaky tests that pass locally but fail in CI. - NEVER use `doubleClick` for element interactions —
doubleClickis only for coordinate-based double-clicks (x,y positions). For double-clicking elements, use the element-basedclickaction twice in sequence. Only usedoubleClickwhen AC explicitly specifies coordinates. - NEVER use `mouseClick` for element interactions —
mouseClickis only for coordinate-based clicks (x,y positions). For clicking elements, always useclickwith test IDs. Only usemouseClickwhen AC explicitly specifies coordinates. - NEVER use `mouseMove` without a follow-up action —
mouseMovepositions the cursor but doesn't interact with anything. It should only be used before actions likemouseDown,mouseUp,mouseClick, or when AC explicitly requires moving to specific coordinates before other mouse operations. - NEVER use `mouseDown` or `mouseUp` without `mouseMove` first — these actions press/release buttons at the current cursor position. Always use
mouseMoveto position the cursor beforemouseDown/mouseUp, otherwise the position is unpredictable. - NEVER invent assertions — only add
expectText,expectVisible,expectNotVisiblewhen AC explicitly states expected outcomes (exception:expectUrlfor navigation, visibility pairs for show/hide actions) - NEVER store absolute file paths in source metadata — the expected convention is to use repo-relative paths for git repos, basename only for external files
- NEVER assume targets or values — if AC says "click the button" without identifying which button, ask for clarification rather than guessing. Generic targets like
button.genericbypass the controlled vocabulary system and create tests that break because they match multiple elements unpredictably. - NEVER skip validation — even if JSON looks correct, always run
npx validate-planbefore writing files to catch errors and reduce incorrect artifact cleanup - NEVER reuse existing plans or tests — this has caused problems in the past with changes being lost, so always regenerate all steps from AC source to ensure accuracy
- NEVER write a plan file without validating first — validation catches structural errors; writing invalid plans creates broken artifacts requiring manual cleanup
- NEVER process multiple steps of one file in parallel — complete the full pipeline (AC → plan → test → summary) for each file before moving to the next to avoid partial artifacts and state confusion
- NEVER take shortcuts. - agents have gone off the rails when trying to define their own shortcuts, so when triggered you must always run the full workflow.
Assessment mode output format
When validation fails, report issues in this structure:
❌ AC are not conversion-ready. Issues found:
File: [filename]
1. [Line/Scenario reference]: [Specific issue]
- Problem: [What's wrong]
- Example: [Quote from AC]
- Fix: [What needs to change]
File: [filename]
2. [Next issue...]Example output:
❌ AC are not conversion-ready. Issues found:
File: form-actions.feature
1. Scenario "User submits form": Unknown action verb
- Problem: "hovers" is not a recognized Playwright action
- Example: "the user hovers over the tooltip"
- Fix: Use a supported action (click, fill, select) or clarify the intent
File: login-flow.feature
2. Scenario "User logs in": Missing target intent
- Problem: Test hook selector incomplete (button.form instead of button.form.submit)
- Example: "clicks the button on the form"
- Fix: Specify intent: "clicks the Submit button on the form"When assessment passes:
✓ AC are conversion-ready
Validated ([X] AC in [Y] files):
- Structure: Proper format (bullets or Gherkin) with correct step ordering
- Targets: All meet the area/component/intent pattern with controlled vocabulary
- Actions: All verbs recognized (click/fill/select) with input values where required
- Expected outcomes: All explicitly stated and measurable
- Vocabulary: All areas/components match test-hooks.md keywords
These AC can be converted without modification.
Files analyzed:
[filename 1]
[filename 2]
...import type { ConsoleMessage, Page, TestInfo } from "@playwright/test";
export async function setupConsoleTracking(args: {
page: Page;
testInfo: TestInfo;
}) {
const { page, testInfo } = args;
const consoleMessages: Array<{ type: string; text: string; stepIndex: number; timestamp: string; location: { url: string; lineNumber?: number; columnNumber?: number } }> = [];
let currentStep = 0;
page.on('console', (msg: ConsoleMessage) => {
consoleMessages.push({
type: msg.type(),
text: msg.text(),
stepIndex: currentStep,
timestamp: new Date().toISOString(),
location: msg.location()
});
});
return {
setStep: (step: number) => { currentStep = step; },
attachMessages: async () => {
if (consoleMessages.length > 0) {
await testInfo.attach('console-messages', {
contentType: 'application/json',
body: Buffer.from(JSON.stringify(consoleMessages, null, 2), 'utf8')
});
}
}
};
}
import type { Page, TestInfo } from "@playwright/test";
export async function attachFailureArtifacts(args: {
page: Page;
testInfo: TestInfo;
stepIndex: number;
action: string;
testId?: string;
}) {
const { page, testInfo, stepIndex, action, testId } = args;
if (!testInfo) return;
const payload = {
url: page.url(),
stepIndex,
action,
testId,
};
await testInfo.attach("step failure", {
contentType: "application/json",
body: Buffer.from(JSON.stringify(payload, null, 2), "utf8"),
});
try {
const screenshot = await page.screenshot({ fullPage: true });
await testInfo.attach("step screenshot", {
contentType: "image/png",
body: screenshot,
});
} catch {
// ignore screenshot issues
}
try {
const video = page.video?.();
if (video) {
const path = await video.path();
await testInfo.attach("step video", {
contentType: "video/webm",
path,
});
}
} catch {
// ignore video issues
}
}
import { defineConfig, devices } from '@playwright/test';
/**
* Read environment variables from file.
* https://github.com/motdotla/dotenv
*/
// import dotenv from 'dotenv';
// import path from 'path';
// dotenv.config({ path: path.resolve(__dirname, '.env') });
/**
* See https://playwright.dev/docs/test-configuration.
*/
export default defineConfig({
testDir: './tests',
testMatch: /.*\.spec\.ts/,
/* Run tests in files in parallel */
fullyParallel: true,
/* Fail the build on CI if you accidentally left test.only in the source code. */
forbidOnly: !!process.env.CI,
/* Retry on CI only */
retries: process.env.CI ? 2 : 0,
/* Opt out of parallel tests on CI. */
workers: process.env.CI ? 1 : undefined,
/* Reporter to use. See https://playwright.dev/docs/test-reporters */
reporter: 'html',
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
use: {
/* Base URL to use in actions like `await page.goto('')`. */
baseURL: 'http://localhost:3000',
/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
trace: 'on-first-retry',
},
/* Configure projects for major browsers */
projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
},
{
name: 'firefox',
use: { ...devices['Desktop Firefox'] },
},
{
name: 'webkit',
use: { ...devices['Desktop Safari'] },
},
/* Test against mobile viewports. */
// {
// name: 'Mobile Chrome',
// use: { ...devices['Pixel 5'] },
// },
// {
// name: 'Mobile Safari',
// use: { ...devices['iPhone 12'] },
// },
/* Test against branded browsers. */
// {
// name: 'Microsoft Edge',
// use: { ...devices['Desktop Edge'], channel: 'msedge' },
// },
// {
// name: 'Google Chrome',
// use: { ...devices['Desktop Chrome'], channel: 'chrome' },
// },
]
});
Changelog
All notable changes to the accelint-ac-to-playwright skill are documented in this file.
The format is based on Keep a Changelog, and this project adheres to Semantic Versioning.
[1.1.9] - 2026-06-04
Added
- CHANGELOG.md with complete version history from 0.5 through 1.1.8 (#115)
- Rationale: Provides transparency into skill evolution and rationale for changes; required per repository conventions
Version
- Bumped from 1.1.8 → 1.1.9
[1.1.8] - 2026-05-29
Fixed
- Removed
anytype usage in codebase (#113) - Rationale: Improves type safety by enforcing explicit typing
- Replaced null return value with -1 in error cases (#113)
- Rationale: Makes error states more explicit and easier to handle
Version
- Bumped from 1.1.7 → 1.1.8
[1.1.7] - 2026-05-29
Changed
- Updated vulnerable dependencies (#112)
- Rationale: Security maintenance to address known vulnerabilities
Version
- Bumped from 1.1.6 → 1.1.7
[1.1.6] - 2026-05-27
Added
- Missing visibility assertion test (#111)
- Rationale: Improves test coverage for visibility checking functionality
Version
- Bumped from 1.1.5 → 1.1.6
[1.1.5] - 2026-05-27
Changed
- Updated
selectOptionto use label-based selection instead of value (#110) - Rationale: More intuitive methodology that matches how users think about dropdown options
Version
- Bumped from 1.1.4 → 1.1.5
[1.1.4] - 2026-05-26
Changed
- Improved
sourceDescriptionhandling (#109) - Rationale: Eliminates fragile regex parsing of sourceDescription from generated code; passes data through pipeline directly instead of round-tripping through TypeScript annotations
Version
- Bumped from 1.1.3 → 1.1.4
[1.1.3] - 2026-05-26
Changed
- Enhanced visibility pairing validation (#108)
- Rationale: Allows multiple elements to change visibility from single action; validator now groups by target and counts only actions between pairs instead of requiring exactly 2 steps apart
Version
- Bumped from 1.1.2 → 1.1.3
[1.1.2] - 2026-05-20
Fixed
- Made
notvisibleassertion properly handle cases where 0 elements are present (#105) - Rationale: Previously failed when element didn't exist in DOM; now correctly passes since non-existent elements are obviously not visible, aligning with Playwright's
toBeVisible()behavior where absence equals not visible
Version
- Bumped from 1.1.1 → 1.1.2
[1.1.1] - 2026-05-18
Added
- TypeScript type inference from Zod schemas using
z.infer(#103) - Rationale: Eliminates manual type definitions that duplicate schema structure; single source of truth reduces maintenance burden and prevents schema/type drift
Version
- Bumped from 1.1.0 → 1.1.1
[1.1.0] - 2026-05-18
Added
- Shared test fixtures extraction (#102)
- Rationale: Reduces duplication across test files and ensures consistency; common test data was being redefined in multiple places
Version
- Bumped from 1.0.6 → 1.1.0
[1.0.6] - 2026-05-18
Added
- Case-insensitive keyboard key handling (#100)
- Rationale: Users shouldn't have to remember exact capitalization for key names; "Enter", "enter", and "ENTER" should all work identically
Version
- Bumped from 1.0.5 → 1.0.6
[1.0.5] - 2026-05-18
Added
- Numpad key support (#99)
- Rationale: Enables testing of numeric keypad interactions; applications often have different handlers for numpad vs top-row numbers
Version
- Bumped from 1.0.4 → 1.0.5
[1.0.4] - 2026-05-18
Added
- Target validation for actions (#98)
- Rationale: Ensures all actions specify valid targets using area/component/intent pattern; catches malformed acceptance criteria before test generation
Version
- Bumped from 1.0.3 → 1.0.4
[1.0.3] - 2026-05-18
Added
- Tag validation for Gherkin scenarios (#97)
- Rationale: Validates @tag syntax and prevents malformed tags in Gherkin files; invalid tags cause test runner failures that are hard to debug
Version
- Bumped from 1.0.2 → 1.0.3
[1.0.2] - 2026-05-12
Fixed
- Improved regex handling in URL expectations (#96)
- Rationale: Regular expressions in URL matchers were being incorrectly escaped; enables flexible URL matching for dynamic routes and query parameters
Version
- Bumped from 1.0.1 → 1.0.2
[1.0.1] - 2026-05-08
Changed
- Removed outdated example files (#91)
- Rationale: Examples were no longer representative of current functionality; prevents confusion from outdated patterns
Version
- Bumped from 1.0.0 → 1.0.1
[1.0.0] - 2026-03-19
Changed
- Promoted skill to stable 1.0 release (#79, #80)
- Rationale: Skill considered production-ready after comprehensive feature development
Version
- Bumped from 0.12 → 1.0.0
[0.12] - 2026-03-05
Added
- Drag composed action (#76)
- Rationale: Enables more efficient testing of mouse drag interactions; common UI pattern that requires coordinated mouseDown → mouseMove → mouseUp
Version
- Bumped from 0.11 → 0.12
[0.11] - 2026-03-05
Added
- Schema validation for paired actions (#75)
- Rationale: Enforces that certain actions must appear in pairs; prevents malformed test plans where keyDown has no matching keyUp
- Enforcement of mouseDown/mouseUp pairing (#75)
- Rationale: Mouse button must be released after being pressed
- Enforcement of keyDown/keyUp pairing (#75)
- Rationale: Key must be released after being pressed
- Enforcement of expectVisible/expectNotVisible pairing validation (#75)
- Rationale: An element should be verified to be the opposite visibility before the action that changes its visiblility to prevent false greens
Version
- Bumped from 0.10 → 0.11
[0.10] - 2026-02-26
Added
reloadaction for page refresh (#70)- Rationale: Enables testing of scenarios requiring page reload
hoveraction for mouse hover interactions (#70)- Rationale: Common UI pattern for tooltips, dropdowns, and interactive elements
Version
- Bumped from 0.9 → 0.10
[0.9] - 2026-02-26
Added
- Console logs to Playwright test reports (#69)
- Rationale: Improves debugging by capturing browser console output; frontend errors often appear in console, not test output
Version
- Bumped from 0.8 → 0.9
[0.8] - 2026-02-26
Added
- Assessment mode to evaluate AC quality before conversion (#67)
- Rationale: Allows validation without full test generation; provides faster feedback on AC readiness for automation
Version
- Bumped from 0.7 → 0.8
[0.7] - 2026-02-26
Added
- Mouse actions (#66)
mouseClickactionmouseMoveactionmouseDownandmouseUpactionsdoubleClickactionscrollaction- Rationale: Enables precise coordinate-based interactions and scrolling for scenarios where element-based selectors are insufficient or unavailable
Version
- Bumped from 0.6 → 0.7
[0.6] - 2026-02-26
Added
- Keyboard actions (#65)
pressactionkeyDownandkeyUpactions- Rationale: Enables keyboard modifier combinations (Shift+g, Control+Enter) and single key presses required for complex keyboard-driven workflows
Version
- Bumped from 0.5 → 0.6
[0.5] - 2026-02-20
Added
- Initial skill creation with GitHub Actions setup (#40)
- Rationale: Establishes foundational AC-to-Playwright conversion capability
Version
- Initial release as 0.5
{
"name": "accelint-ac-to-playwright",
"version": "1.0.0",
"private": true,
"description": "Convert acceptance criteria into JSON test plans and Playwright spec files",
"license": "UNLICENSED",
"bin": {
"generate-tests": "dist/scripts/cli/generate-tests.js",
"validate-plan": "dist/scripts/cli/validate-plan.js",
"append-json-summary-entry": "dist/scripts/cli/append-json-summary-entry.js",
"create-markdown-summary": "dist/scripts/cli/create-markdown-summary.js"
},
"scripts": {
"build": "tsc",
"test": "vitest --coverage"
},
"devDependencies": {
"@types/node": "^25.0.3",
"@vitest/coverage-istanbul": "^4.0.18",
"typescript": "^5.9.3",
"vitest": "^4.0.18"
},
"dependencies": {
"zod": "^4.2.1"
},
"engines": {
"node": ">=25.3.0"
}
}
accelint-ac-to-playwright
This skill converts acceptance criteria into JSON test plans and then Playwright spec files.
Contents
SKILL.md— skill instructions.references/:- acceptance-criteria.md contains guidance for writing and reading AC
- test-hooks.md contains rules for structuring test hooks
- This schema is used to validate JSON test plans
scripts/— translators, validators, and CLI entry points.assets/templates/— template files users can copy as starting points:- playwright.config.ts — portable Playwright config for running generated specs.
Quick usage
To generate Playwright tests from plan files, using your agent of choice, trigger this skill's usage with a prompt like:
Create Playwright tests from the AC files located at <insert path here>Change the AC file at <insert path here> into a Playwright test file
When running the CLI, you must provide the tests and summary directories explicitly:
npx generate-tests path/to/plan.json --tests-dir path/to/tests --summary-dir path/to/summariesCurrent functionality
AC files are first converted to JSON plan files, which are validated against a schema. Validated JSON plan files are then converted to Playwright tests.
Tests can currently use the following actions:
- click - clicks an element.
- doubleClick - double-clicks at x,y coordinates (not element-based).
- fill - adds text to an element (generally
<input>or<textarea>elements only). Use this for entering data into form fields. - goto - generally only used at the start of a test to get to the starting URL.
- hover - hovers over an element.
- keyDown - presses and holds a modifier key (accepts
Shift,Control, or app-specific modifiera). Must be paired withkeyUpto release the key. - keyUp - releases a held modifier key (accepts
Shift,Control, or app-specific modifiera). Must be paired with a precedingkeyDown. - mouseClick - clicks at x,y coordinates (not element-based).
- mouseDown - presses a mouse button at the current cursor position.
- mouseMove - moves the mouse cursor to x,y coordinates.
- mouseUp - releases a mouse button at the current cursor position.
- press - presses and immediately releases a single keyboard key (accepts unmodified characters like
a,1,,or named keys likeEnter,Tab,F7,Space,ArrowLeft). For simple keyboard actions or for pressing keys while a modifier is held (betweenkeyDownandkeyUp). Intended for page-wide keyboard shortcuts, not for entering text into input fields (usefillinstead). - reload - refreshes the page.
- scroll - scrolls the page in a direction by a specified pixel amount.
- select - picks an item from a select dropdown.
And the following assertions:
- expectNotVisible - the element should not be visible on the page (can be present in the DOM or not).
- expectText - the element should contain some specific text.
- expectUrl - the current page should be some specific URL.
- expectVisible - the element should be visible on the page.
Acceptance criteria notes
Acceptance criteria can be provided either in Gherkin (.feature files) or bullets (.md files). Gherkin provides more functionality as well as better clarity to the agent and is the recommended option.
In order to produce test plan files deterministically and without excessive questions, some care when drafting AC is essential. Please see the full guidelines (and examples) for both bullet-format and Gherkin-format AC at acceptance-criteria.md.
Playwright config notes
The skill provides a template at assets/templates/playwright.config.ts that can be copied to your project.
testDirdefaults to./tests. Please update this based on where the tests and config land in your repo.baseURLdefaults tohttp://localhost:3000as a placeholder. Please update if necessary.- The rest of the config can be reviewed and changed as necessary based on your target repo and environment.
Acceptance criteria guidelines
File structure
- Individual tests: one file can contain multiple AC. One AC maps to one test. AC can be written either in bullet format in an
.mdfile or in Gherkin format in a.featurefile. - Test suites: one file maps to one test suite, which turns into one test plan file and then one resulting Playwright test file.
General guidelines
- Sequencing: actions should be written in the order they should occur.
- Pattern: tests should always be written starting with the initial context (what is true when the test starts), followed by the action steps of the test (the events that take place), and finally ending with the expected outcomes. If you're tempted to add more events and expectations after the first set, write a second test instead.
- Phrasing: tests should be written in third-person present tense, and should generally follow the pattern subject -> verb -> target.
- Bad:
- "open the settings page"
- "I click the submit button"
- "should see the tracks table"
- Good:
- "the user is on the settings page"
- "the user clicks the Submit button on the form"
- "the tracks table shows up on the page"
A note on procedure-driven vs behavior-driven test language:
Historically, test writers have been encouraged to write tests with a focus on the behaviors the test is meant to execute and not the procedure for how to do it. This works great for many codebases, especially ones where humans are writing the underlying code for the test steps.
What this looks like:
When a user logs inHowever, in our case, we are defining AC that are meant to be completely and independently translated from AC to test code by an LLM. We must include explicit, imperative, action-oriented details about how the test should accomplish its task in order to avoid forcing the LLM to guess at the "how".
What this looks like:
When a user fills the email input field with 'test@example.com'
And a user fills the password input field with 'secure123'
And a user clicks the Submit button on the login formAvoid ambiguity
- Start URL: the default starting page is
/. If a test needs a different starting page, state it at the start of your AC. - Example: "Given the user is on the settings page"
- Action verbs: use clear action verbs so that the agent can map to test steps (such as "click", "fill", "select", "hover", "reload", "press", "see"). Avoid vague verbs like "interact" or "use".
- Input values: include exact values for fills/selects. For dropdowns, specify the visible option text that users see.
- Example: "the user fills the email input field with 'test@example.com'"
- Example: "the user selects 'Premium Plan' from the plan dropdown on the form"
- Expected outcomes: state exactly what should happen and how to verify it.
- Example: "success text that says 'Submitted' appears on a toast"
- Visibility changes: be explicit when something appears/disappears. The agent is looking for clue words to understand that visibility changes are expected (e.g., "visible", "appears", "shows", "see", "changes", "hides", and similar wording).
- Example: "the tracks table shows up on the page"
Mouse actions
When writing AC that involve mouse operations, distinguish between element-based and coordinate-based actions:
- Element clicks (most common): "the user clicks the Submit button on the form"
- Uses test hooks to identify elements (see Targets below)
- The agent translates this to a standard
clickaction with a target - Coordinate clicks (for precise positioning): "the user clicks at position 150, 200"
- Uses x,y coordinates for clicking specific positions
- The agent translates this to a
mouseClickaction - Optional: specify button type: "the user right-clicks at position 300, 400"
- Double-clicks (for coordinate-based actions): "the user double-clicks at position 150, 200"
- Uses x,y coordinates for double-clicking specific positions
- The agent translates this to a
doubleClickaction - Optional: specify button type: "the user double-clicks with the right button at position 300, 400"
- Mouse movement (for positioning): "the user moves the mouse to position 150, 250"
- Positions the cursor at specific x,y coordinates without clicking
- The agent translates this to a
mouseMoveaction - Press and hold: "the user presses the left mouse button"
- Presses a mouse button at the current cursor position
- The agent translates this to a
mouseDownaction - Always use
mouseMovefirst to position the cursor - Optional: specify button type: "the user presses the right mouse button"
- Release button: "the user releases the mouse button"
- Releases a held mouse button at the current cursor position
- The agent translates this to a
mouseUpaction - Optional: specify button type: "the user releases the middle mouse button"
- Drag operations (for drawing): "the user drags the mouse from position 100, 100 to position 200, 200"
- Combines move → press → move → release into a single action
- The agent translates this to a
dragaction with start and end coordinates - Optional: specify button type: "the user drags with the right button from position 100, 100 to position 200, 200"
- Scrolling (for page navigation): "the user scrolls down 200 pixels"
- Scrolls the page in a specified direction by a pixel amount
- The agent translates this to a
scrollaction - Valid directions:
up,down,left,right - Example: "the user scrolls right 150 pixels"
Valid buttons: left (default), right, middle
Use coordinate-based actions only when the AC explicitly requires precise positioning (drawing apps, canvas interactions, drag-and-drop with coordinates).
Keyboard actions
When writing AC that involve keyboard interactions, use natural language to describe what keys are pressed:
- Single key press: "the user presses Enter" or "the user presses the g key"
- Modifier combination: "the user presses Shift+g" or "the user presses Control+Enter"
The agent will automatically translate modifier combinations into the proper sequence: 1. Hold down the modifier key 2. Press the non-modifier key 3. Release the modifier key
Valid modifiers: Shift, Control, a
Targets
To make your target unambiguous to the agent, use this pattern:
<intent> <component> on the <area>
Where:
<intent>is the destination/meaning (noun).<component>is one of the component keywords (button, link, input, dropdown, checkbox, radio, text, div, component).<area>is one of the area keywords (nav, header, footer, form, drawer, card, toast, modal, table, page, area).
Examples:
- "From checkout, a user can submit the order by clicking the <u>place order button on the form</u>"
- "Given the user is on the home page, when the user clicks the <u>settings link in the header</u>, then the user arrives on the Settings page and see the <u>page heading text in the header</u> say 'Settings'"
- "When a user clicks the <u>Save button on the form</u>, then the user sees <u>success text in a toast</u>"
Notes:
- Avoid vague words like "option" or "item" without a component type.
- If the component isn't known, the agent will fall back to
component. - If the area isn't known, the agent will fall back to
area. - The pattern used "on the", but you could use "in a" or similar language as appropriate for readability.
- If there is a specific component or area that you think should be added to the list of keywords, start a discussion about adding it.
Bullet-style AC files
The agent understands a - bullet to signify a single AC, which maps to a single test. Any lines that don't start with a bullet will be ignored. One file can contain multiple AC.
Notes or header lines in markdown format can appear anywhere in the file. Any lines that don't start with a bullet will be ignored by the agent, so any notes would be for humans who may read the AC file.
Example
``` file.md
Optional header
Optional text to note some details.
- From the home page, a user can navigate to the Settings page by clicking the settings link in the header and should see the page heading text in the header say "Settings".
## Gherkin-style AC files
The Gherkin specific keywords that the agent understands are:
- `Feature:` - provides a high-level description. One feature per file.
- `Background:` - shared context for all scenarios in a file, in the form of one or more `Given` steps which will be executed before each and every scenario in the file. Should appear before the first scenario in a file.
- `Scenario:` - a test, which consists of one or more steps. A file can have many scenarios.
- `Scenario Outline:` - a test, but one where the same test is run multiple times with different combinations of inputs/outputs. Parameters in the scenario outline are represented with `<>`, such as `<page>`. Always paired with one or more `Examples:` blocks immediately after.
- `Examples:` - a table containing values to be run through the scenario outline. The header row should correspond to parameters in the scenario outline, while subsequent rows are the test values.
- `Given` - these steps describe the initial context of the system.
- `When` - these steps are used to describe an action or event.
- `Then` - these steps are used to describe an expected outcome.
- `And`, `But` - these keywords can be used to replace successive `Given`s, `When`s, or `Then`s for readability.
Within a scenario or scenario outline, any `Given` steps should come first, then any `When` steps, then the `Then` steps. Remember, if you're tempted to add more Whens and Thens after the first set, then what you really want is another test instead.
Comments can appear on any line in the file as long as the first non-space character on the line is `#`.
Tags can be added in multiple places - right above the `Feature:` keyword, any `Scenario:` keyword, and any `Scenario Outline:` keywords. Tags above the `Feature:` keyword apply to all scenarios and scenario outlines in the file. Tags above a specific test only apply to that test.
Indenting is normally done like this:@this-tag-is-unindented Feature: This line is not indented
This comment is indented once.
Background: Indented once Given all steps are indented twice
@this-tag-is-indented-once Scenario Outline: This line is indented once Then this step is indented <times> too
Examples: | times | | twice |
### Example
@this-tag-applies-to-all-scenarios-in-the-file @another-one Feature: Site navigation
Here's a comment.
Background: Given there is a logged-in non-admin user
@scenario-level-tag @smoke Scenario: User navigates to the settings page Given the user is on the home page When the user clicks the settings link in the header Then the user is on the settings page And the page heading text in the header says "Settings"
@regression @wip Scenario Outline: User can only access authorized pages Given the user is on the home page When the user clicks the <location> link in the header Then the user sees a <type> message
Examples: | location | type | | admin | error | | help | success |
/**
* Controlled vocabulary for target area keywords.
* Last item in array is the fallback when no specific area matches.
*/
export const areaKeywords = [
"nav",
"header",
"footer",
"form",
"drawer",
"card",
"toast",
"modal",
"table",
"page",
"area",
] as const;
/**
* Controlled vocabulary for target component keywords.
* Last item in array is the fallback when no specific component matches.
*/
export const componentKeywords = [
"button",
"link",
"input",
"dropdown",
"checkbox",
"radio",
"text",
"div",
"component",
] as const;
Target conventions
The pattern <area>.<component>.<intent> is used for target values.
- area (controlled list): see
target-vocabulary.tsfor the canonical list - component (controlled list): see
target-vocabulary.tsfor the canonical list - intent: noun, lowercase, multi‑word joined with dashes (no verbs)
Area and component selection rules:
- If an area/component keyword appears explicitly in the AC (e.g., "header" for area, "input" for component), use that keyword.
- If multiple keywords appear, choose the one that appears first in the corresponding controlled list.
- If no keyword appears, use the fallback keyword (last item) from the corresponding controlled list.
Intent selection rules:
- Use the semantic intent or destination implied by the AC.
- Prefer the shortest unique noun phrase that captures what the user is trying to reach/do.
- If the AC names a label and it matches the intent, use the label text (lowercased/dashed).
- If two elements in the same area/component would share the same intent, add the smallest clarifying noun (e.g.,
settings-profilevssettings-security).
Examples:
nav.link.settingsform.input.email-addresstoast.text.success
#!/usr/bin/env node
import fs from "node:fs";
import path from "node:path";
import { z } from "zod";
import { type TestSuite, testSuiteSchema } from "../plan-schema";
import { handleCliCommonErrors } from "../utils/cli";
// Explicit types matching actual usage patterns (instead of Pick<typeof fs, ...>)
// This allows TypeScript to properly type-check mocks without overload ambiguity
type FsSubset = {
existsSync: (path: fs.PathLike) => boolean;
readFileSync: (
path: fs.PathOrFileDescriptor,
encoding: BufferEncoding,
) => string;
mkdirSync: (
path: fs.PathLike,
options?: fs.MakeDirectoryOptions,
) => string | undefined;
writeFileSync: (
path: fs.PathOrFileDescriptor,
data: string | NodeJS.ArrayBufferView,
options?: fs.WriteFileOptions,
) => void;
};
type PathSubset = {
dirname: (path: string) => string;
};
export type CliRuntime = {
fs: FsSubset;
path: PathSubset;
log: (...args: unknown[]) => void;
error: (...args: unknown[]) => void;
};
const defaultRuntime: CliRuntime = {
fs,
path,
log: console.log,
error: console.error,
};
type SummaryEntry = {
input: string;
outputs: {
plan: string;
test: string;
};
tests: Array<{
name: string;
requiredTestHooks: string[];
}>;
};
type SummaryFile = {
runDate: string;
entries: SummaryEntry[];
};
type ParsedArgs = {
summaryJson?: string;
input?: string;
plan?: string;
test?: string;
runDate?: string;
help: boolean;
errors: string[];
};
// CLI entrypoint
if (require.main === module) {
const code = run(process.argv);
process.exit(code);
}
export function run(
argv: string[],
runtime: CliRuntime = defaultRuntime,
): number {
// Parse args
const parsed = parseArgs(argv.slice(2));
const exitCode = handleCliCommonErrors({
parsed,
runtime,
printUsage,
required: {
summaryJson: "Error: Missing required option: --summary-json <path>",
input: "Error: Missing required option: --input <ac-file>",
plan: "Error: Missing required option: --plan <plan.json>",
test: "Error: Missing required option: --test <spec.ts>",
},
});
if (exitCode >= 0) return exitCode;
if (!parsed.summaryJson || !parsed.input || !parsed.plan || !parsed.test) {
return 1;
}
// Reads inputs
const summaryPath = parsed.summaryJson;
const inputPath = parsed.input;
const planPath = parsed.plan;
const testPath = parsed.test;
// Read the raw plan
let planRaw: string;
try {
planRaw = runtime.fs.readFileSync(planPath, "utf8");
} catch {
runtime.error(`Error: Unable to read plan file: ${planPath}`);
return 1;
}
// Parse the raw plan
let parsedPlan: unknown;
try {
parsedPlan = JSON.parse(planRaw);
} catch {
runtime.error(`Error: Invalid JSON in plan file: ${planPath}`);
return 1;
}
// Invalid plan per the schema
const planValidation = testSuiteSchema.safeParse(parsedPlan);
if (!planValidation.success) {
runtime.error(`Error: Invalid test suite in plan file: ${planPath}`);
runtime.error(z.prettifyError(planValidation.error));
return 1;
}
// Build summary entry
const plan = planValidation.data;
const entry: SummaryEntry = {
input: inputPath,
outputs: {
plan: planPath,
test: testPath,
},
tests: buildTestSummaries(plan),
};
// Grab the existing summary file if present or start a new one
let summary: SummaryFile;
if (runtime.fs.existsSync(summaryPath)) {
try {
const existing = JSON.parse(runtime.fs.readFileSync(summaryPath, "utf8"));
summary = normalizeSummaryFile(existing, summaryPath);
} catch (error) {
runtime.error(
`Error: Unable to parse existing summary file: ${summaryPath}`,
);
if (error instanceof Error) runtime.error(error.message);
return 1;
}
} else {
const runDate = parsed.runDate ?? new Date().toISOString();
summary = { runDate, entries: [] };
}
// Add new entry and write file
summary.entries.push(entry);
runtime.fs.mkdirSync(runtime.path.dirname(summaryPath), { recursive: true });
runtime.fs.writeFileSync(
summaryPath,
`${JSON.stringify(summary, null, 2)}\n`,
"utf8",
);
runtime.log(`-> Appended entry: ${inputPath}`);
runtime.log(` Summary JSON: ${summaryPath}`);
return 0;
}
// Helper functions
// Parses the arguments sent in
function parseArgs(args: string[]): ParsedArgs {
const parsed: ParsedArgs = {
help: false,
errors: [],
};
for (let i = 0; i < args.length; i += 1) {
const arg = args[i];
if (arg === "--help" || arg === "-h") {
parsed.help = true;
return parsed;
}
// Summary arg
if (arg === "--summary-json") {
const value = args[i + 1];
if (!value) parsed.errors.push("Error: Missing value for --summary-json");
else parsed.summaryJson = value;
i += 1;
continue;
}
if (arg.startsWith("--summary-json=")) {
const value = arg.slice("--summary-json=".length);
if (!value) parsed.errors.push("Error: Missing value for --summary-json");
else parsed.summaryJson = value;
continue;
}
// Input arg
if (arg === "--input") {
const value = args[i + 1];
if (!value) parsed.errors.push("Error: Missing value for --input");
else parsed.input = value;
i += 1;
continue;
}
if (arg.startsWith("--input=")) {
const value = arg.slice("--input=".length);
if (!value) parsed.errors.push("Error: Missing value for --input");
else parsed.input = value;
continue;
}
// Plan arg
if (arg === "--plan") {
const value = args[i + 1];
if (!value) parsed.errors.push("Error: Missing value for --plan");
else parsed.plan = value;
i += 1;
continue;
}
if (arg.startsWith("--plan=")) {
const value = arg.slice("--plan=".length);
if (!value) parsed.errors.push("Error: Missing value for --plan");
else parsed.plan = value;
continue;
}
// Test arg
if (arg === "--test") {
const value = args[i + 1];
if (!value) parsed.errors.push("Error: Missing value for --test");
else parsed.test = value;
i += 1;
continue;
}
if (arg.startsWith("--test=")) {
const value = arg.slice("--test=".length);
if (!value) parsed.errors.push("Error: Missing value for --test");
else parsed.test = value;
continue;
}
// Date arg
if (arg === "--run-date") {
const value = args[i + 1];
if (!value) parsed.errors.push("Error: Missing value for --run-date");
else parsed.runDate = value;
i += 1;
continue;
}
if (arg.startsWith("--run-date=")) {
const value = arg.slice("--run-date=".length);
if (!value) parsed.errors.push("Error: Missing value for --run-date");
else parsed.runDate = value;
continue;
}
if (arg.startsWith("-")) {
parsed.errors.push(`Error: Unknown option: ${arg}`);
continue;
}
parsed.errors.push(`Error: Unexpected argument: ${arg}`);
}
return parsed;
}
// Prints usage instructions
function printUsage(log: (...args: unknown[]) => void): void {
log("Usage:");
log(
" npx append-json-summary-entry --summary-json <path> --input <ac-file> --plan <plan.json> --test <spec.ts>",
);
log("");
log("Optional:");
log(" --run-date <iso>");
}
// Converts each test in the plan into a summary entry
function buildTestSummaries(plan: TestSuite): SummaryEntry["tests"] {
return plan.tests.map((test) => ({
name: test.name,
requiredTestHooks: extractHooks(test.steps),
}));
}
// Collects all hooks a test uses, deduped, in the order seen
function extractHooks(steps: TestSuite["tests"][number]["steps"]): string[] {
const hooks: string[] = [];
const seen = new Set<string>();
for (const step of steps) {
if ("target" in step && typeof step.target === "string") {
const hook = step.target;
if (!seen.has(hook)) {
seen.add(hook);
hooks.push(hook);
}
}
}
return hooks;
}
// Extracts a summary file so more can be added to it
function normalizeSummaryFile(input: unknown, filePath: string): SummaryFile {
if (
!input ||
typeof input !== "object" ||
Array.isArray(input) ||
!("runDate" in input) ||
!("entries" in input)
) {
throw new Error(`Invalid summary file format: ${filePath}`);
}
const runDate = (input as SummaryFile).runDate;
const entries = (input as SummaryFile).entries;
if (typeof runDate !== "string" || !Array.isArray(entries)) {
throw new Error(`Invalid summary file format: ${filePath}`);
}
return { runDate, entries };
}
// @internal exports for unit tests
export {
buildTestSummaries as _buildTestSummaries,
extractHooks as _extractHooks,
normalizeSummaryFile as _normalizeSummaryFile,
parseArgs as _parseArgs,
};
#!/usr/bin/env node
import fs from "node:fs";
import path from "node:path";
import { handleCliCommonErrors } from "../utils/cli";
// Explicit types matching actual usage patterns (instead of Pick<typeof fs, ...>)
// This allows TypeScript to properly type-check mocks without overload ambiguity
type FsSubset = {
readFileSync: (
path: fs.PathOrFileDescriptor,
encoding: BufferEncoding,
) => string;
mkdirSync: (
path: fs.PathLike,
options?: fs.MakeDirectoryOptions,
) => string | undefined;
writeFileSync: (
path: fs.PathOrFileDescriptor,
data: string | NodeJS.ArrayBufferView,
options?: fs.WriteFileOptions,
) => void;
};
type PathSubset = {
dirname: (path: string) => string;
};
export type CliRuntime = {
fs: FsSubset;
path: PathSubset;
log: (...args: unknown[]) => void;
error: (...args: unknown[]) => void;
};
const defaultRuntime: CliRuntime = {
fs,
path,
log: console.log,
error: console.error,
};
type SummaryEntry = {
input: string;
outputs: {
plan: string;
test: string;
};
tests: Array<{
name: string;
requiredTestHooks: string[];
}>;
};
type SummaryFile = {
runDate: string;
entries: SummaryEntry[];
};
type ParsedArgs = {
summaryJson?: string;
summaryMd?: string;
help: boolean;
errors: string[];
};
// CLI entrypoint
if (require.main === module) {
const code = run(process.argv);
process.exit(code);
}
export function run(
argv: string[],
runtime: CliRuntime = defaultRuntime,
): number {
// Parse args
const parsed = parseArgs(argv.slice(2));
const exitCode = handleCliCommonErrors({
parsed,
runtime,
printUsage,
required: {
summaryJson: "Error: Missing required option: --summary-json <path>",
summaryMd: "Error: Missing required option: --summary-md <path>",
},
});
if (exitCode >= 0) return exitCode;
if (!parsed.summaryJson || !parsed.summaryMd) return 1;
// Grab the summary file
let summary: SummaryFile;
try {
const raw = runtime.fs.readFileSync(parsed.summaryJson, "utf8");
const parsedJson = JSON.parse(raw);
summary = normalizeSummaryFile(parsedJson, parsed.summaryJson);
} catch (error) {
runtime.error(
`Error: Unable to read summary JSON file: ${parsed.summaryJson}`,
);
if (error instanceof Error) runtime.error(error.message);
return 1;
}
// Build the text and write the file
const inputs = uniqueInOrder(summary.entries.map((entry) => entry.input));
const outputs = uniqueInOrder(
summary.entries.map((entry) => entry.outputs.test),
);
const requiredHooksByTestFile = collectRequiredHooksByTestFile(
summary.entries,
);
const markdown = renderMarkdown({
runDate: summary.runDate,
inputs,
outputs,
requiredHooksByTestFile,
});
runtime.fs.mkdirSync(runtime.path.dirname(parsed.summaryMd), {
recursive: true,
});
runtime.fs.writeFileSync(parsed.summaryMd, markdown, "utf8");
runtime.log(`-> Wrote: ${parsed.summaryMd}`);
return 0;
}
// Helper functions
// Parses the arguments sent in
function parseArgs(args: string[]): ParsedArgs {
const parsed: ParsedArgs = {
help: false,
errors: [],
};
for (let i = 0; i < args.length; i += 1) {
const arg = args[i];
if (arg === "--help" || arg === "-h") {
parsed.help = true;
return parsed;
}
// Summary arg (json input)
if (arg === "--summary-json") {
const value = args[i + 1];
if (!value) parsed.errors.push("Error: Missing value for --summary-json");
else parsed.summaryJson = value;
i += 1;
continue;
}
if (arg.startsWith("--summary-json=")) {
const value = arg.slice("--summary-json=".length);
if (!value) parsed.errors.push("Error: Missing value for --summary-json");
else parsed.summaryJson = value;
continue;
}
// Summary arg (md output)
if (arg === "--summary-md") {
const value = args[i + 1];
if (!value) parsed.errors.push("Error: Missing value for --summary-md");
else parsed.summaryMd = value;
i += 1;
continue;
}
if (arg.startsWith("--summary-md=")) {
const value = arg.slice("--summary-md=".length);
if (!value) parsed.errors.push("Error: Missing value for --summary-md");
else parsed.summaryMd = value;
continue;
}
if (arg.startsWith("-")) {
parsed.errors.push(`Error: Unknown option: ${arg}`);
continue;
}
parsed.errors.push(`Error: Unexpected argument: ${arg}`);
}
return parsed;
}
// Prints usage instructions
function printUsage(log: (...args: unknown[]) => void): void {
log("Usage:");
log(
" npx create-markdown-summary --summary-json <path> --summary-md <path>",
);
}
// Validates summary json structure
function normalizeSummaryFile(input: unknown, filePath: string): SummaryFile {
if (
!input ||
typeof input !== "object" ||
Array.isArray(input) ||
!("runDate" in input) ||
!("entries" in input)
) {
throw new Error(`Invalid summary file format: ${filePath}`);
}
const runDate = (input as SummaryFile).runDate;
const entries = (input as SummaryFile).entries;
if (typeof runDate !== "string" || !Array.isArray(entries)) {
throw new Error(`Invalid summary file format: ${filePath}`);
}
return { runDate, entries };
}
// Dedupes while preserving order
function uniqueInOrder(items: string[]): string[] {
const seen = new Set<string>();
const result: string[] = [];
for (const item of items) {
if (!seen.has(item)) {
seen.add(item);
result.push(item);
}
}
return result;
}
// Writes the markdown
function renderMarkdown(data: {
runDate: string;
inputs: string[];
outputs: string[];
requiredHooksByTestFile: Map<
string,
Array<{ name: string; hooks: string[] }>
>;
}): string {
const lines: string[] = [];
lines.push("# Post-creation summary");
lines.push("");
lines.push(`- Run date: ${data.runDate}`);
lines.push("");
lines.push("## Inputs: AC files");
lines.push(...renderList(data.inputs));
lines.push("");
lines.push("## Outputs: Playwright test files");
lines.push(...renderList(data.outputs));
lines.push("");
lines.push("## Next steps");
lines.push(
"- Copy the generated Playwright spec files AND the fixtures/ directory into your project repo. The spec files import test utilities from fixtures/ and will fail to compile without it.",
);
lines.push(
"- Ensure the codebase includes the required test hooks (data-testid attributes on the appropriate elements). If there's any ambiguity, review the input AC file alongside the JSON plan to most easily see the intended test flow and targets.",
);
lines.push(
"- If Playwright isn't yet configured for your project and you requested the config template, copy it to your repo root and adjust baseURL, testDir, and reporter/output paths to match your project.",
);
lines.push(
"- Consider reviewing your Vitest (or other) test configs to make sure these new Playwright *.spec.ts files won't get picked up with other testing.",
);
lines.push(
"- Add a CI step to run Playwright and upload traces/screenshots on failure.",
);
lines.push("");
lines.push("## Required test hooks");
if (data.requiredHooksByTestFile.size === 0) {
lines.push("- (none)");
} else {
for (const [testFile, tests] of data.requiredHooksByTestFile) {
lines.push(`- ${testFile}`);
for (const test of tests) {
lines.push(` - ${test.name}`);
for (const hook of test.hooks) {
lines.push(` - ${hook}`);
}
}
}
}
lines.push("");
return `${lines.join("\n")}`;
}
// Helps with bullets
function renderList(items: string[]): string[] {
if (items.length === 0) {
return ["- (none)"];
}
return items.map((item) => `- ${item}`);
}
// Collects required hooks grouped by output test file in entry order.
function collectRequiredHooksByTestFile(
entries: SummaryEntry[],
): Map<string, Array<{ name: string; hooks: string[] }>> {
const output = new Map<string, Array<{ name: string; hooks: string[] }>>();
for (const entry of entries) {
const testsWithHooks = entry.tests
.filter((test) => test.requiredTestHooks.length > 0)
.map((test) => ({ name: test.name, hooks: test.requiredTestHooks }));
if (testsWithHooks.length === 0) continue;
output.set(entry.outputs.test, testsWithHooks);
}
return output;
}
// @internal exports for unit tests
export {
collectRequiredHooksByTestFile as _collectRequiredHooksByTestFile,
normalizeSummaryFile as _normalizeSummaryFile,
parseArgs as _parseArgs,
renderMarkdown as _renderMarkdown,
uniqueInOrder as _uniqueInOrder,
};
#!/usr/bin/env node
// This cli script allows a user to specify the location of a json test plan
// and kick off creation of Playwright test files.
import fs from "node:fs";
import path from "node:path";
import { z } from "zod";
import { testSuiteSchema } from "../plan-schema";
import { type PlanFile, translatePlan } from "../translate-plan-to-tests";
import { handleCliCommonErrors } from "../utils/cli";
import { run as appendSummaryEntry } from "./append-json-summary-entry";
import { run as createMarkdownSummary } from "./create-markdown-summary";
// Types
// Explicit types matching actual usage patterns (instead of Pick<typeof fs, ...>)
// This allows TypeScript to properly type-check mocks without overload ambiguity
type FsSubset = {
existsSync: (path: fs.PathLike) => boolean;
statSync: (path: fs.PathLike) => fs.Stats;
// Explicitly type the withFileTypes overload used in expandSegment()
readdirSync: (
path: fs.PathLike,
options: { withFileTypes: true }
) => fs.Dirent[];
readFileSync: (
path: fs.PathOrFileDescriptor,
encoding: BufferEncoding,
) => string;
mkdirSync: (
path: fs.PathLike,
options?: fs.MakeDirectoryOptions,
) => string | undefined;
writeFileSync: (
path: fs.PathOrFileDescriptor,
data: string | NodeJS.ArrayBufferView,
options?: fs.WriteFileOptions,
) => void;
};
type PathSubset = {
resolve: (...paths: string[]) => string;
dirname: (path: string) => string;
basename: (path: string, suffix?: string) => string;
join: (...paths: string[]) => string;
isAbsolute: (path: string) => boolean;
parse: (path: string) => path.ParsedPath;
};
export type CliRuntime = {
fs: FsSubset;
path: PathSubset;
appendSummaryEntry: (argv: string[]) => number;
createMarkdownSummary: (argv: string[]) => number;
now: () => Date;
log: (...args: unknown[]) => void;
error: (...args: unknown[]) => void;
};
const defaultRuntime: CliRuntime = {
fs,
path,
appendSummaryEntry,
createMarkdownSummary,
now: () => new Date(),
log: console.log,
error: console.error,
};
// CLI entrypoint
if (require.main === module) {
const code = run(process.argv);
process.exit(code);
}
export function run(
argv: string[],
runtime: CliRuntime = defaultRuntime,
): number {
// Parse args
const parsed = parseArgs(argv.slice(2));
const exitCode = handleCliCommonErrors({
parsed,
runtime,
printUsage,
required: {
testsDir: "Error: Missing required option: --tests-dir <path>",
summaryDir: "Error: Missing required option: --summary-dir <path>",
},
});
if (exitCode >= 0) return exitCode;
if (!parsed.testsDir || !parsed.summaryDir) return 1;
const summaryJsonPath = runtime.path.join(
parsed.summaryDir,
formatSummaryFilename(runtime.now()),
);
// Reads inputs
const inputs = parsed.inputs;
const files: string[] = [];
for (const input of inputs) {
let matches: string[] = [];
try {
matches = processInput(input, runtime);
} catch (e) {
runtime.error(e instanceof Error ? e.message : String(e));
return 1;
}
// Warn if any globs matched nothing
if (input.includes("*") && matches.length === 0) {
runtime.error(`Warning: Glob pattern matched no files: ${input}`);
}
files.push(...matches);
}
// Errors if no inputs were given or resolved from globs
if (files.length === 0) {
runtime.error(
"Error: Missing inputs. Please submit input file(s) separated by spaces.",
);
printUsage(runtime.error);
return 1;
}
// Normalize inputs (keep both original + resolved)
const inputPairs = files.map((original) => ({
original,
resolved: runtime.path.resolve(original),
}));
// Deduplicate by resolved path (preserve first original)
const seen = new Set<string>();
const uniquePairs: typeof inputPairs = [];
for (const pair of inputPairs) {
if (!seen.has(pair.resolved)) {
seen.add(pair.resolved);
uniquePairs.push(pair);
}
}
// Errors if dotfiles or dotdirs are passed in as inputs
const hidden = uniquePairs.filter((p) => hasHiddenSegment(p.original));
if (hidden.length) {
runtime.error("Error: Hidden files and dirs are not allowed as inputs:");
for (const h of hidden) runtime.error(` ${h.original}`);
return 1;
}
// Errors if specific input files aren't found
const missing = uniquePairs.filter((p) => !runtime.fs.existsSync(p.resolved));
if (missing.length) {
runtime.error("Error: Input file(s) not found.");
for (const m of missing) runtime.error(` ${m.original}`);
return 1;
}
// Creates test files from inputs
for (const { original, resolved } of uniquePairs) {
const rawPlan = runtime.fs.readFileSync(resolved, "utf8");
let parsedPlan: unknown;
try {
parsedPlan = JSON.parse(rawPlan);
} catch {
runtime.error(`Invalid JSON: ${original}`);
return 1;
}
const validationResult = testSuiteSchema.safeParse(parsedPlan);
if (!validationResult.success) {
runtime.error(`Invalid test suite: ${original}`);
runtime.error(z.prettifyError(validationResult.error));
return 1;
}
const planFile = parsedPlan as PlanFile;
const testFile = translatePlan(planFile, { outDir: parsed.testsDir });
runtime.fs.mkdirSync(runtime.path.dirname(testFile.path), {
recursive: true,
});
runtime.fs.writeFileSync(testFile.path, testFile.content, "utf8");
runtime.log(`-> Read: ${original}`);
runtime.log(` Wrote: ${testFile.path}`);
runtime.log(` To run, do: npx playwright test ${testFile.path}`);
const appendCode = runtime.appendSummaryEntry([
"node",
"append-json-summary-entry.js",
"--summary-json",
summaryJsonPath,
"--input",
testFile.sourceDescription,
"--plan",
original,
"--test",
testFile.path,
]);
if (appendCode !== 0) {
runtime.error("Error: Failed to append summary entry.");
return 1;
}
}
const summaryMdPath = summaryJsonPath.replace(/\.json$/, ".md");
const markdownCode = runtime.createMarkdownSummary([
"node",
"create-markdown-summary.js",
"--summary-json",
summaryJsonPath,
"--summary-md",
summaryMdPath,
]);
if (markdownCode !== 0) {
runtime.error("Error: Failed to create markdown summary.");
return 1;
}
return 0;
}
// Helper functions
type ParsedArgs = {
inputs: string[];
testsDir?: string;
summaryDir?: string;
help: boolean;
errors: string[];
};
function parseArgs(args: string[]): ParsedArgs {
const parsed: ParsedArgs = {
inputs: [],
help: false,
errors: [],
};
for (let i = 0; i < args.length; i += 1) {
const arg = args[i];
if (arg === "--help" || arg === "-h") {
parsed.help = true;
return parsed;
}
if (arg === "--tests-dir") {
const value = args[i + 1];
if (!value) {
parsed.errors.push("Error: Missing value for --tests-dir");
} else {
parsed.testsDir = value;
i += 1;
}
continue;
}
if (arg.startsWith("--tests-dir=")) {
const value = arg.slice("--tests-dir=".length);
if (!value) parsed.errors.push("Error: Missing value for --tests-dir");
else parsed.testsDir = value;
continue;
}
if (arg === "--summary-dir") {
const value = args[i + 1];
if (!value) {
parsed.errors.push("Error: Missing value for --summary-dir");
} else {
parsed.summaryDir = value;
i += 1;
}
continue;
}
if (arg.startsWith("--summary-dir=")) {
const value = arg.slice("--summary-dir=".length);
if (!value) parsed.errors.push("Error: Missing value for --summary-dir");
else parsed.summaryDir = value;
continue;
}
if (arg.startsWith("-")) {
parsed.errors.push(`Error: Unknown option: ${arg}`);
continue;
}
parsed.inputs.push(arg);
}
return parsed;
}
function printUsage(log: (...args: unknown[]) => void): void {
log("Usage:");
log(
" npx generate-tests <plan.json> [more plans...] --tests-dir <path> --summary-dir <path>",
);
}
// Checks if a path has dotfiles or dotdirs
function hasHiddenSegment(filePath: string): boolean {
const normalized = filePath.replace(/\\/g, "/");
const segments = normalized.split("/").filter(Boolean);
return segments.some((s) => s.startsWith(".") && s !== "." && s !== "..");
}
// Escapes regex chars to make a string safe to include in regex
function escapeRegex(s: string): string {
return s.replace(/[.*+^${}()|[\]\\]/g, "\\$&");
}
// Converts a filename with * into regex
function starPatternToRegex(pattern: string): RegExp {
const escaped = escapeRegex(pattern).replace(/\\\*/g, ".*");
return new RegExp(`^${escaped}$`);
}
// Checks input for globs and processes them appropriately
// Mac/most Linux (bash) does this automatically at the command line
// This ensures parallel functionality for Windows and Linux non-bash users
function processInput(input: string, runtime: CliRuntime): string[] {
const { fs, path } = runtime;
// It's a literal path
if (!input.includes("*")) return [input];
// No recursion
if (input.includes("**")) {
throw new Error(`Error: Unsupported: recursive glob (**): ${input}`);
}
const isAbs = path.isAbsolute(input);
const root = isAbs ? path.parse(input).root : "";
const normalizedInput = input.replace(/\\/g, "/");
const normalizedRoot = root.replace(/\\/g, "/");
const relativePart = isAbs
? normalizedInput.slice(normalizedRoot.length)
: normalizedInput;
// Expand wildcards
const segments = relativePart.split("/").filter(Boolean);
let currentPaths: string[] = [isAbs ? root : process.cwd()];
for (const segment of segments) {
currentPaths = expandSegment(currentPaths, segment, runtime);
if (currentPaths.length === 0) return [];
}
// Keep only files (and exclude dotfiles defensively)
const finalFiles = currentPaths
.filter((p) => {
const name = path.basename(p);
if (name.startsWith(".")) return false;
return fs.existsSync(p) && fs.statSync(p).isFile();
})
.sort();
return finalFiles;
}
function formatSummaryFilename(now: Date): string {
const iso = now.toISOString().replace(/\.\d{3}Z$/, "Z");
return `${iso.replace("T", "-").replace(/:/g, "-")}-summary.json`;
}
// Expands a given segment of an input path that has a glob
function expandSegment(
basePaths: string[],
segment: string,
runtime: CliRuntime,
): string[] {
const { fs, path } = runtime;
// If no wildcard, just append this segment to every base path
if (!segment.includes("*")) {
return basePaths.map((base) => path.join(base, segment));
}
const segmentRegex = starPatternToRegex(segment);
const matches: string[] = [];
for (const base of basePaths) {
if (!fs.existsSync(base) || !fs.statSync(base).isDirectory()) continue;
for (const entry of fs.readdirSync(base, { withFileTypes: true })) {
if (entry.name.startsWith(".")) continue; // exclude dotfiles + dotdirs
if (!segmentRegex.test(entry.name)) continue; // does it match?
matches.push(path.join(base, entry.name));
}
}
return matches.sort();
}
// @internal exports for unit tests
export {
escapeRegex as _escapeRegex,
expandSegment as _expandSegment,
hasHiddenSegment as _hasHiddenSegment,
processInput as _processInput,
starPatternToRegex as _starPatternToRegex,
parseArgs as _parseArgs,
formatSummaryFilename as _formatSummaryFilename,
};
#!/usr/bin/env node
import fs from "node:fs";
import { testSuiteSchema } from "../plan-schema";
// Explicit types matching actual usage patterns (instead of Pick<typeof fs, ...>)
// This allows TypeScript to properly type-check mocks without overload ambiguity
export type Runtime = {
fs: {
readFileSync: (
path: fs.PathOrFileDescriptor,
encoding: BufferEncoding,
) => string;
};
log: (...args: unknown[]) => void;
error: (...args: unknown[]) => void;
};
const defaultRuntime: Runtime = {
fs,
log: console.log,
error: console.error,
};
export function run(argv: string[], runtime: Runtime = defaultRuntime): number {
const filePath = argv[2];
if (!filePath) {
runtime.error("Usage: npx validate-plan path/to/plan.json");
return 1;
}
const raw = runtime.fs.readFileSync(filePath, "utf8");
let parsed: unknown;
try {
parsed = JSON.parse(raw);
} catch {
runtime.error("Invalid JSON");
return 1;
}
const result = testSuiteSchema.safeParse(parsed);
if (!result.success) {
runtime.error(JSON.stringify(result.error.format(), null, 2));
return 1;
}
runtime.log("JSON passed validation");
return 0;
}
if (require.main === module) {
process.exit(run(process.argv));
}
import { z } from "zod";
/**
* Valid Playwright keyboard key names
* Source: https://playwright.dev/docs/api/class-keyboard#keyboard-press
*/
export const validPlaywrightKeys = [
"Enter",
"Tab",
"Escape",
"Backspace",
"Delete",
"ArrowLeft",
"ArrowRight",
"ArrowUp",
"ArrowDown",
"Home",
"End",
"PageUp",
"PageDown",
"Insert",
"Space",
"F1",
"F2",
"F3",
"F4",
"F5",
"F6",
"F7",
"F8",
"F9",
"F10",
"F11",
"F12",
"Shift",
"Control",
"Alt",
"Numpad0",
"Numpad1",
"Numpad2",
"Numpad3",
"Numpad4",
"Numpad5",
"Numpad6",
"Numpad7",
"Numpad8",
"Numpad9",
"NumpadAdd",
"NumpadSubtract",
"NumpadDecimal",
"NumpadEnter",
] as const;
/**
* Characters that can be typed without holding Shift on a US keyboard
*/
const unmodifiedCharacters = "abcdefghijklmnopqrstuvwxyz0123456789`-=[]\\;',./";
/**
* Zod validator for keyboard keys for presses
* Accepts either:
* - A single unmodified character (no Shift required on US keyboard)
* - A valid Playwright key name from the list above (case-insensitive)
*/
export const pressKeyValidator = z.string().transform((val, ctx) => {
// Single characters must match exactly (case-sensitive)
if (val.length === 1) {
if (unmodifiedCharacters.includes(val)) {
return val;
}
ctx.addIssue({
code: "custom",
message: "Key must be a single unmodified character (a-z, 0-9, or symbols that don't require Shift) or a valid Playwright key name (e.g., Enter, Tab, Escape, Space, ArrowLeft, F1, etc.).",
});
return z.NEVER;
}
// Named keys: case-insensitive lookup, return canonical casing
const lowerInput = val.toLowerCase();
const matchedKey = validPlaywrightKeys.find(k => k.toLowerCase() === lowerInput);
if (matchedKey) {
return matchedKey;
}
ctx.addIssue({
code: "custom",
message: "Key must be a single unmodified character (a-z, 0-9, or symbols that don't require Shift) or a valid Playwright key name (e.g., Enter, Tab, Escape, Space, ArrowLeft, F1, etc.).",
});
return z.NEVER;
});
/**
* Valid modifier keys for the application under test
* These are the only keys that can be held down with keyDown/keyUp actions
*/
export const validModifierKeys = ["Shift", "Control", "a"] as const;
/**
* Zod validator for modifier keys (keyDown/keyUp actions)
* Only accepts the specific modifier keys used by the application under test (case-insensitive)
*/
export const modifierKeyValidator = z.string().transform((val, ctx) => {
// Case-insensitive lookup, return canonical casing
const lowerInput = val.toLowerCase();
const matchedKey = validModifierKeys.find(k => k.toLowerCase() === lowerInput);
if (matchedKey) {
return matchedKey;
}
ctx.addIssue({
code: "custom",
message: 'Key must be one of the valid modifier keys for this application: "Shift", "Control", or "a".',
});
return z.NEVER;
});
import { z } from "zod";
/**
* Valid mouse button types
*/
export const validMouseButtons = ["left", "right", "middle"] as const;
/**
* Zod validator for mouse button parameter
*/
export const mouseButtonValidator = z.enum(validMouseButtons);
/**
* Valid mouse wheel scroll directions
*/
export const validWheelDirections = ["up", "down", "left", "right"] as const;
/**
* Zod validator for mouse wheel direction parameter
*/
export const wheelDirectionValidator = z.enum(validWheelDirections);
import { z } from "zod";
import { modifierKeyValidator, pressKeyValidator } from "./keyboard-key-validator";
import { mouseButtonValidator, wheelDirectionValidator } from "./mouse-validator";
import { tagValidator } from "./tag-validator";
import { targetValidator } from "./target-validator";
/**
* Step schemas
*/
const clickStep = z.object({
type: z.literal("action").default("action"),
action: z.literal("click"),
target: targetValidator,
}).strict();
const doubleClickStep = z.object({
type: z.literal("action").default("action"),
action: z.literal("doubleClick"),
x: z.number().int().min(0),
y: z.number().int().min(0),
button: mouseButtonValidator.optional().default("left"),
}).strict();
const dragStep = z.object({
type: z.literal("action").default("action"),
action: z.literal("drag"),
fromX: z.number().int().min(0),
fromY: z.number().int().min(0),
toX: z.number().int().min(0),
toY: z.number().int().min(0),
button: mouseButtonValidator.optional().default("left"),
}).strict();
const expectNotVisibleStep = z.object({
type: z.literal("assertion").default("assertion"),
action: z.literal("expectNotVisible"),
target: targetValidator,
}).strict();
const expectTextStep = z.object({
type: z.literal("assertion").default("assertion"),
action: z.literal("expectText"),
target: targetValidator,
value: z.string(),
}).strict();
const expectUrlStep = z.object({
type: z.literal("assertion").default("assertion"),
action: z.literal("expectUrl"),
value: z.string(),
}).strict();
const expectVisibleStep = z.object({
type: z.literal("assertion").default("assertion"),
action: z.literal("expectVisible"),
target: targetValidator,
}).strict();
const fillStep = z.object({
type: z.literal("action").default("action"),
action: z.literal("fill"),
target: targetValidator,
value: z.string(),
}).strict();
const gotoStep = z.object({
type: z.literal("action").default("action"),
action: z.literal("goto"),
value: z.string(),
}).strict();
const hoverStep = z.object({
type: z.literal("action").default("action"),
action: z.literal("hover"),
target: targetValidator,
}).strict();
const keyDownStep = z.object({
type: z.literal("action").default("action"),
action: z.literal("keyDown"),
value: modifierKeyValidator,
}).strict();
const keyUpStep = z.object({
type: z.literal("action").default("action"),
action: z.literal("keyUp"),
value: modifierKeyValidator,
}).strict();
const mouseClickStep = z.object({
type: z.literal("action").default("action"),
action: z.literal("mouseClick"),
x: z.number().int().min(0),
y: z.number().int().min(0),
button: mouseButtonValidator.optional().default("left"),
}).strict();
const mouseDownStep = z.object({
type: z.literal("action").default("action"),
action: z.literal("mouseDown"),
button: mouseButtonValidator.optional().default("left"),
}).strict();
const mouseMoveStep = z.object({
type: z.literal("action").default("action"),
action: z.literal("mouseMove"),
x: z.number().int().min(0),
y: z.number().int().min(0),
}).strict();
const mouseUpStep = z.object({
type: z.literal("action").default("action"),
action: z.literal("mouseUp"),
button: mouseButtonValidator.optional().default("left"),
}).strict();
const pressStep = z.object({
type: z.literal("action").default("action"),
action: z.literal("press"),
value: pressKeyValidator,
}).strict();
const reloadStep = z.object({
type: z.literal("action").default("action"),
action: z.literal("reload"),
}).strict();
const scrollStep = z.object({
type: z.literal("action").default("action"),
action: z.literal("scroll"),
direction: wheelDirectionValidator,
amount: z.number().int().positive(),
}).strict();
const selectStep = z.object({
type: z.literal("action").default("action"),
action: z.literal("select"),
target: targetValidator,
value: z.string(),
}).strict();
export const stepSchema = z.discriminatedUnion("action", [
clickStep,
doubleClickStep,
dragStep,
expectNotVisibleStep,
expectTextStep,
expectUrlStep,
expectVisibleStep,
fillStep,
gotoStep,
hoverStep,
keyDownStep,
keyUpStep,
mouseClickStep,
mouseDownStep,
mouseMoveStep,
mouseUpStep,
pressStep,
reloadStep,
scrollStep,
selectStep,
]);
/**
* Test + Suite schemas
*/
export const testSchema = z.object({
name: z.string(),
startUrl: z.string(),
tags: z.array(tagValidator).min(1).optional(),
steps: z.array(stepSchema).min(1),
}).superRefine((test, ctx) => {
let unpairedMouseDown: { index: number; button: string } | null = null;
let unpairedKeyDown: { index: number; value: string } | null = null;
let hasError = false;
for (let index = 0; index < test.steps.length; index++) {
const step = test.steps[index];
if (step.action === "mouseDown") {
// If there's already an unpaired mouseDown, that's an error (regardless of button)
if (unpairedMouseDown !== null) {
ctx.addIssue({
code: "custom",
message: `mouseDown at step ${index} occurs before completing the previous mouseDown at step ${unpairedMouseDown.index}. Each mouseDown must be followed by exactly one mouseUp before another mouseDown.`,
path: ["steps", index, "action"],
});
hasError = true;
break;
}
// Track the button that was pressed (schema guarantees button exists via default)
unpairedMouseDown = { index, button: step.button };
} else if (step.action === "mouseUp") {
// mouseUp without a preceding unpaired mouseDown is an error
if (unpairedMouseDown === null) {
ctx.addIssue({
code: "custom",
message: `mouseUp at step ${index} has no preceding mouseDown. mouseUp requires a mouseDown action earlier in the steps array.`,
path: ["steps", index, "action"],
});
hasError = true;
break;
} else {
// Check that the button matches (schema guarantees button exists via default)
if (step.button !== unpairedMouseDown.button) {
ctx.addIssue({
code: "custom",
message: `mouseUp at step ${index} uses button "${step.button}" but the paired mouseDown at step ${unpairedMouseDown.index} used button "${unpairedMouseDown.button}". The button must match between mouseDown and mouseUp.`,
path: ["steps", index, "button"],
});
hasError = true;
break;
}
// Pair completed, reset tracker
unpairedMouseDown = null;
}
} else if (step.action === "keyDown") {
// If there's already an unpaired keyDown, that's an error
if (unpairedKeyDown !== null) {
ctx.addIssue({
code: "custom",
message: `keyDown at step ${index} occurs before completing the previous keyDown at step ${unpairedKeyDown.index}. Each keyDown must be followed by exactly one keyUp before another keyDown.`,
path: ["steps", index, "action"],
});
hasError = true;
break;
}
// Track the modifier key that was pressed
unpairedKeyDown = { index, value: step.value };
} else if (step.action === "keyUp") {
// keyUp without a preceding unpaired keyDown is an error
if (unpairedKeyDown === null) {
ctx.addIssue({
code: "custom",
message: `keyUp at step ${index} has no preceding keyDown. keyUp requires a keyDown action earlier in the steps array.`,
path: ["steps", index, "action"],
});
hasError = true;
break;
} else {
// Check that the modifier key matches
if (step.value !== unpairedKeyDown.value) {
ctx.addIssue({
code: "custom",
message: `keyUp at step ${index} uses key "${step.value}" but the paired keyDown at step ${unpairedKeyDown.index} used key "${unpairedKeyDown.value}". The modifier key must match between keyDown and keyUp.`,
path: ["steps", index, "value"],
});
hasError = true;
break;
}
// Pair completed, reset tracker
unpairedKeyDown = null;
}
}
}
// After processing all steps, check if there's an unpaired mouseDown (only if no error yet)
if (!hasError && unpairedMouseDown !== null) {
ctx.addIssue({
code: "custom",
message: `mouseDown at step ${unpairedMouseDown.index} has no following mouseUp. Each mouseDown must be followed by exactly one mouseUp.`,
path: ["steps", unpairedMouseDown.index, "action"],
});
}
// Check if there's an unpaired keyDown (only if no error yet)
if (!hasError && unpairedKeyDown !== null) {
ctx.addIssue({
code: "custom",
message: `keyDown at step ${unpairedKeyDown.index} has no following keyUp. Each keyDown must be followed by exactly one keyUp.`,
path: ["steps", unpairedKeyDown.index, "action"],
});
}
// Validate visibility assertion pairing
if (!hasError) {
// Helper to check if a step is an action (not an assertion)
const isAction = (step: z.infer<typeof stepSchema>): boolean => {
return step.type === "action";
};
// Helper to count actions between two indices
const countActionsBetween = (startIndex: number, endIndex: number): number => {
let count = 0;
for (let i = startIndex + 1; i < endIndex; i++) {
if (isAction(test.steps[i])) {
count++;
}
}
return count;
};
// Group visibility assertions by target
const assertionsByTarget = new Map<string, Array<{
index: number;
action: "expectVisible" | "expectNotVisible";
}>>();
test.steps.forEach((step, index) => {
if (step.action === "expectVisible" || step.action === "expectNotVisible") {
const targetAssertions = assertionsByTarget.get(step.target);
if (targetAssertions) {
targetAssertions.push({
index,
action: step.action,
});
} else {
assertionsByTarget.set(step.target, [{
index,
action: step.action,
}]);
}
}
});
// Validate each target
for (const [target, assertions] of assertionsByTarget.entries()) {
// Must have exactly 2 assertions
if (assertions.length !== 2) {
ctx.addIssue({
code: "custom",
message: `Target "${target}" has ${assertions.length} visibility assertion(s), but must have exactly 2 (one expectVisible and one expectNotVisible with exactly one action between them).`,
path: ["steps"],
});
hasError = true;
break;
}
const [first, second] = assertions;
// Must be opposite types
if (first.action === second.action) {
ctx.addIssue({
code: "custom",
message: `Target "${target}" has two ${first.action} assertions. Visibility assertions must be opposite types (one expectVisible and one expectNotVisible).`,
path: ["steps", second.index, "action"],
});
hasError = true;
break;
}
// Must have exactly 1 action between them
const actionCount = countActionsBetween(first.index, second.index);
if (actionCount !== 1) {
ctx.addIssue({
code: "custom",
message: `Target "${target}" has ${actionCount} action(s) between visibility assertions at steps ${first.index} and ${second.index}, but must have exactly 1 action.`,
path: ["steps", second.index, "action"],
});
hasError = true;
break;
}
}
}
}).strict();
/**
* Exports
*/
export const testSuiteSchema = z.object({
suiteName: z.string(),
tags: z.array(tagValidator).min(1).optional(),
source: z.object({
repo: z.string(),
path: z.string(),
}).strict(),
tests: z.array(testSchema).min(1),
}).strict();
export type TestSuite = z.infer<typeof testSuiteSchema>;
import { z } from "zod";
/**
* Zod validator for tag strings - ensures tags start with '@'
*/
export const tagValidator = z.string().refine(
(tag) => tag.startsWith('@'),
{ message: "Tags must start with '@'" }
);
import { z } from "zod";
import { areaKeywords, componentKeywords } from "../references/target-vocabulary";
/**
* Zod validator for target strings - enforces area.component.intent pattern
*/
export const targetValidator = z.string().superRefine((target, ctx) => {
const parts = target.split(".");
// Check structure first
if (parts.length !== 3) {
ctx.addIssue({
code: "custom",
message: "Target must follow area.component.intent pattern with exactly two dots"
});
return;
}
const [area, component, intent] = parts;
// Check area vocabulary
if (!areaKeywords.includes(area as typeof areaKeywords[number])) {
ctx.addIssue({
code: "custom",
message: `Invalid area keyword. Must be one of: ${areaKeywords.join(", ")}`
});
return;
}
// Check component vocabulary
if (!componentKeywords.includes(component as typeof componentKeywords[number])) {
ctx.addIssue({
code: "custom",
message: `Invalid component keyword. Must be one of: ${componentKeywords.join(", ")}`
});
return;
}
// Check intent format
if (!intent) {
ctx.addIssue({
code: "custom",
message: "Target intent cannot be empty"
});
return;
}
if (!/^[a-z]+(-[a-z]+)*$/.test(intent)) {
ctx.addIssue({
code: "custom",
message: "Intent must be lowercase letters only, multi-word joined with dashes (no spaces, underscores, or uppercase)"
});
return;
}
});
import { describe, expect, it } from "vitest";
import type { CliRuntime as AppendRuntime } from "../cli/append-json-summary-entry";
import {
_extractHooks,
_normalizeSummaryFile,
_parseArgs,
run,
} from "../cli/append-json-summary-entry";
import { makeAppendRuntime } from "./summary-scripts.test-utils";
describe("parseArgs", () => {
it("parses required args", () => {
const result = _parseArgs([
"--summary-json",
"summary.json",
"--input",
"ac.feature",
"--plan",
"plan.json",
"--test",
"spec.ts",
]);
expect(result.summaryJson).toBe("summary.json");
expect(result.input).toBe("ac.feature");
expect(result.plan).toBe("plan.json");
expect(result.test).toBe("spec.ts");
expect(result.errors).toEqual([]);
});
it("returns error for missing values", () => {
const result = _parseArgs(["--summary-json"]);
expect(result.errors.join("\n")).toContain("Missing value for --summary-json");
});
it("returns error for unknown flag", () => {
const result = _parseArgs(["--wat"]);
expect(result.errors.join("\n")).toContain("Unknown option: --wat");
});
it("returns error for unexpected argument", () => {
const result = _parseArgs(["plan.json"]);
expect(result.errors.join("\n")).toContain("Unexpected argument: plan.json");
});
it("parses --run-date with equals value", () => {
const result = _parseArgs([
"--summary-json=summary.json",
"--input=ac.feature",
"--plan=plan.json",
"--test=spec.ts",
"--run-date=2026-01-28T14:03:52Z",
]);
expect(result.runDate).toBe("2026-01-28T14:03:52Z");
expect(result.errors).toEqual([]);
});
it("returns error for missing --run-date value", () => {
const result = _parseArgs([
"--summary-json=summary.json",
"--input=ac.feature",
"--plan=plan.json",
"--test=spec.ts",
"--run-date=",
]);
expect(result.errors.join("\n")).toContain("Missing value for --run-date");
});
});
describe("extractHooks", () => {
it("dedupes targets in first-seen order", () => {
const hooks = _extractHooks([
{ type: "action", action: "click", target: "alpha" },
{ type: "assertion", action: "expectUrl", value: "/settings" },
{ type: "action", action: "fill", target: "beta", value: "x" },
{ type: "action", action: "click", target: "alpha" },
]);
expect(hooks).toEqual(["alpha", "beta"]);
});
});
describe("run()", () => {
it("prints usage and exits when --help is provided", () => {
const { runtime, logs } = makeAppendRuntime();
const code = run(["node", "append-json-summary-entry.js", "--help"], runtime);
expect(code).toBe(0);
expect(logs.join("\n")).toContain("Usage:");
});
it("prints usage and exits on argument parse errors", () => {
const { runtime, errors } = makeAppendRuntime();
const code = run(["node", "append-json-summary-entry.js", "--wat"], runtime);
expect(code).toBe(1);
expect(errors.join("\n")).toContain("Unknown option: --wat");
expect(errors.join("\n")).toContain("Usage:");
});
it("errors when required options are missing", () => {
const { runtime, errors } = makeAppendRuntime();
const code = run(["node", "append-json-summary-entry.js"], runtime);
expect(code).toBe(1);
expect(errors.join("\n")).toContain(
"Error: Missing required option: --summary-json <path>"
);
});
it("errors when plan file cannot be read", () => {
const { runtime, errors } = makeAppendRuntime({
fs: {
readFileSync: (() => {
throw new Error("nope");
}) as unknown as AppendRuntime["fs"]["readFileSync"],
},
});
const code = run(
[
"node",
"append-json-summary-entry.js",
"--summary-json",
"summary.json",
"--input",
"ac.feature",
"--plan",
"plan.json",
"--test",
"spec.ts",
],
runtime
);
expect(code).toBe(1);
expect(errors.join("\n")).toContain("Unable to read plan file");
});
it("errors when plan file has invalid JSON", () => {
const { runtime, errors } = makeAppendRuntime({
fs: {
readFileSync: (() => "{ not json }") as unknown as AppendRuntime["fs"]["readFileSync"],
},
});
const code = run(
[
"node",
"append-json-summary-entry.js",
"--summary-json",
"summary.json",
"--input",
"ac.feature",
"--plan",
"plan.json",
"--test",
"spec.ts",
],
runtime
);
expect(code).toBe(1);
expect(errors.join("\n")).toContain("Invalid JSON in plan file");
});
it("errors when plan file fails schema validation", () => {
const invalidPlan = JSON.stringify({
suiteName: "Suite",
tests: [{ name: "Test A", steps: [] }],
});
const { runtime, errors } = makeAppendRuntime({
fs: {
readFileSync: (() => invalidPlan) as unknown as AppendRuntime["fs"]["readFileSync"],
},
});
const code = run(
[
"node",
"append-json-summary-entry.js",
"--summary-json",
"summary.json",
"--input",
"ac.feature",
"--plan",
"plan.json",
"--test",
"spec.ts",
],
runtime
);
expect(code).toBe(1);
expect(errors.join("\n")).toContain("Invalid test suite in plan file");
});
it("errors when existing summary JSON is invalid", () => {
const validPlan = JSON.stringify({
suiteName: "Suite",
source: { repo: "some-repo", path: "path/to/ac.feature" },
tests: [{ name: "Test A", startUrl: "/", steps: [{ action: "click", target: "page.button.submit" }] }],
});
const { runtime, errors } = makeAppendRuntime({
fs: {
existsSync: () => true,
readFileSync: ((p: Parameters<AppendRuntime["fs"]["readFileSync"]>[0]) => {
if (String(p) === "summary.json") return "{ nope }";
return validPlan;
}) as unknown as AppendRuntime["fs"]["readFileSync"],
},
});
const code = run(
[
"node",
"append-json-summary-entry.js",
"--summary-json",
"summary.json",
"--input",
"ac.feature",
"--plan",
"plan.json",
"--test",
"spec.ts",
],
runtime
);
expect(code).toBe(1);
expect(errors.join("\n")).toContain("Unable to parse existing summary file");
});
it("creates a new summary file when missing", () => {
// Arrange
const planPath = "plan.json";
const summaryPath = "summary.json";
const inputPath = "ac.feature";
const testPath = "spec.ts";
const runDate = "2026-01-28T14:03:52Z";
const validPlan = JSON.stringify({
suiteName: "Suite",
source: { repo: "some-repo", path: "path/to/ac.feature" },
tests: [
{
name: "Test A",
startUrl: "/",
steps: [
{ action: "click", target: "form.button.login" },
{ action: "expectUrl", value: "/home" },
{ action: "fill", target: "form.input.email", value: "a@b.com" },
],
},
],
});
let writtenJson = "";
const { runtime, errors } = makeAppendRuntime({
fs: {
existsSync: (p: Parameters<AppendRuntime["fs"]["existsSync"]>[0]) =>
String(p) !== summaryPath,
readFileSync: ((p: Parameters<AppendRuntime["fs"]["readFileSync"]>[0]) => {
if (String(p) === planPath) return validPlan;
throw new Error("Unexpected read");
}) as unknown as AppendRuntime["fs"]["readFileSync"],
writeFileSync: ((
_p: Parameters<AppendRuntime["fs"]["writeFileSync"]>[0],
data: Parameters<AppendRuntime["fs"]["writeFileSync"]>[1]
) => {
writtenJson = String(data);
}) as unknown as AppendRuntime["fs"]["writeFileSync"],
},
});
// Act
const code = run(
[
"node",
"append-json-summary-entry.js",
"--summary-json",
summaryPath,
"--input",
inputPath,
"--plan",
planPath,
"--test",
testPath,
"--run-date",
runDate,
],
runtime
);
// Assert
expect(code).toBe(0);
expect(errors.length).toBe(0);
const parsed = JSON.parse(writtenJson);
expect(parsed.runDate).toBe(runDate);
expect(parsed.entries).toHaveLength(1);
expect(parsed.entries[0].input).toBe(inputPath);
expect(parsed.entries[0].outputs.plan).toBe(planPath);
expect(parsed.entries[0].outputs.test).toBe(testPath);
expect(parsed.entries[0].tests[0].requiredTestHooks).toEqual([
"form.button.login",
"form.input.email",
]);
});
it("appends to an existing summary file", () => {
const planPath = "plan.json";
const summaryPath = "summary.json";
const inputPath = "ac.feature";
const testPath = "spec.ts";
const existingSummary = JSON.stringify({
runDate: "2026-01-28T14:03:52Z",
entries: [
{
input: "existing.feature",
outputs: { plan: "existing.json", test: "existing.spec.ts" },
tests: [{ name: "Existing", requiredTestHooks: [] }],
},
],
});
const validPlan = JSON.stringify({
suiteName: "Suite",
source: { repo: "some-repo", path: "path/to/ac.feature" },
tests: [
{
name: "Test A",
startUrl: "/",
steps: [{ action: "click", target: "form.button.login" }],
},
],
});
let writtenJson = "";
const { runtime } = makeAppendRuntime({
fs: {
existsSync: (p: Parameters<AppendRuntime["fs"]["existsSync"]>[0]) =>
String(p) === summaryPath,
readFileSync: ((p: Parameters<AppendRuntime["fs"]["readFileSync"]>[0]) => {
if (String(p) === summaryPath) return existingSummary;
if (String(p) === planPath) return validPlan;
throw new Error("Unexpected read");
}) as unknown as AppendRuntime["fs"]["readFileSync"],
writeFileSync: ((
_p: Parameters<AppendRuntime["fs"]["writeFileSync"]>[0],
data: Parameters<AppendRuntime["fs"]["writeFileSync"]>[1]
) => {
writtenJson = String(data);
}) as unknown as AppendRuntime["fs"]["writeFileSync"],
},
});
const code = run(
[
"node",
"append-json-summary-entry.js",
"--summary-json",
summaryPath,
"--input",
inputPath,
"--plan",
planPath,
"--test",
testPath,
],
runtime
);
expect(code).toBe(0);
const parsed = JSON.parse(writtenJson);
expect(parsed.entries).toHaveLength(2);
expect(parsed.entries[1].input).toBe(inputPath);
});
});
describe("normalizeSummaryFile", () => {
it("throws on invalid shape", () => {
expect(() => _normalizeSummaryFile({ runDate: 123 }, "summary.json")).toThrow(
"Invalid summary file format"
);
});
it("throws when entries is not an array", () => {
expect(() =>
_normalizeSummaryFile({ runDate: "2026-01-28T14:03:52Z", entries: "nope" }, "summary.json")
).toThrow("Invalid summary file format");
});
});
import { describe, expect, it } from "vitest";
import type { CliRuntime as MarkdownRuntime } from "../cli/create-markdown-summary";
import { _normalizeSummaryFile, _parseArgs, run } from "../cli/create-markdown-summary";
import { makeMarkdownRuntime } from "./summary-scripts.test-utils";
describe("parseArgs", () => {
it("parses required args", () => {
const result = _parseArgs([
"--summary-json",
"summary.json",
"--summary-md",
"summary.md",
]);
expect(result.summaryJson).toBe("summary.json");
expect(result.summaryMd).toBe("summary.md");
expect(result.errors).toEqual([]);
});
it("returns error for missing values", () => {
const result = _parseArgs(["--summary-json"]);
expect(result.errors.join("\n")).toContain("Missing value for --summary-json");
});
it("returns error for unknown flag", () => {
const result = _parseArgs(["--wat"]);
expect(result.errors.join("\n")).toContain("Unknown option: --wat");
});
it("returns error for unexpected argument", () => {
const result = _parseArgs(["extra"]);
expect(result.errors.join("\n")).toContain("Unexpected argument: extra");
});
it("parses --summary-json and --summary-md with equals values", () => {
const result = _parseArgs([
"--summary-json=summary.json",
"--summary-md=summary.md",
]);
expect(result.summaryJson).toBe("summary.json");
expect(result.summaryMd).toBe("summary.md");
expect(result.errors).toEqual([]);
});
it("returns error for missing --summary-md value", () => {
const result = _parseArgs(["--summary-md="]);
expect(result.errors.join("\n")).toContain("Missing value for --summary-md");
});
});
describe("run()", () => {
it("prints usage and exits when --help is provided", () => {
const { runtime, logs } = makeMarkdownRuntime();
const code = run(["node", "create-markdown-summary.js", "--help"], runtime);
expect(code).toBe(0);
expect(logs.join("\n")).toContain("Usage:");
});
it("prints usage and exits on argument parse errors", () => {
const { runtime, errors } = makeMarkdownRuntime();
const code = run(["node", "create-markdown-summary.js", "--wat"], runtime);
expect(code).toBe(1);
expect(errors.join("\n")).toContain("Unknown option: --wat");
expect(errors.join("\n")).toContain("Usage:");
});
it("errors when required options are missing", () => {
const { runtime, errors } = makeMarkdownRuntime();
const code = run(["node", "create-markdown-summary.js"], runtime);
expect(code).toBe(1);
expect(errors.join("\n")).toContain(
"Error: Missing required option: --summary-json <path>"
);
});
it("errors when summary JSON cannot be read", () => {
const { runtime, errors } = makeMarkdownRuntime({
fs: {
readFileSync: (() => {
throw new Error("nope");
}) as unknown as MarkdownRuntime["fs"]["readFileSync"],
},
});
const code = run(
[
"node",
"create-markdown-summary.js",
"--summary-json",
"summary.json",
"--summary-md",
"summary.md",
],
runtime
);
expect(code).toBe(1);
expect(errors.join("\n")).toContain("Unable to read summary JSON file");
});
it("renders (none) when lists are empty", () => {
const summaryPath = "summary.json";
const markdownPath = "summary.md";
const summaryJson = JSON.stringify({
runDate: "2026-01-28T14:03:52Z",
entries: [],
});
let writtenMarkdown = "";
const { runtime } = makeMarkdownRuntime({
fs: {
readFileSync: (() => summaryJson) as unknown as MarkdownRuntime["fs"]["readFileSync"],
writeFileSync: ((
_p: Parameters<MarkdownRuntime["fs"]["writeFileSync"]>[0],
data: Parameters<MarkdownRuntime["fs"]["writeFileSync"]>[1]
) => {
writtenMarkdown = String(data);
}) as unknown as MarkdownRuntime["fs"]["writeFileSync"],
},
});
const code = run(
[
"node",
"create-markdown-summary.js",
"--summary-json",
summaryPath,
"--summary-md",
markdownPath,
],
runtime
);
expect(code).toBe(0);
expect(writtenMarkdown).toContain("## Inputs: AC files");
expect(writtenMarkdown).toContain("- (none)");
expect(writtenMarkdown).toContain("## Outputs: Playwright test files");
expect(writtenMarkdown).toContain("## Required test hooks");
});
it("renders markdown summary from JSON", () => {
// Arrange
const summaryPath = "summary.json";
const markdownPath = "summary.md";
const summaryJson = JSON.stringify({
runDate: "2026-01-28T14:03:52Z",
entries: [
{
input: "ac-1.feature",
outputs: { plan: "plans/ac-1.json", test: "tests/ac-1.spec.ts" },
tests: [
{ name: "Test A", requiredTestHooks: ["beta", "alpha"] },
{ name: "Test B", requiredTestHooks: ["alpha"] },
{ name: "Test Z", requiredTestHooks: ["alpha"] },
],
},
{
input: "ac-2.md",
outputs: { plan: "plans/ac-2.json", test: "tests/ac-2.spec.ts" },
tests: [
{ name: "Test A", requiredTestHooks: ["alpha"] },
{ name: "Test C", requiredTestHooks: [] },
],
},
],
});
let writtenMarkdown = "";
const { runtime } = makeMarkdownRuntime({
fs: {
readFileSync: (() => summaryJson) as unknown as MarkdownRuntime["fs"]["readFileSync"],
writeFileSync: ((
_p: Parameters<MarkdownRuntime["fs"]["writeFileSync"]>[0],
data: Parameters<MarkdownRuntime["fs"]["writeFileSync"]>[1]
) => {
writtenMarkdown = String(data);
}) as unknown as MarkdownRuntime["fs"]["writeFileSync"],
},
});
// Act
const code = run(
[
"node",
"create-markdown-summary.js",
"--summary-json",
summaryPath,
"--summary-md",
markdownPath,
],
runtime
);
// Assert
expect(code).toBe(0);
expect(writtenMarkdown).toContain("# Post-creation summary");
expect(writtenMarkdown).toContain("- Run date: 2026-01-28T14:03:52Z");
expect(writtenMarkdown).toContain("## Inputs: AC files");
expect(writtenMarkdown).toContain("- ac-1.feature");
expect(writtenMarkdown).toContain("- ac-2.md");
expect(writtenMarkdown).toContain("## Outputs: Playwright test files");
expect(writtenMarkdown).toContain("- tests/ac-1.spec.ts");
expect(writtenMarkdown).toContain("- tests/ac-2.spec.ts");
expect(writtenMarkdown).toContain("## Required test hooks");
const lines = writtenMarkdown.split("\n");
const requiredIndex = lines.findIndex(
(line) => line === "## Required test hooks"
);
expect(requiredIndex).toBeGreaterThan(-1);
const ac1Index = lines.findIndex(
(line, index) => index > requiredIndex && line === "- tests/ac-1.spec.ts"
);
expect(ac1Index).toBeGreaterThan(-1);
const ac1Lines: string[] = [];
for (let i = ac1Index + 1; i < lines.length; i += 1) {
const line = lines[i];
if (line.startsWith("- ") && !line.startsWith(" - ")) break;
if (line.startsWith(" - ") || line.startsWith(" - ")) {
ac1Lines.push(line);
}
}
expect(ac1Lines).toEqual([
" - Test A",
" - beta",
" - alpha",
" - Test B",
" - alpha",
" - Test Z",
" - alpha",
]);
const ac2Index = lines.findIndex(
(line, index) => index > requiredIndex && line === "- tests/ac-2.spec.ts"
);
expect(ac2Index).toBeGreaterThan(-1);
const ac2Lines: string[] = [];
for (let i = ac2Index + 1; i < lines.length; i += 1) {
const line = lines[i];
if (line.startsWith("- ") && !line.startsWith(" - ")) break;
if (line.startsWith(" - ") || line.startsWith(" - ")) {
ac2Lines.push(line);
}
}
expect(ac2Lines).toEqual([" - Test A", " - alpha"]);
});
});
describe("normalizeSummaryFile", () => {
it("throws on invalid shape", () => {
expect(() => _normalizeSummaryFile({ entries: [] }, "summary.json")).toThrow(
"Invalid summary file format"
);
});
it("throws when entries is not an array", () => {
expect(() =>
_normalizeSummaryFile({ runDate: "2026-01-28T14:03:52Z", entries: "nope" }, "summary.json")
).toThrow("Invalid summary file format");
});
});
{
"suiteName": "All Actions Present",
"source": { "repo": "some-repo", "path": "path/to/file.md" },
"tests": [
{
"name": "Properly validate all actions",
"startUrl": "https://example.com",
"steps": [
{
"action": "goto",
"value": "https://example.com/login"
},
{
"action": "fill",
"target": "form.input.username",
"value": "someone"
},
{
"action": "fill",
"target": "form.input.password",
"value": "password123"
},
{
"action": "select",
"target": "form.dropdown.role",
"value": "admin"
},
{
"action": "keyDown",
"value": "Shift"
},
{
"action": "press",
"value": "Enter"
},
{
"action": "keyUp",
"value": "Shift"
},
{
"action": "click",
"target": "form.button.login"
},
{
"action": "doubleClick",
"x": 250,
"y": 300
},
{
"action": "expectUrl",
"value": "https://example.com/dashboard"
},
{
"action": "mouseClick",
"x": 150,
"y": 200
},
{
"action": "mouseClick",
"x": 300,
"y": 400,
"button": "right"
},
{
"action": "mouseDown"
},
{
"action": "mouseMove",
"x": 500,
"y": 600
},
{
"action": "mouseUp"
},
{
"action": "scroll",
"direction": "down",
"amount": 200
},
{
"action": "expectNotVisible",
"target": "page.div.welcome"
},
{
"action": "click",
"target": "page.button.show-welcome"
},
{
"action": "expectVisible",
"target": "page.div.welcome"
},
{
"action": "expectVisible",
"target": "page.div.error"
},
{
"action": "click",
"target": "page.button.hide-error"
},
{
"action": "expectNotVisible",
"target": "page.div.error"
},
{
"action": "expectText",
"target": "page.div.welcome",
"value": "Welcome!"
}
]
}
]
}
import { expect, test } from "@playwright/test";
import { setupConsoleTracking } from "./fixtures/console-tracking";
import { attachFailureArtifacts } from "./fixtures/error-handling";
test.describe("Golden file test", {
tag: ["@smoke", "@fast"],
annotation: {
type: "source",
description: "some-repo/acceptance/golden-file-test.feature"
}
}, () => {
test("happy path", {
tag: "@wip"
}, async ({ page }, testInfo) => {
const tracker = await setupConsoleTracking({ page, testInfo });
await page.goto("https://example.com");
tracker.setStep(1);
try {
await expect(page.getByTestId("login.form.login")).toHaveCount(1);
await page.getByTestId("login.form.login").click();
} catch (error) {
await attachFailureArtifacts({ page, testInfo, stepIndex: 1, action: "click", testId: "login.form.login" });
throw error;
}
tracker.setStep(2);
try {
await expect(page.getByTestId("login.form.email")).toHaveCount(1);
await page.getByTestId("login.form.email").fill("a@b.com");
} catch (error) {
await attachFailureArtifacts({ page, testInfo, stepIndex: 2, action: "fill", testId: "login.form.email" });
throw error;
}
tracker.setStep(3);
try {
await expect(page.getByTestId("login.header.h1")).toHaveCount(1);
await expect(page.getByTestId("login.header.h1")).toContainText("Welcome");
} catch (error) {
await attachFailureArtifacts({ page, testInfo, stepIndex: 3, action: "expectText", testId: "login.header.h1" });
throw error;
}
tracker.setStep(4);
try {
await expect(page).toHaveURL(/\/dashboard(?:\/(?:[?#]|$)|[?#]|$)/);
} catch (error) {
await attachFailureArtifacts({ page, testInfo, stepIndex: 4, action: "expectUrl" });
throw error;
}
await tracker.attachMessages();
});
});
{
"suiteName": "Missing Required Fields",
"source": { "repo": "some-repo", "path": "path/to/file.md" },
"tests": [
{
"name": "Missing click.target",
"startUrl": "https://example.com",
"steps": [{ "action": "click" }]
},
{
"name": "Missing expectNotVisible.target",
"startUrl": "https://example.com",
"steps": [{ "action": "expectNotVisible" }]
},
{
"name": "Missing expectText.target",
"startUrl": "https://example.com",
"steps": [{ "action": "expectText", "value": "Welcome" }]
},
{
"name": "Missing expectText.value",
"startUrl": "https://example.com",
"steps": [{ "action": "expectText", "target": "#welcome" }]
},
{
"name": "Missing expectUrl.value",
"startUrl": "https://example.com",
"steps": [{ "action": "expectUrl" }]
},
{
"name": "Missing expectVisible.target",
"startUrl": "https://example.com",
"steps": [{ "action": "expectVisible" }]
},
{
"name": "Missing fill.target",
"startUrl": "https://example.com",
"steps": [{ "action": "fill", "value": "something" }]
},
{
"name": "Missing fill.value",
"startUrl": "https://example.com",
"steps": [{ "action": "fill", "target": "#username" }]
},
{
"name": "Missing goto.value",
"startUrl": "https://example.com",
"steps": [{ "action": "goto" }]
},
{
"name": "Missing select.target",
"startUrl": "https://example.com",
"steps": [{ "action": "select", "value": "admin" }]
},
{
"name": "Missing select.value",
"startUrl": "https://example.com",
"steps": [{ "action": "select", "target": "#role" }]
}
]
}
import { describe, expect, it } from "vitest";
import { _escapeRegex } from "../cli/generate-tests";
describe("escapeRegex", () => {
it("returns the same string when there are no regex metacharacters", () => {
expect(_escapeRegex("abcDEF123_-/:")).toBe("abcDEF123_-/:");
});
it("escapes common regex metacharacters", () => {
expect(_escapeRegex("a.b")).toBe("a\\.b");
expect(_escapeRegex("a*b")).toBe("a\\*b");
expect(_escapeRegex("a+b")).toBe("a\\+b");
expect(_escapeRegex("a^b")).toBe("a\\^b");
expect(_escapeRegex("a$b")).toBe("a\\$b");
});
it("escapes braces, parentheses, brackets, and pipes", () => {
expect(_escapeRegex("{a}")).toBe("\\{a\\}");
expect(_escapeRegex("(a)")).toBe("\\(a\\)");
expect(_escapeRegex("[a]")).toBe("\\[a\\]");
expect(_escapeRegex("a|b")).toBe("a\\|b");
});
it("escapes backslashes", () => {
expect(_escapeRegex("a\\b")).toBe("a\\\\b");
});
it("escapes a string containing all supported metacharacters", () => {
const input = ".*+^$}{()|[]\\";
const expected = "\\.\\*\\+\\^\\$\\}\\{\\(\\)\\|\\[\\]\\\\";
expect(_escapeRegex(input)).toBe(expected);
});
it("escapes mixed strings correctly", () => {
expect(_escapeRegex("file(1).json")).toBe("file\\(1\\)\\.json");
expect(_escapeRegex("path\\to\\file[1].json")).toBe("path\\\\to\\\\file\\[1\\]\\.json");
});
});
import path from "node:path";
import { describe, expect, it } from "vitest";
import { _expandSegment } from "../cli/generate-tests";
import { addDir, makeRuntime } from "./generate-tests.test-utils";
describe("expandSegment", () => {
it("joins paths when segment has no wildcard", () => {
const state = makeRuntime();
const bases = ["/tmp/a", "/tmp/b"];
const result = _expandSegment(bases, "plans", state.runtime);
expect(result).toEqual([
path.join("/tmp/a", "plans"),
path.join("/tmp/b", "plans"),
]);
});
it("expands wildcard segment and filters matches", () => {
const state = makeRuntime();
const base = addDir(state, "/repo/tests", ["plans", "plan-old", "other"]);
const result = _expandSegment([base], "plan*", state.runtime);
expect(result).toEqual([
path.join(base, "plan-old"),
path.join(base, "plans"),
]);
});
it("excludes dotfiles/dotdirs", () => {
const state = makeRuntime();
const base = addDir(state, "/repo/tests", [".plans", "plans", "plan-old"]);
const result = _expandSegment([base], "*", state.runtime);
expect(result).toEqual([
path.join(base, "plan-old"),
path.join(base, "plans"),
]);
});
it("skips bases that are not directories", () => {
const state = makeRuntime();
const notADir = path.resolve("/repo/not-a-dir");
const base = addDir(state, "/repo/tests", ["plans"]);
const result = _expandSegment([notADir, base], "*", state.runtime);
expect(result).toEqual([path.join(base, "plans")]);
});
it("returns empty array when no entries match", () => {
const state = makeRuntime();
const base = addDir(state, "/repo/tests", ["plans", "other"]);
const result = _expandSegment([base], "nomatch*", state.runtime);
expect(result).toEqual([]);
});
it("returns sorted results", () => {
const state = makeRuntime();
const base = addDir(state, "/repo/tests", ["b", "a", "c"]);
const result = _expandSegment([base], "*", state.runtime);
expect(result).toEqual([
path.join(base, "a"),
path.join(base, "b"),
path.join(base, "c"),
]);
});
it("aggregates matches across multiple base paths", () => {
const state = makeRuntime();
const base1 = addDir(state, "/repo/tests1", ["plans", "plan-old"]);
const base2 = addDir(state, "/repo/tests2", ["plans"]);
const result = _expandSegment([base1, base2], "plan*", state.runtime);
expect(result).toEqual([
path.join(base1, "plan-old"),
path.join(base1, "plans"),
path.join(base2, "plans"),
]);
});
});
import { describe, expect, it } from "vitest";
import { _hasHiddenSegment } from "../cli/generate-tests";
describe("hasHiddenSegment", () => {
it("returns false for normal paths", () => {
expect(_hasHiddenSegment("path/to/plans/a.json")).toBe(false);
expect(_hasHiddenSegment("path/to/plans/subdir/a.json")).toBe(false);
});
it("returns true for dotfiles", () => {
expect(_hasHiddenSegment(".a.json")).toBe(true);
expect(_hasHiddenSegment("path/to/plans/.a.json")).toBe(true);
});
it("returns true for dot directories", () => {
expect(_hasHiddenSegment(".plans/a.json")).toBe(true);
expect(_hasHiddenSegment("tests/.plans/a.json")).toBe(true);
expect(_hasHiddenSegment("path/to/plans/.hidden/a.json")).toBe(true);
expect(_hasHiddenSegment("tests/.plans/.a.json")).toBe(true);
});
it("returns false for relative path markers '.' and '..'", () => {
expect(_hasHiddenSegment("./path/to/plans/a.json")).toBe(false);
expect(_hasHiddenSegment("../path/to/plans/a.json")).toBe(false);
expect(_hasHiddenSegment("../../path/to/plans/a.json")).toBe(false);
});
it("handles Windows-style paths", () => {
expect(_hasHiddenSegment("tests\\plans\\a.json")).toBe(false);
expect(_hasHiddenSegment("tests\\plans\\.hidden\\a.json")).toBe(true);
expect(_hasHiddenSegment(".hidden\\a.json")).toBe(true);
});
it("returns true when any segment is hidden", () => {
expect(_hasHiddenSegment("tests/.hidden/plans/a.json")).toBe(true);
expect(_hasHiddenSegment("path/to/plans/.hidden/a.json")).toBe(true);
expect(_hasHiddenSegment("./path/to/plans/.hidden/a.json")).toBe(true);
expect(_hasHiddenSegment("../path/to/plans/.hidden/a.json")).toBe(true);
});
it("handles absolute paths", () => {
expect(_hasHiddenSegment("/tmp/path/to/plans/a.json")).toBe(false);
expect(_hasHiddenSegment("/tmp/tests/.plans/a.json")).toBe(true);
expect(_hasHiddenSegment("C:\\tmp\\tests\\plans\\a.json")).toBe(false);
expect(_hasHiddenSegment("C:\\tmp\\tests\\.plans\\a.json")).toBe(true);
});
});
import { describe, expect, it } from "vitest";
import { _parseArgs } from "../cli/generate-tests";
const baseArgs = ["plan.json", "--tests-dir", "tests", "--summary-dir", "summaries"];
function parseWithDefaults(extras: string[] = []) {
return _parseArgs([...baseArgs, ...extras]);
}
describe("parseArgs", () => {
it("parses --tests-dir with separate value", () => {
const result = parseWithDefaults();
expect(result.testsDir).toBe("tests");
expect(result.summaryDir).toBe("summaries");
expect(result.inputs).toEqual(["plan.json"]);
expect(result.errors).toEqual([]);
expect(result.help).toBe(false);
});
it("parses --tests-dir with equals value", () => {
const result = _parseArgs(["plan.json", "--tests-dir=tests", "--summary-dir=summaries"]);
expect(result.testsDir).toBe("tests");
expect(result.summaryDir).toBe("summaries");
expect(result.inputs).toEqual(["plan.json"]);
expect(result.errors).toEqual([]);
});
it("allows --tests-dir after inputs", () => {
const result = _parseArgs([
"plan.json",
"other.json",
"--tests-dir",
"out",
"--summary-dir",
"summaries",
]);
expect(result.testsDir).toBe("out");
expect(result.summaryDir).toBe("summaries");
expect(result.inputs).toEqual(["plan.json", "other.json"]);
});
it("returns error for missing tests dir value", () => {
const result = _parseArgs(["plan.json", "--tests-dir"]);
expect(result.errors.join("\n")).toContain("Missing value for --tests-dir");
});
it("returns error for missing summary dir value", () => {
const result = _parseArgs(["plan.json", "--summary-dir"]);
expect(result.errors.join("\n")).toContain("Missing value for --summary-dir");
});
it("returns error for unknown flags", () => {
const result = _parseArgs(["--wat"]);
expect(result.errors.join("\n")).toContain("Unknown option: --wat");
});
it("parses --summary-dir with separate value", () => {
const result = parseWithDefaults();
expect(result.summaryDir).toBe("summaries");
expect(result.errors).toEqual([]);
});
it("parses --summary-dir with equals value", () => {
const result = _parseArgs(["plan.json", "--tests-dir", "tests", "--summary-dir=summaries"]);
expect(result.summaryDir).toBe("summaries");
expect(result.errors).toEqual([]);
});
it("sets help when --help is provided", () => {
const result = _parseArgs(["--help"]);
expect(result.help).toBe(true);
});
});
Related skills
FAQ
What are the two modes?
SKILL.md defines Assessment mode for readiness review only and Full conversion mode that generates plans and tests after assessment passes.
Which reference files are mandatory?
acceptance-criteria.md must be read fully before processing; test-hooks.md loads for assessment and plan naming patterns.
When does conversion stop?
If assessment reports failures or JSON plan validation fails, the skill halts without writing plans or tests for that file.