@ishlabs/cli 0.8.1 → 0.8.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +323 -21
- package/dist/auth.d.ts +17 -1
- package/dist/auth.js +62 -9
- package/dist/commands/ask.d.ts +5 -0
- package/dist/commands/ask.js +722 -0
- package/dist/commands/config.js +25 -1
- package/dist/commands/docs.d.ts +17 -0
- package/dist/commands/docs.js +147 -0
- package/dist/commands/init.d.ts +16 -0
- package/dist/commands/init.js +182 -0
- package/dist/commands/iteration.d.ts +5 -1
- package/dist/commands/iteration.js +243 -31
- package/dist/commands/profile.d.ts +5 -0
- package/dist/commands/profile.js +313 -0
- package/dist/commands/source.d.ts +10 -0
- package/dist/commands/source.js +78 -0
- package/dist/commands/study-run.d.ts +11 -0
- package/dist/commands/study-run.js +552 -0
- package/dist/commands/study-tester.d.ts +8 -0
- package/dist/commands/study-tester.js +149 -0
- package/dist/commands/study.js +145 -70
- package/dist/commands/workspace.js +193 -7
- package/dist/config.d.ts +3 -1
- package/dist/config.js +10 -10
- package/dist/connect.d.ts +4 -1
- package/dist/connect.js +127 -94
- package/dist/index.js +82 -34
- package/dist/lib/alias-store.d.ts +3 -0
- package/dist/lib/alias-store.js +9 -7
- package/dist/lib/api-client.d.ts +9 -6
- package/dist/lib/api-client.js +87 -26
- package/dist/lib/ask-questions.d.ts +9 -0
- package/dist/lib/ask-questions.js +35 -0
- package/dist/lib/ask-variants.d.ts +48 -0
- package/dist/lib/ask-variants.js +236 -0
- package/dist/lib/auth.d.ts +1 -1
- package/dist/lib/auth.js +24 -8
- package/dist/lib/colors.d.ts +30 -0
- package/dist/lib/colors.js +48 -0
- package/dist/lib/command-helpers.d.ts +74 -0
- package/dist/lib/command-helpers.js +232 -6
- package/dist/lib/docs.d.ts +32 -0
- package/dist/lib/docs.js +930 -0
- package/dist/lib/local-sim/browser.d.ts +0 -1
- package/dist/lib/local-sim/browser.js +0 -2
- package/dist/lib/local-sim/install.d.ts +4 -7
- package/dist/lib/local-sim/install.js +6 -21
- package/dist/lib/output.d.ts +25 -3
- package/dist/lib/output.js +465 -20
- package/dist/lib/paths.d.ts +14 -0
- package/dist/lib/paths.js +36 -0
- package/dist/lib/profile-sources.d.ts +55 -0
- package/dist/lib/profile-sources.js +157 -0
- package/dist/lib/site-access.d.ts +80 -0
- package/dist/lib/site-access.js +188 -0
- package/dist/lib/skill-content.d.ts +31 -0
- package/dist/lib/skill-content.js +462 -0
- package/dist/lib/study-inputs.d.ts +20 -0
- package/dist/lib/study-inputs.js +72 -0
- package/dist/lib/types.d.ts +207 -9
- package/dist/lib/types.js +7 -0
- package/dist/lib/upload.js +2 -2
- package/dist/upgrade.js +11 -1
- package/package.json +1 -1
- package/dist/commands/simulation.d.ts +0 -10
- package/dist/commands/simulation.js +0 -647
- package/dist/commands/tester-profile.d.ts +0 -5
- package/dist/commands/tester-profile.js +0 -109
- package/dist/commands/tester.d.ts +0 -5
- package/dist/commands/tester.js +0 -73
|
@@ -1,647 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* ish simulation — Run, monitor, and cancel simulations.
|
|
3
|
-
*
|
|
4
|
-
* Primary command: `ish simulation run` — orchestrates the full flow:
|
|
5
|
-
* 1. Creates iteration (if not provided)
|
|
6
|
-
* 2. Creates testers from profiles
|
|
7
|
-
* 3. Starts simulations (interactive or media, based on study modality)
|
|
8
|
-
*/
|
|
9
|
-
import * as readline from "node:readline/promises";
|
|
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";
|
|
13
|
-
function parseMaxInteractions(value) {
|
|
14
|
-
const n = parseInt(value, 10);
|
|
15
|
-
if (isNaN(n) || n < 1)
|
|
16
|
-
throw new Error(`Invalid --max-interactions value: ${value}`);
|
|
17
|
-
return n;
|
|
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
|
-
}
|
|
139
|
-
export function registerSimulationCommands(program) {
|
|
140
|
-
const sim = program
|
|
141
|
-
.command("simulation")
|
|
142
|
-
.alias("sim")
|
|
143
|
-
.description("Run and monitor simulations");
|
|
144
|
-
// --- Primary: `simulation run` ---
|
|
145
|
-
sim
|
|
146
|
-
.command("run")
|
|
147
|
-
.description("Run simulations (creates iteration + testers + starts simulations)")
|
|
148
|
-
.option("--workspace <id>", "Workspace ID")
|
|
149
|
-
.option("--study <id>", "Study ID")
|
|
150
|
-
.option("--profiles <ids>", "Comma-separated tester profile IDs (auto-selected from last iteration if omitted)")
|
|
151
|
-
.option("--iteration <id>", "Use existing iteration (skip creation)")
|
|
152
|
-
.option("--iteration-name <name>", "Name for new iteration (forces creating a new iteration)")
|
|
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
|
|
171
|
-
.option("--max-interactions <n>", "Max interactions per tester")
|
|
172
|
-
.option("--language <lang>", "Language code (e.g. en, sv)")
|
|
173
|
-
.option("--locale <locale>", "Locale code (e.g. en-US)")
|
|
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 ~/.ish/local-sim.log")
|
|
181
|
-
.option("--parallel <n>", "Run N testers in parallel (local mode only, default: all)")
|
|
182
|
-
.addHelpText("after", `
|
|
183
|
-
Note: --workspace and --study are optional if you have set active context
|
|
184
|
-
via \`ish workspace use <alias>\` and \`ish study use <alias>\`.
|
|
185
|
-
Profiles and iteration settings are auto-reused from the latest iteration.
|
|
186
|
-
|
|
187
|
-
Examples:
|
|
188
|
-
# Re-run with same settings (after setting context):
|
|
189
|
-
$ ish sim run -y
|
|
190
|
-
|
|
191
|
-
# Interactive — first run:
|
|
192
|
-
$ ish 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
|
-
$ ish sim run --content-text "Your email content..." --config c-c3c
|
|
196
|
-
$ ish sim run --content-text @./email.html --config c-c3c
|
|
197
|
-
|
|
198
|
-
# Video (URL or local file):
|
|
199
|
-
$ ish sim run --content-url ./video.mp4 --config c-c3c
|
|
200
|
-
|
|
201
|
-
# Image (local files):
|
|
202
|
-
$ ish sim run --image-urls "./a.png,./b.png" --config c-c3c
|
|
203
|
-
|
|
204
|
-
# Document:
|
|
205
|
-
$ ish sim run --content-url ./report.pdf --config c-c3c
|
|
206
|
-
|
|
207
|
-
# Video ad with copy text:
|
|
208
|
-
$ ish sim run --content-url ./ad.mp4 --copy-text "Buy now — 50% off!" --config c-c3c
|
|
209
|
-
|
|
210
|
-
# Social post with caption:
|
|
211
|
-
$ ish sim run --image-urls ./post.png --copy-text @./caption.txt --social-platform instagram --config c-c3c
|
|
212
|
-
|
|
213
|
-
# Re-run existing iteration:
|
|
214
|
-
$ ish sim run --iteration i-d4e
|
|
215
|
-
|
|
216
|
-
# Local browser simulation (no remote Browserbase):
|
|
217
|
-
$ ish sim run --local --url http://localhost:3000
|
|
218
|
-
$ ish sim run --local --url http://localhost:3000 --headed --slow-mo 500`)
|
|
219
|
-
.action(async (opts, cmd) => {
|
|
220
|
-
await withClient(cmd, async (client, globals) => {
|
|
221
|
-
const log = (msg) => { if (!globals.quiet)
|
|
222
|
-
console.error(msg); };
|
|
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 `ish 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
|
|
243
|
-
let profileIds = opts.profiles
|
|
244
|
-
? opts.profiles.split(",").map((s) => s.trim()).filter(Boolean).map(resolveId)
|
|
245
|
-
: [];
|
|
246
|
-
const profileNames = new Map();
|
|
247
|
-
let iterationId = opts.iteration ? resolveId(opts.iteration) : undefined;
|
|
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;
|
|
258
|
-
}
|
|
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);
|
|
301
|
-
}
|
|
302
|
-
}
|
|
303
|
-
}
|
|
304
|
-
}
|
|
305
|
-
if (profileIds.length === 0) {
|
|
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
|
-
}
|
|
329
|
-
}
|
|
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: ish config list");
|
|
344
|
-
}
|
|
345
|
-
}
|
|
346
|
-
}
|
|
347
|
-
// Confirmation step
|
|
348
|
-
if (!globals.json && !opts.yes) {
|
|
349
|
-
log("");
|
|
350
|
-
log(" Simulation settings:");
|
|
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
|
-
}
|
|
378
|
-
if (opts.language)
|
|
379
|
-
log(` Language: ${opts.language}`);
|
|
380
|
-
log(` Profiles (${profileIds.length}):`);
|
|
381
|
-
for (const pid of profileIds) {
|
|
382
|
-
const name = profileNames.get(pid);
|
|
383
|
-
log(` - ${name ? `${name} (${pid})` : pid}`);
|
|
384
|
-
}
|
|
385
|
-
log("");
|
|
386
|
-
const rl = readline.createInterface({ input: process.stdin, output: process.stderr });
|
|
387
|
-
const answer = await rl.question(" Proceed? [Y/n] ");
|
|
388
|
-
rl.close();
|
|
389
|
-
if (answer && !["y", "yes", ""].includes(answer.toLowerCase().trim())) {
|
|
390
|
-
log("Aborted.");
|
|
391
|
-
process.exit(0);
|
|
392
|
-
}
|
|
393
|
-
log("");
|
|
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
|
-
}
|
|
399
|
-
// Step 1: Create or use existing iteration
|
|
400
|
-
if (!iterationId) {
|
|
401
|
-
const iterName = resolvedOpts.iterationName || `CLI ${new Date().toISOString().slice(0, 16)}`;
|
|
402
|
-
const iterBody = {
|
|
403
|
-
name: iterName,
|
|
404
|
-
details: buildIterationDetails(modality, resolvedOpts),
|
|
405
|
-
};
|
|
406
|
-
log(`Creating iteration "${iterName}"...`);
|
|
407
|
-
const iter = await client.post(`/studies/${resolvedStudy}/iterations`, iterBody);
|
|
408
|
-
iterationId = iter.id;
|
|
409
|
-
log(`Created iteration "${iterName}"`);
|
|
410
|
-
}
|
|
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
|
-
}
|
|
516
|
-
if (globals.json) {
|
|
517
|
-
output({
|
|
518
|
-
iteration_id: iterationId,
|
|
519
|
-
testers: createdTesters.map((t) => ({ id: t.id, profile_name: t.tester_profile?.name })),
|
|
520
|
-
simulations: simResults,
|
|
521
|
-
}, true);
|
|
522
|
-
}
|
|
523
|
-
else {
|
|
524
|
-
for (let i = 0; i < simResults.length; i++) {
|
|
525
|
-
const tester = createdTesters[i];
|
|
526
|
-
const profileName = tester?.tester_profile?.name || "Unknown";
|
|
527
|
-
log(` ${profileName.padEnd(24)} QUEUED`);
|
|
528
|
-
}
|
|
529
|
-
const url = getWebUrl(globals, `/${resolvedWorkspace}/${resolvedStudy}/timeline`);
|
|
530
|
-
log(`\n ${terminalLink(url, "Open in browser ↗")}\n`);
|
|
531
|
-
log(`Run \`ish simulation poll --study ${resolvedStudy}\` to check progress.`);
|
|
532
|
-
}
|
|
533
|
-
});
|
|
534
|
-
});
|
|
535
|
-
// --- Poll: check simulation progress ---
|
|
536
|
-
sim
|
|
537
|
-
.command("poll")
|
|
538
|
-
.description("Check simulation progress")
|
|
539
|
-
.argument("[job_id]", "Job ID (for single simulation)")
|
|
540
|
-
.option("--study <id>", "Study ID (poll all simulations for study)")
|
|
541
|
-
.addHelpText("after", "\nExamples:\n $ ish simulation poll --study <study_id>\n $ ish simulation poll <job_id> --json")
|
|
542
|
-
.action(async (jobId, opts, cmd) => {
|
|
543
|
-
await withClient(cmd, async (client, globals) => {
|
|
544
|
-
if (jobId) {
|
|
545
|
-
// Single job status
|
|
546
|
-
const data = await client.get(`/simulation/status/${resolveId(jobId)}`);
|
|
547
|
-
output(data, globals.json);
|
|
548
|
-
}
|
|
549
|
-
else if (opts.study) {
|
|
550
|
-
const rid = resolveId(opts.study);
|
|
551
|
-
const study = await client.get(`/studies/${rid}`);
|
|
552
|
-
const isMedia = isMediaModality(study.modality);
|
|
553
|
-
// Collect all testers across iterations
|
|
554
|
-
const allTesters = [];
|
|
555
|
-
for (const iteration of study.iterations || []) {
|
|
556
|
-
for (const tester of iteration.testers || []) {
|
|
557
|
-
allTesters.push({
|
|
558
|
-
id: tester.id,
|
|
559
|
-
status: tester.status,
|
|
560
|
-
tester_name: tester.tester_profile?.name || "Unknown",
|
|
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 }),
|
|
564
|
-
});
|
|
565
|
-
}
|
|
566
|
-
}
|
|
567
|
-
formatSimulationPoll(allTesters, globals.json, isMedia);
|
|
568
|
-
if (!globals.json && study.product_id) {
|
|
569
|
-
const url = getWebUrl(globals, `/${study.product_id}/${rid}/timeline`);
|
|
570
|
-
console.error(`\n ${terminalLink(url, "Open in browser ↗")}\n`);
|
|
571
|
-
}
|
|
572
|
-
}
|
|
573
|
-
else {
|
|
574
|
-
throw new Error("Provide a job_id argument or --study flag");
|
|
575
|
-
}
|
|
576
|
-
});
|
|
577
|
-
});
|
|
578
|
-
// --- Lower-level commands ---
|
|
579
|
-
sim
|
|
580
|
-
.command("start")
|
|
581
|
-
.description("Start a single interactive simulation (low-level)")
|
|
582
|
-
.option("--workspace <id>", "Workspace ID")
|
|
583
|
-
.option("--study <id>", "Study ID")
|
|
584
|
-
.requiredOption("--tester <id>", "Tester ID")
|
|
585
|
-
.option("--config <id>", "Simulation config ID (resolved from profile if omitted)")
|
|
586
|
-
.option("--platform <platform>", "Platform (browser, android, figma, code)", "browser")
|
|
587
|
-
.option("--url <url>", "URL to test")
|
|
588
|
-
.option("--screen-format <format>", "Screen format (mobile_portrait, desktop)")
|
|
589
|
-
.option("--max-interactions <n>", "Max interactions")
|
|
590
|
-
.option("--language <lang>", "Language code")
|
|
591
|
-
.option("--locale <locale>", "Locale code")
|
|
592
|
-
.action(async (opts, cmd) => {
|
|
593
|
-
await withClient(cmd, async (client, globals) => {
|
|
594
|
-
const body = {
|
|
595
|
-
product_id: resolveWorkspace(opts.workspace),
|
|
596
|
-
study_id: resolveStudy(opts.study),
|
|
597
|
-
tester_id: resolveId(opts.tester),
|
|
598
|
-
...(opts.config && { config_id: resolveId(opts.config) }),
|
|
599
|
-
platform: opts.platform,
|
|
600
|
-
...(opts.url && { url: opts.url }),
|
|
601
|
-
...(opts.screenFormat && { screen_format: opts.screenFormat }),
|
|
602
|
-
...(opts.maxInteractions && { max_interactions: parseMaxInteractions(opts.maxInteractions) }),
|
|
603
|
-
...(opts.language && { language: opts.language }),
|
|
604
|
-
...(opts.locale && { locale: opts.locale }),
|
|
605
|
-
};
|
|
606
|
-
const data = await client.post("/simulation/interactive/start", body);
|
|
607
|
-
output(data, globals.json);
|
|
608
|
-
});
|
|
609
|
-
});
|
|
610
|
-
sim
|
|
611
|
-
.command("start-media")
|
|
612
|
-
.description("Start a media simulation (low-level)")
|
|
613
|
-
.option("--workspace <id>", "Workspace ID")
|
|
614
|
-
.option("--study <id>", "Study ID")
|
|
615
|
-
.requiredOption("--tester <id>", "Tester ID")
|
|
616
|
-
.requiredOption("--config <id>", "Simulation config ID")
|
|
617
|
-
.option("--max-interactions <n>", "Max interactions")
|
|
618
|
-
.option("--language <lang>", "Language code")
|
|
619
|
-
.addHelpText("after", `
|
|
620
|
-
Examples:
|
|
621
|
-
$ ish sim start-media --workspace W --study S --tester T --config C
|
|
622
|
-
$ ish sim start-media --workspace W --study S --tester T --config C --max-interactions 10`)
|
|
623
|
-
.action(async (opts, cmd) => {
|
|
624
|
-
await withClient(cmd, async (client, globals) => {
|
|
625
|
-
const body = {
|
|
626
|
-
product_id: resolveWorkspace(opts.workspace),
|
|
627
|
-
study_id: resolveStudy(opts.study),
|
|
628
|
-
tester_id: resolveId(opts.tester),
|
|
629
|
-
config_id: resolveId(opts.config),
|
|
630
|
-
...(opts.maxInteractions && { max_interactions: parseMaxInteractions(opts.maxInteractions) }),
|
|
631
|
-
...(opts.language && { language: opts.language }),
|
|
632
|
-
};
|
|
633
|
-
const data = await client.post("/simulation/media/start", body);
|
|
634
|
-
output(data, globals.json);
|
|
635
|
-
});
|
|
636
|
-
});
|
|
637
|
-
sim
|
|
638
|
-
.command("cancel")
|
|
639
|
-
.description("Cancel a simulation")
|
|
640
|
-
.argument("<job_id>", "Job ID")
|
|
641
|
-
.action(async (jobId, _opts, cmd) => {
|
|
642
|
-
await withClient(cmd, async (client, globals) => {
|
|
643
|
-
const data = await client.post(`/simulation/cancel/${resolveId(jobId)}`);
|
|
644
|
-
output(data, globals.json);
|
|
645
|
-
});
|
|
646
|
-
});
|
|
647
|
-
}
|