speqs 0.5.1 → 0.7.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.
Files changed (40) hide show
  1. package/dist/commands/config.js +13 -4
  2. package/dist/commands/iteration.js +64 -13
  3. package/dist/commands/simulation.d.ts +1 -1
  4. package/dist/commands/simulation.js +454 -121
  5. package/dist/commands/study.js +140 -27
  6. package/dist/commands/tester-profile.js +17 -4
  7. package/dist/commands/tester.js +12 -3
  8. package/dist/commands/workspace.js +51 -6
  9. package/dist/config.d.ts +2 -0
  10. package/dist/index.js +3 -1
  11. package/dist/lib/alias-store.d.ts +22 -3
  12. package/dist/lib/alias-store.js +60 -12
  13. package/dist/lib/api-client.d.ts +31 -0
  14. package/dist/lib/api-client.js +83 -27
  15. package/dist/lib/auth.js +4 -1
  16. package/dist/lib/command-helpers.d.ts +4 -0
  17. package/dist/lib/command-helpers.js +41 -4
  18. package/dist/lib/local-sim/actions.d.ts +22 -0
  19. package/dist/lib/local-sim/actions.js +379 -0
  20. package/dist/lib/local-sim/browser.d.ts +63 -0
  21. package/dist/lib/local-sim/browser.js +332 -0
  22. package/dist/lib/local-sim/debug-report.d.ts +21 -0
  23. package/dist/lib/local-sim/debug-report.js +186 -0
  24. package/dist/lib/local-sim/debug.d.ts +44 -0
  25. package/dist/lib/local-sim/debug.js +103 -0
  26. package/dist/lib/local-sim/install.d.ts +25 -0
  27. package/dist/lib/local-sim/install.js +72 -0
  28. package/dist/lib/local-sim/loop.d.ts +60 -0
  29. package/dist/lib/local-sim/loop.js +526 -0
  30. package/dist/lib/local-sim/types.d.ts +232 -0
  31. package/dist/lib/local-sim/types.js +8 -0
  32. package/dist/lib/local-sim/upload.d.ts +6 -0
  33. package/dist/lib/local-sim/upload.js +24 -0
  34. package/dist/lib/output.d.ts +16 -1
  35. package/dist/lib/output.js +250 -61
  36. package/dist/lib/types.d.ts +7 -30
  37. package/dist/lib/types.js +9 -1
  38. package/dist/lib/upload.d.ts +47 -0
  39. package/dist/lib/upload.js +178 -0
  40. package/package.json +3 -2
@@ -4,18 +4,138 @@
4
4
  * Primary command: `speqs simulation run` — orchestrates the full flow:
5
5
  * 1. Creates iteration (if not provided)
6
6
  * 2. Creates testers from profiles
7
- * 3. Starts simulations
7
+ * 3. Starts simulations (interactive or media, based on study modality)
8
8
  */
9
9
  import * as readline from "node:readline/promises";
10
- import { withClient, getWebUrl, terminalLink } from "../lib/command-helpers.js";
10
+ import { withClient, getWebUrl, terminalLink, resolveWorkspace, resolveStudy } from "../lib/command-helpers.js";
11
11
  import { resolveId } from "../lib/alias-store.js";
12
- import { output, outputError, formatSimulationPoll } from "../lib/output.js";
12
+ import { output, formatSimulationPoll } from "../lib/output.js";
13
13
  function parseMaxInteractions(value) {
14
14
  const n = parseInt(value, 10);
15
15
  if (isNaN(n) || n < 1)
16
16
  throw new Error(`Invalid --max-interactions value: ${value}`);
17
17
  return n;
18
18
  }
19
+ function parseSlowMo(value) {
20
+ const n = parseInt(value, 10);
21
+ if (isNaN(n) || n < 0)
22
+ throw new Error(`Invalid --slow-mo value: ${value}`);
23
+ return n;
24
+ }
25
+ import { MEDIA_MODALITIES } from "../lib/types.js";
26
+ import { resolveContentUrl, resolveContentUrls, resolveTextContent } from "../lib/upload.js";
27
+ import { runLocalSimulations } from "../lib/local-sim/loop.js";
28
+ import { ensureBrowser } from "../lib/local-sim/install.js";
29
+ function isMediaModality(modality) {
30
+ return !!modality && MEDIA_MODALITIES.includes(modality);
31
+ }
32
+ /**
33
+ * Build iteration details based on study modality and CLI options.
34
+ */
35
+ function buildCopyContent(opts) {
36
+ if (!opts.copyText)
37
+ return undefined;
38
+ return {
39
+ text: opts.copyText,
40
+ ...(opts.copyHtml && { html: opts.copyHtml }),
41
+ ...(opts.socialPlatform && { social_platform: opts.socialPlatform }),
42
+ ...(opts.copyPosition && { position: opts.copyPosition }),
43
+ };
44
+ }
45
+ function buildIterationDetails(modality, opts) {
46
+ switch (modality) {
47
+ case "text":
48
+ if (!opts.contentText) {
49
+ throw new Error("Text studies require --content-text. Provide the text content to evaluate.");
50
+ }
51
+ return {
52
+ type: "text",
53
+ content_text: opts.contentText,
54
+ ...(opts.contentHtml && { content_html: opts.contentHtml }),
55
+ ...(opts.title && { title: opts.title }),
56
+ ...(opts.mimeType && { mime_type: opts.mimeType }),
57
+ };
58
+ case "video":
59
+ case "audio": {
60
+ if (!opts.contentUrl) {
61
+ throw new Error(`${modality} studies require --content-url. Provide the URL to the ${modality} file.`);
62
+ }
63
+ const copy = buildCopyContent(opts);
64
+ return {
65
+ type: modality,
66
+ content_url: opts.contentUrl,
67
+ ...(opts.title && { title: opts.title }),
68
+ ...(opts.mimeType && { mime_type: opts.mimeType }),
69
+ ...(copy && { copy_content: copy }),
70
+ };
71
+ }
72
+ case "image": {
73
+ if (!opts.imageUrls) {
74
+ throw new Error("Image studies require --image-urls. Provide comma-separated image URLs.");
75
+ }
76
+ const copy = buildCopyContent(opts);
77
+ return {
78
+ type: "image",
79
+ image_urls: opts.imageUrls.split(",").map((s) => s.trim()).filter(Boolean),
80
+ ...(opts.title && { title: opts.title }),
81
+ ...(opts.mimeType && { mime_type: opts.mimeType }),
82
+ ...(copy && { copy_content: copy }),
83
+ };
84
+ }
85
+ case "document":
86
+ if (!opts.contentUrl) {
87
+ throw new Error("Document studies require --content-url. Provide the URL to the document.");
88
+ }
89
+ return {
90
+ type: "document",
91
+ content_url: opts.contentUrl,
92
+ ...(opts.title && { title: opts.title }),
93
+ ...(opts.mimeType && { mime_type: opts.mimeType }),
94
+ };
95
+ default:
96
+ // Interactive
97
+ if (!opts.url) {
98
+ throw new Error("Interactive studies require --url. Provide the URL to test.");
99
+ }
100
+ return {
101
+ type: "interactive",
102
+ platform: opts.platform || "browser",
103
+ url: opts.url,
104
+ screen_format: opts.screenFormat || "desktop",
105
+ ...(opts.locale && { locale: opts.locale }),
106
+ };
107
+ }
108
+ }
109
+ /**
110
+ * Copy relevant fields from a previous iteration's details for reuse.
111
+ */
112
+ function copyDetailsFromPrevious(modality, details) {
113
+ if (isMediaModality(modality)) {
114
+ const copy = details.copy_content;
115
+ return {
116
+ ...(typeof details.content_text === "string" && { contentText: details.content_text }),
117
+ ...(typeof details.content_html === "string" && { contentHtml: details.content_html }),
118
+ ...(typeof details.content_url === "string" && { contentUrl: details.content_url }),
119
+ ...(Array.isArray(details.image_urls) && { imageUrls: details.image_urls.join(",") }),
120
+ ...(typeof details.title === "string" && { title: details.title }),
121
+ ...(typeof details.mime_type === "string" && { mimeType: details.mime_type }),
122
+ ...(copy && typeof copy.text === "string" && { copyText: copy.text }),
123
+ ...(copy && typeof copy.html === "string" && { copyHtml: copy.html }),
124
+ ...(copy && typeof copy.social_platform === "string" && { socialPlatform: copy.social_platform }),
125
+ ...(copy && typeof copy.position === "string" && { copyPosition: copy.position }),
126
+ };
127
+ }
128
+ const screenFormat = typeof details.screen_format === "string"
129
+ ? details.screen_format
130
+ : typeof details.screenFormat === "string"
131
+ ? details.screenFormat
132
+ : undefined;
133
+ return {
134
+ ...(typeof details.platform === "string" && { platform: details.platform }),
135
+ ...(typeof details.url === "string" && { url: details.url }),
136
+ ...(screenFormat && { screenFormat }),
137
+ };
138
+ }
19
139
  export function registerSimulationCommands(program) {
20
140
  const sim = program
21
141
  .command("simulation")
@@ -25,102 +145,236 @@ export function registerSimulationCommands(program) {
25
145
  sim
26
146
  .command("run")
27
147
  .description("Run simulations (creates iteration + testers + starts simulations)")
28
- .requiredOption("--workspace <id>", "Workspace ID")
29
- .requiredOption("--study <id>", "Study ID")
148
+ .option("--workspace <id>", "Workspace ID")
149
+ .option("--study <id>", "Study ID")
30
150
  .option("--profiles <ids>", "Comma-separated tester profile IDs (auto-selected from last iteration if omitted)")
31
151
  .option("--iteration <id>", "Use existing iteration (skip creation)")
32
152
  .option("--iteration-name <name>", "Name for new iteration (forces creating a new iteration)")
33
- .option("--platform <platform>", "Platform (browser, android, figma, code)")
34
- .option("--url <url>", "URL to test")
35
- .option("--screen-format <format>", "Screen format (mobile_portrait, desktop)")
153
+ // Interactive options
154
+ .option("--platform <platform>", "Platform (browser, android, figma, code) — interactive only")
155
+ .option("--url <url>", "URL to test — interactive only")
156
+ .option("--screen-format <format>", "Screen format (mobile_portrait, desktop) — interactive only")
157
+ // Media options
158
+ .option("--content-text <text>", "Text content to evaluate, or @filepath to read from file — text modality")
159
+ .option("--content-html <html>", "HTML version of the text, or @filepath to read from file — text modality")
160
+ .option("--content-url <url>", "URL or local file path to media file — video, audio, document modalities")
161
+ .option("--image-urls <urls>", "Comma-separated image URLs or local file paths — image modality")
162
+ .option("--title <title>", "Content title — media modalities")
163
+ .option("--mime-type <type>", "MIME type (e.g. video/mp4) — media modalities")
164
+ // Copy/caption options (ads & social posts)
165
+ .option("--copy-text <text>", "Ad copy or social post caption (or @filepath) — ads & social posts")
166
+ .option("--copy-html <html>", "HTML version of copy text (or @filepath)")
167
+ .option("--social-platform <platform>", "Social platform (instagram, tiktok, facebook, linkedin, x)")
168
+ .option("--copy-position <pos>", "Copy position relative to media (before, after)", "after")
169
+ .option("--config <id>", "Simulation config ID (required for media, auto-resolved for interactive)")
170
+ // Shared options
36
171
  .option("--max-interactions <n>", "Max interactions per tester")
37
172
  .option("--language <lang>", "Language code (e.g. en, sv)")
38
173
  .option("--locale <locale>", "Locale code (e.g. en-US)")
39
174
  .option("-y, --yes", "Skip confirmation prompt")
175
+ // Local simulation options
176
+ .option("--local", "Run simulation with local browser (Playwright) instead of remote")
177
+ .option("--headed", "Show browser window (local mode only)")
178
+ .option("--slow-mo <ms>", "Slow down actions by ms (local mode only)")
179
+ .option("--devtools", "Open Chrome DevTools (local mode only)")
180
+ .option("--debug", "Enable detailed debug logging to stderr and ~/.speqs/local-sim.log")
181
+ .option("--parallel <n>", "Run N testers in parallel (local mode only, default: all)")
40
182
  .addHelpText("after", `
183
+ Note: --workspace and --study are optional if you have set active context
184
+ via \`speqs workspace use <alias>\` and \`speqs study use <alias>\`.
185
+ Profiles and iteration settings are auto-reused from the latest iteration.
186
+
41
187
  Examples:
42
- # Re-run with same settings and profiles as last time:
43
- $ speqs sim run --workspace W --study S
188
+ # Re-run with same settings (after setting context):
189
+ $ speqs sim run -y
190
+
191
+ # Interactive — first run:
192
+ $ speqs sim run --workspace w-6ec --study s-b2c --profiles tp-795,tp-af2 --url https://example.com
193
+
194
+ # Text/email (inline or from file):
195
+ $ speqs sim run --content-text "Your email content..." --config c-c3c
196
+ $ speqs sim run --content-text @./email.html --config c-c3c
197
+
198
+ # Video (URL or local file):
199
+ $ speqs sim run --content-url ./video.mp4 --config c-c3c
44
200
 
45
- # Re-run with different profiles:
46
- $ speqs sim run --workspace W --study S --profiles p1,p2
201
+ # Image (local files):
202
+ $ speqs sim run --image-urls "./a.png,./b.png" --config c-c3c
47
203
 
48
- # Force a new iteration:
49
- $ speqs sim run --workspace W --study S --iteration-name "Round 2"
204
+ # Document:
205
+ $ speqs sim run --content-url ./report.pdf --config c-c3c
50
206
 
51
- # First run (no previous iteration):
52
- $ speqs sim run --workspace W --study S --profiles p1,p2 --url https://example.com`)
207
+ # Video ad with copy text:
208
+ $ speqs sim run --content-url ./ad.mp4 --copy-text "Buy now 50% off!" --config c-c3c
209
+
210
+ # Social post with caption:
211
+ $ speqs sim run --image-urls ./post.png --copy-text @./caption.txt --social-platform instagram --config c-c3c
212
+
213
+ # Re-run existing iteration:
214
+ $ speqs sim run --iteration i-d4e
215
+
216
+ # Local browser simulation (no remote Browserbase):
217
+ $ speqs sim run --local --url http://localhost:3000
218
+ $ speqs sim run --local --url http://localhost:3000 --headed --slow-mo 500`)
53
219
  .action(async (opts, cmd) => {
54
220
  await withClient(cmd, async (client, globals) => {
55
221
  const log = (msg) => { if (!globals.quiet)
56
222
  console.error(msg); };
57
- // Step 0: Resolve defaults from latest iteration
58
- let resolvedPlatform = opts.platform;
59
- let resolvedUrl = opts.url;
60
- let resolvedScreenFormat = opts.screenFormat;
223
+ const resolvedWorkspace = resolveWorkspace(opts.workspace);
224
+ const resolvedStudy = resolveStudy(opts.study);
225
+ if (opts.iteration && opts.iterationName) {
226
+ throw new Error("Cannot use both --iteration and --iteration-name. Use --iteration to reuse an existing iteration, or --iteration-name to create a new one.");
227
+ }
228
+ // Step 0: Fetch study to determine modality and resolve defaults
229
+ const study = await client.get(`/studies/${resolvedStudy}`);
230
+ const modality = study.modality || "interactive";
231
+ const isMedia = isMediaModality(modality);
232
+ if (!study.assignments || study.assignments.length === 0) {
233
+ throw new Error("Study has no assignments. Add tasks with --assignments when creating the study, or use `speqs study generate`.");
234
+ }
235
+ // Validate conflicting options
236
+ if (isMedia && opts.url) {
237
+ throw new Error(`This study uses "${modality}" modality — --url is for interactive studies. Use --content-text, --content-url, or --image-urls instead.`);
238
+ }
239
+ if (!isMedia && (opts.contentText || opts.contentUrl || opts.imageUrls)) {
240
+ throw new Error(`This study uses "interactive" modality — --content-text, --content-url, and --image-urls are for media studies. Use --url instead.`);
241
+ }
242
+ // Resolve defaults from latest iteration
61
243
  let profileIds = opts.profiles
62
244
  ? opts.profiles.split(",").map((s) => s.trim()).filter(Boolean).map(resolveId)
63
245
  : [];
64
246
  const profileNames = new Map();
65
247
  let iterationId = opts.iteration ? resolveId(opts.iteration) : undefined;
66
- const resolvedWorkspace = resolveId(opts.workspace);
67
- const resolvedStudy = resolveId(opts.study);
68
- if (!iterationId || profileIds.length === 0 || !resolvedPlatform || !resolvedScreenFormat) {
69
- const study = await client.get(`/studies/${resolvedStudy}`);
70
- if (!study.assignments || study.assignments.length === 0) {
71
- outputError(new Error("Study has no assignments. Add tasks with --assignments when creating the study, or use `speqs study generate`."), globals.json);
72
- process.exit(1);
248
+ // Mutable copies for resolving from previous iteration
249
+ let resolvedOpts = { ...opts };
250
+ const iterations = study.iterations || [];
251
+ const latest = iterations.length > 0 ? iterations[iterations.length - 1] : null;
252
+ if (latest) {
253
+ const iterLabel = latest.label || latest.name || latest.id.slice(0, 8);
254
+ log(`Using iteration "${iterLabel}" as baseline`);
255
+ // Reuse iteration if not creating a new one
256
+ if (!iterationId && !opts.iterationName) {
257
+ iterationId = latest.id;
73
258
  }
74
- const iterations = study.iterations || [];
75
- const latest = iterations.length > 0 ? iterations[iterations.length - 1] : null;
76
- if (latest) {
77
- const iterLabel = latest.label || latest.name || latest.id.slice(0, 8);
78
- log(`Using iteration "${iterLabel}" as baseline`);
79
- // Reuse iteration if not creating a new one
80
- if (!iterationId && !opts.iterationName) {
81
- iterationId = latest.id;
82
- }
83
- // Fill platform/url/screen-format from iteration details
84
- const details = latest.details;
85
- if (details) {
86
- if (!resolvedPlatform)
87
- resolvedPlatform = details.platform;
88
- if (!resolvedUrl)
89
- resolvedUrl = details.url;
90
- if (!resolvedScreenFormat)
91
- resolvedScreenFormat = details.screen_format || details.screenFormat;
92
- }
93
- // Auto-select profiles from latest iteration's testers
94
- if (profileIds.length === 0 && latest.testers && latest.testers.length > 0) {
95
- const seen = new Set();
96
- for (const t of latest.testers) {
97
- const pid = t.tester_profile_id || t.tester_profile?.id;
98
- if (pid && !seen.has(pid)) {
99
- seen.add(pid);
100
- profileIds.push(pid);
101
- const name = t.tester_profile?.name;
102
- if (name)
103
- profileNames.set(pid, name);
104
- }
259
+ // Fill defaults from previous iteration details
260
+ const details = latest.details;
261
+ if (details) {
262
+ const prev = copyDetailsFromPrevious(modality, details);
263
+ if (!resolvedOpts.platform && prev.platform)
264
+ resolvedOpts.platform = prev.platform;
265
+ if (!resolvedOpts.url && prev.url)
266
+ resolvedOpts.url = prev.url;
267
+ if (!resolvedOpts.screenFormat && prev.screenFormat)
268
+ resolvedOpts.screenFormat = prev.screenFormat;
269
+ if (!resolvedOpts.contentText && prev.contentText)
270
+ resolvedOpts.contentText = prev.contentText;
271
+ if (!resolvedOpts.contentHtml && prev.contentHtml)
272
+ resolvedOpts.contentHtml = prev.contentHtml;
273
+ if (!resolvedOpts.contentUrl && prev.contentUrl)
274
+ resolvedOpts.contentUrl = prev.contentUrl;
275
+ if (!resolvedOpts.imageUrls && prev.imageUrls)
276
+ resolvedOpts.imageUrls = prev.imageUrls;
277
+ if (!resolvedOpts.title && prev.title)
278
+ resolvedOpts.title = prev.title;
279
+ if (!resolvedOpts.mimeType && prev.mimeType)
280
+ resolvedOpts.mimeType = prev.mimeType;
281
+ if (!resolvedOpts.copyText && prev.copyText)
282
+ resolvedOpts.copyText = prev.copyText;
283
+ if (!resolvedOpts.copyHtml && prev.copyHtml)
284
+ resolvedOpts.copyHtml = prev.copyHtml;
285
+ if (!resolvedOpts.socialPlatform && prev.socialPlatform)
286
+ resolvedOpts.socialPlatform = prev.socialPlatform;
287
+ if (!resolvedOpts.copyPosition && prev.copyPosition)
288
+ resolvedOpts.copyPosition = prev.copyPosition;
289
+ }
290
+ // Auto-select profiles from latest iteration's testers
291
+ if (profileIds.length === 0 && latest.testers && latest.testers.length > 0) {
292
+ const seen = new Set();
293
+ for (const t of latest.testers) {
294
+ const pid = t.tester_profile_id || t.tester_profile?.id;
295
+ if (pid && !seen.has(pid)) {
296
+ seen.add(pid);
297
+ profileIds.push(pid);
298
+ const name = t.tester_profile?.name;
299
+ if (name)
300
+ profileNames.set(pid, name);
105
301
  }
106
302
  }
107
303
  }
108
304
  }
109
- // Apply hardcoded fallbacks
110
- resolvedPlatform = resolvedPlatform || "browser";
111
- resolvedScreenFormat = resolvedScreenFormat || "desktop";
112
305
  if (profileIds.length === 0) {
113
- outputError(new Error("No profiles specified and no previous iteration to copy from. Use --profiles <ids>."), globals.json);
114
- process.exit(1);
306
+ throw new Error("No profiles specified and no previous iteration to copy from. Use --profiles <ids>.");
307
+ }
308
+ // Resolve local file paths → upload to storage and get URLs
309
+ if (isMedia) {
310
+ if (resolvedOpts.contentText) {
311
+ resolvedOpts.contentText = resolveTextContent(resolvedOpts.contentText);
312
+ }
313
+ if (resolvedOpts.contentHtml) {
314
+ resolvedOpts.contentHtml = resolveTextContent(resolvedOpts.contentHtml);
315
+ }
316
+ if (resolvedOpts.copyText) {
317
+ resolvedOpts.copyText = resolveTextContent(resolvedOpts.copyText);
318
+ }
319
+ if (resolvedOpts.copyHtml) {
320
+ resolvedOpts.copyHtml = resolveTextContent(resolvedOpts.copyHtml);
321
+ }
322
+ if (resolvedOpts.contentUrl) {
323
+ resolvedOpts.contentUrl = await resolveContentUrl(client, resolvedStudy, resolvedOpts.contentUrl, { mimeTypeOverride: resolvedOpts.mimeType, quiet: globals.quiet });
324
+ }
325
+ if (resolvedOpts.imageUrls) {
326
+ const urls = await resolveContentUrls(client, resolvedStudy, resolvedOpts.imageUrls, { mimeTypeOverride: resolvedOpts.mimeType, quiet: globals.quiet });
327
+ resolvedOpts.imageUrls = urls.join(",");
328
+ }
115
329
  }
116
- // Confirmation step show resolved settings
330
+ // Resolve config_id for media simulations
331
+ const resolvedConfigOverride = opts.config ? resolveId(opts.config) : undefined;
332
+ const profileConfigMap = new Map();
333
+ if (isMedia && !resolvedConfigOverride) {
334
+ // Resolve config from each profile's simulation_config_id
335
+ for (const pid of profileIds) {
336
+ const profile = await client.get(`/tester-profiles/${pid}`);
337
+ if (profile.simulation_config_id) {
338
+ profileConfigMap.set(pid, profile.simulation_config_id);
339
+ }
340
+ else {
341
+ throw new Error(`Profile ${profileNames.get(pid) || pid} has no simulation config assigned.\n` +
342
+ "Use --config <id> to specify one, or assign a config to the profile.\n" +
343
+ "List configs with: speqs config list");
344
+ }
345
+ }
346
+ }
347
+ // Confirmation step
117
348
  if (!globals.json && !opts.yes) {
118
349
  log("");
119
350
  log(" Simulation settings:");
120
- log(` Platform: ${resolvedPlatform}`);
121
- log(` Screen format: ${resolvedScreenFormat}`);
122
- if (resolvedUrl)
123
- log(` URL: ${resolvedUrl}`);
351
+ log(` Modality: ${modality}`);
352
+ if (study.content_type)
353
+ log(` Content type: ${study.content_type}`);
354
+ if (isMedia) {
355
+ if (resolvedOpts.title)
356
+ log(` Title: ${resolvedOpts.title}`);
357
+ if (resolvedOpts.contentText)
358
+ log(` Content: ${resolvedOpts.contentText.slice(0, 80)}${resolvedOpts.contentText.length > 80 ? "..." : ""}`);
359
+ if (resolvedOpts.contentUrl)
360
+ log(` Content URL: ${resolvedOpts.contentUrl}`);
361
+ if (resolvedOpts.imageUrls)
362
+ log(` Image URLs: ${resolvedOpts.imageUrls}`);
363
+ if (resolvedOpts.mimeType)
364
+ log(` MIME type: ${resolvedOpts.mimeType}`);
365
+ if (resolvedOpts.copyText)
366
+ log(` Copy text: ${resolvedOpts.copyText.slice(0, 80)}${resolvedOpts.copyText.length > 80 ? "..." : ""}`);
367
+ if (resolvedOpts.socialPlatform)
368
+ log(` Platform: ${resolvedOpts.socialPlatform}`);
369
+ if (resolvedConfigOverride)
370
+ log(` Config: ${resolvedConfigOverride}`);
371
+ }
372
+ else {
373
+ log(` Platform: ${resolvedOpts.platform || "browser"}`);
374
+ log(` Screen format: ${resolvedOpts.screenFormat || "desktop"}`);
375
+ if (resolvedOpts.url)
376
+ log(` URL: ${resolvedOpts.url}`);
377
+ }
124
378
  if (opts.language)
125
379
  log(` Language: ${opts.language}`);
126
380
  log(` Profiles (${profileIds.length}):`);
@@ -138,62 +392,136 @@ Examples:
138
392
  }
139
393
  log("");
140
394
  }
395
+ // Ensure browser is ready before creating server-side state
396
+ if (opts.local) {
397
+ await ensureBrowser({ quiet: globals.quiet, skipPrompt: globals.json });
398
+ }
141
399
  // Step 1: Create or use existing iteration
142
400
  if (!iterationId) {
143
- const iterName = opts.iterationName || `CLI ${new Date().toISOString().slice(0, 16)}`;
401
+ const iterName = resolvedOpts.iterationName || `CLI ${new Date().toISOString().slice(0, 16)}`;
144
402
  const iterBody = {
145
403
  name: iterName,
146
- details: {
147
- type: "interactive",
148
- platform: resolvedPlatform,
149
- url: resolvedUrl || "",
150
- screen_format: resolvedScreenFormat,
151
- ...(opts.locale && { locale: opts.locale }),
152
- },
404
+ details: buildIterationDetails(modality, resolvedOpts),
153
405
  };
154
406
  log(`Creating iteration "${iterName}"...`);
155
407
  const iter = await client.post(`/studies/${resolvedStudy}/iterations`, iterBody);
156
408
  iterationId = iter.id;
157
409
  log(`Created iteration "${iterName}"`);
158
410
  }
159
- // Step 2: Create testers from profiles
160
- const testerInputs = profileIds.map((profileId) => ({
161
- tester_profile_id: profileId,
162
- tester_type: "ai",
163
- status: "draft",
164
- ...(opts.language && { language: opts.language }),
165
- platform: resolvedPlatform,
166
- }));
167
- log(`Creating ${testerInputs.length} tester${testerInputs.length > 1 ? "s" : ""}...`);
168
- const batchResult = await client.post(`/iterations/${iterationId}/testers/batch`, { testers: testerInputs });
169
- const createdTesters = batchResult.testers;
170
- log(`Created ${createdTesters.length} tester${createdTesters.length > 1 ? "s" : ""}`);
171
- // Step 3: Start simulations (batch) config resolved from profiles by backend
172
- const simItems = createdTesters.map((t) => ({
173
- study_id: resolvedStudy,
174
- tester_id: t.id,
175
- ...(opts.language && { language: opts.language }),
176
- ...(opts.locale && { locale: opts.locale }),
177
- }));
178
- log(`Starting ${simItems.length} simulation${simItems.length > 1 ? "s" : ""}...`);
179
- const simResult = await client.post("/simulation/interactive/start/batch", {
180
- product_id: resolvedWorkspace,
181
- simulations: simItems,
182
- platform: resolvedPlatform,
183
- ...(resolvedUrl && { url: resolvedUrl }),
184
- screen_format: resolvedScreenFormat,
185
- ...(opts.maxInteractions && { max_interactions: parseMaxInteractions(opts.maxInteractions) }),
186
- }, { timeout: 60_000 });
411
+ else if (!opts.iteration) {
412
+ // Auto-reused iteration update its details to reflect current run
413
+ const newDetails = buildIterationDetails(modality, resolvedOpts);
414
+ await client.put(`/iterations/${iterationId}`, { details: newDetails });
415
+ }
416
+ // Step 2: Create testers from profiles (or reuse from explicit iteration)
417
+ let createdTesters;
418
+ if (opts.iteration && !opts.profiles) {
419
+ // Reuse existing testers from the explicitly provided iteration
420
+ const existingIter = await client.get(`/iterations/${iterationId}`);
421
+ if (existingIter.testers && existingIter.testers.length > 0) {
422
+ createdTesters = existingIter.testers;
423
+ log(`Reusing ${createdTesters.length} existing tester${createdTesters.length > 1 ? "s" : ""} from iteration`);
424
+ }
425
+ else {
426
+ throw new Error("Iteration has no existing testers. Use --profiles to create new testers.");
427
+ }
428
+ }
429
+ else {
430
+ const testerInputs = profileIds.map((profileId) => ({
431
+ tester_profile_id: profileId,
432
+ tester_type: "ai",
433
+ status: "draft",
434
+ ...(opts.language && { language: opts.language }),
435
+ ...(!isMedia && { platform: resolvedOpts.platform || "browser" }),
436
+ }));
437
+ log(`Creating ${testerInputs.length} tester${testerInputs.length > 1 ? "s" : ""}...`);
438
+ const batchResult = await client.post(`/iterations/${iterationId}/testers/batch`, { testers: testerInputs });
439
+ createdTesters = batchResult.testers;
440
+ log(`Created ${createdTesters.length} tester${createdTesters.length > 1 ? "s" : ""}`);
441
+ }
442
+ // Step 3: Start simulations
443
+ // Local mode: run simulations with local browser
444
+ if (opts.local) {
445
+ if (isMedia) {
446
+ throw new Error("Local mode is only supported for interactive simulations.");
447
+ }
448
+ const testerNameMap = new Map();
449
+ for (const t of createdTesters) {
450
+ testerNameMap.set(t.id, t.tester_profile?.name ?? "Unknown");
451
+ }
452
+ await runLocalSimulations(client, {
453
+ workspaceId: resolvedWorkspace,
454
+ studyId: resolvedStudy,
455
+ iterationId: iterationId,
456
+ testerIds: createdTesters.map((t) => t.id),
457
+ testerNames: testerNameMap,
458
+ url: resolvedOpts.url,
459
+ screenFormat: resolvedOpts.screenFormat,
460
+ locale: opts.locale,
461
+ maxInteractions: opts.maxInteractions ? parseMaxInteractions(opts.maxInteractions) : undefined,
462
+ headed: !!opts.headed,
463
+ slowMo: opts.slowMo ? parseSlowMo(opts.slowMo) : undefined,
464
+ devtools: opts.devtools,
465
+ debug: opts.debug,
466
+ parallel: opts.parallel ? parseInt(opts.parallel, 10) : undefined,
467
+ quiet: globals.quiet,
468
+ json: globals.json,
469
+ });
470
+ if (globals.json) {
471
+ output({
472
+ iteration_id: iterationId,
473
+ testers: createdTesters.map((t) => ({ id: t.id, profile_name: t.tester_profile?.name })),
474
+ mode: "local",
475
+ }, true);
476
+ }
477
+ return;
478
+ }
479
+ // Remote mode: delegate to backend
480
+ log(`Starting ${createdTesters.length} simulation${createdTesters.length > 1 ? "s" : ""}...`);
481
+ let simResults;
482
+ if (isMedia) {
483
+ // Media batch endpoint — resolve config per tester from override or profile
484
+ const mediaBatchItems = createdTesters.map((t, i) => ({
485
+ study_id: resolvedStudy,
486
+ tester_id: t.id,
487
+ config_id: resolvedConfigOverride || profileConfigMap.get(profileIds[i]),
488
+ ...(opts.language && { language: opts.language }),
489
+ }));
490
+ const simResult = await client.post("/simulation/media/start/batch", {
491
+ product_id: resolvedWorkspace,
492
+ simulations: mediaBatchItems,
493
+ ...(opts.maxInteractions && { max_interactions: parseMaxInteractions(opts.maxInteractions) }),
494
+ }, { timeout: 60_000 });
495
+ simResults = simResult.results;
496
+ }
497
+ else {
498
+ // Interactive batch endpoint
499
+ const simItems = createdTesters.map((t) => ({
500
+ study_id: resolvedStudy,
501
+ tester_id: t.id,
502
+ ...(opts.config && { config_id: resolveId(opts.config) }),
503
+ ...(opts.language && { language: opts.language }),
504
+ ...(opts.locale && { locale: opts.locale }),
505
+ }));
506
+ const simResult = await client.post("/simulation/interactive/start/batch", {
507
+ product_id: resolvedWorkspace,
508
+ simulations: simItems,
509
+ platform: resolvedOpts.platform || "browser",
510
+ ...(resolvedOpts.url && { url: resolvedOpts.url }),
511
+ screen_format: resolvedOpts.screenFormat || "desktop",
512
+ ...(opts.maxInteractions && { max_interactions: parseMaxInteractions(opts.maxInteractions) }),
513
+ }, { timeout: 60_000 });
514
+ simResults = simResult.results;
515
+ }
187
516
  if (globals.json) {
188
517
  output({
189
518
  iteration_id: iterationId,
190
519
  testers: createdTesters.map((t) => ({ id: t.id, profile_name: t.tester_profile?.name })),
191
- simulations: simResult.results,
520
+ simulations: simResults,
192
521
  }, true);
193
522
  }
194
523
  else {
195
- // Human-readable summary (to stderr not data)
196
- for (let i = 0; i < simResult.results.length; i++) {
524
+ for (let i = 0; i < simResults.length; i++) {
197
525
  const tester = createdTesters[i];
198
526
  const profileName = tester?.tester_profile?.name || "Unknown";
199
527
  log(` ${profileName.padEnd(24)} QUEUED`);
@@ -220,8 +548,8 @@ Examples:
220
548
  }
221
549
  else if (opts.study) {
222
550
  const rid = resolveId(opts.study);
223
- // Get study to find all testers with running/queued simulations
224
551
  const study = await client.get(`/studies/${rid}`);
552
+ const isMedia = isMediaModality(study.modality);
225
553
  // Collect all testers across iterations
226
554
  const allTesters = [];
227
555
  for (const iteration of study.iterations || []) {
@@ -231,18 +559,19 @@ Examples:
231
559
  status: tester.status,
232
560
  tester_name: tester.tester_profile?.name || "Unknown",
233
561
  interaction_count: Array.isArray(tester.interactions) ? tester.interactions.length : 0,
562
+ ...(tester.error && { error: tester.error }),
563
+ ...(tester.failure_reason && { error: tester.failure_reason }),
234
564
  });
235
565
  }
236
566
  }
237
- formatSimulationPoll(allTesters, globals.json);
567
+ formatSimulationPoll(allTesters, globals.json, isMedia);
238
568
  if (!globals.json && study.product_id) {
239
569
  const url = getWebUrl(globals, `/${study.product_id}/${rid}/timeline`);
240
570
  console.error(`\n ${terminalLink(url, "Open in browser ↗")}\n`);
241
571
  }
242
572
  }
243
573
  else {
244
- outputError(new Error("Provide a job_id argument or --study flag"), globals.json);
245
- process.exit(1);
574
+ throw new Error("Provide a job_id argument or --study flag");
246
575
  }
247
576
  });
248
577
  });
@@ -250,8 +579,8 @@ Examples:
250
579
  sim
251
580
  .command("start")
252
581
  .description("Start a single interactive simulation (low-level)")
253
- .requiredOption("--workspace <id>", "Workspace ID")
254
- .requiredOption("--study <id>", "Study ID")
582
+ .option("--workspace <id>", "Workspace ID")
583
+ .option("--study <id>", "Study ID")
255
584
  .requiredOption("--tester <id>", "Tester ID")
256
585
  .option("--config <id>", "Simulation config ID (resolved from profile if omitted)")
257
586
  .option("--platform <platform>", "Platform (browser, android, figma, code)", "browser")
@@ -263,8 +592,8 @@ Examples:
263
592
  .action(async (opts, cmd) => {
264
593
  await withClient(cmd, async (client, globals) => {
265
594
  const body = {
266
- product_id: resolveId(opts.workspace),
267
- study_id: resolveId(opts.study),
595
+ product_id: resolveWorkspace(opts.workspace),
596
+ study_id: resolveStudy(opts.study),
268
597
  tester_id: resolveId(opts.tester),
269
598
  ...(opts.config && { config_id: resolveId(opts.config) }),
270
599
  platform: opts.platform,
@@ -281,19 +610,23 @@ Examples:
281
610
  sim
282
611
  .command("start-media")
283
612
  .description("Start a media simulation (low-level)")
284
- .requiredOption("--workspace <id>", "Workspace ID")
285
- .requiredOption("--study <id>", "Study ID")
613
+ .option("--workspace <id>", "Workspace ID")
614
+ .option("--study <id>", "Study ID")
286
615
  .requiredOption("--tester <id>", "Tester ID")
287
- .option("--config <id>", "Simulation config ID (resolved from profile if omitted)")
616
+ .requiredOption("--config <id>", "Simulation config ID")
288
617
  .option("--max-interactions <n>", "Max interactions")
289
618
  .option("--language <lang>", "Language code")
619
+ .addHelpText("after", `
620
+ Examples:
621
+ $ speqs sim start-media --workspace W --study S --tester T --config C
622
+ $ speqs sim start-media --workspace W --study S --tester T --config C --max-interactions 10`)
290
623
  .action(async (opts, cmd) => {
291
624
  await withClient(cmd, async (client, globals) => {
292
625
  const body = {
293
- product_id: resolveId(opts.workspace),
294
- study_id: resolveId(opts.study),
626
+ product_id: resolveWorkspace(opts.workspace),
627
+ study_id: resolveStudy(opts.study),
295
628
  tester_id: resolveId(opts.tester),
296
- ...(opts.config && { config_id: resolveId(opts.config) }),
629
+ config_id: resolveId(opts.config),
297
630
  ...(opts.maxInteractions && { max_interactions: parseMaxInteractions(opts.maxInteractions) }),
298
631
  ...(opts.language && { language: opts.language }),
299
632
  };