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