devharness 0.9.16 → 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 (41) hide show
  1. package/build/helpers/env-file.d.ts +35 -0
  2. package/build/helpers/env-file.d.ts.map +1 -0
  3. package/build/helpers/env-file.js +53 -0
  4. package/build/helpers/env-file.js.map +1 -0
  5. package/build/tools/interpolation.d.ts +20 -3
  6. package/build/tools/interpolation.d.ts.map +1 -1
  7. package/build/tools/interpolation.js +52 -14
  8. package/build/tools/interpolation.js.map +1 -1
  9. package/build/tools/replay-env-file.test.d.ts +2 -0
  10. package/build/tools/replay-env-file.test.d.ts.map +1 -0
  11. package/build/tools/replay-env-file.test.js +137 -0
  12. package/build/tools/replay-env-file.test.js.map +1 -0
  13. package/build/tools/replay-env-token.test.d.ts +2 -0
  14. package/build/tools/replay-env-token.test.d.ts.map +1 -0
  15. package/build/tools/replay-env-token.test.js +113 -0
  16. package/build/tools/replay-env-token.test.js.map +1 -0
  17. package/build/tools/replay-executor.d.ts +18 -0
  18. package/build/tools/replay-executor.d.ts.map +1 -1
  19. package/build/tools/replay-executor.js +10 -6
  20. package/build/tools/replay-executor.js.map +1 -1
  21. package/build/tools/replay-nested-variables.test.d.ts +2 -0
  22. package/build/tools/replay-nested-variables.test.d.ts.map +1 -0
  23. package/build/tools/replay-nested-variables.test.js +139 -0
  24. package/build/tools/replay-nested-variables.test.js.map +1 -0
  25. package/build/tools/replay-tools.d.ts +4 -0
  26. package/build/tools/replay-tools.d.ts.map +1 -1
  27. package/build/tools/replay-tools.js +186 -7
  28. package/build/tools/replay-tools.js.map +1 -1
  29. package/build/tools/replay-typed-text-variables.test.d.ts +2 -0
  30. package/build/tools/replay-typed-text-variables.test.d.ts.map +1 -0
  31. package/build/tools/replay-typed-text-variables.test.js +119 -0
  32. package/build/tools/replay-typed-text-variables.test.js.map +1 -0
  33. package/build/tools/replay-variable-key-validation.test.d.ts +2 -0
  34. package/build/tools/replay-variable-key-validation.test.d.ts.map +1 -0
  35. package/build/tools/replay-variable-key-validation.test.js +124 -0
  36. package/build/tools/replay-variable-key-validation.test.js.map +1 -0
  37. package/docs/instructions.md +3 -0
  38. package/docs/replay.md +82 -2
  39. package/package.json +1 -1
  40. package/plugin/skills/devharness/SKILL.md +2 -2
  41. 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)
package/docs/replay.md CHANGED
@@ -593,6 +593,14 @@ Every recorded `input({ action: 'type' })` step gets an auto-generated key of
593
593
  the form `var_<0-based step index>_<selector, non-alphanumerics replaced by _>`
594
594
  (or `var_<i>_text` when the step has no selector).
595
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
+
596
604
  ```javascript
597
605
  // Original recording had: input({ action: 'type', selector: '#email', text: 'original@email.com' })
598
606
  replay({
@@ -600,12 +608,84 @@ replay({
600
608
  sequenceId: 'seq-login-flow',
601
609
  connectionReason: 'test-session',
602
610
  variables: {
603
- 'var_2_#email': 'new@email.com',
604
- 'var_3_#password': 'newpassword'
611
+ 'var_2__email': 'new@email.com',
612
+ 'var_3__password': 'newpassword'
605
613
  }
606
614
  })
607
615
  ```
608
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
+
609
689
  **If the sequence contains any typed text and you omit `variables` entirely,
610
690
  `run` does not execute** - it returns a prompt listing the substitutable keys
611
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.16",
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.16
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