SDODS

Release notes

What changed in each SDODS version, generated from the changesets of the @sdods/* packages.

Every @sdods/* package is released together under one version number and published to npm. Upgrade with sdods upgrade (or npm i -g @sdods/cli@latest); see the CLI reference.

0.20.0

  • GitHub Copilot support: --adapter copilot runs the SDODS agent roles through a logged-in Copilot CLI (copilot -p --output-format json, sdods MCP server attached, shell, file writes, web and the proposal review tools denied); sdods mcp install copilot registers the server in ~/.copilot/mcp-config.json; sdods agent install --for copilot writes .github/agents/sdods-<role>.agent.md; sdods doctor shows a cli:copilot row; the installer takes --mcp copilot.

0.19.0

  • heal.ciMode: warn | fail | off decides what self-healing does when CI is set. The default, warn, heals and then lists every heal as a GitHub Actions annotation and in the job summary; fail fails the step with HEAL_BLOCKED_IN_CI and names the locator that would have healed it; off disables healing in CI. Local runs heal as before.
  • sdods import cucumber <dir> -p <slug> maps a Cucumber-JS suite into an SDODS project as a proposal: features tagged with a layer and suite, library steps reused, the rest stubbed to port, and an import report. sdods import playwright <dir> analyses a Playwright suite. The source suite is read, never executed.
  • --reporter junit|allure (and reports.junit / reports.allure) now work: allure-playwright is a dependency of @sdods/core, so Allure results are written instead of the run failing to load the reporter.
  • Secrets stay out of agent prompts and tool results: agents' own file tools may not read dotenv files, logins, datasets or run artifacts; API response snapshots are redacted by key; URL passwords and more secret-looking keys are masked in config show and project_get_config; data_preview masks more columns.

0.18.0

  • Security fixes from the 2026-10-26 audit (docs/audits/SDODS_GAP_ANALYSIS.md):

    • Run ids. A run id is refused unless it names one directory: letters, digits, ., _ and -, not starting with .. This applies to MCP run_tests, run_tests_async and run_get, to sdods run --run-id, and to config resolution. run_tests {runId: "../.."} used to delete the workspace when the run finished. isRunId is now exported from @sdods/contracts.
    • Ingest. Ingest reads attachment files only from inside the run directory, and an uploaded sdods/meta attachment can no longer move where files are written. A trusted caller whose attachments live elsewhere passes the new attachmentRoots option to ingestRun.
    • MCP.
      • feature_write stages files only inside the project.
      • Project slugs and proposal ids can no longer walk out of their directories.
      • analyze_change_impact refuses a range that starts with -.
    • Server.
      • Agent jobs and proposals are listed, shown, streamed, cancelled, accepted and rejected only for projects where the caller has the editor role.
      • GET /api/agents/jobs honours ?project=.
      • audit:read is platform-admin only, so existing non-admin tokens that listed it now get 403 on /api/audit.

0.17.0

  • sdods serve bound to loopback (the default) now answers only to localhost, 127.0.0.1 and ::1 Host headers, which blocks DNS-rebinding pages. Behind a reverse proxy on the same machine that forwards the public Host, set SDODS_ALLOWED_HOSTS (comma list, * disables the check). Public binds such as HOST=0.0.0.0 keep accepting any host. sdods mcp --http applies the same rule to its own listener.

0.16.0

  • The web UI uses the SDODS brand: IBM Plex Sans and Mono (bundled, so every machine renders the same type), the rail blue on ink and paper in place of the indigo and teal defaults, and readable brand-coloured links in dark mode (7.4:1, up from 3:1). Borders in dark mode follow the theme instead of drawing bright lines between rows.

0.15.0

  • The web UI uses the SDODS brand: IBM Plex Sans and Mono (bundled, so every machine renders the same type), the rail blue on ink and paper in place of the indigo and teal defaults, and readable brand-coloured links in dark mode (7.4:1, up from 3:1). Borders in dark mode follow the theme instead of drawing bright lines between rows.

0.14.1

  • sdods doctor lists only credentials a user can act on. The ci-only level and the NPM_TOKEN row are gone, and the workspace .env.example no longer lists the SDODS repository's own CI secrets (they are in CONTRIBUTING.md).

0.14.0

  • @sdods/core/steps exports rawResponseOf(apiContext) (and the RawSnapshot type): the last raw, redirect-preserving response sent in the scenario, or undefined. Project steps can assert on the snapshot the core raw-request steps captured instead of sending their own request.

0.13.2

  • sdods feedback opens the sdods.com feedback form instead of a prefilled GitHub issue, which 404s now that the source repository is private. --title, --message and, with --bug, the version, OS and doctor summary are printed for you to paste into the form; --json returns them as details. --discuss points at sdods.com/questions.

0.13.1

  • Playwright 1.64. The browser tools gain browser_emulate_media (color scheme, reduced motion, forced colors, contrast, media type) and the new upstream arguments: browser_find maxResults and filename, browser_tabs isolatedContext, browser_start_video fps and cursor, and browser_video_show_actions style. Tag expressions use @cucumber/tag-expressions in place of the deprecated cucumber-tag-expressions, so installs no longer print a deprecation warning. The CI image, the installers and the Docker images pin Bun 1.4.3.

  • Browser tools that save a file (browser_snapshot, browser_take_screenshot, browser_pdf_save, browser_console_messages, browser_network_requests, browser_network_request, browser_evaluate, browser_start_video, and browser_find from Playwright 1.64) now write only into the session's output directory. A relative filename used to resolve against the repository root, so a call could overwrite files in the working tree; it now resolves inside the output directory, and a path outside it fails with BROWSER_PATH_OUTSIDE.

0.13.0

Features

  • sdods run has its own console output. Each scenario is one line, [n/total] ✔ Feature › Scenario [ui · chromium] (1.2s), in place of the runner's generated spec paths and hook lines. Failures show the feature file, the error (without generated-spec code or browser launch logs), the failure screenshot, the video and sdods trace <zip>. run --list lists scenarios by title. New --open always|on-failure|never (or SDODS_OPEN) opens the SDODS dashboard after a run; by default only on failure at an interactive terminal, never in CI or under a coding agent. The web UI puts the SDODS dashboard first and labels the HTML report and trace viewer as Playwright's.

Fixes

  • sdods run no longer ends with the runner's "To open last HTML report run: npx playwright show-report" hint. It prints the SDODS command to open the results instead, and sdods trace --run <id> after a failure. Every runner and generator invocation (run, watch, lint, steps list, record, auth capture, trace, show-report, report merge, browsers install) now resolves the workspace's own package instead of a bare npx, which could prompt to download a package or fetch an unrelated one. watch, record and auth capture check for missing browsers up front and name sdods browsers install; doctor --fix and init install browsers through the same path. GitHub failure issues name sdods trace <zip>. show-report accepts --last. sdods --help credits Playwright and playwright-bdd.

0.12.1

  • sdods run and sdods watch run bddgen through Node from the workspace's installed playwright-bdd instead of npx bddgen. Where npx finds no node_modules/.bin shim it recognises (bun installs on Windows), it downloaded the unrelated registry package bddgen@1.0.5 and every run failed with "bddgen failed to generate specs".

0.12.0

  • Runs no longer fail with Playwright's "Executable doesn't exist … run npx playwright install" when a test browser is missing or half-downloaded. sdods run checks, before generating specs, that every browser its targets launch (chromium for @api targets too) finished downloading for the exact revision the workspace's Playwright uses. With --install-browsers or SDODS_AUTO_INSTALL_BROWSERS=1 (set by the desktop app) it downloads what is missing into the cache the runner reads; otherwise it stops with the sdods browsers install command. sdods browsers list and sdods doctor use the same check.

    Stopping a run from the UI or API now ends the whole process tree (the CLI, Playwright, its workers and browsers) on Windows, macOS and Linux, and requires the editor role, like starting one. New POST /api/runs/:id/rerun ({ scope: 'all' | 'failed', scenarios? }) replays a run's full selection, only its failed scenarios, or named ones; runs now store the selection they were started with (runs.params_json). New GET/PUT /api/me/preferences/:key store per-user UI preferences (user_preferences table), used to remember the last run selection per workspace and project.

0.11.3

  • Fix sdods failing to start on Windows with ERR_UNSUPPORTED_ESM_URL_SCHEME: the bin shim now imports its entry point as a file:// URL. This also unblocks the desktop app's first-run install on Windows.

    sdods init now adds @axe-core/playwright to the workspace, so the demo project's @a11y scenario passes on a fresh install instead of failing with "Accessibility audits need @axe-core/playwright".

0.11.2

  • Fixes found by using every web UI feature against a real server.

    • Workspaces created in the web UI (or with POST /api/workspaces) are written to sdods.workspace.yaml before the database, so projects can be created in and imported into them. Creating a workspace that is already declared returns 409.
    • Project Settings saves again: PUT /api/projects/:slug accepts { patch }, the top-level keys that changed, applied to the yaml in place so comments and keys the form does not show are kept. The form now sends only what changed.
    • Dataset upload: the preview uses POST .../datasets with preview=1 (the page called a route that does not exist), and the name, environment and storage fields are sent before the file so the server reads them. A file dataset is registered under data.sources, so it is listed and usable as @data:<name>.
    • Creating a user with the email field left empty no longer fails with "Invalid email address".
    • The web app's mock dev server (bun run web:dev) starts again: browser code imports @sdods/contracts/schemas instead of the root export, which pulled in node:crypto.
    • Docs: account menu, Settings → Profile and Password & sessions, the account REST routes, and refreshed web UI screenshots.

0.11.1

  • Repository metadata moved to the new GitHub organisation (reachable as https://sdods.com/github): repository.url and bugs.url point there, sdods feedback opens issues there, and the documented server image moved with it.

  • Fixes found by using every web UI feature against a real server.

    • Workspaces created in the web UI (or with POST /api/workspaces) are written to sdods.workspace.yaml before the database, so projects can be created in and imported into them. Creating a workspace that is already declared returns 409.
    • Project Settings saves again: PUT /api/projects/:slug accepts { patch }, the top-level keys that changed, applied to the yaml in place so comments and keys the form does not show are kept. The form now sends only what changed.
    • Dataset upload: the preview uses POST .../datasets with preview=1 (the page called a route that does not exist), and the name, environment and storage fields are sent before the file so the server reads them. A file dataset is registered under data.sources, so it is listed and usable as @data:<name>.
    • Creating a user with the email field left empty no longer fails with "Invalid email address".
    • The web app's mock dev server (bun run web:dev) starts again: browser code imports @sdods/contracts/schemas instead of the root export, which pulled in node:crypto.
    • Docs: account menu, Settings → Profile and Password & sessions, the account REST routes, and refreshed web UI screenshots.

0.11.0

  • Account menu, profile and sessions in the web UI.

    • The sidebar footer is now an account menu: profile, password and sessions, preferences, API tokens, MCP clients, admin pages, theme (system, light, dark), feedback and sign out.
    • Settings gains Profile (display name, email, role, memberships), Password & sessions (change password with the current one, list and sign out other sessions) and Preferences (theme, default organization, workspace and project).
    • @sdods/db: migration 0009_user_profile adds users.display_name; listSessionsForUser, deleteSessionById, and deleteSessionsForUser(userId, { exceptToken }).
    • @sdods/server: PATCH /api/me, POST /api/me/password, GET /api/me/sessions, DELETE /api/me/sessions/:id, POST /api/me/sessions/revoke-others (browser sessions only, never API tokens). /api/auth/me returns email, display name, last login, creation date and the current session id. PATCH /api/users/:id accepts displayName.
    • Web pages that called routes the server does not have now use the real ones: Recorder (job events, recorded spec via GET /api/projects/:slug/recorded/:file, convert with kind: convert), Environments (create and edit through PUT .../envs/:name, which now edits the file in place instead of rewriting it), Schedules (save, pause/resume, history), Integrations (PUT .../integrations validates before writing and merges MCP servers by name) and the Agents live log.
    • sdods users set-role rejects an unknown role instead of reporting success without changing anything.

0.10.0

Features

  • Process gates judge sharded runs, CLI-ingested runs record their verdict, and @a11y can audit every page (#176, #177).

    • sdods report merge --process <name> [-p <slug>] judges a process's gates on the merged shards: the pass rate and flaky count from the merged results, the @a11y/@perf evidence from the shards' run directories and the reports attached in the blobs. It prints the same gate table as sdods run --process, writes gates.json into the --run directory and exits 1 with GATE_FAILED. Without --process, a merge now also judges the process a shard's run.json names when that process declares gates.
    • sdods report merge merges several shard report directories, including blobs recorded under different checkouts (CI's Playwright container and its bare runner). playwright merge-reports reads one directory and refuses mixed test roots, so the reports are staged into one directory with a merge config that pins the root. Before this, CI's merge step failed with "too many arguments" and its || echo hid it. A failed merge now shows Playwright's error rather than the tail of its stack.
    • Ingest records the process gate verdict under totals_json.gates and marks a gated-out run failed, for CLI, merged and server-started runs alike (gates.json in the run directory, or judged during sdods report ingest and server ingest when the run's process has gates). RunRecord.gates carries it, and the run page in the web UI shows the gate table.
    • a11y.scope: final | every-page (default final), overridable per environment. With every-page, the @a11y hook audits each distinct URL a step leaves the scenario on, after the page settles at the point the @perf hook reads it, skips URLs an explicit audit step covered, and fails once at the end naming each violating URL. sdods/a11y-scenario carries a pages list, and the a11y gate counts one audit per URL.

Fixes

  • Dependencies moved to their latest compatible releases: zod 4.6, yaml 2.9.1, fastify 5.12.4, @axe-core/playwright 4.13 (axe-core 4.13 rules), @anthropic-ai/sdk 0.125, @anthropic-ai/claude-agent-sdk 0.3.272, playwright-bdd 9.2.1 and tar 7.5.22. The installer now pins Bun 1.4.2.

    Tooling moves to TypeScript 6.0 and ESLint 10, and fastify-plugin to 6. Errors re-thrown from a caught failure (JSON bodies that do not parse after templating, GitHub evidence updates, an OpenAI-compatible server that does not answer) now carry the original error as cause.

    sdods init scaffolds projects on TypeScript 6.0, playwright-bdd 9.2.1 and tsx 4.23.13, and the workflow sdods schedule generates uses actions/checkout@v7, actions/setup-node@v7 and actions/upload-artifact@v7.

0.9.0

  • @a11y and @perf now run, and process gates are evaluated.

    • @a11y: at the end of a UI or hybrid scenario, the page it ended on is audited with axe-core (WCAG 2.x A and AA). The scenario fails on any violation at or above the new a11y.failOn setting (serious by default). a11y.include and a11y.exclude scope the audit, and an environment can override each key. The result is attached as sdods/a11y-scenario. A URL that an explicit whole-page audit step already checked is not audited again.
    • @perf: every document the scenario loads records its vitals once it has loaded and painted. At the end of the scenario, each recording is compared with the page budgets in perf.budgets, and the p95 of the scenario's live API calls (plus any latency samples) is compared with apiP95Ms. The scenario fails in four cases: a budget is breached, a budgeted vital is unmeasured, no budgets are configured (CONFIG_INVALID), or no budget applies to anything the scenario measured. The result is attached as sdods/perf-scenario.
    • sdods run --process <name> evaluates gates.minPassRate, gates.maxFlaky, gates.a11y and gates.perfBudgets after the run and prints a gate table. When a gate fails, it exits 1 with the new error code GATE_FAILED. The a11y and perf gates also fail when nothing was audited or judged. The verdict is written to gates.json, summary.json and the --json output. The server's run manager records it on the run under totals_json.gates and marks a gated-out run failed. Gates are not evaluated on a single shard.
    • The axe audit and the vitals and budget helpers moved out of the step files into a11y/audit.ts and perf/vitals.ts, so the hooks can use them without registering the steps a second time. The step files still re-export them.
  • User pools no longer starve workers silently (#153).

    • sdods run stops before generating specs when a selected @user:<role> has fewer pool accounts than the scenarios of that role the workers could run at once. It exits 2 with USER_POOL_TOO_SMALL and names the role, the account count and the worker count. The count follows the selection (--layer, --browser, --tags, --module, --feature, --since, tag-gate skips, serial files, shards) and is skipped for mode: shared, leaseScope: scenario, database or OpenAPI datasets, --list, --ui and --debug. --allow-pool-contention runs anyway. sdods doctor prints accounts per role against the worker count (-w to set it).
    • data.userPool.leaseScope: scenario | worker (default worker, unchanged). With scenario an account is released when each scenario ends, including accounts leased by the I use a leased user … steps, so one account serialises its scenarios instead of starving whole workers. In that mode waitMs defaults to leaseTtlMs, and the time spent waiting is added to the scenario's timeout.
    • Waiters for a role are served in arrival order, so a worker that just released an account no longer wins it straight back.
    • Removed: data.userPool.leaseStore: db. It was accepted and never implemented, so sharded runs that relied on it did not share leases. It is now a configuration error ("leaseStore 'db' was never implemented; use 'file' (see #153)"); use file, or give each runner its own accounts.
    • data.userPool.waitMs is now optional in the schema (its effective default is unchanged for leaseScope: worker).
    • Fix: the shard offset in lease owners used --workers ?? 1, so with Playwright's default worker count a worker on shard 2 could share an owner with one on shard 1.
  • Visual baselines you review before they change, per-baseline masks, and Linux baselines from CI.

    • screenshots.maxDiffPixelRatio (default 0.01, previously hard-coded) and screenshots.baselines.<name> with its own mask (added to screenshots.mask) and maxDiffPixelRatio, in the project or an environment.
    • New step the page should match the visual baseline {string} masking {string} with comma-separated selectors.
    • A failed visual check keeps its expected, actual and diff images and a <name>.failure.json record under the scenario's visual/<run target>/ directory, so they survive the CI artifact upload.
    • sdods baselines diff [--run <id>] lists failed visual checks; sdods baselines accept <name…> --run <id> (or --all) copies the actual screenshot to features/__screenshots__/<run target>/<platform>/ for the platform the run happened on. sdods run --update-snapshots still works and now warns that it overwrites without review.
    • Server: GET /api/runs/:id/baselines and POST /api/runs/:id/baselines/accept (features:write, editor role); the web UI shows Accept as baseline with the diff on a failed visual step.

0.8.0

Features

  • GitHub issues can embed evidence that renders inline in private repositories (#82). With integrations.github.evidence.host: branch (off by default), screenshots, video.webm and a GIF preview (when ffmpeg is on PATH) go to an orphan sdods-evidence branch through the git data API, in one fast-forward commit per run that is retried when another job moved the branch first, and are embedded as blob/<branch>/<path>?raw=true. The evidence can go to a separate repository with its own token (evidence.repo, evidence.tokenEnv). Per-file and per-run size caps apply (maxFileBytes 5 MB, maxRunBytes 25 MB), and files over them are listed in the issue. sdods integrations test checks that the token can push to the evidence repository, and sdods integrations evidence prune [--older-than 14d] rewrites the branch without old runs. Traces are never uploaded.

  • sdods load -p <project> -e <env> <profile> runs API load tests through k6. A profile in projects/<slug>/load/<profile>.yaml lists requests (method, path relative to api.baseUrl, headers, body, expected status and body checks) with k6 stages or vus/duration and thresholds. SDODS generates a k6 script whose credentials come from the environment's api.auth as __ENV lookups, never inlined, prints the target URL and peak virtual users, runs k6 run --summary-export (or the grafana/k6 image with --runner docker) and exits 1 when thresholds fail. Load is opt-in: an environment must set load.allowed: true, load.maxVus caps the peak, and write methods need load.allowWrites: true. --dry-run writes the script without k6. Results land in .sdods/runs/<id>/load/<profile>/.

  • Role matrices (#118): declare an actor × surface grid once in projects/<slug>/roles.matrix.yaml and generate the Scenario Outline examples from it.

    • @sdods/contracts: RolesMatrixFileSchema and ROLES_MATRIX_FILE. A matrix lists its roles, a default outcome, optional outcomes and title, and rows whose extra keys are Examples columns; expect is one outcome or a per-role map.
    • @sdods/core: loadRolesMatrices, expandFeatureText and planMatrixExpansion. A Scenario Outline tagged @matrix:<name> gets one Examples: block per role, tagged @user:<role>, between # sdods:matrix:begin/end markers; re-running is idempotent and hand-written Examples are kept. sdods lint accepts @matrix:<name> and reports an unknown matrix (tags/matrix), an invalid matrix file or undeclared role, a misplaced template or unknown placeholder, an out-of-date feature (matrix/stale, warning), and a matrix role with no user-pool account in an environment (matrix/unseeded-role, warning).
    • @sdods/cli: sdods matrix expand [-p <slug>] [--check] [--dry-run]; --check exits 3 when a feature is out of date, for CI.
    • @sdods/mcp: matrix_expand stages the expanded features as a proposal instead of writing them. The conventions list @matrix:<name> among the value tags.
  • Requirement traceability (#119). A new value tag @req:<id> links a scenario to a requirement; it may repeat, and on a Feature or Rule it applies to every scenario under it. The id is opaque. An optional traceability: block in sdods.project.yaml (requirements: a YAML or CSV list of ids and titles, link: a URL template with {id}, require: boolean) makes lint reject ids missing from the list (tags/req) and, with require: true, scenarios without a @req: tag (tags/req-missing).

    sdods report traceability -p <slug> [-e <env>] [--run <id> | --last] [--format json|csv|md|html] [-o <file>] exports requirement → scenarios (feature, line, name, tags) → final result per runner project in the chosen run (status, browser, attempts, flaky, duration, timestamps), with the run's id, environment, commit and SDODS version, a coverage summary (passed / failed / not run / not covered) and an empty sign-off block that SDODS never fills in. It reads messages.ndjson from the run directory, falling back to per-scenario meta.json; no database or server is needed. @sdods/core/analyze exports buildTraceabilityReport and renderTraceability.

    The agent conventions (@sdods/mcp CONVENTIONS) list @req:<id> among the optional value tags.

  • Canvas steps for graph and node editors (canvas.steps.ts): drag an element onto another or by an offset, drag a node by id, connect a handle of one node to a handle of another, select a node, set a field inside a node, and assert the node count, an edge from one node to another (or its absence), a node's visibility and a badge inside a node.

    • Drags are real pointer gestures (press, nudge, multi-step moves, a final move onto the target, release), so React Flow, d3-drag and native HTML5 draggable palettes all react. The press lands on a plain part of the element, never on a field or handle inside it.
    • New project key canvas in @sdods/contracts: CSS selector templates for root, nodes, node, handle, edge, field, badge and selected, defaulting to React Flow's DOM. Templates are validated for their required placeholders ({id}, {handle}, {source}/{target}, {field}, {badge}), and {testIdAttribute} expands to the project's attribute.
    • A project whose own steps already use these phrasings can keep them with steps.core.exclude: [canvas].
  • Email assertions over Mailpit. A new mail block in the environment yaml (provider: mailpit, url, optional basic auth with a ${VAR} password) enables seven steps: clear the inbox for an address, wait for an email with a subject within N seconds, assert the latest email's text or sender, save a link or a one-time code from it, and open its link. "The latest email" is scoped to the scenario: only mail received after the scenario started or after the last clear counts, and the wait step pins the message it matched. @sdods/core also exports the MailInbox interface and MailpitInbox adapter.

Fixes

  • sdods report merge <dirs...> --run <id> now writes into that run. The parent report command also defines --run, and Commander handed the option to it, so merge silently fell back to the latest run (or failed with "No run to merge into").

  • Per-scenario browser emulation: @locale:<bcp47>, @timezone:<IANA>, @theme:<light|dark|no-preference>, @viewport:<W>x<H> and @device:<name> set the browser context before it opens, over the environment's use: block, on a Scenario, a Feature, a Rule or one Examples: block. sdods lint validates the values. New steps: I use the locale {string}, I use the timezone {string}, I use the {string} color scheme, I use the viewport {int} by {int}, I use the device {string}, the page should reflow without horizontal scrolling, the page should have no untranslated keys and the page should have no untranslated keys matching {string}.

    Fix: mobile-chrome and mobile-safari run targets ran in a 1280x720 window instead of the device's viewport, because the generated project set viewport: undefined, which Playwright reads as "use the default".

    The agent rule lines (CONVENTIONS in @sdods/mcp, the sdods skill in @sdods/cli) list the new tags.

  • Add @sdods/mcp/gherkin: parseGherkin, basicTagCheck and similarity without the CLI, file system or tool registry, for code that checks a feature it was handed. The root export is unchanged.

0.7.3

  • ApiContext.headers is now case-insensitive, as HTTP header names are. It is a HeaderMap (exported from @sdods/core), a Map whose set, get, has and delete lower-case the name, so delete('x-api-key') removes a header set as X-API-Key and get('cookie') finds one set as Cookie. Names are stored lower-cased, and when a scenario sets the same header under two casings the last set wins. What goes on the wire is unchanged: the client already lower-cased names when building a request. Project workarounds that installed their own case-insensitive map over the apiContext fixture can be removed.

  • A JSONPath ($…) that matches nothing now reads as absent (undefined), the same as a dotted path, instead of []. the response JSON path {string} should exist fails on a missing key and should not exist passes, whatever the path syntax. I save the response JSON path {string} as {string}, should have {int} items / at least {int} items and the UI should show the text from JSON path {string} fail with RUN_FAILED and a hint when the path matches nothing; a key that is present with the value null or [] still saves. getPath returns undefined for no match, the value for one match and an array for two or more, and the new matchedPath throws on no match.

    Behaviour change: a wildcard over an empty array ($.items[*] on { "items": [] }) is now absent rather than [], so the response JSON path "$.items[*]" should have 0 items fails. Point the path at the array itself ($.items).

0.7.2

  • Add @sdods/mcp/gherkin: parseGherkin, basicTagCheck and similarity without the CLI, file system or tool registry, for code that checks a feature it was handed. The root export is unchanged.

0.7.1

  • Sponsorship is behind one switch, SPONSOR_ENABLED in @sdods/contracts/sponsor, and it is off. While off, sdods --help, the web UI sidebar, the desktop menu and the installers no longer link to the sponsor page, and sdods.com serves its sponsor pages as not found.

0.7.0

Features

  • SDODS for AI coding tools.

    • sdods skills list and sdods skills install [--agent claude,agents,cursor,copilot,gemini] [--global] copy the bundled Agent Skills (sdods, sdods-run, sdods-record, sdods-start-ui) to where Claude Code, Codex, Cursor, Copilot and Gemini CLI read them; npx -y @sdods/cli skills install needs no global install. sdods init installs the same set into .claude/skills and .agents/skills, and no longer copies SDODS's own release skills.
    • sdods mcp install gemini writes .gemini/settings.json.
    • Every stdio snippet now launches npx -y @sdods/cli mcp. npx sdods mcp pointed at a package that does not exist, so clients configured from mcp install --file, agent install, the web UI or /api/mcp/info failed with CONNECTION_CLOSED outside a checkout.
    • sdods mcp install claude|codex --http-url passes the bearer token (${SDODS_TOKEN} / --bearer-token-env-var SDODS_TOKEN); before, both registered a server that answered 401.
    • sdods mcp install windsurf writes Windsurf's real config, ~/.codeium/windsurf/mcp_config.json, with serverUrl for remote servers.

Fixes

  • SDODS can now be sponsored. sdods.com/sponsor takes one-time or monthly sponsorships through Stripe, in any amount, and companies can ask for an invoice or a bank transfer. sdods --help, the installer's closing notes, the web UI sidebar and the desktop app's SDODS menu each link to that page.

0.6.0

  • sdods users reset --yes removes every user with their sessions, API tokens and memberships, so the next sdods serve offers the one-time /setup link again; projects, runs and schedules are kept. sdods users set-password <username> --password <pw> [--activate] sets a password from the terminal, signs the user out everywhere and can re-enable a deactivated account, so a locked-out sole admin no longer has to wipe the database. Both are audited. The docs gain a "Reinstall or reset" section for installed, clone, Docker and desktop setups.

0.5.2

  • Stop publishing Playwright traces, and redact the ones a run keeps (#101).

    A trace.zip records every request's Cookie/Authorization headers, Set-Cookie responses, the context's storageState (cookies, localStorage, IndexedDB — where Firebase keeps its refresh token) and typed values, so for projects with pool accounts it carries live sessions.

    • Integrations never upload, attach or link a trace. GitHub issues no longer link trace.zip through SDODS_PUBLIC_URL or the CI artifacts page, and uploadToRelease no longer uploads it; Jira no longer attaches it. Both name the trace's local path under a warning that it contains credentials, with the npx playwright show-trace command. The video is still linked or uploaded (it shows the screen, not headers or storage).
    • sdods run redacts every trace after the run, before ingest and integrations: runner-output/**/trace.zip, the HTML report's copies under html-report/data/, zips nested in blob shard reports, and the BASE64 trace bodies embedded in messages.ndjson (which ingest turns back into trace.zip). Credential headers (Cookie, Set-Cookie, Authorization, Proxy-Authorization, X-Api-Key and similar), cookie values, localStorage/sessionStorage/IndexedDB values, httpCredentials passwords, password inputs in DOM snapshots and values typed into password/secret/token fields become [redacted]. Other entries are copied through unchanged and the trace viewer opens the result. Request/response bodies and page text are not redacted.
    • New evidence.redactTraces (default true; env files may override it). evidence.trace and sdods run --trace <mode> already control whether traces are recorded at all.
    • Docs warn that traces are credential-bearing (GitHub and Jira guide, sdods trace, project.yaml reference).

0.5.1

  • Make every credential an API call carries explicit, and wire two config fields that did nothing.

    • I use an isolated API client (or apiContext.isolated = true) sends the rest of the scenario's calls through a separate request context with an empty cookie jar, created on first use and disposed at teardown. On @ui/@hybrid the shared request context starts from the @user:<role> storageState, so "an invalid API key is refused" passed on the session cookie. The raw (without following redirects) and event-stream steps honour it. The step library reference documents which layers carry which jar.
    • String bodies are sent as bytes. Playwright JSON-encodes a string that does not parse when the content-type is exactly application/json, so { this is not json arrived as "{ this is not json". New step I send a {method} request to {string} with the raw body: sends its doc string untouched (not parsed, not template-rendered).
    • I use a leased user with role {string} for API calls no longer attaches the user's token behind the scenario's back for custom auth strategies, whose token() may mint a different credential class than the session. New project setting auth.apiToken: implicit | explicit (default: explicit for custom, implicit for every other strategy, unchanged) and new step I authenticate the API with the leased user's token. Which credential was attached, and from where, is logged at info. Behaviour change for custom strategies that relied on the implicit bearer: add the step, or set auth.apiToken: implicit.
    • Recorded API evidence carries request.auth (none, bearer, basic, header:<name>, never the value) and request.isolated.
    • env.api.auth: { type: oauth-client-credentials } is implemented: a client_credentials grant to tokenUrl (with scope/audience when set), sent as a bearer and cached per worker until 30 s before expires_in. A refused grant fails the call as AUTH_FAILED instead of sending it anonymous, and an auth type the client does not implement throws NOT_SUPPORTED. The oauth-client-credentials auth strategy's token() shares the implementation. Raw requests now resolve auth exactly like the client, which also makes I use no authentication apply to them (null ?? env used to fall back to the environment credential).
    • retries.byTag reaches the runner. Each entry becomes a sibling runner project with the same name that greps the tag and carries its retries; the base project excludes those tags, and a scenario with several such tags runs once with the highest count. --retries still overrides it, and a scenario's own @retries:N tag overrides both.
  • Applying a cached session no longer navigates the page (#99).

    • I use a leased user with role "…" restored localStorage by page.goto(origin). An app that sends a signed-in visitor away from / on the client then had a redirect chain still running when the step returned, and the scenario's first page.goto failed with net::ERR_ABORTED / "interrupted by another navigation". Cookies are still added to the context; localStorage is now planted by a context init script that runs before the app's own scripts, only on the matching origin (or written straight into the page when it is already on that origin). The page is left where it was.
    • The localStorage is planted once per context: the init script is removed after the first document of that origin loads, so an app that signs out mid-scenario is not silently signed back in on the next navigation.
  • I should not see the text checks what the user can see, and the text steps get exact variants.

    • I should not see the text {string} passes when no VISIBLE element contains the text. It used to assert that no element in the DOM contained it, so a closed FAQ answer (hidden) or a collapsed panel failed the step although nothing was on screen. It now filters on visibility (getByText(...).filter({ visible: true }) + toHaveCount(0)), which is strict-mode safe and retries until the text goes away.
    • I should see the text {string} looks past hidden matches: a hidden copy earlier in the DOM no longer fails the step when a visible copy is on the page.
    • New I should see the exact text {string} and I should not see the exact text {string} match an element's whole text, so "already" no longer collides with "the clients you already serve".
    • Migration: new the page should not contain the text {string} keeps the old DOM-absence check (substring, hidden elements included). A scenario that relied on I should not see the text failing for hidden text should switch to it.
    • All new steps render {{variables}} through renderStrict(), so an unset variable fails the step instead of passing a negative check.

0.5.0

Features

  • Enabled integrations now act on sdods run, and GitHub issues from failures carry video and trace, survive a fresh CI runner and have their labels checked.

    • sdods run publishes the finished run to every enabled integration (check runs, PR comment, issues per createIssueOnFailure) through the same code path as sdods integrations notify. integrations.github.createIssueOnFailure used to do nothing unless a separate notify step existed. --no-notify opts out; cancelled runs, runs with no scenario results and sharded runs do not notify. A notify failure is printed as a warning and never changes the exit code, and --json output reports the outcome under notify.
    • GitHub issue bodies link the failing attempt's video.webm and trace.zip from runner-output/, read from runner-results.json, with the npx playwright show-trace command. uploadToRelease uploads them with their real content type instead of image/png.
    • Before creating an issue, the GitHub provider searches the repository for an open issue carrying sdods-fingerprint:<fingerprint> and comments on it, so a runner without .sdods/issue-links.json no longer opens a duplicate. Notifying the same run twice no longer comments twice.
    • sdods integrations test fails when a configured GitHub label does not exist in the repository and names the missing labels; --create-labels creates them.
  • Trace, video, parallelism and a setup tier are now configurable instead of hard-coded in the runner config.

    • evidence: { trace, video, screenshot } in sdods.project.yaml, overridable per key in envs/<env>.yaml, with sdods run --trace <mode> / --video <mode> and SDODS_TRACE / SDODS_VIDEO on top. Values are Playwright's modes and are validated. Defaults are unchanged (on-first-retry, retain-on-failure, off); with retries.local: 0 the old default meant no trace locally, so --trace on is now the way to get one without forcing a retry.
    • fullyParallel (default true) per project and per process. false keeps the scenarios of a feature file in order.
    • setup: { tags: '@setup' } per project, or per process (setup: false to turn it off): each run target gets a <target>--setup companion that runs the matching scenarios first, in the same browser, and the target depends on it, so the rest of the run does not start when a probe or login fails. Setup scenarios ignore --tags and run once.
    • parseRunnerProjectName reads the new <project>--<layer>[--<browser>]--setup names as their real layer and browser with phase: 'setup'.
    • A config value that is invalid only after ${VAR}, SDODS_* or CLI overrides now fails with a configuration error naming the path instead of a raw validation error.

Fixes

  • Cucumber messages record which step passed or failed again.

    On Playwright below 1.63, a workspace that installs SDODS from npm recorded every Gherkin step SKIPPED in messages.ndjson (and the cucumber HTML report), with failures attached to a hook. playwright-bdd matches a step's result by its line in the generated spec, and those Playwright versions report the line in their transformed copy instead. Measured on 1.60.0, 1.61.1, 1.62.0 and 1.62.1 against 1.63.0.

    • @playwright/test 1.63.0 is now the floor: sdods init scaffolds ^1.63.0, @sdods/core declares >=1.63 as its peer range, and the server image uses mcr.microsoft.com/playwright:v1.63.0-noble.
    • sdods run warns and sdods doctor fails a "step results" check when the workspace resolves an older @playwright/test, and both name the upgrade command.
  • sdods auth capture loads steps/auth.ts the same way sdods run does.

    • A project's steps/auth.ts that imports a sibling helper the conventional way (import './helper.js' for helper.ts) worked under sdods run, which goes through Playwright's TypeScript loader, and crashed sdods auth capture with Cannot find module .../helper.js: capture used a bare import(), so the published CLI handed the file to Node's native type stripping, which does not map .js to .ts. Capture now imports it through tsx, which maps .js to .ts, transpiles full TypeScript (enums included) and honours the project's module type, like Playwright's loader. The hooks are registered for that import only and removed afterwards. tsx is now a dependency of @sdods/core.
    • A project without "type": "module" has its auth export read from the CommonJS module too, instead of silently falling back to the yaml strategy.
  • Built-in steps render every {{variable}} the same way, and field steps no longer trip over a control whose label merely contains the field's.

    • Every built-in step renders its string arguments through one shared helper, renderStrict(). the page URL should contain, the page title should contain, the test-id, dropdown, checkbox, upload, visual-baseline and mock steps rendered nothing before, so "/workspace/{{fixtureWorkspaceId}}" was matched with its braces and could never pass. The API steps now also render JSON paths, header names and values, regex patterns and schema names.
    • A variable with no value now fails the step with no such variable: <name> instead of being matched as the literal {{name}}. For a negative assertion that is the difference between a check and a no-op: I should not see the text "{{tenantBDatasetId}}" used to pass whatever the page showed, and a request to /datasets/{{tenantBDatasetId}} used to go out and 404. Doc-string bodies (JSON, HTML, raw text) still render leniently, since free text may carry {{…}} of its own. Arguments that name the variable being written (… as {string}) are identifiers and are not rendered.
    • I fill the {string} field with {string}, I fill the form:, I select … dropdown, I check … checkbox and I upload … prefer an exact label and a control whose role fits the action, then fall back to the partial label. getByLabel('Password') also matched a "Show password" toggle, and the fill failed on a strict-mode violation.
    • Healer.resolve() treats a visible locator that matches several elements as a heal trigger for click, fill, select and check: it narrows to the one element whose role fits the action and records a HealEvent, or fails with HEAL_FAILED naming the match count. It used to return the ambiguous locator, so the action threw a strict-mode violation that healing never saw. Assertions and hovers are unchanged.

0.4.0

Features

  • A run that executes no scenarios now fails instead of passing.

    • sdods run exits 2 when the selection matches nothing, and says so. Zero scenarios with exit 0 looked exactly like a green run, so a mistyped --tags or --feature kept CI passing while testing nothing. One shard of several may still come back empty, and --allow-empty restores the old behaviour for a run that is expected to select nothing.
    • A malformed tag expression (--tags "@smoke and (") is a configuration error (exit 2) naming the expression, raised before specs are generated; it used to crash bddgen with a stack trace and a hint about undefined steps.
    • --feature must name a feature file in the project. It accepts a path relative to features/, to the project, to the repository, or an absolute one; a path that does not exist is refused instead of selecting nothing.
    • sdods features list --tags evaluates full tag expressions, the same way sdods run does. It compared the whole expression to each tag, so @ui and @smoke listed nothing.

Fixes

  • Cucumber messages record which step passed or failed again.

    On Playwright below 1.63, a workspace that installs SDODS from npm recorded every Gherkin step SKIPPED in messages.ndjson (and the cucumber HTML report), with failures attached to a hook. playwright-bdd matches a step's result by its line in the generated spec, and those Playwright versions report the line in their transformed copy instead. Measured on 1.60.0, 1.61.1, 1.62.0 and 1.62.1 against 1.63.0.

    • @playwright/test 1.63.0 is now the floor: sdods init scaffolds ^1.63.0, @sdods/core declares >=1.63 as its peer range, and the server image uses mcr.microsoft.com/playwright:v1.63.0-noble.
    • sdods run warns and sdods doctor fails a "step results" check when the workspace resolves an older @playwright/test, and both name the upgrade command.

0.3.2

  • The tag gate now really does decide before a pool account is leased.

    0.3.0 claimed it did, on the strength of $sdodsTagGate being declared first in test scope. Declaration order is not an ordering guarantee: Playwright instantiates fixtures in DEPENDENCY order, and user is pulled in by storageState, which the browser context needs. So the lease ran first.

    An @env:local @user:noconsent scenario run against staging therefore failed with No users with role "noconsent" in dataset "users" instead of skipping. Sixteen of seventy-two @env:local scenarios failed that way in one run, including two whose whole purpose is to fire a deliberate burst at a rate limiter — exactly the scenarios the tag exists to keep off a shared environment.

    user now depends on $sdodsTagGate, which is the only ordering guarantee Playwright offers. Same run afterwards: 72 skipped, 0 failed.

0.3.1

  • steps.core.exclude — the migration path 0.3.0 should have shipped with.

    0.3.0 added ten step libraries at once. They share ONE step namespace with a project's own steps, and playwright-bdd fails generation outright when two definitions match the same text, so the upgrade broke every consumer that had already written those phrasings — before a single scenario ran, with Multiple definitions matched scenario step. The first repo to upgrade hit 67 collisions, which is not a coincidence: the core libraries were modelled on the gaps that project documented.

    The only remedy was to rewrite all 67 in one commit, unverified, because the suite could not run until every one was gone.

    steps: core: exclude: [a11y, browser, dom, net, perf]

    excludes by file basename. With nothing excluded the pattern is byte-identical to what shipped before, so projects that never set it are unaffected. An unknown name throws CONFIG_INVALID listing what is available — a typo that silently excluded nothing would leave the author believing the collision was handled while generation still failed, pointing at the step rather than at the typo.

0.3.0

Features

  • Four tags the runner validated and then ignored now actually do something.

    @env:, @skip:<browser> and @flag: were checked at lint time and had no runtime path at all — the linter confirmed the tag was spelled correctly and the runner ignored it, so a project could carry hundreds of @env: tags and still point every one of them at production. @quarantine was not a tag this framework knew about, so every recipe excluded it by hand in a tag expression that drifts and that nobody can audit centrally.

    scenarioSkipReason() is a pure function in config/tags.ts, applied by a new automatic fixture declared first in test scope — so a scenario is excluded before an account is leased, a browser context is built, or a session is minted. The reason is recorded as an annotation as well as a skip, because a report that says "skipped" without saying why is how parked scenarios go unnoticed.

    @flag: gates only when the environment declares a flag list. An absent list means "not known", and a gate that fails closed on missing metadata would silently skip an entire suite.

    sdods run --since <range> selects only the features a git range could have broken, using the analyzeChangeImpact mapping that already existed and was reachable only through MCP. When nothing is impacted it says so and runs nothing, rather than running everything or exiting green on an empty run.

  • Five new step libraries, and a publish path that cannot ship a stale build.

    a11y.steps.ts runs a real axe-core audit through @axe-core/playwright and adds the structural checks a rule engine cannot make — heading outline, alt text, per-rule isolation. gates.a11y and the @a11y tag stop being schema-only.

    perf.steps.ts wires the previously inert PerfBudgetsSchema: pageLoadMs, lcpMs, fcpMs, ttfbMs from real web-vitals, and apiP95Ms from sampled request latency.

    net.steps.ts adds download capture (filename, content type, CSV rows, JSON path) and SSE/streaming assertions. dom.steps.ts adds focus trapping, dialog and popover state, and keyboard navigation. browser.steps.ts adds cookies, storage, viewport and colour-scheme control, and console-error capture.

    Every step that quantifies over a set fails when the set is empty. "Every image has an alt" over a page with no images is silence, not a pass, and silence is what makes an accessibility suite worthless.

    scripts/publish-npm.sh now builds clean instead of relying on incremental tsc -b, and refuses to publish a package whose staged dist/ is older than its src/. @sdods/core@0.2.2 shipped apiContext.auth = undefined while its source said auth = null; because the publish loop skips versions already on the registry, that tarball can never be corrected at that version.

  • Five step libraries for the surfaces Gherkin could not reach.

    iframe.steps.ts — a frame is entered, acted in, and left. Threading an optional frame through every UI step would touch every signature for a surface most scenarios never see, so the scope is per-page state instead. Unblocks hosted checkout, which is the commonest untestable surface there is.

    tabs.steps.ts — the action that opens a tab and the wait for it are ONE step. waitForEvent('page') after the click is a race that loses a fast popup. Switching is explicit, because a library that silently re-points page makes every later assertion ambiguous about which document it read.

    db.steps.ts — read-only assertions over the existing Kysely fixture, which had no step reading it. There is deliberately no INSERT: seeding through the database is how a suite comes to assert against states the product cannot produce. Table and column names are validated against a strict identifier pattern, because an identifier is interpolated rather than parameterised.

    clock.steps.ts — installing the clock is separate from moving it, and must come first. A 55-minute session TTL is not a thing a suite can wait through, so without this those scenarios are not slow, they are unwritable.

    webhook.steps.ts — an ephemeral loopback receiver whose URL is published as {{callback.url}}, torn down after every scenario including a failing one. Deliveries are asserted by polling, never by sleeping.

Fixes

  • Seven analyzer and auth-strategy defects, each of which stated something false with confidence.

    detectPackageManager now walks up to the workspace root, so scanning a member directory of a monorepo no longer reports unknown / monorepo: false.

    Base URLs are ranked by how specifically the variable NAME claims to be the base URL, instead of first-wins by file order — SIM_AGENT_API_URL no longer beats NEXT_PUBLIC_API_URL from 39 lines above it.

    An express dependency no longer forces api = ui: the .listen() port is only trusted when it names a different origin, so a full-stack app falls through to ui + '/api' as intended.

    Gitignored .env* files are skipped rather than read and republished into the report. .env.example no longer outranks a real environment for base URLs.

    detectAuth breaks ties by an explicit precedence and reports the ambiguity — with an epsilon, because these scores are sums of decimal weights and an exact === would call a 2e-16 difference a clear winner.

    sdods analyze gains --max-files / --max-depth, and the default file budget rises from 8,000 to 25,000: a single real app was 6,400 files, so the walk truncated before reaching .github/ and then reported "no CI" as a fact.

    The sso and token auth strategies throw with a hint instead of silently returning no browser session.

  • sdods auth capture no longer skips the token when the login state is fresh.

    The login state and the API token are two artefacts with two lifetimes, and the freshness check covered only the first — but it continued past the whole user, so a role could hold a fresh browser session and no token file at all. The API layer reads that file (steps/data.steps.ts), so every @user:<role> scenario fell through to a live token() mint: one identity-provider sign-in per scenario. In mybotbox-qa#57 that produced 1,010 QUOTA_EXCEEDED records in a single run, which is 67% of that suite's API-layer failures.

    The freshness check now gates the login only. The token is minted when there is no token file yet, or when --force asks for a fresh one — so repeated captures no longer leak a new API key into the application under test on every call, which the previous unconditional mint did.

The versions below came before changesets; their notes were written by hand.

0.2.1

Fixes found by driving the web UI and sweeping every CLI command.

  • Sign out works. It could do nothing at all: logging out without a live session returned 401, and the client discarded the rejection, so the button had no visible effect. Logout is now idempotent and always clears the cookie, while an authenticated logout still requires a CSRF token so no cross-site page can force one. Signing out also clears every cached query, so no data crosses a session boundary on a shared machine.
  • An expired session returns you to the sign-in form instead of rendering "AUTH_REQUIRED: Authentication required" inside a shell that still looked signed in.
  • users deactivate, users set-role and tokens revoke no longer crash with "root.opts is not a function". users deactivate is how access is revoked, so this was not cosmetic.
  • tokens revoke tells the truth: an unknown or already-revoked id now warns instead of reporting success.
  • The platform database is resolved from the workspace root. Running a command from a subdirectory used to open a different, empty database — sdods serve started inside a run's artifacts folder rejected every login because the users lived in the workspace database.
  • The projects list shows its processes rather than processes: , , ,.

0.2.0

First published release: npm i -g @sdods/cli installs SDODS as compiled packages, so there is no longer a source checkout in ~/.sdods/app.

  • The installer defaults to the published packages; --source git clones a checkout for working on SDODS itself, and is never used as a silent fallback.

  • sdods init scaffolds the demo project from assets shipped inside @sdods/cli.

  • sdods serve ships the dashboard inside @sdods/server.

    0.1.0 was never released for this project; the version number was already taken on npm by an earlier, unrelated placeholder, so the first release is 0.2.0.

0.1.0 (unreleased)

  • Monorepo with @sdods/contracts, @sdods/core, @sdods/cli and the demo-shop project.
  • Six-layer configuration precedence with provenance, secret-literal refusal and ${VAR:-default} interpolation.
  • Organization → workspace → project → module hierarchy and named processes.
  • sdods project, env, config, doctor commands.
  • This documentation site.

Using an AI coding agent? Install SDODS:

Install the SDODS plugin in Claude Code: skills, subagents, commands and the MCP server.

  1. /plugin marketplace add https://sdods.com/skills.git
  2. /plugin install sdods@sdods
Learn more about using SDODS with AI tools

On this page