@elench/testkit 0.1.86 → 0.1.88
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/README.md +19 -12
- package/lib/cli/agents/providers/claude.mjs +1 -1
- package/lib/cli/agents/providers/codex.mjs +1 -1
- package/lib/cli/assistant/prompt-builder.mjs +78 -0
- package/lib/cli/assistant/protocol.mjs +67 -0
- package/lib/cli/assistant/session.mjs +92 -0
- package/lib/cli/assistant/slash-commands.mjs +160 -0
- package/lib/cli/assistant/state.mjs +279 -0
- package/lib/cli/assistant/tool-registry.mjs +236 -0
- package/lib/cli/assistant/tool-run-reporter.mjs +80 -0
- package/lib/cli/command-helpers.mjs +40 -24
- package/lib/cli/commands/assistant.mjs +84 -0
- package/lib/cli/entrypoint.mjs +37 -11
- package/lib/cli/presentation/tree-reporter.mjs +34 -28
- package/lib/cli/tui/assistant-app.mjs +131 -0
- package/lib/cli/tui/detail-pane.mjs +161 -0
- package/lib/cli/tui/filter-bar.mjs +12 -0
- package/lib/cli/tui/fuzzy-match.mjs +106 -0
- package/lib/cli/tui/inspect-app.mjs +306 -0
- package/lib/cli/tui/inspect-artifact-adapter.mjs +3 -0
- package/lib/cli/tui/inspect-live-adapter.mjs +15 -0
- package/lib/cli/tui/inspect-model.mjs +817 -0
- package/lib/cli/tui/inspect-state.mjs +321 -0
- package/node_modules/@elench/next-analysis/package.json +1 -1
- package/node_modules/@elench/testkit-bridge/package.json +2 -2
- package/node_modules/@elench/testkit-protocol/package.json +1 -1
- package/node_modules/@elench/ts-analysis/package.json +1 -1
- package/package.json +6 -6
- package/lib/cli/commands/artifacts.mjs +0 -45
- package/lib/cli/commands/investigate.mjs +0 -87
- package/lib/cli/commands/logs.mjs +0 -47
- package/lib/cli/commands/show.mjs +0 -47
- package/lib/cli/commands/watch.mjs +0 -23
- package/lib/cli/tui/run-app.mjs +0 -1
- package/lib/cli/tui/run-session-app.mjs +0 -432
- package/lib/cli/tui/run-session-state.mjs +0 -505
- package/lib/cli/tui/run-tree-state.mjs +0 -1
- package/lib/cli/tui/watch-app.mjs +0 -220
package/README.md
CHANGED
|
@@ -11,9 +11,17 @@ The package is now driven by `testkit.config.ts`, not `testkit.config.json`.
|
|
|
11
11
|
```bash
|
|
12
12
|
cd my-product
|
|
13
13
|
|
|
14
|
-
#
|
|
14
|
+
# Launch the interactive assistant
|
|
15
15
|
npx @elench/testkit
|
|
16
16
|
|
|
17
|
+
# Ask for one assistant turn non-interactively
|
|
18
|
+
npx @elench/testkit assistant --message "/status"
|
|
19
|
+
npx @elench/testkit assistant --message "/run e2e --service api"
|
|
20
|
+
npx @elench/testkit assistant --message "Why did the latest failure happen?"
|
|
21
|
+
|
|
22
|
+
# Run every testkit-managed suite in batch mode
|
|
23
|
+
npx @elench/testkit run
|
|
24
|
+
|
|
17
25
|
# Inspect discovered tests without running them
|
|
18
26
|
npx @elench/testkit discover
|
|
19
27
|
npx @elench/testkit discover --output-mode verbose
|
|
@@ -55,12 +63,10 @@ npx @elench/testkit status
|
|
|
55
63
|
npx @elench/testkit destroy
|
|
56
64
|
npx @elench/testkit cleanup
|
|
57
65
|
|
|
58
|
-
# Inspect the latest run artifact
|
|
59
|
-
npx @elench/testkit
|
|
60
|
-
npx @elench/testkit
|
|
61
|
-
npx @elench/testkit
|
|
62
|
-
npx @elench/testkit logs __testkit__/health/health.int.testkit.ts
|
|
63
|
-
npx @elench/testkit watch
|
|
66
|
+
# Inspect the latest run artifact through the assistant
|
|
67
|
+
npx @elench/testkit assistant --message '/inspect "__testkit__/health/health.int.testkit.ts"'
|
|
68
|
+
npx @elench/testkit assistant --pane artifacts --message '/inspect "__testkit__/health/health.int.testkit.ts"'
|
|
69
|
+
npx @elench/testkit assistant --pane logs --message '/inspect "__testkit__/health/health.int.testkit.ts"'
|
|
64
70
|
|
|
65
71
|
# Automatic regression intelligence
|
|
66
72
|
# Configure testkit.regressions.json and testkit classifies new vs known regressions automatically during runs
|
|
@@ -69,11 +75,12 @@ npx @elench/testkit watch
|
|
|
69
75
|
npx @elench/testkit db snapshot capture --service api --output scripts/testkit/schema-baseline.sql
|
|
70
76
|
```
|
|
71
77
|
|
|
72
|
-
`testkit` now
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
78
|
+
`testkit` is now assistant-first in an interactive TTY. The assistant keeps a
|
|
79
|
+
chat transcript and a live workbench around the current run artifact, selected
|
|
80
|
+
file, logs, setup operations, and emitted artifacts. Batch `run` output stays
|
|
81
|
+
intentionally short: one line per completed file, a concise failure block, and
|
|
82
|
+
a final summary. Service logs, captured runtime output, emitted artifacts, and
|
|
83
|
+
assistant-visible run state are persisted under `.testkit/results/`.
|
|
77
84
|
|
|
78
85
|
`testkit discover` also maintains a small durable per-test history index at
|
|
79
86
|
`.testkit/history/tests.json`. The index tracks first/last seen timestamps,
|
|
@@ -15,7 +15,7 @@ export function startClaudeHostedSession({ cwd, prompt, onEvent, purpose = "inve
|
|
|
15
15
|
"--include-partial-messages",
|
|
16
16
|
];
|
|
17
17
|
|
|
18
|
-
if (purpose === "investigate") {
|
|
18
|
+
if (purpose === "investigate" || purpose === "assistant") {
|
|
19
19
|
args.push("--permission-mode", "plan");
|
|
20
20
|
}
|
|
21
21
|
|
|
@@ -16,7 +16,7 @@ export function startCodexHostedSession({ cwd, prompt, onEvent, purpose = "inves
|
|
|
16
16
|
const outputFile = path.join(tempDir, "final-message.txt");
|
|
17
17
|
const args = ["exec", "--json", "-o", outputFile];
|
|
18
18
|
|
|
19
|
-
if (purpose === "investigate") {
|
|
19
|
+
if (purpose === "investigate" || purpose === "assistant") {
|
|
20
20
|
args.push("-s", "read-only");
|
|
21
21
|
}
|
|
22
22
|
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { buildInspectPaneContent } from "../tui/detail-pane.mjs";
|
|
2
|
+
import { buildAssistantResponseContract } from "./protocol.mjs";
|
|
3
|
+
|
|
4
|
+
export function buildAssistantPrompt({
|
|
5
|
+
productDir,
|
|
6
|
+
snapshot,
|
|
7
|
+
transcript = [],
|
|
8
|
+
tools = [],
|
|
9
|
+
userMessage,
|
|
10
|
+
} = {}) {
|
|
11
|
+
const selectionSummary = buildSelectionSummary(snapshot);
|
|
12
|
+
const panePreview = buildPanePreview(productDir, snapshot);
|
|
13
|
+
const summaryRows = snapshot?.summaryData?.rows || [];
|
|
14
|
+
|
|
15
|
+
return [
|
|
16
|
+
"You are Testkit Assistant.",
|
|
17
|
+
"You help users run tests, inspect failures, read artifacts/logs, and explain the current local test state.",
|
|
18
|
+
"Prefer using tools when the user is asking testkit to inspect or run something.",
|
|
19
|
+
buildAssistantResponseContract({ tools }),
|
|
20
|
+
"",
|
|
21
|
+
"Current run summary:",
|
|
22
|
+
...(summaryRows.length > 0 ? summaryRows.map(([label, value]) => `- ${label}: ${value}`) : ["- No run artifact is currently loaded."]),
|
|
23
|
+
"",
|
|
24
|
+
"Current selection:",
|
|
25
|
+
selectionSummary,
|
|
26
|
+
"",
|
|
27
|
+
"Current pane preview:",
|
|
28
|
+
...(panePreview.length > 0 ? panePreview : ["(empty)"]),
|
|
29
|
+
"",
|
|
30
|
+
"Recent conversation:",
|
|
31
|
+
...formatTranscript(transcript),
|
|
32
|
+
"",
|
|
33
|
+
`User message: ${String(userMessage || "").trim()}`,
|
|
34
|
+
].join("\n");
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function buildSelectionSummary(snapshot) {
|
|
38
|
+
const entry = snapshot?.selectedEntry;
|
|
39
|
+
if (!entry) return "No selection.";
|
|
40
|
+
if (entry.kind === "file") {
|
|
41
|
+
return `File ${entry.filePath} (${entry.status}) in ${entry.serviceName}/${entry.type}.`;
|
|
42
|
+
}
|
|
43
|
+
if (entry.kind === "suite") {
|
|
44
|
+
return `Suite ${entry.suiteName} (${entry.type}) in service ${entry.serviceName}.`;
|
|
45
|
+
}
|
|
46
|
+
if (entry.kind === "type") {
|
|
47
|
+
return `Type ${entry.type} in service ${entry.serviceName}.`;
|
|
48
|
+
}
|
|
49
|
+
if (entry.kind === "service") {
|
|
50
|
+
return `Service ${entry.serviceName}.`;
|
|
51
|
+
}
|
|
52
|
+
return entry.label || "Unknown selection.";
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function buildPanePreview(productDir, snapshot) {
|
|
56
|
+
if (!productDir || !snapshot) return [];
|
|
57
|
+
try {
|
|
58
|
+
const pane = buildInspectPaneContent({
|
|
59
|
+
productDir,
|
|
60
|
+
snapshot,
|
|
61
|
+
paneMode: snapshot.paneMode || "detail",
|
|
62
|
+
logTail: 8,
|
|
63
|
+
});
|
|
64
|
+
return (pane.lines || []).slice(0, 24).map((line) => `- ${line}`);
|
|
65
|
+
} catch (error) {
|
|
66
|
+
return [`(pane unavailable: ${error instanceof Error ? error.message : String(error)})`];
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function formatTranscript(transcript) {
|
|
71
|
+
const entries = Array.isArray(transcript) ? transcript.slice(-10) : [];
|
|
72
|
+
if (entries.length === 0) return ["- No prior turns."];
|
|
73
|
+
return entries.map((entry) => {
|
|
74
|
+
const role = entry.role || "system";
|
|
75
|
+
const text = String(entry.text || "").replace(/\s+/g, " ").trim();
|
|
76
|
+
return `- ${role}: ${text}`;
|
|
77
|
+
});
|
|
78
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
export function buildAssistantResponseContract({ tools = [] } = {}) {
|
|
2
|
+
return [
|
|
3
|
+
"Respond with exactly one JSON object and no surrounding commentary.",
|
|
4
|
+
'Use {"type":"answer","message":"..."} when you can answer directly.',
|
|
5
|
+
'Use {"type":"tool","tool":"<name>","arguments":{...},"commentary":"..."} when you need testkit to act before you answer.',
|
|
6
|
+
"Only request one tool at a time.",
|
|
7
|
+
`Available tools: ${tools.map((tool) => tool.name).join(", ") || "none"}.`,
|
|
8
|
+
].join("\n");
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function parseAssistantEnvelope(text) {
|
|
12
|
+
const raw = String(text || "").trim();
|
|
13
|
+
if (!raw) {
|
|
14
|
+
return {
|
|
15
|
+
type: "answer",
|
|
16
|
+
message: "",
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const candidate = extractJsonObject(raw);
|
|
21
|
+
if (!candidate) {
|
|
22
|
+
return {
|
|
23
|
+
type: "answer",
|
|
24
|
+
message: raw,
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
try {
|
|
29
|
+
const parsed = JSON.parse(candidate);
|
|
30
|
+
if (parsed?.type === "tool" && parsed.tool) {
|
|
31
|
+
return {
|
|
32
|
+
type: "tool",
|
|
33
|
+
tool: String(parsed.tool),
|
|
34
|
+
arguments: normalizePlainObject(parsed.arguments),
|
|
35
|
+
commentary: parsed.commentary ? String(parsed.commentary) : "",
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
if (parsed?.type === "answer") {
|
|
39
|
+
return {
|
|
40
|
+
type: "answer",
|
|
41
|
+
message: parsed.message ? String(parsed.message) : "",
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
} catch {
|
|
45
|
+
// Fall through to raw answer.
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
return {
|
|
49
|
+
type: "answer",
|
|
50
|
+
message: raw,
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function extractJsonObject(text) {
|
|
55
|
+
const fencedMatch = text.match(/```json\s*([\s\S]*?)```/i);
|
|
56
|
+
if (fencedMatch?.[1]) return fencedMatch[1].trim();
|
|
57
|
+
|
|
58
|
+
const start = text.indexOf("{");
|
|
59
|
+
const end = text.lastIndexOf("}");
|
|
60
|
+
if (start < 0 || end <= start) return null;
|
|
61
|
+
return text.slice(start, end + 1);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function normalizePlainObject(value) {
|
|
65
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return {};
|
|
66
|
+
return value;
|
|
67
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { startAgentSession, resolvePreferredProvider } from "../agents/index.mjs";
|
|
2
|
+
import { buildAssistantPrompt } from "./prompt-builder.mjs";
|
|
3
|
+
import { listAssistantTools, executeAssistantTool } from "./tool-registry.mjs";
|
|
4
|
+
import { parseAssistantEnvelope } from "./protocol.mjs";
|
|
5
|
+
|
|
6
|
+
export async function runAssistantConversationTurn({
|
|
7
|
+
productDir,
|
|
8
|
+
inspectState,
|
|
9
|
+
transcript,
|
|
10
|
+
userMessage,
|
|
11
|
+
provider = "auto",
|
|
12
|
+
configs,
|
|
13
|
+
onStatus,
|
|
14
|
+
onToolEvent,
|
|
15
|
+
} = {}) {
|
|
16
|
+
const tools = listAssistantTools();
|
|
17
|
+
const toolContext = {
|
|
18
|
+
productDir,
|
|
19
|
+
inspectState,
|
|
20
|
+
configs,
|
|
21
|
+
onEvent: onToolEvent,
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
let currentTranscript = [...(transcript || []), { role: "user", text: userMessage }];
|
|
25
|
+
const emitted = [];
|
|
26
|
+
|
|
27
|
+
for (let attempt = 0; attempt < 6; attempt += 1) {
|
|
28
|
+
const snapshot = inspectState.getSnapshot();
|
|
29
|
+
const prompt = buildAssistantPrompt({
|
|
30
|
+
productDir,
|
|
31
|
+
snapshot,
|
|
32
|
+
transcript: currentTranscript,
|
|
33
|
+
tools,
|
|
34
|
+
userMessage,
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
onStatus?.(`Thinking with ${resolvePreferredProvider(provider)}...`);
|
|
38
|
+
const events = [];
|
|
39
|
+
const session = startAgentSession({
|
|
40
|
+
provider,
|
|
41
|
+
cwd: productDir,
|
|
42
|
+
prompt,
|
|
43
|
+
purpose: "assistant",
|
|
44
|
+
onEvent(event) {
|
|
45
|
+
events.push(event);
|
|
46
|
+
if (event.type === "status" || event.type === "tool") onStatus?.(formatProviderEvent(event));
|
|
47
|
+
},
|
|
48
|
+
});
|
|
49
|
+
const result = await session.completion;
|
|
50
|
+
const envelope = parseAssistantEnvelope(result.finalText || "");
|
|
51
|
+
|
|
52
|
+
if (envelope.type === "tool") {
|
|
53
|
+
if (envelope.commentary) {
|
|
54
|
+
emitted.push({ role: "assistant", text: envelope.commentary });
|
|
55
|
+
currentTranscript.push({ role: "assistant", text: envelope.commentary });
|
|
56
|
+
}
|
|
57
|
+
const toolResult = await executeAssistantTool(envelope.tool, envelope.arguments, toolContext);
|
|
58
|
+
const toolText = toolResult.text || `${envelope.tool} completed`;
|
|
59
|
+
emitted.push({
|
|
60
|
+
role: "tool",
|
|
61
|
+
text: toolText,
|
|
62
|
+
toolName: envelope.tool,
|
|
63
|
+
data: toolResult.data || null,
|
|
64
|
+
});
|
|
65
|
+
currentTranscript.push({
|
|
66
|
+
role: "tool",
|
|
67
|
+
text: `${envelope.tool}: ${toolText}`,
|
|
68
|
+
});
|
|
69
|
+
onToolEvent?.({ type: "tool-result", tool: envelope.tool, text: toolText });
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
emitted.push({
|
|
74
|
+
role: "assistant",
|
|
75
|
+
text: envelope.message || result.finalText || "",
|
|
76
|
+
});
|
|
77
|
+
return emitted;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
emitted.push({
|
|
81
|
+
role: "assistant",
|
|
82
|
+
text: "I hit the assistant tool-call limit before reaching a final answer.",
|
|
83
|
+
});
|
|
84
|
+
return emitted;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function formatProviderEvent(event) {
|
|
88
|
+
if (event.type === "tool") {
|
|
89
|
+
return `${event.provider}: ${event.name}${event.detail ? ` (${event.detail})` : ""}`;
|
|
90
|
+
}
|
|
91
|
+
return `${event.provider}: ${event.message}`;
|
|
92
|
+
}
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
const RUN_TYPES = new Set(["int", "e2e", "scenario", "dal", "load", "pw", "all"]);
|
|
2
|
+
const PANES = new Set(["detail", "artifacts", "logs", "setup"]);
|
|
3
|
+
const PROVIDERS = new Set(["auto", "claude", "codex"]);
|
|
4
|
+
|
|
5
|
+
export function parseSlashCommand(input) {
|
|
6
|
+
const trimmed = String(input || "").trim();
|
|
7
|
+
if (!trimmed.startsWith("/")) return null;
|
|
8
|
+
const tokens = tokenizeShellLike(trimmed.slice(1));
|
|
9
|
+
const command = tokens.shift() || "";
|
|
10
|
+
|
|
11
|
+
if (command === "help") return { type: "help" };
|
|
12
|
+
if (command === "clear") return { type: "clear" };
|
|
13
|
+
if (command === "quit" || command === "exit") return { type: "quit" };
|
|
14
|
+
|
|
15
|
+
if (command === "provider") {
|
|
16
|
+
const provider = tokens[0] || "auto";
|
|
17
|
+
if (!PROVIDERS.has(provider)) {
|
|
18
|
+
throw new Error(`/provider expects one of: ${[...PROVIDERS].join(", ")}`);
|
|
19
|
+
}
|
|
20
|
+
return { type: "provider", provider };
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
if (command === "pane") {
|
|
24
|
+
const pane = tokens[0] || "detail";
|
|
25
|
+
if (!PANES.has(pane)) {
|
|
26
|
+
throw new Error(`/pane expects one of: ${[...PANES].join(", ")}`);
|
|
27
|
+
}
|
|
28
|
+
return { type: "pane", pane };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
if (command === "file" || command === "focus") {
|
|
32
|
+
if (!tokens[0]) throw new Error(`/${command} expects a file path`);
|
|
33
|
+
return { type: "file", file: tokens.join(" ") };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
if (command === "service") {
|
|
37
|
+
if (!tokens[0]) throw new Error("/service expects a service name");
|
|
38
|
+
return { type: "service", service: tokens[0] };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
if (command === "inspect") {
|
|
42
|
+
return {
|
|
43
|
+
type: "inspect",
|
|
44
|
+
file: tokens[0] || null,
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
if (command === "status") return { type: "status" };
|
|
49
|
+
if (command === "discover") return { type: "discover" };
|
|
50
|
+
if (command === "doctor") return { type: "doctor" };
|
|
51
|
+
|
|
52
|
+
if (command === "run") {
|
|
53
|
+
return {
|
|
54
|
+
type: "run",
|
|
55
|
+
options: parseRunCommandTokens(tokens),
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
throw new Error(`Unknown slash command "/${command}"`);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function formatSlashHelpLines() {
|
|
63
|
+
return [
|
|
64
|
+
"/run [type] [--service name] [--file path] [--suite selector]",
|
|
65
|
+
"/file <path>",
|
|
66
|
+
"/service <name>",
|
|
67
|
+
"/pane <detail|artifacts|logs|setup>",
|
|
68
|
+
"/inspect [path]",
|
|
69
|
+
"/discover",
|
|
70
|
+
"/status",
|
|
71
|
+
"/doctor",
|
|
72
|
+
"/provider <auto|claude|codex>",
|
|
73
|
+
"/clear",
|
|
74
|
+
"/quit",
|
|
75
|
+
];
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function parseRunCommandTokens(tokens) {
|
|
79
|
+
const options = {
|
|
80
|
+
type: [],
|
|
81
|
+
suite: [],
|
|
82
|
+
file: [],
|
|
83
|
+
service: null,
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
for (let index = 0; index < tokens.length; index += 1) {
|
|
87
|
+
const token = tokens[index];
|
|
88
|
+
if (!token) continue;
|
|
89
|
+
if (RUN_TYPES.has(token)) {
|
|
90
|
+
options.type.push(token);
|
|
91
|
+
continue;
|
|
92
|
+
}
|
|
93
|
+
if (token === "--service") {
|
|
94
|
+
options.service = tokens[index + 1] || null;
|
|
95
|
+
index += 1;
|
|
96
|
+
continue;
|
|
97
|
+
}
|
|
98
|
+
if (token === "--file") {
|
|
99
|
+
if (tokens[index + 1]) options.file.push(tokens[index + 1]);
|
|
100
|
+
index += 1;
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
if (token === "--suite") {
|
|
104
|
+
if (tokens[index + 1]) options.suite.push(tokens[index + 1]);
|
|
105
|
+
index += 1;
|
|
106
|
+
continue;
|
|
107
|
+
}
|
|
108
|
+
if (token === "--type") {
|
|
109
|
+
if (tokens[index + 1]) {
|
|
110
|
+
options.type.push(...String(tokens[index + 1]).split(",").map((value) => value.trim()).filter(Boolean));
|
|
111
|
+
}
|
|
112
|
+
index += 1;
|
|
113
|
+
continue;
|
|
114
|
+
}
|
|
115
|
+
throw new Error(`Unsupported /run token "${token}"`);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
return options;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function tokenizeShellLike(input) {
|
|
122
|
+
const tokens = [];
|
|
123
|
+
let current = "";
|
|
124
|
+
let quote = null;
|
|
125
|
+
|
|
126
|
+
for (let index = 0; index < input.length; index += 1) {
|
|
127
|
+
const char = input[index];
|
|
128
|
+
if (quote) {
|
|
129
|
+
if (char === quote) {
|
|
130
|
+
quote = null;
|
|
131
|
+
} else if (char === "\\" && index + 1 < input.length) {
|
|
132
|
+
current += input[index + 1];
|
|
133
|
+
index += 1;
|
|
134
|
+
} else {
|
|
135
|
+
current += char;
|
|
136
|
+
}
|
|
137
|
+
continue;
|
|
138
|
+
}
|
|
139
|
+
if (char === '"' || char === "'") {
|
|
140
|
+
quote = char;
|
|
141
|
+
continue;
|
|
142
|
+
}
|
|
143
|
+
if (/\s/.test(char)) {
|
|
144
|
+
if (current) {
|
|
145
|
+
tokens.push(current);
|
|
146
|
+
current = "";
|
|
147
|
+
}
|
|
148
|
+
continue;
|
|
149
|
+
}
|
|
150
|
+
if (char === "\\" && index + 1 < input.length) {
|
|
151
|
+
current += input[index + 1];
|
|
152
|
+
index += 1;
|
|
153
|
+
continue;
|
|
154
|
+
}
|
|
155
|
+
current += char;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
if (current) tokens.push(current);
|
|
159
|
+
return tokens;
|
|
160
|
+
}
|