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