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.
- package/dist/commands/config.js +13 -4
- package/dist/commands/iteration.js +64 -13
- package/dist/commands/simulation.d.ts +1 -1
- package/dist/commands/simulation.js +454 -121
- package/dist/commands/study.js +140 -27
- package/dist/commands/tester-profile.js +17 -4
- package/dist/commands/tester.js +12 -3
- package/dist/commands/workspace.js +51 -6
- package/dist/config.d.ts +2 -0
- package/dist/index.js +3 -1
- package/dist/lib/alias-store.d.ts +22 -3
- package/dist/lib/alias-store.js +60 -12
- package/dist/lib/api-client.d.ts +31 -0
- package/dist/lib/api-client.js +83 -27
- package/dist/lib/auth.js +4 -1
- package/dist/lib/command-helpers.d.ts +4 -0
- package/dist/lib/command-helpers.js +41 -4
- package/dist/lib/local-sim/actions.d.ts +22 -0
- package/dist/lib/local-sim/actions.js +379 -0
- package/dist/lib/local-sim/browser.d.ts +63 -0
- package/dist/lib/local-sim/browser.js +332 -0
- package/dist/lib/local-sim/debug-report.d.ts +21 -0
- package/dist/lib/local-sim/debug-report.js +186 -0
- package/dist/lib/local-sim/debug.d.ts +44 -0
- package/dist/lib/local-sim/debug.js +103 -0
- package/dist/lib/local-sim/install.d.ts +25 -0
- package/dist/lib/local-sim/install.js +72 -0
- package/dist/lib/local-sim/loop.d.ts +60 -0
- package/dist/lib/local-sim/loop.js +526 -0
- package/dist/lib/local-sim/types.d.ts +232 -0
- package/dist/lib/local-sim/types.js +8 -0
- package/dist/lib/local-sim/upload.d.ts +6 -0
- package/dist/lib/local-sim/upload.js +24 -0
- package/dist/lib/output.d.ts +16 -1
- package/dist/lib/output.js +250 -61
- package/dist/lib/types.d.ts +7 -30
- package/dist/lib/types.js +9 -1
- package/dist/lib/upload.d.ts +47 -0
- package/dist/lib/upload.js +178 -0
- 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,
|
|
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
|
-
.
|
|
29
|
-
.
|
|
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
|
-
|
|
34
|
-
.option("--
|
|
35
|
-
.option("--
|
|
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
|
|
43
|
-
$ speqs sim run
|
|
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
|
-
#
|
|
46
|
-
$ speqs sim run --
|
|
201
|
+
# Image (local files):
|
|
202
|
+
$ speqs sim run --image-urls "./a.png,./b.png" --config c-c3c
|
|
47
203
|
|
|
48
|
-
#
|
|
49
|
-
$ speqs sim run --
|
|
204
|
+
# Document:
|
|
205
|
+
$ speqs sim run --content-url ./report.pdf --config c-c3c
|
|
50
206
|
|
|
51
|
-
#
|
|
52
|
-
$ speqs sim run --
|
|
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
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
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
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
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
|
-
|
|
75
|
-
const
|
|
76
|
-
if (
|
|
77
|
-
const
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
if (!
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
if (
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
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
|
-
|
|
114
|
-
|
|
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
|
-
//
|
|
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(`
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
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 =
|
|
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
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
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:
|
|
520
|
+
simulations: simResults,
|
|
192
521
|
}, true);
|
|
193
522
|
}
|
|
194
523
|
else {
|
|
195
|
-
|
|
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
|
-
|
|
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
|
-
.
|
|
254
|
-
.
|
|
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:
|
|
267
|
-
study_id:
|
|
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
|
-
.
|
|
285
|
-
.
|
|
613
|
+
.option("--workspace <id>", "Workspace ID")
|
|
614
|
+
.option("--study <id>", "Study ID")
|
|
286
615
|
.requiredOption("--tester <id>", "Tester ID")
|
|
287
|
-
.
|
|
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:
|
|
294
|
-
study_id:
|
|
626
|
+
product_id: resolveWorkspace(opts.workspace),
|
|
627
|
+
study_id: resolveStudy(opts.study),
|
|
295
628
|
tester_id: resolveId(opts.tester),
|
|
296
|
-
|
|
629
|
+
config_id: resolveId(opts.config),
|
|
297
630
|
...(opts.maxInteractions && { max_interactions: parseMaxInteractions(opts.maxInteractions) }),
|
|
298
631
|
...(opts.language && { language: opts.language }),
|
|
299
632
|
};
|