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.
- 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 +18 -0
- package/build/tools/replay-executor.d.ts.map +1 -1
- package/build/tools/replay-executor.js +10 -6
- package/build/tools/replay-executor.js.map +1 -1
- 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-tools.d.ts +4 -0
- package/build/tools/replay-tools.d.ts.map +1 -1
- package/build/tools/replay-tools.js +186 -7
- 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 +3 -0
- package/docs/replay.md +82 -2
- package/package.json +1 -1
- package/plugin/skills/devharness/SKILL.md +2 -2
- package/plugin/skills/devharness/references/sequences.md +33 -3
|
@@ -38,6 +38,7 @@ getKnownToolNames?: () => string[]): {
|
|
|
38
38
|
description: z.ZodOptional<z.ZodString>;
|
|
39
39
|
expectedOutcome: z.ZodOptional<z.ZodString>;
|
|
40
40
|
startUrl: z.ZodOptional<z.ZodString>;
|
|
41
|
+
envFile: z.ZodOptional<z.ZodString>;
|
|
41
42
|
baseUrl: z.ZodOptional<z.ZodString>;
|
|
42
43
|
indices: z.ZodOptional<z.ZodArray<z.ZodNumber, "many">>;
|
|
43
44
|
lines: z.ZodOptional<z.ZodArray<z.ZodNumber, "many">>;
|
|
@@ -133,6 +134,7 @@ getKnownToolNames?: () => string[]): {
|
|
|
133
134
|
preferCoordinates?: boolean | undefined;
|
|
134
135
|
preferSelectors?: boolean | undefined;
|
|
135
136
|
stepCount?: number | undefined;
|
|
137
|
+
envFile?: string | undefined;
|
|
136
138
|
indices?: number[] | undefined;
|
|
137
139
|
sequenceId?: string | undefined;
|
|
138
140
|
runId?: string | undefined;
|
|
@@ -191,6 +193,7 @@ getKnownToolNames?: () => string[]): {
|
|
|
191
193
|
preferCoordinates?: boolean | undefined;
|
|
192
194
|
preferSelectors?: boolean | undefined;
|
|
193
195
|
stepCount?: number | undefined;
|
|
196
|
+
envFile?: string | undefined;
|
|
194
197
|
indices?: number[] | undefined;
|
|
195
198
|
sequenceId?: string | undefined;
|
|
196
199
|
runId?: string | undefined;
|
|
@@ -256,6 +259,7 @@ getKnownToolNames?: () => string[]): {
|
|
|
256
259
|
preferCoordinates?: boolean | undefined;
|
|
257
260
|
preferSelectors?: boolean | undefined;
|
|
258
261
|
stepCount?: number | undefined;
|
|
262
|
+
envFile?: string | undefined;
|
|
259
263
|
indices?: number[] | undefined;
|
|
260
264
|
sequenceId?: string | undefined;
|
|
261
265
|
runId?: string | undefined;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"replay-tools.d.ts","sourceRoot":"","sources":["../../src/tools/replay-tools.ts"],"names":[],"mappings":"AAAA;;GAEG;AAEH,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AAIxB,OAAO,KAAK,EAAE,eAAe,EAAyD,MAAM,wBAAwB,CAAC;AACrH,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,aAAa,CAAC;AAgFnD,MAAM,WAAW,eAAe;IAC9B,sEAAsE;IACtE,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,EAAE,MAAM,CAAC;IACb,UAAU,CAAC,EAAE,MAAM,CAAC;CACrB;AAED;;;;;;;GAOG;AACH,wBAAgB,oBAAoB,CAClC,QAAQ,EAAE,KAAK,CAAC;IAAE,IAAI,CAAC,EAAE,OAAO,CAAA;CAAE,CAAC,EACnC,cAAc,EAAE,MAAM,EAAE,GACvB,eAAe,EAAE,CAWnB;
|
|
1
|
+
{"version":3,"file":"replay-tools.d.ts","sourceRoot":"","sources":["../../src/tools/replay-tools.ts"],"names":[],"mappings":"AAAA;;GAEG;AAEH,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AAIxB,OAAO,KAAK,EAAE,eAAe,EAAyD,MAAM,wBAAwB,CAAC;AACrH,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,aAAa,CAAC;AAgFnD,MAAM,WAAW,eAAe;IAC9B,sEAAsE;IACtE,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,EAAE,MAAM,CAAC;IACb,UAAU,CAAC,EAAE,MAAM,CAAC;CACrB;AAED;;;;;;;GAOG;AACH,wBAAgB,oBAAoB,CAClC,QAAQ,EAAE,KAAK,CAAC;IAAE,IAAI,CAAC,EAAE,OAAO,CAAA;CAAE,CAAC,EACnC,cAAc,EAAE,MAAM,EAAE,GACvB,eAAe,EAAE,CAWnB;AA88GD,wBAAgB,iBAAiB,CAC/B,eAAe,EAAE,eAAe,EAChC,eAAe,EAAE,eAAe,EAChC,oBAAoB,CAAC,EAAE,CAAC,gBAAgB,EAAE,MAAM,KAAK,OAAO,CAAC,GAAG,CAAC,EACjE,iBAAiB,CAAC,EAAE,CAAC,gBAAgB,EAAE,MAAM,KAAK,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC;AACxE;;;;;GAKG;AACH,iBAAiB,CAAC,EAAE,MAAM,MAAM,EAAE;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;EAwDnC"}
|
|
@@ -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,6 +139,7 @@ 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)'),
|
|
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"),
|
|
136
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'),
|
|
@@ -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)'),
|
|
@@ -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,6 +931,35 @@ 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
965
|
for (const [index, entry] of selected.entries()) {
|
|
@@ -878,7 +1000,7 @@ async function handleRunAll(args, recorder, executeToolCall, getPageForConnectio
|
|
|
878
1000
|
stepTo: undefined,
|
|
879
1001
|
stepCount: undefined,
|
|
880
1002
|
startUrl: undefined,
|
|
881
|
-
}, recorder, executeToolCall, getPageForConnection, abortSignal, getConnectionPort);
|
|
1003
|
+
}, recorder, executeToolCall, getPageForConnection, abortSignal, getConnectionPort, { validateVariableKeys: false });
|
|
882
1004
|
const text = (res?.content || []).map((c) => c?.text || '').join('\n');
|
|
883
1005
|
// performRun stamps _meta.replay on every terminal response, so trust that
|
|
884
1006
|
// over the prose. Regexing for a line starting with "Error:" both misses
|
|
@@ -1281,7 +1403,13 @@ function socketFailures(before, after, required, requiredOn) {
|
|
|
1281
1403
|
}
|
|
1282
1404
|
return { settled: out, absent };
|
|
1283
1405
|
}
|
|
1284
|
-
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) {
|
|
1285
1413
|
// Load sequence
|
|
1286
1414
|
const loadResult = await loadSequence({ name: args.name, sequenceId: args.sequenceId }, recorder);
|
|
1287
1415
|
if (!loadResult.success) {
|
|
@@ -1303,9 +1431,15 @@ async function handleRun(args, recorder, executeToolCall, getPageForConnection,
|
|
|
1303
1431
|
if (!connectionReason && !analysis.hasLaunchBeforeConnection && needsConnection) {
|
|
1304
1432
|
connectionReason = deriveConnectionReference(sequence.name);
|
|
1305
1433
|
}
|
|
1306
|
-
// 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.
|
|
1307
1439
|
const extractedVariables = extractTextVariables(commands);
|
|
1308
|
-
|
|
1440
|
+
const needsAnswer = Object.values(extractedVariables)
|
|
1441
|
+
.some(v => !hasTemplateToken(v.value));
|
|
1442
|
+
if (needsAnswer && args.variables === undefined) {
|
|
1309
1443
|
const idParam = args.sequenceId || args.name;
|
|
1310
1444
|
// Tag it: this response is a PROMPT, not a run. runAll has to be able to
|
|
1311
1445
|
// tell "asked you a question" from "executed and passed", or a suite goes
|
|
@@ -1315,6 +1449,43 @@ async function handleRun(args, recorder, executeToolCall, getPageForConnection,
|
|
|
1315
1449
|
_meta: { tool: 'replay', action: 'run', timestamp: Date.now(), replay: { success: false, prompted: true } }
|
|
1316
1450
|
};
|
|
1317
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
|
+
}
|
|
1318
1489
|
// Validate the connection rebinding before any side effects. A key that names
|
|
1319
1490
|
// no recorded reference is a typo the user needs to hear about now: silently
|
|
1320
1491
|
// ignoring it would leave the step on its recorded reference and, in the worst
|
|
@@ -1404,6 +1575,7 @@ async function handleRun(args, recorder, executeToolCall, getPageForConnection,
|
|
|
1404
1575
|
sequence, analysis, connectionReason, needsConnection,
|
|
1405
1576
|
launchedConnections: new Set(),
|
|
1406
1577
|
...(connectionMap && { connectionMap }),
|
|
1578
|
+
...(runEnv && { runEnv }),
|
|
1407
1579
|
};
|
|
1408
1580
|
// wait: true - pre-0.7 blocking behaviour, driven by the MCP request signal.
|
|
1409
1581
|
// Also what nested `replay run` STEPS use (the executor injects wait: true),
|
|
@@ -1615,7 +1787,7 @@ async function handleRun(args, recorder, executeToolCall, getPageForConnection,
|
|
|
1615
1787
|
* record's terminal status - never derived by parsing the response.
|
|
1616
1788
|
*/
|
|
1617
1789
|
async function performRun(deps, abortSignal, runId, onProgress) {
|
|
1618
|
-
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;
|
|
1619
1791
|
// Build execution context
|
|
1620
1792
|
const ctx = {
|
|
1621
1793
|
executeToolCall,
|
|
@@ -1627,7 +1799,14 @@ async function performRun(deps, abortSignal, runId, onProgress) {
|
|
|
1627
1799
|
...(connectionMap && { connectionMap }),
|
|
1628
1800
|
// Carried on the context so nested sequences inherit the retarget; the
|
|
1629
1801
|
// top-level sequence was already rebased in handleRun.
|
|
1630
|
-
...(args.baseUrl && { rebaseOrigin: args.baseUrl })
|
|
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 })
|
|
1631
1810
|
};
|
|
1632
1811
|
// Ensure connection is ready
|
|
1633
1812
|
let didAutoLaunch = false;
|