speqs 0.4.0 → 0.5.0
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/dist/commands/config.d.ts +5 -0
- package/dist/commands/config.js +72 -0
- package/dist/commands/iteration.d.ts +5 -0
- package/dist/commands/iteration.js +82 -0
- package/dist/commands/simulation.d.ts +10 -0
- package/dist/commands/simulation.js +306 -0
- package/dist/commands/study.d.ts +5 -0
- package/dist/commands/study.js +131 -0
- package/dist/commands/tester-profile.d.ts +5 -0
- package/dist/commands/tester-profile.js +95 -0
- package/dist/commands/tester.d.ts +5 -0
- package/dist/commands/tester.js +63 -0
- package/dist/commands/workspace.d.ts +5 -0
- package/dist/commands/workspace.js +86 -0
- package/dist/index.js +32 -9
- package/dist/lib/api-client.d.ts +27 -0
- package/dist/lib/api-client.js +121 -0
- package/dist/lib/auth.d.ts +8 -0
- package/dist/lib/auth.js +70 -0
- package/dist/lib/command-helpers.d.ts +24 -0
- package/dist/lib/command-helpers.js +94 -0
- package/dist/lib/output.d.ts +19 -0
- package/dist/lib/output.js +454 -0
- package/dist/lib/types.d.ts +202 -0
- package/dist/lib/types.js +4 -0
- package/package.json +1 -1
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* speqs config — Manage simulation configs.
|
|
3
|
+
*/
|
|
4
|
+
import { withClient, readJsonFileOrStdin } from "../lib/command-helpers.js";
|
|
5
|
+
import { output, formatConfigList } from "../lib/output.js";
|
|
6
|
+
export function registerConfigCommands(program) {
|
|
7
|
+
const config = program
|
|
8
|
+
.command("config")
|
|
9
|
+
.description("Manage simulation configs");
|
|
10
|
+
config
|
|
11
|
+
.command("list")
|
|
12
|
+
.description("List all simulation configs")
|
|
13
|
+
.addHelpText("after", "\nExamples:\n $ speqs config list\n $ speqs config list --json")
|
|
14
|
+
.action(async (_opts, cmd) => {
|
|
15
|
+
await withClient(cmd, async (client, globals) => {
|
|
16
|
+
const data = await client.get("/dev/simulation-configs");
|
|
17
|
+
formatConfigList(data, globals.json);
|
|
18
|
+
});
|
|
19
|
+
});
|
|
20
|
+
config
|
|
21
|
+
.command("create")
|
|
22
|
+
.description("Create a simulation config")
|
|
23
|
+
.requiredOption("--file <path>", "JSON file with config data")
|
|
24
|
+
.action(async (opts, cmd) => {
|
|
25
|
+
await withClient(cmd, async (client, globals) => {
|
|
26
|
+
const body = await readJsonFileOrStdin(opts.file);
|
|
27
|
+
const data = await client.post("/dev/simulation-configs", body);
|
|
28
|
+
output(data, globals.json);
|
|
29
|
+
});
|
|
30
|
+
});
|
|
31
|
+
config
|
|
32
|
+
.command("get")
|
|
33
|
+
.description("Get simulation config details")
|
|
34
|
+
.argument("<id>", "Config ID")
|
|
35
|
+
.action(async (id, _opts, cmd) => {
|
|
36
|
+
await withClient(cmd, async (client, globals) => {
|
|
37
|
+
const data = await client.get(`/dev/simulation-configs/${id}`);
|
|
38
|
+
output(data, globals.json);
|
|
39
|
+
});
|
|
40
|
+
});
|
|
41
|
+
config
|
|
42
|
+
.command("schema")
|
|
43
|
+
.description("Get simulation config schema with defaults")
|
|
44
|
+
.action(async (_opts, cmd) => {
|
|
45
|
+
await withClient(cmd, async (client, globals) => {
|
|
46
|
+
const data = await client.get("/dev/simulation-configs/schema");
|
|
47
|
+
output(data, globals.json);
|
|
48
|
+
});
|
|
49
|
+
});
|
|
50
|
+
config
|
|
51
|
+
.command("update")
|
|
52
|
+
.description("Update a simulation config")
|
|
53
|
+
.argument("<id>", "Config ID")
|
|
54
|
+
.requiredOption("--file <path>", "JSON file with update data")
|
|
55
|
+
.action(async (id, opts, cmd) => {
|
|
56
|
+
await withClient(cmd, async (client, globals) => {
|
|
57
|
+
const body = await readJsonFileOrStdin(opts.file);
|
|
58
|
+
const data = await client.put(`/dev/simulation-configs/${id}`, body);
|
|
59
|
+
output(data, globals.json);
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
config
|
|
63
|
+
.command("delete")
|
|
64
|
+
.description("Delete a simulation config")
|
|
65
|
+
.argument("<id>", "Config ID")
|
|
66
|
+
.action(async (id, _opts, cmd) => {
|
|
67
|
+
await withClient(cmd, async (client, globals) => {
|
|
68
|
+
await client.del(`/dev/simulation-configs/${id}`);
|
|
69
|
+
output({ message: "Config deleted" }, globals.json);
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* speqs iteration — Manage iterations (usually created via `simulation run`).
|
|
3
|
+
*/
|
|
4
|
+
import { withClient } from "../lib/command-helpers.js";
|
|
5
|
+
import { output, formatIterationList } from "../lib/output.js";
|
|
6
|
+
export function registerIterationCommands(program) {
|
|
7
|
+
const iteration = program
|
|
8
|
+
.command("iteration")
|
|
9
|
+
.description("Manage iterations (usually created via `simulation run`)");
|
|
10
|
+
iteration
|
|
11
|
+
.command("list")
|
|
12
|
+
.description("List iterations for a study")
|
|
13
|
+
.requiredOption("--study <id>", "Study ID")
|
|
14
|
+
.addHelpText("after", "\nExamples:\n $ speqs iteration list --study <id>\n $ speqs iteration list --study <id> --json")
|
|
15
|
+
.action(async (opts, cmd) => {
|
|
16
|
+
await withClient(cmd, async (client, globals) => {
|
|
17
|
+
const data = await client.get(`/studies/${opts.study}/iterations`);
|
|
18
|
+
formatIterationList(data, globals.json);
|
|
19
|
+
});
|
|
20
|
+
});
|
|
21
|
+
iteration
|
|
22
|
+
.command("create")
|
|
23
|
+
.description("Create a new iteration (low-level)")
|
|
24
|
+
.requiredOption("--study <id>", "Study ID")
|
|
25
|
+
.requiredOption("--name <name>", "Iteration name")
|
|
26
|
+
.option("--description <description>", "Iteration description")
|
|
27
|
+
.option("--details-json <json>", "Iteration details as JSON string")
|
|
28
|
+
.action(async (opts, cmd) => {
|
|
29
|
+
await withClient(cmd, async (client, globals) => {
|
|
30
|
+
const body = {
|
|
31
|
+
name: opts.name,
|
|
32
|
+
...(opts.description && { description: opts.description }),
|
|
33
|
+
...(opts.detailsJson && { details: JSON.parse(opts.detailsJson) }),
|
|
34
|
+
};
|
|
35
|
+
const data = await client.post(`/studies/${opts.study}/iterations`, body);
|
|
36
|
+
output(data, globals.json);
|
|
37
|
+
});
|
|
38
|
+
});
|
|
39
|
+
iteration
|
|
40
|
+
.command("get")
|
|
41
|
+
.description("Get iteration details")
|
|
42
|
+
.argument("<id>", "Iteration ID")
|
|
43
|
+
.action(async (id, _opts, cmd) => {
|
|
44
|
+
await withClient(cmd, async (client, globals) => {
|
|
45
|
+
const data = await client.get(`/iterations/${id}`);
|
|
46
|
+
output(data, globals.json);
|
|
47
|
+
});
|
|
48
|
+
});
|
|
49
|
+
iteration
|
|
50
|
+
.command("update")
|
|
51
|
+
.description("Update an iteration")
|
|
52
|
+
.argument("<id>", "Iteration ID")
|
|
53
|
+
.option("--name <name>", "Iteration name")
|
|
54
|
+
.option("--description <description>", "Iteration description")
|
|
55
|
+
.option("--details-json <json>", "Iteration details as JSON string")
|
|
56
|
+
.option("--label <label>", "Iteration label (uppercase letters)")
|
|
57
|
+
.action(async (id, opts, cmd) => {
|
|
58
|
+
await withClient(cmd, async (client, globals) => {
|
|
59
|
+
const body = {};
|
|
60
|
+
if (opts.name !== undefined)
|
|
61
|
+
body.name = opts.name;
|
|
62
|
+
if (opts.description !== undefined)
|
|
63
|
+
body.description = opts.description;
|
|
64
|
+
if (opts.detailsJson !== undefined)
|
|
65
|
+
body.details = JSON.parse(opts.detailsJson);
|
|
66
|
+
if (opts.label !== undefined)
|
|
67
|
+
body.label = opts.label;
|
|
68
|
+
const data = await client.put(`/iterations/${id}`, body);
|
|
69
|
+
output(data, globals.json);
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
iteration
|
|
73
|
+
.command("delete")
|
|
74
|
+
.description("Delete an iteration")
|
|
75
|
+
.argument("<id>", "Iteration ID")
|
|
76
|
+
.action(async (id, _opts, cmd) => {
|
|
77
|
+
await withClient(cmd, async (client, globals) => {
|
|
78
|
+
await client.del(`/iterations/${id}`);
|
|
79
|
+
output({ message: "Iteration deleted" }, globals.json);
|
|
80
|
+
});
|
|
81
|
+
});
|
|
82
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* speqs simulation — Run, monitor, and cancel simulations.
|
|
3
|
+
*
|
|
4
|
+
* Primary command: `speqs simulation run` — orchestrates the full flow:
|
|
5
|
+
* 1. Creates iteration (if not provided)
|
|
6
|
+
* 2. Creates testers from profiles
|
|
7
|
+
* 3. Starts simulations
|
|
8
|
+
*/
|
|
9
|
+
import type { Command } from "commander";
|
|
10
|
+
export declare function registerSimulationCommands(program: Command): void;
|
|
@@ -0,0 +1,306 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* speqs simulation — Run, monitor, and cancel simulations.
|
|
3
|
+
*
|
|
4
|
+
* Primary command: `speqs simulation run` — orchestrates the full flow:
|
|
5
|
+
* 1. Creates iteration (if not provided)
|
|
6
|
+
* 2. Creates testers from profiles
|
|
7
|
+
* 3. Starts simulations
|
|
8
|
+
*/
|
|
9
|
+
import * as readline from "node:readline/promises";
|
|
10
|
+
import { withClient, getWebUrl, terminalLink } from "../lib/command-helpers.js";
|
|
11
|
+
import { output, outputError, formatSimulationPoll } from "../lib/output.js";
|
|
12
|
+
function parseMaxInteractions(value) {
|
|
13
|
+
const n = parseInt(value, 10);
|
|
14
|
+
if (isNaN(n) || n < 1)
|
|
15
|
+
throw new Error(`Invalid --max-interactions value: ${value}`);
|
|
16
|
+
return n;
|
|
17
|
+
}
|
|
18
|
+
export function registerSimulationCommands(program) {
|
|
19
|
+
const sim = program
|
|
20
|
+
.command("simulation")
|
|
21
|
+
.alias("sim")
|
|
22
|
+
.description("Run and monitor simulations");
|
|
23
|
+
// --- Primary: `simulation run` ---
|
|
24
|
+
sim
|
|
25
|
+
.command("run")
|
|
26
|
+
.description("Run simulations (creates iteration + testers + starts simulations)")
|
|
27
|
+
.requiredOption("--workspace <id>", "Workspace ID")
|
|
28
|
+
.requiredOption("--study <id>", "Study ID")
|
|
29
|
+
.option("--profiles <ids>", "Comma-separated tester profile IDs (auto-selected from last iteration if omitted)")
|
|
30
|
+
.option("--iteration <id>", "Use existing iteration (skip creation)")
|
|
31
|
+
.option("--iteration-name <name>", "Name for new iteration (forces creating a new iteration)")
|
|
32
|
+
.option("--platform <platform>", "Platform (browser, android, figma, code)")
|
|
33
|
+
.option("--url <url>", "URL to test")
|
|
34
|
+
.option("--screen-format <format>", "Screen format (mobile_portrait, desktop)")
|
|
35
|
+
.option("--max-interactions <n>", "Max interactions per tester")
|
|
36
|
+
.option("--language <lang>", "Language code (e.g. en, sv)")
|
|
37
|
+
.option("--locale <locale>", "Locale code (e.g. en-US)")
|
|
38
|
+
.option("-y, --yes", "Skip confirmation prompt")
|
|
39
|
+
.addHelpText("after", `
|
|
40
|
+
Examples:
|
|
41
|
+
# Re-run with same settings and profiles as last time:
|
|
42
|
+
$ speqs sim run --workspace W --study S
|
|
43
|
+
|
|
44
|
+
# Re-run with different profiles:
|
|
45
|
+
$ speqs sim run --workspace W --study S --profiles p1,p2
|
|
46
|
+
|
|
47
|
+
# Force a new iteration:
|
|
48
|
+
$ speqs sim run --workspace W --study S --iteration-name "Round 2"
|
|
49
|
+
|
|
50
|
+
# First run (no previous iteration):
|
|
51
|
+
$ speqs sim run --workspace W --study S --profiles p1,p2 --url https://example.com`)
|
|
52
|
+
.action(async (opts, cmd) => {
|
|
53
|
+
await withClient(cmd, async (client, globals) => {
|
|
54
|
+
const log = (msg) => { if (!globals.quiet)
|
|
55
|
+
console.error(msg); };
|
|
56
|
+
// Step 0: Resolve defaults from latest iteration
|
|
57
|
+
let resolvedPlatform = opts.platform;
|
|
58
|
+
let resolvedUrl = opts.url;
|
|
59
|
+
let resolvedScreenFormat = opts.screenFormat;
|
|
60
|
+
let profileIds = opts.profiles
|
|
61
|
+
? opts.profiles.split(",").map((s) => s.trim()).filter(Boolean)
|
|
62
|
+
: [];
|
|
63
|
+
const profileNames = new Map();
|
|
64
|
+
let iterationId = opts.iteration;
|
|
65
|
+
if (!iterationId || profileIds.length === 0 || !resolvedPlatform || !resolvedScreenFormat) {
|
|
66
|
+
const study = await client.get(`/studies/${opts.study}`);
|
|
67
|
+
const iterations = study.iterations || [];
|
|
68
|
+
const latest = iterations.length > 0 ? iterations[iterations.length - 1] : null;
|
|
69
|
+
if (latest) {
|
|
70
|
+
const iterLabel = latest.label || latest.name || latest.id.slice(0, 8);
|
|
71
|
+
log(`Using iteration "${iterLabel}" as baseline`);
|
|
72
|
+
// Reuse iteration if not creating a new one
|
|
73
|
+
if (!iterationId && !opts.iterationName) {
|
|
74
|
+
iterationId = latest.id;
|
|
75
|
+
}
|
|
76
|
+
// Fill platform/url/screen-format from iteration details
|
|
77
|
+
const details = latest.details;
|
|
78
|
+
if (details) {
|
|
79
|
+
if (!resolvedPlatform)
|
|
80
|
+
resolvedPlatform = details.platform;
|
|
81
|
+
if (!resolvedUrl)
|
|
82
|
+
resolvedUrl = details.url;
|
|
83
|
+
if (!resolvedScreenFormat)
|
|
84
|
+
resolvedScreenFormat = details.screen_format || details.screenFormat;
|
|
85
|
+
}
|
|
86
|
+
// Auto-select profiles from latest iteration's testers
|
|
87
|
+
if (profileIds.length === 0 && latest.testers && latest.testers.length > 0) {
|
|
88
|
+
const seen = new Set();
|
|
89
|
+
for (const t of latest.testers) {
|
|
90
|
+
const pid = t.tester_profile_id || t.tester_profile?.id;
|
|
91
|
+
if (pid && !seen.has(pid)) {
|
|
92
|
+
seen.add(pid);
|
|
93
|
+
profileIds.push(pid);
|
|
94
|
+
const name = t.tester_profile?.name;
|
|
95
|
+
if (name)
|
|
96
|
+
profileNames.set(pid, name);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
// Apply hardcoded fallbacks
|
|
103
|
+
resolvedPlatform = resolvedPlatform || "browser";
|
|
104
|
+
resolvedScreenFormat = resolvedScreenFormat || "desktop";
|
|
105
|
+
if (profileIds.length === 0) {
|
|
106
|
+
outputError(new Error("No profiles specified and no previous iteration to copy from. Use --profiles <ids>."), globals.json);
|
|
107
|
+
process.exit(1);
|
|
108
|
+
}
|
|
109
|
+
// Confirmation step — show resolved settings
|
|
110
|
+
if (!globals.json && !opts.yes) {
|
|
111
|
+
log("");
|
|
112
|
+
log(" Simulation settings:");
|
|
113
|
+
log(` Platform: ${resolvedPlatform}`);
|
|
114
|
+
log(` Screen format: ${resolvedScreenFormat}`);
|
|
115
|
+
if (resolvedUrl)
|
|
116
|
+
log(` URL: ${resolvedUrl}`);
|
|
117
|
+
if (opts.language)
|
|
118
|
+
log(` Language: ${opts.language}`);
|
|
119
|
+
log(` Profiles (${profileIds.length}):`);
|
|
120
|
+
for (const pid of profileIds) {
|
|
121
|
+
const name = profileNames.get(pid);
|
|
122
|
+
log(` - ${name ? `${name} (${pid})` : pid}`);
|
|
123
|
+
}
|
|
124
|
+
log("");
|
|
125
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stderr });
|
|
126
|
+
const answer = await rl.question(" Proceed? [Y/n] ");
|
|
127
|
+
rl.close();
|
|
128
|
+
if (answer && !["y", "yes", ""].includes(answer.toLowerCase().trim())) {
|
|
129
|
+
log("Aborted.");
|
|
130
|
+
process.exit(0);
|
|
131
|
+
}
|
|
132
|
+
log("");
|
|
133
|
+
}
|
|
134
|
+
// Step 1: Create or use existing iteration
|
|
135
|
+
if (!iterationId) {
|
|
136
|
+
const iterName = opts.iterationName || `CLI ${new Date().toISOString().slice(0, 16)}`;
|
|
137
|
+
const iterBody = {
|
|
138
|
+
name: iterName,
|
|
139
|
+
details: {
|
|
140
|
+
type: "interactive",
|
|
141
|
+
platform: resolvedPlatform,
|
|
142
|
+
url: resolvedUrl || "",
|
|
143
|
+
screen_format: resolvedScreenFormat,
|
|
144
|
+
...(opts.locale && { locale: opts.locale }),
|
|
145
|
+
},
|
|
146
|
+
};
|
|
147
|
+
log(`Creating iteration "${iterName}"...`);
|
|
148
|
+
const iter = await client.post(`/studies/${opts.study}/iterations`, iterBody);
|
|
149
|
+
iterationId = iter.id;
|
|
150
|
+
log(`Created iteration "${iterName}"`);
|
|
151
|
+
}
|
|
152
|
+
// Step 2: Create testers from profiles
|
|
153
|
+
const testerInputs = profileIds.map((profileId) => ({
|
|
154
|
+
tester_profile_id: profileId,
|
|
155
|
+
tester_type: "ai",
|
|
156
|
+
status: "draft",
|
|
157
|
+
...(opts.language && { language: opts.language }),
|
|
158
|
+
platform: resolvedPlatform,
|
|
159
|
+
}));
|
|
160
|
+
log(`Creating ${testerInputs.length} tester${testerInputs.length > 1 ? "s" : ""}...`);
|
|
161
|
+
const batchResult = await client.post(`/iterations/${iterationId}/testers/batch`, { testers: testerInputs });
|
|
162
|
+
const createdTesters = batchResult.testers;
|
|
163
|
+
log(`Created ${createdTesters.length} tester${createdTesters.length > 1 ? "s" : ""}`);
|
|
164
|
+
// Step 3: Start simulations (batch) — config resolved from profiles by backend
|
|
165
|
+
const simItems = createdTesters.map((t) => ({
|
|
166
|
+
study_id: opts.study,
|
|
167
|
+
tester_id: t.id,
|
|
168
|
+
...(opts.language && { language: opts.language }),
|
|
169
|
+
...(opts.locale && { locale: opts.locale }),
|
|
170
|
+
}));
|
|
171
|
+
log(`Starting ${simItems.length} simulation${simItems.length > 1 ? "s" : ""}...`);
|
|
172
|
+
const simResult = await client.post("/simulation/interactive/start/batch", {
|
|
173
|
+
product_id: opts.workspace,
|
|
174
|
+
simulations: simItems,
|
|
175
|
+
platform: resolvedPlatform,
|
|
176
|
+
...(resolvedUrl && { url: resolvedUrl }),
|
|
177
|
+
screen_format: resolvedScreenFormat,
|
|
178
|
+
...(opts.maxInteractions && { max_interactions: parseMaxInteractions(opts.maxInteractions) }),
|
|
179
|
+
}, { timeout: 60_000 });
|
|
180
|
+
if (globals.json) {
|
|
181
|
+
output({
|
|
182
|
+
iteration_id: iterationId,
|
|
183
|
+
testers: createdTesters.map((t) => ({ id: t.id, profile_name: t.tester_profile?.name })),
|
|
184
|
+
simulations: simResult.results,
|
|
185
|
+
}, true);
|
|
186
|
+
}
|
|
187
|
+
else {
|
|
188
|
+
// Human-readable summary (to stderr — not data)
|
|
189
|
+
for (let i = 0; i < simResult.results.length; i++) {
|
|
190
|
+
const tester = createdTesters[i];
|
|
191
|
+
const profileName = tester?.tester_profile?.name || "Unknown";
|
|
192
|
+
log(` ${profileName.padEnd(24)} QUEUED`);
|
|
193
|
+
}
|
|
194
|
+
const url = getWebUrl(globals, `/${opts.workspace}/${opts.study}/timeline`);
|
|
195
|
+
log(`\n ${terminalLink(url, "Open in browser ↗")}\n`);
|
|
196
|
+
log(`Run \`speqs simulation poll --study ${opts.study}\` to check progress.`);
|
|
197
|
+
}
|
|
198
|
+
});
|
|
199
|
+
});
|
|
200
|
+
// --- Poll: check simulation progress ---
|
|
201
|
+
sim
|
|
202
|
+
.command("poll")
|
|
203
|
+
.description("Check simulation progress")
|
|
204
|
+
.argument("[job_id]", "Job ID (for single simulation)")
|
|
205
|
+
.option("--study <id>", "Study ID (poll all simulations for study)")
|
|
206
|
+
.addHelpText("after", "\nExamples:\n $ speqs simulation poll --study <study_id>\n $ speqs simulation poll <job_id> --json")
|
|
207
|
+
.action(async (jobId, opts, cmd) => {
|
|
208
|
+
await withClient(cmd, async (client, globals) => {
|
|
209
|
+
if (jobId) {
|
|
210
|
+
// Single job status
|
|
211
|
+
const data = await client.get(`/simulation/status/${jobId}`);
|
|
212
|
+
output(data, globals.json);
|
|
213
|
+
}
|
|
214
|
+
else if (opts.study) {
|
|
215
|
+
// Get study to find all testers with running/queued simulations
|
|
216
|
+
const study = await client.get(`/studies/${opts.study}`);
|
|
217
|
+
// Collect all testers across iterations
|
|
218
|
+
const allTesters = [];
|
|
219
|
+
for (const iteration of study.iterations || []) {
|
|
220
|
+
for (const tester of iteration.testers || []) {
|
|
221
|
+
allTesters.push({
|
|
222
|
+
id: tester.id,
|
|
223
|
+
status: tester.status,
|
|
224
|
+
tester_name: tester.tester_profile?.name || "Unknown",
|
|
225
|
+
interaction_count: Array.isArray(tester.interactions) ? tester.interactions.length : 0,
|
|
226
|
+
});
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
formatSimulationPoll(allTesters, globals.json);
|
|
230
|
+
if (!globals.json && study.product_id) {
|
|
231
|
+
const url = getWebUrl(globals, `/${study.product_id}/${opts.study}/timeline`);
|
|
232
|
+
console.error(`\n ${terminalLink(url, "Open in browser ↗")}\n`);
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
else {
|
|
236
|
+
outputError(new Error("Provide a job_id argument or --study flag"), globals.json);
|
|
237
|
+
process.exit(1);
|
|
238
|
+
}
|
|
239
|
+
});
|
|
240
|
+
});
|
|
241
|
+
// --- Lower-level commands ---
|
|
242
|
+
sim
|
|
243
|
+
.command("start")
|
|
244
|
+
.description("Start a single interactive simulation (low-level)")
|
|
245
|
+
.requiredOption("--workspace <id>", "Workspace ID")
|
|
246
|
+
.requiredOption("--study <id>", "Study ID")
|
|
247
|
+
.requiredOption("--tester <id>", "Tester ID")
|
|
248
|
+
.option("--config <id>", "Simulation config ID (resolved from profile if omitted)")
|
|
249
|
+
.option("--platform <platform>", "Platform (browser, android, figma, code)", "browser")
|
|
250
|
+
.option("--url <url>", "URL to test")
|
|
251
|
+
.option("--screen-format <format>", "Screen format (mobile_portrait, desktop)")
|
|
252
|
+
.option("--max-interactions <n>", "Max interactions")
|
|
253
|
+
.option("--language <lang>", "Language code")
|
|
254
|
+
.option("--locale <locale>", "Locale code")
|
|
255
|
+
.action(async (opts, cmd) => {
|
|
256
|
+
await withClient(cmd, async (client, globals) => {
|
|
257
|
+
const body = {
|
|
258
|
+
product_id: opts.workspace,
|
|
259
|
+
study_id: opts.study,
|
|
260
|
+
tester_id: opts.tester,
|
|
261
|
+
...(opts.config && { config_id: opts.config }),
|
|
262
|
+
platform: opts.platform,
|
|
263
|
+
...(opts.url && { url: opts.url }),
|
|
264
|
+
...(opts.screenFormat && { screen_format: opts.screenFormat }),
|
|
265
|
+
...(opts.maxInteractions && { max_interactions: parseMaxInteractions(opts.maxInteractions) }),
|
|
266
|
+
...(opts.language && { language: opts.language }),
|
|
267
|
+
...(opts.locale && { locale: opts.locale }),
|
|
268
|
+
};
|
|
269
|
+
const data = await client.post("/simulation/interactive/start", body);
|
|
270
|
+
output(data, globals.json);
|
|
271
|
+
});
|
|
272
|
+
});
|
|
273
|
+
sim
|
|
274
|
+
.command("start-media")
|
|
275
|
+
.description("Start a media simulation (low-level)")
|
|
276
|
+
.requiredOption("--workspace <id>", "Workspace ID")
|
|
277
|
+
.requiredOption("--study <id>", "Study ID")
|
|
278
|
+
.requiredOption("--tester <id>", "Tester ID")
|
|
279
|
+
.option("--config <id>", "Simulation config ID (resolved from profile if omitted)")
|
|
280
|
+
.option("--max-interactions <n>", "Max interactions")
|
|
281
|
+
.option("--language <lang>", "Language code")
|
|
282
|
+
.action(async (opts, cmd) => {
|
|
283
|
+
await withClient(cmd, async (client, globals) => {
|
|
284
|
+
const body = {
|
|
285
|
+
product_id: opts.workspace,
|
|
286
|
+
study_id: opts.study,
|
|
287
|
+
tester_id: opts.tester,
|
|
288
|
+
...(opts.config && { config_id: opts.config }),
|
|
289
|
+
...(opts.maxInteractions && { max_interactions: parseMaxInteractions(opts.maxInteractions) }),
|
|
290
|
+
...(opts.language && { language: opts.language }),
|
|
291
|
+
};
|
|
292
|
+
const data = await client.post("/simulation/media/start", body);
|
|
293
|
+
output(data, globals.json);
|
|
294
|
+
});
|
|
295
|
+
});
|
|
296
|
+
sim
|
|
297
|
+
.command("cancel")
|
|
298
|
+
.description("Cancel a simulation")
|
|
299
|
+
.argument("<job_id>", "Job ID")
|
|
300
|
+
.action(async (jobId, _opts, cmd) => {
|
|
301
|
+
await withClient(cmd, async (client, globals) => {
|
|
302
|
+
const data = await client.post(`/simulation/cancel/${jobId}`);
|
|
303
|
+
output(data, globals.json);
|
|
304
|
+
});
|
|
305
|
+
});
|
|
306
|
+
}
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* speqs study — Manage studies.
|
|
3
|
+
*/
|
|
4
|
+
import { withClient, getWebUrl, terminalLink } from "../lib/command-helpers.js";
|
|
5
|
+
import { formatStudyList, formatStudyDetail, formatStudyResults, output } from "../lib/output.js";
|
|
6
|
+
export function registerStudyCommands(program) {
|
|
7
|
+
const study = program
|
|
8
|
+
.command("study")
|
|
9
|
+
.description("Manage studies");
|
|
10
|
+
study
|
|
11
|
+
.command("list")
|
|
12
|
+
.description("List studies for a workspace")
|
|
13
|
+
.requiredOption("--workspace <id>", "Workspace ID")
|
|
14
|
+
.addHelpText("after", "\nExamples:\n $ speqs study list --workspace <id>\n $ speqs study list --workspace <id> --json")
|
|
15
|
+
.action(async (opts, cmd) => {
|
|
16
|
+
await withClient(cmd, async (client, globals) => {
|
|
17
|
+
const data = await client.get(`/products/${opts.workspace}/studies`);
|
|
18
|
+
formatStudyList(data, globals.json);
|
|
19
|
+
});
|
|
20
|
+
});
|
|
21
|
+
study
|
|
22
|
+
.command("create")
|
|
23
|
+
.description("Create a new study")
|
|
24
|
+
.requiredOption("--workspace <id>", "Workspace ID")
|
|
25
|
+
.requiredOption("--name <name>", "Study name")
|
|
26
|
+
.option("--description <description>", "Study description")
|
|
27
|
+
.option("--modality <modality>", "Study modality (interactive, video, audio, text, image)")
|
|
28
|
+
.option("--content-type <type>", "Content type")
|
|
29
|
+
.addHelpText("after", "\nExamples:\n $ speqs study create --workspace <id> --name \"Onboarding UX\" --modality interactive\n $ speqs study create --workspace <id> --name \"Landing Page\" --json")
|
|
30
|
+
.action(async (opts, cmd) => {
|
|
31
|
+
await withClient(cmd, async (client, globals) => {
|
|
32
|
+
const body = {
|
|
33
|
+
product_id: opts.workspace,
|
|
34
|
+
name: opts.name,
|
|
35
|
+
...(opts.description && { description: opts.description }),
|
|
36
|
+
...(opts.modality && { modality: opts.modality }),
|
|
37
|
+
...(opts.contentType && { content_type: opts.contentType }),
|
|
38
|
+
};
|
|
39
|
+
const data = await client.post(`/products/${opts.workspace}/studies`, body);
|
|
40
|
+
formatStudyDetail(data, globals.json);
|
|
41
|
+
if (!globals.json && data.id) {
|
|
42
|
+
const url = getWebUrl(globals, `/${opts.workspace}/${data.id}/overview`);
|
|
43
|
+
console.error(`\n ${terminalLink(url, "Open in browser ↗")}\n`);
|
|
44
|
+
}
|
|
45
|
+
});
|
|
46
|
+
});
|
|
47
|
+
study
|
|
48
|
+
.command("generate")
|
|
49
|
+
.description("Generate a study from a problem description using AI")
|
|
50
|
+
.requiredOption("--workspace <id>", "Workspace ID")
|
|
51
|
+
.requiredOption("--problem <description>", "Problem description (what you want to understand)")
|
|
52
|
+
.option("--target-url <url>", "URL of the product to test")
|
|
53
|
+
.addHelpText("after", "\nExamples:\n $ speqs study generate --workspace <id> --problem \"How do users navigate the onboarding flow?\"\n $ speqs study generate --workspace <id> --problem \"Test checkout\" --target-url https://example.com --json")
|
|
54
|
+
.action(async (opts, cmd) => {
|
|
55
|
+
await withClient(cmd, async (client, globals) => {
|
|
56
|
+
const body = {
|
|
57
|
+
problem_description: opts.problem,
|
|
58
|
+
...(opts.targetUrl && { target_url: opts.targetUrl }),
|
|
59
|
+
};
|
|
60
|
+
const data = await client.post(`/products/${opts.workspace}/studies/generate`, body);
|
|
61
|
+
formatStudyDetail(data, globals.json);
|
|
62
|
+
if (!globals.json && data.id) {
|
|
63
|
+
const url = getWebUrl(globals, `/${opts.workspace}/${data.id}/overview`);
|
|
64
|
+
console.error(`\n ${terminalLink(url, "Open in browser ↗")}\n`);
|
|
65
|
+
}
|
|
66
|
+
});
|
|
67
|
+
});
|
|
68
|
+
study
|
|
69
|
+
.command("get")
|
|
70
|
+
.description("Get study overview (assignments, questions, testers)")
|
|
71
|
+
.argument("<id>", "Study ID")
|
|
72
|
+
.addHelpText("after", "\nExamples:\n $ speqs study get <id>\n $ speqs study get <id> --json")
|
|
73
|
+
.action(async (id, _opts, cmd) => {
|
|
74
|
+
await withClient(cmd, async (client, globals) => {
|
|
75
|
+
const data = await client.get(`/studies/${id}`);
|
|
76
|
+
formatStudyDetail(data, globals.json);
|
|
77
|
+
if (!globals.json && data.product_id) {
|
|
78
|
+
const url = getWebUrl(globals, `/${data.product_id}/${id}/overview`);
|
|
79
|
+
console.error(`\n ${terminalLink(url, "Open in browser ↗")}\n`);
|
|
80
|
+
}
|
|
81
|
+
});
|
|
82
|
+
});
|
|
83
|
+
study
|
|
84
|
+
.command("results")
|
|
85
|
+
.description("View aggregated results (sentiment, interview answers)")
|
|
86
|
+
.argument("<id>", "Study ID")
|
|
87
|
+
.addHelpText("after", "\nExamples:\n $ speqs study results <id>\n $ speqs study results <id> --json")
|
|
88
|
+
.action(async (id, _opts, cmd) => {
|
|
89
|
+
await withClient(cmd, async (client, globals) => {
|
|
90
|
+
const data = await client.get(`/studies/${id}`);
|
|
91
|
+
formatStudyResults(data, globals.json);
|
|
92
|
+
if (!globals.json && data.product_id) {
|
|
93
|
+
const url = getWebUrl(globals, `/${data.product_id}/${id}/overview`);
|
|
94
|
+
console.error(`\n ${terminalLink(url, "Open in browser ↗")}\n`);
|
|
95
|
+
}
|
|
96
|
+
});
|
|
97
|
+
});
|
|
98
|
+
study
|
|
99
|
+
.command("update")
|
|
100
|
+
.description("Update a study")
|
|
101
|
+
.argument("<id>", "Study ID")
|
|
102
|
+
.option("--name <name>", "Study name")
|
|
103
|
+
.option("--description <description>", "Study description")
|
|
104
|
+
.option("--status <status>", "Study status (draft, running, completed)")
|
|
105
|
+
.option("--modality <modality>", "Study modality")
|
|
106
|
+
.action(async (id, opts, cmd) => {
|
|
107
|
+
await withClient(cmd, async (client, globals) => {
|
|
108
|
+
const body = {};
|
|
109
|
+
if (opts.name !== undefined)
|
|
110
|
+
body.name = opts.name;
|
|
111
|
+
if (opts.description !== undefined)
|
|
112
|
+
body.description = opts.description;
|
|
113
|
+
if (opts.status !== undefined)
|
|
114
|
+
body.status = opts.status;
|
|
115
|
+
if (opts.modality !== undefined)
|
|
116
|
+
body.modality = opts.modality;
|
|
117
|
+
const data = await client.put(`/studies/${id}`, body);
|
|
118
|
+
formatStudyDetail(data, globals.json);
|
|
119
|
+
});
|
|
120
|
+
});
|
|
121
|
+
study
|
|
122
|
+
.command("delete")
|
|
123
|
+
.description("Delete a study")
|
|
124
|
+
.argument("<id>", "Study ID")
|
|
125
|
+
.action(async (id, _opts, cmd) => {
|
|
126
|
+
await withClient(cmd, async (client, globals) => {
|
|
127
|
+
await client.del(`/studies/${id}`);
|
|
128
|
+
output({ message: "Study deleted" }, globals.json);
|
|
129
|
+
});
|
|
130
|
+
});
|
|
131
|
+
}
|