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.
- package/build/github/issue-body.d.ts +4 -4
- package/build/helpers/env-file.d.ts +35 -0
- package/build/helpers/env-file.d.ts.map +1 -0
- package/build/helpers/env-file.js +53 -0
- package/build/helpers/env-file.js.map +1 -0
- package/build/tools/interpolation.d.ts +20 -3
- package/build/tools/interpolation.d.ts.map +1 -1
- package/build/tools/interpolation.js +52 -14
- package/build/tools/interpolation.js.map +1 -1
- package/build/tools/replay-env-file.test.d.ts +2 -0
- package/build/tools/replay-env-file.test.d.ts.map +1 -0
- package/build/tools/replay-env-file.test.js +137 -0
- package/build/tools/replay-env-file.test.js.map +1 -0
- package/build/tools/replay-env-token.test.d.ts +2 -0
- package/build/tools/replay-env-token.test.d.ts.map +1 -0
- package/build/tools/replay-env-token.test.js +113 -0
- package/build/tools/replay-env-token.test.js.map +1 -0
- package/build/tools/replay-executor.d.ts +27 -0
- package/build/tools/replay-executor.d.ts.map +1 -1
- package/build/tools/replay-executor.js +25 -7
- package/build/tools/replay-executor.js.map +1 -1
- package/build/tools/replay-nested-rebase.test.d.ts +15 -0
- package/build/tools/replay-nested-rebase.test.d.ts.map +1 -0
- package/build/tools/replay-nested-rebase.test.js +139 -0
- package/build/tools/replay-nested-rebase.test.js.map +1 -0
- package/build/tools/replay-nested-variables.test.d.ts +2 -0
- package/build/tools/replay-nested-variables.test.d.ts.map +1 -0
- package/build/tools/replay-nested-variables.test.js +139 -0
- package/build/tools/replay-nested-variables.test.js.map +1 -0
- package/build/tools/replay-rebase.test.js +25 -0
- package/build/tools/replay-rebase.test.js.map +1 -1
- package/build/tools/replay-run-all-parity.test.d.ts +2 -0
- package/build/tools/replay-run-all-parity.test.d.ts.map +1 -0
- package/build/tools/replay-run-all-parity.test.js +90 -0
- package/build/tools/replay-run-all-parity.test.js.map +1 -0
- package/build/tools/replay-tools.d.ts +25 -21
- package/build/tools/replay-tools.d.ts.map +1 -1
- package/build/tools/replay-tools.js +202 -14
- package/build/tools/replay-tools.js.map +1 -1
- package/build/tools/replay-typed-text-variables.test.d.ts +2 -0
- package/build/tools/replay-typed-text-variables.test.d.ts.map +1 -0
- package/build/tools/replay-typed-text-variables.test.js +119 -0
- package/build/tools/replay-typed-text-variables.test.js.map +1 -0
- package/build/tools/replay-variable-key-validation.test.d.ts +2 -0
- package/build/tools/replay-variable-key-validation.test.d.ts.map +1 -0
- package/build/tools/replay-variable-key-validation.test.js +124 -0
- package/build/tools/replay-variable-key-validation.test.js.map +1 -0
- package/docs/instructions.md +5 -2
- package/docs/replay.md +99 -6
- package/package.json +1 -1
- package/plugin/skills/devharness/SKILL.md +2 -2
- package/plugin/skills/devharness/references/sequences.md +33 -3
|
@@ -3,6 +3,8 @@
|
|
|
3
3
|
*/
|
|
4
4
|
import { z } from 'zod';
|
|
5
5
|
import { selectSuiteFiles, sequenceFolders } from '../helpers/sequence-tree.js';
|
|
6
|
+
import { promises as fs } from 'fs';
|
|
7
|
+
import { join } from 'path';
|
|
6
8
|
import { createTool } from '../validation-helpers.js';
|
|
7
9
|
import { createSuccessResponse, createErrorResponse } from '../messages.js';
|
|
8
10
|
import { showReplayOverlay } from '../interaction-recorder.js';
|
|
@@ -117,6 +119,10 @@ import { readHistoryLines, getHistoryFilePath } from '../debug-logger.js';
|
|
|
117
119
|
import { startRecording, eventsToCommands, generateCondensedTimeline, isCommentEvent, } from '../interaction-recorder.js';
|
|
118
120
|
import { addIssue, initializeTracker, saveIssueSequence, } from '../issue-tracker.js';
|
|
119
121
|
import { configManager } from '../config.js';
|
|
122
|
+
import { hasTemplateToken } from './interpolation.js';
|
|
123
|
+
import { parseEnvFile } from '../helpers/env-file.js';
|
|
124
|
+
import { getProjectDir } from '../helpers/paths.js';
|
|
125
|
+
import { isAbsolute } from 'path';
|
|
120
126
|
// =============================================================================
|
|
121
127
|
// Schema Definition
|
|
122
128
|
// =============================================================================
|
|
@@ -133,7 +139,8 @@ const replaySchema = z.object({
|
|
|
133
139
|
description: z.string().optional(),
|
|
134
140
|
expectedOutcome: z.string().optional(),
|
|
135
141
|
startUrl: z.string().optional().describe('create: sequence start URL. run: replace the stored startUrl for this run only (e.g. a freshly minted link)'),
|
|
136
|
-
|
|
142
|
+
envFile: z.string().optional().describe("run/runAll: a KEY=value file supplying the {{env:NAME}} tokens this run resolves. A relative path resolves against the project directory (the one holding .devharness); absolute is used as-is. Its values WIN over the server's own environment, and the server's environment is never modified - two runs may name different files. A missing file, or a line that is neither blank, a # comment, nor NAME=value, fails before anything runs. Keeps a credential out of the sequence file and out of this call, and changing it needs no client restart"),
|
|
143
|
+
baseUrl: z.string().optional().describe('run/runAll: retarget at another deployment — every absolute URL (startUrl, command params, a declared connection\u2019s launch url) keeps its path/query but takes this origin, in the sequence itself and in every sequence it reaches through a conditional or forEach. On runAll it applies to every sequence in the suite. Not preserved across a mid-run pause/step resume'),
|
|
137
144
|
indices: z.array(z.number()).optional().describe('Command indices'),
|
|
138
145
|
lines: z.array(z.number()).optional().describe('Log line numbers'),
|
|
139
146
|
sequenceId: z.string().optional(),
|
|
@@ -155,7 +162,7 @@ const replaySchema = z.object({
|
|
|
155
162
|
requiredSockets: z.array(z.string()).optional().describe("declare: URL substrings of the WebSockets this sequence's assertions ride on, e.g. ['/api/sync/socket']. Match the app's own path, not the origin, so it survives baseUrl. Replaces the whole list; [] clears it"),
|
|
156
163
|
connections: z.record(z.string()).optional().describe("run: rebind a multi-connection sequence's recorded references onto this session - { \"<recorded reference>\": \"<reference here>\" }. Only needed when steps carry their own connectionReason (replay({action:'get', outputFormat:'commands'}) shows which)"),
|
|
157
164
|
record: z.boolean().optional(),
|
|
158
|
-
variables: z.record(z.string()).optional(),
|
|
165
|
+
variables: z.record(z.string()).optional().describe("run/runAll: replace 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. A key naming no typed-text step is rejected (runAll checks against the whole suite's union). Reaches sequences a conditional or forEach nests into. For a credential prefer {{env:NAME}} in the step itself, which keeps the value out of the sequence file and out of this call; an explicit value here still wins over the environment"),
|
|
159
166
|
stepTimeout: z.number().optional().describe('Per-step ms (default 30000). A step exceeding min(stepTimeout, remaining totalTimeout) fails the run at that step. wait steps are exempt (own timeoutMs) but still capped by totalTimeout'),
|
|
160
167
|
totalTimeout: z.number().optional().describe('Total ms'),
|
|
161
168
|
startFrom: z.number().optional().describe('Start step (1-indexed)'),
|
|
@@ -184,7 +191,7 @@ const replaySchema = z.object({
|
|
|
184
191
|
strict: z.enum(['errors', 'warnings']).optional().describe("run/runAll: fail the run when it PRODUCES console output - 'errors' fails on new console errors, 'warnings' also fails on new warnings. Counted per connection and diffed against the start of the run, so pre-existing noise is not blamed on this sequence. A sequence can be functionally correct and still be logging; strict is how you separate those questions"),
|
|
185
192
|
folder: z.string().optional().describe("runAll: sequences subfolder to run, relative to the sequences dir (e.g. 'spine'). Omit to run every sequence outside folders whose name starts with '_'. The whole tree is always LOADED first so name references (a conditional's then, a forEach's do) resolve wherever the helper lives"),
|
|
186
193
|
continueOnFailure: z.boolean().optional().describe('runAll: keep going after a sequence fails and report every result (default true). false stops at the first failure'),
|
|
187
|
-
killChromeOnFinish: z.boolean().optional().describe("run: after finishing (skipped on pause/abort), kill the browsers this run owns - its own connection plus any a launchChrome step actually created. A step that reached an already-bound reference only borrowed that browser and it is left running, so an instance you launched yourself survives. Also skipped for any browser whose port another live connection shares (a launchChrome step usually opens a tab in the same instance), and the run reports which connection kept it alive."),
|
|
194
|
+
killChromeOnFinish: z.boolean().optional().describe("run/runAll: after finishing (skipped on pause/abort), kill the browsers this run owns - its own connection plus any a launchChrome step actually created. A step that reached an already-bound reference only borrowed that browser and it is left running, so an instance you launched yourself survives. Also skipped for any browser whose port another live connection shares (a launchChrome step usually opens a tab in the same instance), and the run reports which connection kept it alive. On runAll only the LAST sequence carries it, so a preamble's browser survives between sequences and a suite that stops early leaves the browsers up."),
|
|
188
195
|
}).strict();
|
|
189
196
|
async function handleHistory(args, recorder) {
|
|
190
197
|
const limit = args.limit || 50;
|
|
@@ -765,6 +772,92 @@ function collectNestedRebindableReferences(commands, recorder, depth = 0, seen =
|
|
|
765
772
|
}
|
|
766
773
|
return { references, complete };
|
|
767
774
|
}
|
|
775
|
+
/**
|
|
776
|
+
* Read the run's `envFile` into the values {{env:NAME}} resolves against.
|
|
777
|
+
*
|
|
778
|
+
* Loaded here, before any side effects, so a missing file or a malformed line
|
|
779
|
+
* fails the run as a parameter error rather than as a step failure halfway
|
|
780
|
+
* through a flow that has already logged in. The values stay on the run's
|
|
781
|
+
* context: writing them into process.env would leak one run's credentials into
|
|
782
|
+
* every concurrent background run, which name their own files.
|
|
783
|
+
*/
|
|
784
|
+
async function loadRunEnv(envFile) {
|
|
785
|
+
const path = isAbsolute(envFile) ? envFile : join(getProjectDir(), envFile);
|
|
786
|
+
let text;
|
|
787
|
+
try {
|
|
788
|
+
text = await fs.readFile(path, 'utf-8');
|
|
789
|
+
}
|
|
790
|
+
catch (err) {
|
|
791
|
+
return { error: `Could not read envFile "${envFile}" (resolved to ${path}): ${err?.code === 'ENOENT' ? 'no such file' : err?.message || String(err)}` };
|
|
792
|
+
}
|
|
793
|
+
const { values, problems } = parseEnvFile(text);
|
|
794
|
+
if (problems.length > 0) {
|
|
795
|
+
const shown = problems.slice(0, 3).map(p => `line ${p.line}: ${p.text}`).join('; ');
|
|
796
|
+
return { error: `envFile "${envFile}" (resolved to ${path}) has ${problems.length} line(s) that are neither blank, a # comment, nor NAME=value with a name matching [A-Za-z_][A-Za-z0-9_]* - ${shown}${problems.length > 3 ? ', ...' : ''}. A skipped line reads as a set variable, so the run stops here.` };
|
|
797
|
+
}
|
|
798
|
+
return { values };
|
|
799
|
+
}
|
|
800
|
+
/**
|
|
801
|
+
* Every `variables` key a run could substitute on: this sequence's typed-text
|
|
802
|
+
* steps plus those of every sequence it reaches through a `conditional`'s
|
|
803
|
+
* `then` or a `forEach`'s `do`.
|
|
804
|
+
*
|
|
805
|
+
* Resolution is memory-only and best-effort, the same rule
|
|
806
|
+
* collectNestedRebindableReferences follows: a helper that lives on disk and
|
|
807
|
+
* has not been loaded is not loaded here (that would register it as a side
|
|
808
|
+
* effect of validation), so `complete: false` says "this list may be short"
|
|
809
|
+
* and the caller must not call a missing key a typo. `runAll` loads the whole
|
|
810
|
+
* tree before it runs anything, so a suite run always gets a complete list.
|
|
811
|
+
*/
|
|
812
|
+
function collectVariableKeys(commands, recorder, depth = 0, seen = new Set()) {
|
|
813
|
+
const keys = new Set(Object.keys(extractTextVariables(commands)));
|
|
814
|
+
// Must track the executor's own cap: with a raised maxConditionalDepth, keys
|
|
815
|
+
// at runtime-reachable depths would be omitted while `complete` still claimed
|
|
816
|
+
// the list was exhaustive, and a valid key would be rejected as a typo.
|
|
817
|
+
if (depth >= configManager.getReplayConfig().maxConditionalDepth) {
|
|
818
|
+
return { keys, complete: false };
|
|
819
|
+
}
|
|
820
|
+
let complete = true;
|
|
821
|
+
for (const cmd of commands) {
|
|
822
|
+
const named = cmd.tool === 'conditional' ? cmd.params?.then
|
|
823
|
+
: cmd.tool === 'forEach' ? cmd.params?.do
|
|
824
|
+
: undefined;
|
|
825
|
+
if (typeof named !== 'string' || !named || seen.has(named))
|
|
826
|
+
continue;
|
|
827
|
+
seen.add(named);
|
|
828
|
+
const nested = recorder.listSequences().find(sq => sq.name === named);
|
|
829
|
+
if (!nested) {
|
|
830
|
+
complete = false;
|
|
831
|
+
continue;
|
|
832
|
+
}
|
|
833
|
+
const deeper = collectVariableKeys(nested.commands, recorder, depth + 1, seen);
|
|
834
|
+
for (const key of deeper.keys)
|
|
835
|
+
keys.add(key);
|
|
836
|
+
complete = complete && deeper.complete;
|
|
837
|
+
}
|
|
838
|
+
return { keys, complete };
|
|
839
|
+
}
|
|
840
|
+
/**
|
|
841
|
+
* Keys the caller supplied that name no typed-text step anywhere the run can
|
|
842
|
+
* reach. Empty when the key list could not be resolved in full, because a key
|
|
843
|
+
* valid for an unloaded helper is not a typo.
|
|
844
|
+
*
|
|
845
|
+
* A `variables` key is matched exactly and nothing else looks at it, so an
|
|
846
|
+
* unmatched key used to be dropped in silence: the step ran on its RECORDED
|
|
847
|
+
* text while the call read as an override. For a recorded credential that
|
|
848
|
+
* means the old password reaching the live app with the run reporting success.
|
|
849
|
+
* Same rule the `connections` rebinding already applies to a reference that
|
|
850
|
+
* names no recorded step.
|
|
851
|
+
*/
|
|
852
|
+
function unmatchedVariableKeys(supplied, commands, recorder) {
|
|
853
|
+
const { keys, complete } = collectVariableKeys(commands, recorder);
|
|
854
|
+
if (!complete)
|
|
855
|
+
return { unmatched: [], known: [...keys].sort() };
|
|
856
|
+
return {
|
|
857
|
+
unmatched: Object.keys(supplied).filter(k => !keys.has(k)),
|
|
858
|
+
known: [...keys].sort(),
|
|
859
|
+
};
|
|
860
|
+
}
|
|
768
861
|
/**
|
|
769
862
|
* Run every sequence in a folder, in filename order, and report one line each.
|
|
770
863
|
*
|
|
@@ -838,9 +931,44 @@ async function handleRunAll(args, recorder, executeToolCall, getPageForConnectio
|
|
|
838
931
|
(folders.length ? `Available folders: ${folders.join(', ')}.` : 'No subfolders exist yet — sequences are all at the top level.')
|
|
839
932
|
});
|
|
840
933
|
}
|
|
934
|
+
// One `variables` map covers the whole suite, so a key is a typo only when it
|
|
935
|
+
// matches NO member. Checked once, before anything runs: a supplied key that
|
|
936
|
+
// lands nowhere is dropped in silence by the executor and every sequence runs
|
|
937
|
+
// on its recorded text, which for a credential means the recorded one reaching
|
|
938
|
+
// the live app with the suite reporting green. The whole tree is in memory by
|
|
939
|
+
// now, so the union is exhaustive.
|
|
940
|
+
if (args.variables && Object.keys(args.variables).length > 0) {
|
|
941
|
+
const suiteKeys = new Set();
|
|
942
|
+
for (const entry of selected) {
|
|
943
|
+
const seq = recorder.listSequences().find(sq => sq.name === entry.name);
|
|
944
|
+
if (!seq)
|
|
945
|
+
continue;
|
|
946
|
+
for (const key of collectVariableKeys(seq.commands, recorder).keys)
|
|
947
|
+
suiteKeys.add(key);
|
|
948
|
+
}
|
|
949
|
+
const unmatched = Object.keys(args.variables).filter(k => !suiteKeys.has(k));
|
|
950
|
+
if (unmatched.length > 0) {
|
|
951
|
+
const known = [...suiteKeys].sort();
|
|
952
|
+
return createErrorResponse('INVALID_PARAMETER', {
|
|
953
|
+
parameter: 'variables',
|
|
954
|
+
value: unmatched.join(', '),
|
|
955
|
+
message: `${unmatched.length > 1 ? 'Those keys name' : `"${unmatched[0]}" names`} no typed-text step in any sequence this run selects. ` +
|
|
956
|
+
`The key is BUILT from the selector - var_<0-based step index>_<selector, non-alphanumerics replaced by _> - so "#password" at step 3 is "var_3__password", with two underscores. ` +
|
|
957
|
+
(known.length
|
|
958
|
+
? `Substitutable across this suite: ${known.join(', ')}.`
|
|
959
|
+
: `No sequence here has typed text to substitute.`),
|
|
960
|
+
});
|
|
961
|
+
}
|
|
962
|
+
}
|
|
841
963
|
const keepGoing = args.continueOnFailure !== false;
|
|
842
964
|
const results = [];
|
|
843
|
-
for (const entry of selected) {
|
|
965
|
+
for (const [index, entry] of selected.entries()) {
|
|
966
|
+
// killChromeOnFinish means the SUITE's finish here, not each sequence's: a
|
|
967
|
+
// teardown between sequences destroys the state a _helpers preamble just
|
|
968
|
+
// established. Only the last sequence carries it, so a suite that stops
|
|
969
|
+
// early (continueOnFailure: false, a cancel) leaves the browsers up for
|
|
970
|
+
// the failure to be read in.
|
|
971
|
+
const isLast = index === selected.length - 1;
|
|
844
972
|
if (abortSignal?.aborted) {
|
|
845
973
|
results.push({ filename: entry.filename, name: entry.name, ok: false, detail: 'cancelled before it ran' });
|
|
846
974
|
continue;
|
|
@@ -863,16 +991,16 @@ async function handleRunAll(args, recorder, executeToolCall, getPageForConnectio
|
|
|
863
991
|
// no-op that a caller then has to notice.
|
|
864
992
|
variables: args.variables ?? {},
|
|
865
993
|
// Per-run args that are actively wrong when fanned across a suite:
|
|
866
|
-
// killChromeOnFinish would tear down the browser between sequences and
|
|
867
|
-
// destroy the state a _helpers preamble just established, and
|
|
868
994
|
// startFrom/stepTo/stepCount/startUrl mean something only for one
|
|
869
|
-
// specific sequence.
|
|
870
|
-
|
|
995
|
+
// specific sequence. baseUrl does carry - it retargets every
|
|
996
|
+
// sequence at the same deployment, which is what a suite run of a
|
|
997
|
+
// recorded set against another environment needs.
|
|
998
|
+
killChromeOnFinish: isLast ? args.killChromeOnFinish : undefined,
|
|
871
999
|
startFrom: undefined,
|
|
872
1000
|
stepTo: undefined,
|
|
873
1001
|
stepCount: undefined,
|
|
874
1002
|
startUrl: undefined,
|
|
875
|
-
}, recorder, executeToolCall, getPageForConnection, abortSignal, getConnectionPort);
|
|
1003
|
+
}, recorder, executeToolCall, getPageForConnection, abortSignal, getConnectionPort, { validateVariableKeys: false });
|
|
876
1004
|
const text = (res?.content || []).map((c) => c?.text || '').join('\n');
|
|
877
1005
|
// performRun stamps _meta.replay on every terminal response, so trust that
|
|
878
1006
|
// over the prose. Regexing for a line starting with "Error:" both misses
|
|
@@ -1275,7 +1403,13 @@ function socketFailures(before, after, required, requiredOn) {
|
|
|
1275
1403
|
}
|
|
1276
1404
|
return { settled: out, absent };
|
|
1277
1405
|
}
|
|
1278
|
-
async function handleRun(args, recorder, executeToolCall, getPageForConnection, abortSignal, getConnectionPort
|
|
1406
|
+
async function handleRun(args, recorder, executeToolCall, getPageForConnection, abortSignal, getConnectionPort,
|
|
1407
|
+
/**
|
|
1408
|
+
* `validateVariableKeys: false` for a sequence running as part of a suite:
|
|
1409
|
+
* `runAll` validates the one map it holds against the whole suite's keys and
|
|
1410
|
+
* a per-sequence check would reject a key meant for a different member.
|
|
1411
|
+
*/
|
|
1412
|
+
opts) {
|
|
1279
1413
|
// Load sequence
|
|
1280
1414
|
const loadResult = await loadSequence({ name: args.name, sequenceId: args.sequenceId }, recorder);
|
|
1281
1415
|
if (!loadResult.success) {
|
|
@@ -1297,9 +1431,15 @@ async function handleRun(args, recorder, executeToolCall, getPageForConnection,
|
|
|
1297
1431
|
if (!connectionReason && !analysis.hasLaunchBeforeConnection && needsConnection) {
|
|
1298
1432
|
connectionReason = deriveConnectionReference(sequence.name);
|
|
1299
1433
|
}
|
|
1300
|
-
// Handle variable extraction and prompting
|
|
1434
|
+
// Handle variable extraction and prompting. A step whose recorded text
|
|
1435
|
+
// carries an interpolation token ({{env:NAME}}, {{var:...}}) is already
|
|
1436
|
+
// parameterised - its value arrives at run time by definition - so it does
|
|
1437
|
+
// not hold the run open for an answer. It stays in the extracted list, so a
|
|
1438
|
+
// caller can still override it and the key validation still accepts it.
|
|
1301
1439
|
const extractedVariables = extractTextVariables(commands);
|
|
1302
|
-
|
|
1440
|
+
const needsAnswer = Object.values(extractedVariables)
|
|
1441
|
+
.some(v => !hasTemplateToken(v.value));
|
|
1442
|
+
if (needsAnswer && args.variables === undefined) {
|
|
1303
1443
|
const idParam = args.sequenceId || args.name;
|
|
1304
1444
|
// Tag it: this response is a PROMPT, not a run. runAll has to be able to
|
|
1305
1445
|
// tell "asked you a question" from "executed and passed", or a suite goes
|
|
@@ -1309,6 +1449,43 @@ async function handleRun(args, recorder, executeToolCall, getPageForConnection,
|
|
|
1309
1449
|
_meta: { tool: 'replay', action: 'run', timestamp: Date.now(), replay: { success: false, prompted: true } }
|
|
1310
1450
|
};
|
|
1311
1451
|
}
|
|
1452
|
+
// The envFile is read before any side effects: a missing file or a malformed
|
|
1453
|
+
// line is a parameter error, not a step failure halfway through a flow that
|
|
1454
|
+
// has already logged in.
|
|
1455
|
+
let runEnv;
|
|
1456
|
+
if (args.envFile) {
|
|
1457
|
+
const loaded = await loadRunEnv(args.envFile);
|
|
1458
|
+
if ('error' in loaded) {
|
|
1459
|
+
return createErrorResponse('INVALID_PARAMETER', {
|
|
1460
|
+
parameter: 'envFile',
|
|
1461
|
+
value: args.envFile,
|
|
1462
|
+
message: loaded.error,
|
|
1463
|
+
});
|
|
1464
|
+
}
|
|
1465
|
+
runEnv = loaded.values;
|
|
1466
|
+
}
|
|
1467
|
+
// Same rule for the typed-text substitutions, before any side effects. A key
|
|
1468
|
+
// matching no step is dropped in silence by the executor and the step runs on
|
|
1469
|
+
// its RECORDED text, so a mistyped key reads as an override while the recorded
|
|
1470
|
+
// value - a password among them - reaches the live app.
|
|
1471
|
+
//
|
|
1472
|
+
// Skipped for a suite member: `runAll` hands ONE map to every sequence and
|
|
1473
|
+
// checks it against the union of the whole suite, so a key valid for another
|
|
1474
|
+
// sequence must not fail this one.
|
|
1475
|
+
if (args.variables && opts?.validateVariableKeys !== false) {
|
|
1476
|
+
const { unmatched, known } = unmatchedVariableKeys(args.variables, commands, recorder);
|
|
1477
|
+
if (unmatched.length > 0) {
|
|
1478
|
+
return createErrorResponse('INVALID_PARAMETER', {
|
|
1479
|
+
parameter: 'variables',
|
|
1480
|
+
value: unmatched.join(', '),
|
|
1481
|
+
message: `${unmatched.length > 1 ? 'Those keys name' : `"${unmatched[0]}" names`} no typed-text step in "${sequence.name}" or any sequence it reaches. ` +
|
|
1482
|
+
`The key is BUILT from the selector - var_<0-based step index>_<selector, non-alphanumerics replaced by _> - so "#password" at step 3 is "var_3__password", with two underscores. ` +
|
|
1483
|
+
(known.length
|
|
1484
|
+
? `Substitutable here: ${known.join(', ')}.`
|
|
1485
|
+
: `This sequence has no typed text to substitute.`),
|
|
1486
|
+
});
|
|
1487
|
+
}
|
|
1488
|
+
}
|
|
1312
1489
|
// Validate the connection rebinding before any side effects. A key that names
|
|
1313
1490
|
// no recorded reference is a typo the user needs to hear about now: silently
|
|
1314
1491
|
// ignoring it would leave the step on its recorded reference and, in the worst
|
|
@@ -1398,6 +1575,7 @@ async function handleRun(args, recorder, executeToolCall, getPageForConnection,
|
|
|
1398
1575
|
sequence, analysis, connectionReason, needsConnection,
|
|
1399
1576
|
launchedConnections: new Set(),
|
|
1400
1577
|
...(connectionMap && { connectionMap }),
|
|
1578
|
+
...(runEnv && { runEnv }),
|
|
1401
1579
|
};
|
|
1402
1580
|
// wait: true - pre-0.7 blocking behaviour, driven by the MCP request signal.
|
|
1403
1581
|
// Also what nested `replay run` STEPS use (the executor injects wait: true),
|
|
@@ -1609,7 +1787,7 @@ async function handleRun(args, recorder, executeToolCall, getPageForConnection,
|
|
|
1609
1787
|
* record's terminal status - never derived by parsing the response.
|
|
1610
1788
|
*/
|
|
1611
1789
|
async function performRun(deps, abortSignal, runId, onProgress) {
|
|
1612
|
-
const { args, recorder, executeToolCall, getPageForConnection, getConnectionPort, sequence, analysis, connectionReason, needsConnection, connectionMap, launchedConnections } = deps;
|
|
1790
|
+
const { args, recorder, executeToolCall, getPageForConnection, getConnectionPort, sequence, analysis, connectionReason, needsConnection, connectionMap, launchedConnections, runEnv } = deps;
|
|
1613
1791
|
// Build execution context
|
|
1614
1792
|
const ctx = {
|
|
1615
1793
|
executeToolCall,
|
|
@@ -1618,7 +1796,17 @@ async function performRun(deps, abortSignal, runId, onProgress) {
|
|
|
1618
1796
|
logPrefix: 'run',
|
|
1619
1797
|
variableStore: {},
|
|
1620
1798
|
launchedConnections,
|
|
1621
|
-
...(connectionMap && { connectionMap })
|
|
1799
|
+
...(connectionMap && { connectionMap }),
|
|
1800
|
+
// Carried on the context so nested sequences inherit the retarget; the
|
|
1801
|
+
// top-level sequence was already rebased in handleRun.
|
|
1802
|
+
...(args.baseUrl && { rebaseOrigin: args.baseUrl }),
|
|
1803
|
+
// Same reason: a shared login helper reached by a conditional is exactly
|
|
1804
|
+
// where a supplied credential has to land.
|
|
1805
|
+
...(args.variables && { variables: args.variables }),
|
|
1806
|
+
// Held on the context rather than written into process.env: concurrent
|
|
1807
|
+
// background runs may name different files, and a global write would let
|
|
1808
|
+
// one run's credentials resolve inside the other.
|
|
1809
|
+
...(runEnv && { runEnv })
|
|
1622
1810
|
};
|
|
1623
1811
|
// Ensure connection is ready
|
|
1624
1812
|
let didAutoLaunch = false;
|