devharness 0.9.15 → 0.9.17

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. package/build/github/issue-body.d.ts +4 -4
  2. package/build/helpers/env-file.d.ts +35 -0
  3. package/build/helpers/env-file.d.ts.map +1 -0
  4. package/build/helpers/env-file.js +53 -0
  5. package/build/helpers/env-file.js.map +1 -0
  6. package/build/tools/interpolation.d.ts +20 -3
  7. package/build/tools/interpolation.d.ts.map +1 -1
  8. package/build/tools/interpolation.js +52 -14
  9. package/build/tools/interpolation.js.map +1 -1
  10. package/build/tools/replay-env-file.test.d.ts +2 -0
  11. package/build/tools/replay-env-file.test.d.ts.map +1 -0
  12. package/build/tools/replay-env-file.test.js +137 -0
  13. package/build/tools/replay-env-file.test.js.map +1 -0
  14. package/build/tools/replay-env-token.test.d.ts +2 -0
  15. package/build/tools/replay-env-token.test.d.ts.map +1 -0
  16. package/build/tools/replay-env-token.test.js +113 -0
  17. package/build/tools/replay-env-token.test.js.map +1 -0
  18. package/build/tools/replay-executor.d.ts +27 -0
  19. package/build/tools/replay-executor.d.ts.map +1 -1
  20. package/build/tools/replay-executor.js +25 -7
  21. package/build/tools/replay-executor.js.map +1 -1
  22. package/build/tools/replay-nested-rebase.test.d.ts +15 -0
  23. package/build/tools/replay-nested-rebase.test.d.ts.map +1 -0
  24. package/build/tools/replay-nested-rebase.test.js +139 -0
  25. package/build/tools/replay-nested-rebase.test.js.map +1 -0
  26. package/build/tools/replay-nested-variables.test.d.ts +2 -0
  27. package/build/tools/replay-nested-variables.test.d.ts.map +1 -0
  28. package/build/tools/replay-nested-variables.test.js +139 -0
  29. package/build/tools/replay-nested-variables.test.js.map +1 -0
  30. package/build/tools/replay-rebase.test.js +25 -0
  31. package/build/tools/replay-rebase.test.js.map +1 -1
  32. package/build/tools/replay-run-all-parity.test.d.ts +2 -0
  33. package/build/tools/replay-run-all-parity.test.d.ts.map +1 -0
  34. package/build/tools/replay-run-all-parity.test.js +90 -0
  35. package/build/tools/replay-run-all-parity.test.js.map +1 -0
  36. package/build/tools/replay-tools.d.ts +25 -21
  37. package/build/tools/replay-tools.d.ts.map +1 -1
  38. package/build/tools/replay-tools.js +202 -14
  39. package/build/tools/replay-tools.js.map +1 -1
  40. package/build/tools/replay-typed-text-variables.test.d.ts +2 -0
  41. package/build/tools/replay-typed-text-variables.test.d.ts.map +1 -0
  42. package/build/tools/replay-typed-text-variables.test.js +119 -0
  43. package/build/tools/replay-typed-text-variables.test.js.map +1 -0
  44. package/build/tools/replay-variable-key-validation.test.d.ts +2 -0
  45. package/build/tools/replay-variable-key-validation.test.d.ts.map +1 -0
  46. package/build/tools/replay-variable-key-validation.test.js +124 -0
  47. package/build/tools/replay-variable-key-validation.test.js.map +1 -0
  48. package/docs/instructions.md +5 -2
  49. package/docs/replay.md +99 -6
  50. package/package.json +1 -1
  51. package/plugin/skills/devharness/SKILL.md +2 -2
  52. package/plugin/skills/devharness/references/sequences.md +33 -3
@@ -0,0 +1,119 @@
1
+ /**
2
+ * The `variables` key is BUILT from the selector, not quoted from it:
3
+ * `var_<0-based step index>_<selector, non-alphanumerics replaced by _>`. A
4
+ * step 2 typing into `#email` is `var_2__email` - two underscores, one from
5
+ * the separator and one from the `#`.
6
+ *
7
+ * A key that matches nothing is dropped in silence and the step runs on its
8
+ * recorded text, so a wrong key reads as an override while the recorded value
9
+ * reaches the live app. `docs/replay.md` and the skill's sequences reference
10
+ * both carried `var_2_#email` in their examples with nothing to catch it -
11
+ * these pin the two producers against each other so a doc example can be
12
+ * copied from a passing test.
13
+ */
14
+ import { describe, it, expect, beforeEach, vi } from 'vitest';
15
+ import { executeSteps } from './replay-executor.js';
16
+ import { extractTextVariables } from './replay-formatters.js';
17
+ import { configManager } from '../config.js';
18
+ import { productionShaped } from '../test-support/fake-execute-tool-call.js';
19
+ const commands = [
20
+ { tool: 'navigate', params: { action: 'goto', url: 'https://app.example.com/login' } },
21
+ { tool: 'input', params: { action: 'click', selector: '#open' } },
22
+ { tool: 'input', params: { action: 'type', selector: '#email', text: 'original@example.com' } },
23
+ { tool: 'input', params: { action: 'type', selector: '#password', text: 'recorded-secret' } },
24
+ { tool: 'input', params: { action: 'type', text: 'no selector here' } },
25
+ ];
26
+ const seq = () => ({ id: 'seq-login', name: 'login', commands, createdAt: 1 });
27
+ function makeHarness() {
28
+ const typed = [];
29
+ // After a `type` step the executor reads the field back through
30
+ // inspect.evaluateExpression and fails the step when it does not match, so
31
+ // the stub has to hold the value the field would now carry.
32
+ const fieldValues = new Map();
33
+ const executeToolCall = vi.fn(productionShaped(async (tool, params) => {
34
+ if (tool === 'input' && params.action === 'type') {
35
+ typed.push({ selector: params.selector, text: String(params.text) });
36
+ if (params.selector)
37
+ fieldValues.set(String(params.selector), String(params.text));
38
+ }
39
+ if (tool === 'inspect' && params.action === 'evaluateExpression') {
40
+ const selector = String(params.expression).match(/querySelector\('([^']*)'\)/)?.[1] ?? '';
41
+ const value = fieldValues.get(selector) ?? '';
42
+ return { content: [{ type: 'text', text: `\`\`\`json\n${JSON.stringify(value)}\n\`\`\`` }] };
43
+ }
44
+ return { content: [{ type: 'text', text: '' }] };
45
+ }));
46
+ const ctx = {
47
+ executeToolCall,
48
+ commandRecorder: { recordCommand: vi.fn(), getCurrentHistoryIndex: () => 0, listSequences: () => [] },
49
+ connectionReason: 'device-a',
50
+ logPrefix: 'test',
51
+ variableStore: {},
52
+ };
53
+ return { typed, ctx };
54
+ }
55
+ beforeEach(() => {
56
+ vi.spyOn(configManager, 'getClickValidationConfig').mockReturnValue({
57
+ enabled: false, validateNavigation: false, requireDomChanges: false,
58
+ domChangesFailMode: 'warn', failOnConsoleErrors: false,
59
+ consoleErrorsFailMode: 'error', validateNetworkPayload: false,
60
+ networkFailMode: 'warn', postClickDelayMs: 0,
61
+ });
62
+ vi.spyOn(configManager, 'getReplayConfig').mockReturnValue({
63
+ maxConditionalDepth: 10, maxRegexLength: 500, showCursor: false,
64
+ playwrightExportPath: './x', puppeteerExportPath: './y', maxDelayMs: 0,
65
+ });
66
+ });
67
+ describe('typed-text variable keys', () => {
68
+ it('replaces every non-alphanumeric in the selector, giving two underscores after the index', () => {
69
+ expect(Object.keys(extractTextVariables(commands))).toEqual([
70
+ 'var_2__email',
71
+ 'var_3__password',
72
+ 'var_4_text',
73
+ ]);
74
+ });
75
+ it('carries the recorded value alongside each key, so `get` shows what a run would type', () => {
76
+ expect(extractTextVariables(commands)['var_3__password'].value).toBe('recorded-secret');
77
+ });
78
+ it('substitutes on the key the extractor displays', async () => {
79
+ const { typed, ctx } = makeHarness();
80
+ await executeSteps({
81
+ sequence: seq(),
82
+ ctx,
83
+ startStep: 0,
84
+ variables: { var_2__email: 'fresh@example.com', var_3__password: 'supplied-secret' },
85
+ });
86
+ expect(typed).toEqual([
87
+ { selector: '#email', text: 'fresh@example.com' },
88
+ { selector: '#password', text: 'supplied-secret' },
89
+ { selector: undefined, text: 'no selector here' },
90
+ ]);
91
+ });
92
+ // The failure the doc examples produced: no error, no warning, and the
93
+ // recorded credential reaches the app.
94
+ it('drops a key that matches nothing and types the recorded text', async () => {
95
+ const { typed, ctx } = makeHarness();
96
+ const result = await executeSteps({
97
+ sequence: seq(),
98
+ ctx,
99
+ startStep: 0,
100
+ variables: { 'var_3_#password': 'supplied-secret' },
101
+ });
102
+ expect(result.results.every(r => r.success)).toBe(true);
103
+ expect(typed[1]).toEqual({ selector: '#password', text: 'recorded-secret' });
104
+ });
105
+ it('keys off the absolute command index, so startFrom does not shift them', async () => {
106
+ const { typed, ctx } = makeHarness();
107
+ await executeSteps({
108
+ sequence: seq(),
109
+ ctx,
110
+ startStep: 3,
111
+ variables: { var_3__password: 'supplied-secret' },
112
+ });
113
+ expect(typed).toEqual([
114
+ { selector: '#password', text: 'supplied-secret' },
115
+ { selector: undefined, text: 'no selector here' },
116
+ ]);
117
+ });
118
+ });
119
+ //# sourceMappingURL=replay-typed-text-variables.test.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"replay-typed-text-variables.test.js","sourceRoot":"","sources":["../../src/tools/replay-typed-text-variables.test.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=replay-variable-key-validation.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"replay-variable-key-validation.test.d.ts","sourceRoot":"","sources":["../../src/tools/replay-variable-key-validation.test.ts"],"names":[],"mappings":""}
@@ -0,0 +1,124 @@
1
+ /**
2
+ * A `variables` key that names no typed-text step is a typo the caller has to
3
+ * hear about before anything runs.
4
+ *
5
+ * The executor matches keys exactly and nothing else looks at them, so an
6
+ * unmatched key was dropped in silence and the step ran on its RECORDED text
7
+ * while the call read as an override - a recorded credential reaching the live
8
+ * app with the run reporting success. The `connections` rebinding already
9
+ * rejects a reference that names no recorded step for the same reason.
10
+ *
11
+ * Two scopes, because the map means different things:
12
+ * - `run` checks against the sequence plus everything it nests into;
13
+ * - `runAll` holds ONE map for the whole suite, so a key is a typo only when
14
+ * it matches no member. A per-sequence check there would reject a key meant
15
+ * for a different one.
16
+ */
17
+ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
18
+ import { promises as fs } from 'fs';
19
+ import { tmpdir } from 'os';
20
+ import { join } from 'path';
21
+ import { CommandRecorder } from '../command-recorder.js';
22
+ import { createReplayTools } from './replay-tools.js';
23
+ import { productionShaped } from '../test-support/fake-execute-tool-call.js';
24
+ let dir;
25
+ let recorder;
26
+ let replay;
27
+ let typed;
28
+ const write = async (name, commands) => {
29
+ await fs.writeFile(join(dir, `${name}.json`), JSON.stringify({
30
+ id: `seq-${name}`, name, createdAt: 1, commands,
31
+ }));
32
+ };
33
+ const typeStep = (selector, text) => ({ tool: 'input', params: { action: 'type', selector, text, connectionReason: 'suite' } });
34
+ beforeEach(async () => {
35
+ dir = await fs.mkdtemp(join(tmpdir(), 'cdp-varkeys-'));
36
+ recorder = new CommandRecorder();
37
+ typed = [];
38
+ vi.spyOn(recorder, 'getSequencesDir').mockReturnValue(dir);
39
+ const fieldValues = new Map();
40
+ ({ replay } = createReplayTools(recorder, vi.fn(productionShaped(async (tool, params) => {
41
+ if (tool === 'input' && params.action === 'type') {
42
+ typed.push({ selector: params.selector, text: String(params.text) });
43
+ if (params.selector)
44
+ fieldValues.set(String(params.selector), String(params.text));
45
+ }
46
+ if (tool === 'inspect' && params.action === 'evaluateExpression') {
47
+ const selector = String(params.expression).match(/querySelector\('([^']*)'\)/)?.[1] ?? '';
48
+ return { content: [{ type: 'text', text: '```json\n' + JSON.stringify(fieldValues.get(selector) ?? '') + '\n```' }] };
49
+ }
50
+ return { content: [{ type: 'text', text: '' }] };
51
+ })), async () => null, async () => 9222, undefined));
52
+ });
53
+ afterEach(async () => {
54
+ recorder.stopSequenceWatch();
55
+ await fs.rm(dir, { recursive: true, force: true });
56
+ });
57
+ const text = (res) => res.content.map((c) => c.text).join('\n');
58
+ describe('run rejects a key that names no step', () => {
59
+ beforeEach(async () => {
60
+ await write('login', [typeStep('#password', 'recorded-secret')]);
61
+ await recorder.loadSequenceFromDisk(join(dir, 'login.json'));
62
+ });
63
+ it('names the key, states the transform, and lists what is substitutable', async () => {
64
+ const res = await replay.handler({
65
+ action: 'run', wait: true, name: 'login', connectionReason: 'suite',
66
+ variables: { 'var_0_#password': 'supplied' },
67
+ });
68
+ expect(res.isError).toBe(true);
69
+ expect(text(res)).toContain('var_0_#password');
70
+ expect(text(res)).toContain('var_0__password');
71
+ expect(typed).toEqual([]);
72
+ });
73
+ it('accepts the key the executor actually builds', async () => {
74
+ const res = await replay.handler({
75
+ action: 'run', wait: true, name: 'login', connectionReason: 'suite',
76
+ variables: { var_0__password: 'supplied' },
77
+ });
78
+ expect(res.isError).toBeUndefined();
79
+ expect(typed).toEqual([{ selector: '#password', text: 'supplied' }]);
80
+ });
81
+ it('accepts a key that names a step in a sequence it nests into', async () => {
82
+ await write('outer', [{ tool: 'conditional', params: { if: '{{localStorage:x}}', then: 'login' } }]);
83
+ await recorder.loadSequenceFromDisk(join(dir, 'outer.json'));
84
+ const res = await replay.handler({
85
+ action: 'run', wait: true, name: 'outer', connectionReason: 'suite',
86
+ variables: { var_0__password: 'supplied' },
87
+ });
88
+ expect(res.isError).toBeUndefined();
89
+ });
90
+ it('accepts an empty map, which is how a caller keeps the recorded values', async () => {
91
+ const res = await replay.handler({
92
+ action: 'run', wait: true, name: 'login', connectionReason: 'suite', variables: {},
93
+ });
94
+ expect(res.isError).toBeUndefined();
95
+ expect(typed).toEqual([{ selector: '#password', text: 'recorded-secret' }]);
96
+ });
97
+ });
98
+ describe('runAll checks the map against the whole suite', () => {
99
+ beforeEach(async () => {
100
+ await write('a-first', [typeStep('#email', 'recorded@example.com')]);
101
+ await write('b-second', [typeStep('#password', 'recorded-secret')]);
102
+ });
103
+ it('accepts a key that matches only one member, and leaves the other on its recorded text', async () => {
104
+ const res = await replay.handler({
105
+ action: 'runAll', connectionReason: 'suite',
106
+ variables: { var_0__password: 'supplied' },
107
+ });
108
+ expect(res.isError).toBeUndefined();
109
+ expect(typed).toEqual([
110
+ { selector: '#email', text: 'recorded@example.com' },
111
+ { selector: '#password', text: 'supplied' },
112
+ ]);
113
+ });
114
+ it('rejects a key that matches no member, before anything runs', async () => {
115
+ const res = await replay.handler({
116
+ action: 'runAll', connectionReason: 'suite',
117
+ variables: { var_0__nosuchfield: 'supplied' },
118
+ });
119
+ expect(res.isError).toBe(true);
120
+ expect(text(res)).toContain('var_0__nosuchfield');
121
+ expect(typed).toEqual([]);
122
+ });
123
+ });
124
+ //# sourceMappingURL=replay-variable-key-validation.test.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"replay-variable-key-validation.test.js","sourceRoot":"","sources":["../../src/tools/replay-variable-key-validation.test.ts"],"names":[],"mappings":"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"}
@@ -209,6 +209,9 @@ runs against (see Quick Start).
209
209
  **HTTP / assertions**: `request`, `assert`, `saveToDisk`
210
210
  - `request`: HTTP request as a sequence step. `destination: 'node'` sends it from the MCP server process (no browser, no CORS/cookies); `destination: 'browser'` runs `fetch()` in a connected tab (that page's cookies/session/origin). `saveAs` captures the response for later steps
211
211
  - `assert`: assert a condition as a sequence step, failing the sequence if false - use `{{var:name.path}}` templates against values captured by a prior `saveAs`
212
+ - **`variables` on `run`/`runAll`**: replaces the text of recorded `input type` steps. Keys are BUILT from the selector - `var_<0-based step index>_<selector, non-alphanumerics replaced by _>` - so `#password` at step 3 is `var_3__password`, two underscores; read them off `get` or off the prompt a run returns rather than composing them by hand. A key naming no typed-text step is rejected before anything runs, with the substitutable keys listed. Substitutions reach the sequences a `conditional` or `forEach` nests into, so a key naming a step in a shared login helper lands there; `runAll` holds one map for the whole suite and accepts a key matching any member
213
+ - **`{{env:NAME}}`**: any step param may hold it, resolved from `process.env` when the step runs. This is how a credential stays OUT of the sequence file - the file holds the token, the value lives in the environment, and neither the file nor the tool call carries the secret. An unset or empty variable fails the step and names the variable; an empty value would otherwise be typed as-is. The name must match `[A-Za-z_][A-Za-z0-9_]*`. A token-bearing step does not hold the run open for a `variables` answer, and an explicitly supplied `variables` value still wins over the environment
214
+ - **`envFile` on `run`/`runAll`**: a KEY=value file supplying the `{{env:NAME}}` tokens for that run - `replay({ action: 'run', name: 'login', envFile: 'sequences.env' })`. A relative path resolves against the project directory (the one holding `.devharness`), absolute is used as-is. The file's values win over the server's own environment and a name it omits falls through to `process.env`; `process.env` is never written, so two background runs may name different files and changing the file needs no client restart. A missing file, or a line that is neither blank, a `#` comment, nor `NAME=value`, fails as a parameter error before any step runs. No `$VAR` expansion inside values
212
215
  - **Capturing values with `saveAs`**: supported on `request` and on `inspect({ action: 'evaluateExpression' })`. They store different shapes - `request` stores the whole response object (so `{{var:login.body.token}}`), `inspect` stores the evaluated value itself (so `{{var:pairingUrl}}` is the string). A `saveAs` that cannot be honoured now fails the step rather than silently capturing nothing. Async expressions work: a returned Promise is awaited and the settled value is captured exactly (JSON-serializable values are captured by value, not from display text)
213
216
 
214
217
  **Wait**: `wait` (exactly one of: selector, selectorGone, expression, ms)
@@ -236,13 +239,13 @@ runs against (see Quick Start).
236
239
 
237
240
  **Replay**: `replay` (actions: history, create, list, get, delete, export, load, listSaved, deleteSaved, run, runAll, step, finish, insert, addConditional, status, cancel, repeat, runFromLog, recordInteraction)
238
241
  - `recordInteraction`: record mouse, keyboard, and navigation events with a visual overlay
239
- - `runAll`: run every sequence in a folder and report one line each - `replay({ action: 'runAll', folder: 'spine', connectionReason: 'my-app' })`. Sequences may live in SUBFOLDERS of the sequences dir (`spine/`, `story/`, `_helpers/`); filenames are relative to that root (`spine/spine-01.json`) and `load` still accepts the bare basename. The whole tree is LOADED before anything runs, so a sequence in one folder can still reference a helper in another by name (a conditional's `then`, a forEach's `do`) - those resolve by sequence name, not by path. Folders whose name starts with `_` are loaded but never run by a bare `runAll`, which is where preamble guards and forEach bodies belong; naming such a folder explicitly runs it anyway. A failure is recorded and the suite continues (`continueOnFailure`, default true). Scoped to one root: the project sequences dir, or the global one with `global: true`. Accepts `baseUrl`, so one call runs a suite against any deployment
242
+ - `runAll`: run every sequence in a folder and report one line each - `replay({ action: 'runAll', folder: 'spine', connectionReason: 'my-app' })`. Sequences may live in SUBFOLDERS of the sequences dir (`spine/`, `story/`, `_helpers/`); filenames are relative to that root (`spine/spine-01.json`) and `load` still accepts the bare basename. The whole tree is LOADED before anything runs, so a sequence in one folder can still reference a helper in another by name (a conditional's `then`, a forEach's `do`) - those resolve by sequence name, not by path. Folders whose name starts with `_` are loaded but never run by a bare `runAll`, which is where preamble guards and forEach bodies belong; naming such a folder explicitly runs it anyway. A failure is recorded and the suite continues (`continueOnFailure`, default true). Scoped to one root: the project sequences dir, or the global one with `global: true`. Accepts `baseUrl`, so one call runs a suite against any deployment - it reaches the nested sequences a conditional or forEach names, too. Accepts `killChromeOnFinish`, which means the SUITE's finish: only the last sequence carries it, so a preamble's browser survives between sequences
240
243
  - `addConditional`: add a guarded branch step - `replay({ action: 'addConditional', name: 'flow', condition: '{{selector:.cookie-banner}}', thenSequence: 'dismiss-banner', insertAfterStep: 2 })`. `conditional` is virtual, never recorded, so this is its only authoring route. Syntax and branch target are validated up front; a sequence already on disk is rewritten in place
241
244
  - `forEach`: a second virtual step - enumerate a source and run a sequence per item: `{ tool: 'forEach', params: { in: '{{var:shares}}', as: 'share', do: 'revoke-one-share', where: 'item.name !== "Employees"', maxItems: 50 } }`. `in` is either an array a prior `saveAs` captured or `{{selectorAll:CSS}}`; `where` is JavaScript with `item`/`index` in scope, not the condition grammar. Conditions probe one named thing, so this is what expresses "for everything that is there". An empty source is a success and the count is reported
242
245
  - `teardown`: an optional command array beside a sequence's `commands`, run when the main steps reach a terminal state - success, a failed step, an abort, or the total timeout - but not when the run pauses. It has its own timeout budget and does not receive the run's abort signal, so a cancelled or timed-out run still cleans up; it shares the variable store, so it can revoke what setup minted. A failing teardown step never changes the run's verdict. Best-effort: a killed server takes pending teardown with it
243
246
  - `export`: export a sequence to file - `format: sequence | playwright | puppeteer`
244
247
  - `repeat`: instantly re-execute commands by history index - `replay({ action: 'repeat', indices: [0, 1, 2] })`. Each tool response shows its history index in the "Repeat" hint
245
- - `run`: does not block - returns a `runId` immediately and executes in the background; poll `status({ runId })` for progress and the final result (kept 30 min in memory), `cancel({ runId })` interrupts the step in flight where the tool allows it (`wait` and `request` are genuinely cancelled, `navigate`/`inspect`/`content` stop waiting, `input` stops dispatching further events, the rest stop at the next step boundary - table in `docs/replay.md`). `wait: true` blocks for the full result (pre-0.7 behaviour). `startUrl` overrides the stored start URL for one run; `baseUrl` retargets every absolute URL at another deployment's origin
248
+ - `run`: does not block - returns a `runId` immediately and executes in the background; poll `status({ runId })` for progress and the final result (kept 30 min in memory), `cancel({ runId })` interrupts the step in flight where the tool allows it (`wait` and `request` are genuinely cancelled, `navigate`/`inspect`/`content` stop waiting, `input` stops dispatching further events, the rest stop at the next step boundary - table in `docs/replay.md`). `wait: true` blocks for the full result (pre-0.7 behaviour). `startUrl` overrides the stored start URL for one run; `baseUrl` retargets every absolute URL at another deployment's origin - the sequence's own steps, a declared connection's launch url, and every sequence it reaches through a conditional or forEach
246
249
  - Use `global: true` with `export` to save to ~/.devharness/sequences/ instead of the working directory
247
250
 
248
251
  **Dashboard**: `dashboard` (actions: open, status, stop)
package/docs/replay.md CHANGED
@@ -456,11 +456,19 @@ replay({ action: 'run', name: 'magic-link-login', startUrl: 'https://app.example
456
456
  ```
457
457
 
458
458
  - **`baseUrl`** rewrites the origin of *every absolute* `http(s)` URL in the
459
- sequence - the stored `startUrl` and any string param in any step (a
460
- `navigate goto` url, a `request` url, ...) - keeping path, query and hash.
461
- Relative URLs are untouched. The stored sequence is never mutated.
459
+ sequence - the stored `startUrl`, any string param in any step (a
460
+ `navigate goto` url, a `request` url, ...), and the launch `url` of any
461
+ connection the sequence declares - keeping path, query and hash. Relative
462
+ URLs are untouched. The stored sequence is never mutated.
463
+ - The retarget travels **into nested sequences**: a `conditional`'s `then` and
464
+ a `forEach`'s `do` load from the recorder in their recorded form, and the
465
+ run's origin is applied to each as it loads, at every depth. Without that a
466
+ retargeted run drives two origins at once - the parent on the target
467
+ deployment, the shared login/setup helper on the recorded one.
468
+ - **`runAll`** takes `baseUrl` too, and applies it to every sequence in the
469
+ suite, so one call runs a recorded set against any deployment.
462
470
  - **`startUrl`** replaces the sequence's start URL wholesale for this run,
463
- applied after any rebasing.
471
+ applied after any rebasing. It is per-sequence, so `runAll` ignores it.
464
472
  - Neither is preserved across a mid-run pause and `step`/`finish` resume, which
465
473
  re-reads the stored sequence.
466
474
 
@@ -555,6 +563,11 @@ browsers down too. The run says so instead: *"Chrome left running (port 9224
555
563
  also serves duo-member-two, killChromeOnFinish)"*. Disconnect or close the other
556
564
  connections first if you want the instance gone.
557
565
 
566
+ On `runAll` the flag means the **suite's** finish: only the last sequence
567
+ carries it. A teardown between sequences would destroy the state a `_helpers`
568
+ preamble just established, and a suite that stops early (`continueOnFailure:
569
+ false`, a cancel) leaves the browsers up for the failure to be read in.
570
+
558
571
  ### Preview Sequence
559
572
 
560
573
  ```javascript
@@ -580,6 +593,14 @@ Every recorded `input({ action: 'type' })` step gets an auto-generated key of
580
593
  the form `var_<0-based step index>_<selector, non-alphanumerics replaced by _>`
581
594
  (or `var_<i>_text` when the step has no selector).
582
595
 
596
+ The selector is transformed, not quoted: `#email` becomes `_email`, so step 2
597
+ against `#email` is keyed `var_2__email` - **two** underscores, one from the
598
+ separator and one from the `#`. A key that names no typed-text step is
599
+ rejected before anything runs, with the substitutable keys listed - the same
600
+ rule `connections` applies to a reference naming no recorded step, and for the
601
+ same reason: an ignored key would leave the step on its recorded text while
602
+ the call read as an override.
603
+
583
604
  ```javascript
584
605
  // Original recording had: input({ action: 'type', selector: '#email', text: 'original@email.com' })
585
606
  replay({
@@ -587,12 +608,84 @@ replay({
587
608
  sequenceId: 'seq-login-flow',
588
609
  connectionReason: 'test-session',
589
610
  variables: {
590
- 'var_2_#email': 'new@email.com',
591
- 'var_3_#password': 'newpassword'
611
+ 'var_2__email': 'new@email.com',
612
+ 'var_3__password': 'newpassword'
592
613
  }
593
614
  })
594
615
  ```
595
616
 
617
+ `replay({ action: 'get', name: '<sequence>' })` prints the keys as the executor
618
+ builds them, and so does the prompt a `run` returns when typed text is present
619
+ and `variables` is omitted. Read them from there rather than composing them by
620
+ hand.
621
+
622
+ The substitutions reach **nested sequences** - a `conditional`'s `then` and a
623
+ `forEach`'s `do`, at every depth - so a key naming a step in a shared login
624
+ helper lands there. `runAll` holds one map for the whole suite and validates it
625
+ against the union of the selected sequences: a key matching any member is
626
+ accepted, and each sequence substitutes on the keys that name its own steps.
627
+
628
+ A supplied value does not remove the recorded literal, which stays in the
629
+ sequence file under `.devharness/sequences/` (gitignored, still plaintext on
630
+ disk). For a credential, use `{{env:NAME}}` instead - see below.
631
+
632
+ ### Keeping a secret out of the sequence file: `{{env:NAME}}`
633
+
634
+ Any step param may hold `{{env:NAME}}`, resolved from `process.env` when the
635
+ step runs. The file holds the token, the value lives in the environment, and
636
+ neither the file nor the tool call carries the secret.
637
+
638
+ ```javascript
639
+ // In the sequence file:
640
+ { tool: 'input', params: { action: 'type', selector: '#password', text: '{{env:APP_PASSWORD}}' } }
641
+
642
+ // The run needs nothing else:
643
+ replay({ action: 'run', name: 'login', connectionReason: 'app' })
644
+ ```
645
+
646
+ - An **unset or empty** variable fails the step, naming the variable. Resolving
647
+ to `''` would submit a blank password and surface as a confusing downstream
648
+ failure instead of the missing configuration that caused it.
649
+ - The name must match `[A-Za-z_][A-Za-z0-9_]*`. `{{env:app-password}}` is not a
650
+ token and passes through as literal text.
651
+ - A token-bearing step does **not** hold the run open for a `variables`
652
+ answer - its value arrives at run time by definition. It stays substitutable,
653
+ and an explicitly supplied `variables` value **wins over** the environment,
654
+ because interpolation runs first and the substitution overwrites it.
655
+ - Environment values are strings, so a whole-string `{{env:PORT}}` yields
656
+ `"3000"`, not `3000`.
657
+
658
+ #### Pointing a run at a file: `envFile`
659
+
660
+ ```javascript
661
+ replay({ action: 'run', name: 'login', connectionReason: 'app',
662
+ envFile: 'sequences.env' })
663
+ ```
664
+
665
+ ```bash
666
+ # sequences.env
667
+ APP_PASSWORD=hunter2
668
+ export APP_USER="alice" # export prefix and surrounding quotes are accepted
669
+ ```
670
+
671
+ - A **relative** path resolves against the project directory (the one holding
672
+ `.devharness`); an absolute path is used as-is.
673
+ - The file's values **win** over the server's own environment. The caller named
674
+ this file for this run; a stale ambient variable shadowing it would
675
+ substitute a different credential with nothing in the output to say so. A
676
+ name the file omits falls through to `process.env`.
677
+ - `process.env` is **never written**. Two background runs may name different
678
+ files, and a global write would let one run's credentials resolve inside the
679
+ other. It follows that changing the file needs no client restart, unlike the
680
+ environment of a running server, which is fixed when it starts.
681
+ - A **missing file**, or a line that is neither blank, a `#` comment, nor
682
+ `NAME=value` with a name matching `[A-Za-z_][A-Za-z0-9_]*`, fails as a
683
+ parameter error before any step runs - not halfway through a flow that has
684
+ already logged in. A skipped line would read as a set variable.
685
+ - There is **no `$VAR` expansion** inside values. A password containing `$` is
686
+ ordinary, and expanding it would type something else.
687
+ - `runAll` takes it too, applying the same file to every sequence in the suite.
688
+
596
689
  **If the sequence contains any typed text and you omit `variables` entirely,
597
690
  `run` does not execute** - it returns a prompt listing the substitutable keys
598
691
  and their recorded values. Pass `variables: {}` to accept the recorded values
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "devharness",
3
- "version": "0.9.15",
3
+ "version": "0.9.17",
4
4
  "description": "MCP server that connects AI assistants to Chrome DevTools Protocol for runtime debugging - set breakpoints, inspect variables, monitor network traffic, and automate browser interactions",
5
5
  "type": "module",
6
6
  "main": "build/index.js",
@@ -2,7 +2,7 @@
2
2
  name: devharness
3
3
  description: Drive and debug a running app via the devharness MCP server - launch or attach to Chrome and Node.js, set breakpoints and logpoints, inspect call stacks and variables, watch console and network, manage dev servers, replay any earlier tool call by its history index, and record reproduction sequences that verify a fix. Use whenever a task involves running or debugging a live app, reproducing or verifying a bug, re-driving setup you already did (relaunching, re-logging in, refilling a form), or the user mentions breakpoints, Chrome DevTools, CDP, replay sequences, or devharness tools (launchChrome, navigate, breakpoint, inspect, replay, server, issues, etc.).
4
4
  compatibility: Requires the devharness MCP server to be connected (tools such as launchChrome, breakpoint, inspect, replay, server, issues). The shell commands need `devharness` on PATH (`npm i -g devharness`); without it use `npx -y devharness@<version> <command>`. Previously published as cdp-tools-mcp.
5
- version: 0.9.15
5
+ version: 0.9.17
6
6
  ---
7
7
 
8
8
  # devharness
@@ -153,4 +153,4 @@ Restart kills Chrome instances this session launched (relaunch with `launchChrom
153
153
  ## Load on demand
154
154
 
155
155
  - Full tool/action catalogue: [references/tool-categories.md](references/tool-categories.md)
156
- - Recording/replaying sequences — `saveAs`, per-step `connectionReason`, conditionals, verifying an issue fix: [references/sequences.md](references/sequences.md)
156
+ - Recording/replaying sequences — `saveAs`, per-step `connectionReason`, conditionals, `variables`, keeping a password out of the sequence file with `{{env:NAME}}`, verifying an issue fix: [references/sequences.md](references/sequences.md)
@@ -225,12 +225,42 @@ use `runId` to address a specific background run.
225
225
 
226
226
  ## Two different "variables" - don't confuse them
227
227
 
228
- **1. `variables` on `run` replaces recorded typed text.** Keyed by the recorded
229
- input, for replaying a signup with a fresh email:
228
+ **1. `variables` on `run` replaces recorded typed text.** Keyed
229
+ `var_<0-based step index>_<selector, non-alphanumerics replaced by _>`, so a
230
+ step 2 typing into `#email` is `var_2__email` - two underscores, one from the
231
+ separator and one from the `#`. Read the keys off `replay({ action: 'get' })`
232
+ or off the prompt a `run` returns when typed text is present and `variables`
233
+ is omitted; a key that names no typed-text step is rejected before anything
234
+ runs, with the substitutable keys listed. Substitutions reach nested sequences
235
+ (a `conditional`'s `then`, a `forEach`'s `do`) at every depth, so a key naming
236
+ a step in a shared login helper lands there. `runAll` holds one map for the
237
+ whole suite and accepts a key that matches any member. The recorded literal
238
+ stays in the sequence file either way.
239
+
240
+ **For a credential, use `{{env:NAME}}` in the step instead.** Any step param
241
+ may hold it; it resolves from `process.env` when the step runs, so the file
242
+ holds the token and neither the file nor the tool call carries the secret. An
243
+ unset or empty variable fails the step, naming the variable - an empty value
244
+ would be typed as-is. A token-bearing step does not prompt for `variables`,
245
+ and an explicitly supplied value still wins over the environment.
246
+
247
+ ```
248
+ { tool: 'input', params: { action: 'type', selector: '#password',
249
+ text: '{{env:APP_PASSWORD}}' } }
250
+ ```
251
+
252
+ `envFile` names a KEY=value file for the run - `replay({ action: 'run', name:
253
+ 'login', envFile: 'sequences.env' })`. A relative path resolves against the
254
+ project directory (the one holding `.devharness`); its values win over the
255
+ server's own environment, a name it omits falls through to `process.env`, and
256
+ `process.env` is never written, so concurrent runs may name different files and
257
+ changing the file needs no client restart. A missing file or a line that is not
258
+ blank, a `#` comment, or `NAME=value` fails before any step runs. No `$VAR`
259
+ expansion inside values. `runAll` takes it too.
230
260
 
231
261
  ```
232
262
  replay({ action: 'run', sequenceId: 'seq-signup',
233
- variables: { 'var_2_#email': 'new@example.com' } })
263
+ variables: { 'var_2__email': 'new@example.com' } })
234
264
  ```
235
265
 
236
266
  **2. `saveAs` captures a value mid-run for later steps.** Supported on