@test-lab-ai/cli 0.2.5 → 0.2.7
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/AGENTS.md +3 -1
- package/bin/testlab.mjs +73 -3
- package/lib/examples.mjs +2 -0
- package/lib/import.mjs +2 -0
- package/package.json +1 -1
package/AGENTS.md
CHANGED
|
@@ -33,7 +33,9 @@ npx @test-lab-ai/cli --help
|
|
|
33
33
|
- If the user's repo has a test-lab plan skill/format, prefer it.
|
|
34
34
|
3. **Write** the plans to a JSON file (or a directory of `*.json`).
|
|
35
35
|
4. **Run** `testlab import <path>`. Use `--dry-run` first to show the user the
|
|
36
|
-
creation order without writing anything.
|
|
36
|
+
creation order without writing anything. To file the plans under a project,
|
|
37
|
+
run `testlab projects list` and pass `--project <id|name>` (auto-picked if
|
|
38
|
+
there is only one; omit for account-level).
|
|
37
39
|
|
|
38
40
|
Authentication: the user runs `testlab login` once (browser), or you set
|
|
39
41
|
`TESTLAB_API_KEY` in the environment for a fully headless run.
|
package/bin/testlab.mjs
CHANGED
|
@@ -24,7 +24,7 @@ import { loadImportFile, runImport } from "../lib/import.mjs"
|
|
|
24
24
|
import { browserLogin } from "../lib/login.mjs"
|
|
25
25
|
import { EXAMPLES_TEXT } from "../lib/examples.mjs"
|
|
26
26
|
import { TESTLAB_SKILLS, AGENTS, detectAgents, installSkill } from "../lib/skills.mjs"
|
|
27
|
-
import { checkForUpdate } from "../lib/update-check.mjs"
|
|
27
|
+
import { checkForUpdate, currentVersion } from "../lib/update-check.mjs"
|
|
28
28
|
|
|
29
29
|
const log = (...a) => console.log(...a)
|
|
30
30
|
function errExit(msg) {
|
|
@@ -41,6 +41,7 @@ Usage:
|
|
|
41
41
|
(credentials, labels, fixtures, plans)
|
|
42
42
|
testlab plans list List your test plans
|
|
43
43
|
testlab plans create -f plan.json Create one plan from JSON
|
|
44
|
+
testlab projects list List your projects
|
|
44
45
|
testlab credentials set <key> --value <value> Set a credential ({{credentials.<key>}})
|
|
45
46
|
testlab credentials list List credential keys (values never shown)
|
|
46
47
|
testlab labels list List your labels
|
|
@@ -56,6 +57,8 @@ Options:
|
|
|
56
57
|
--stdin Read the value (key/credential) from stdin
|
|
57
58
|
--force (login) re-authenticate even if a stored key still works
|
|
58
59
|
--dry-run (import) validate + print plan order without writing
|
|
60
|
+
--project <id|name> (import/plans) assign plans to a project, or "none" for account-level
|
|
61
|
+
--version, -v Print the installed CLI version
|
|
59
62
|
|
|
60
63
|
Get a key at https://test-lab.ai/admin/settings/api-keys · run \`testlab examples\` for JSON shapes`
|
|
61
64
|
|
|
@@ -74,6 +77,8 @@ function parse() {
|
|
|
74
77
|
force: { type: "boolean" },
|
|
75
78
|
global: { type: "boolean" },
|
|
76
79
|
agent: { type: "string" },
|
|
80
|
+
project: { type: "string" },
|
|
81
|
+
version: { type: "boolean", short: "v" },
|
|
77
82
|
help: { type: "boolean", short: "h" },
|
|
78
83
|
},
|
|
79
84
|
})
|
|
@@ -183,7 +188,8 @@ async function cmdPlansCreate(flags) {
|
|
|
183
188
|
} else {
|
|
184
189
|
errExit("provide -f <plan.json>, or both --name and --prompt")
|
|
185
190
|
}
|
|
186
|
-
const
|
|
191
|
+
const projectId = plan.projectId ?? (await resolveProjectId(apiUrl, apiKey, flags))
|
|
192
|
+
const r = await apiFetch(apiUrl, apiKey, "POST", "/api/v1/test-plans", { ...plan, projectId })
|
|
187
193
|
if (!r.ok) errExit(`${r.status}: ${r.json?.error || ""}`)
|
|
188
194
|
log(`✓ Created plan #${r.json.testPlan.id}: ${r.json.testPlan.name}`)
|
|
189
195
|
}
|
|
@@ -229,6 +235,60 @@ async function cmdLabelsList(flags) {
|
|
|
229
235
|
log(`\n${labels.length} label(s)`)
|
|
230
236
|
}
|
|
231
237
|
|
|
238
|
+
// Resolve which project imported plans go into.
|
|
239
|
+
// --project none -> null (account-level, no project)
|
|
240
|
+
// --project <id|name> -> that project
|
|
241
|
+
// (no flag) -> 0 projects: null; 1 project: auto; many: prompt (TTY)
|
|
242
|
+
// or, for an agent/CI (no TTY), error listing the choices.
|
|
243
|
+
async function resolveProjectId(apiUrl, apiKey, flags) {
|
|
244
|
+
const flag = flags.project
|
|
245
|
+
if (flag === "none" || flag === "null") return null
|
|
246
|
+
const r = await apiFetch(apiUrl, apiKey, "GET", "/api/v1/projects")
|
|
247
|
+
if (!r.ok) errExit(`could not list projects (${r.status}): ${r.json?.error || ""}`)
|
|
248
|
+
const projects = r.json?.projects || []
|
|
249
|
+
if (flag) {
|
|
250
|
+
const match = /^\d+$/.test(flag)
|
|
251
|
+
? projects.find((p) => p.id === parseInt(flag, 10))
|
|
252
|
+
: projects.find((p) => p.name.toLowerCase() === flag.toLowerCase())
|
|
253
|
+
if (!match) {
|
|
254
|
+
errExit(`project "${flag}" not found. Available: ${projects.map((p) => p.name).join(", ") || "(none)"}`)
|
|
255
|
+
}
|
|
256
|
+
return match.id
|
|
257
|
+
}
|
|
258
|
+
if (projects.length === 0) return null
|
|
259
|
+
if (projects.length === 1) {
|
|
260
|
+
log(`Using project: ${projects[0].name} (#${projects[0].id})`)
|
|
261
|
+
return projects[0].id
|
|
262
|
+
}
|
|
263
|
+
if (!process.stdin.isTTY) {
|
|
264
|
+
errExit(
|
|
265
|
+
`You have ${projects.length} projects — pick one with --project <id|name> (or --project none):\n` +
|
|
266
|
+
projects.map((p) => ` ${p.id} ${p.name}`).join("\n")
|
|
267
|
+
)
|
|
268
|
+
}
|
|
269
|
+
log(`Which project should these plans go in?`)
|
|
270
|
+
projects.forEach((p, i) => log(` ${i + 1}. ${p.name} (#${p.id})`))
|
|
271
|
+
log(` 0. None (account-level)`)
|
|
272
|
+
const ans = await promptLine("Project [number]: ")
|
|
273
|
+
if (ans === "0") return null
|
|
274
|
+
const idx = parseInt(ans, 10)
|
|
275
|
+
if (idx >= 1 && idx <= projects.length) return projects[idx - 1].id
|
|
276
|
+
errExit("invalid selection")
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
async function cmdProjectsList(flags) {
|
|
280
|
+
const { apiKey, apiUrl } = requireAuth(flags)
|
|
281
|
+
const r = await apiFetch(apiUrl, apiKey, "GET", "/api/v1/projects")
|
|
282
|
+
if (!r.ok) errExit(`${r.status}: ${r.json?.error || ""}`)
|
|
283
|
+
const projects = r.json?.projects || []
|
|
284
|
+
if (projects.length === 0) {
|
|
285
|
+
log("No projects yet. Imported plans stay account-level unless you create a project in the dashboard.")
|
|
286
|
+
return
|
|
287
|
+
}
|
|
288
|
+
for (const p of projects) log(` #${p.id} ${p.name}`)
|
|
289
|
+
log(`\n${projects.length} project(s)`)
|
|
290
|
+
}
|
|
291
|
+
|
|
232
292
|
async function cmdImport(flags, args) {
|
|
233
293
|
const target = args[1]
|
|
234
294
|
if (!target) errExit("usage: testlab import <path> [--dry-run]")
|
|
@@ -241,8 +301,10 @@ async function cmdImport(flags, args) {
|
|
|
241
301
|
const dryRun = flags["dry-run"]
|
|
242
302
|
// --dry-run validates locally and never calls the API, so it doesn't need auth.
|
|
243
303
|
const { apiKey, apiUrl } = dryRun ? resolveAuth(flags) : requireAuth(flags)
|
|
304
|
+
// Resolve the target project (auto-pick / prompt / --project) for real imports only.
|
|
305
|
+
const projectId = dryRun || loaded.plans.length === 0 ? null : await resolveProjectId(apiUrl, apiKey, flags)
|
|
244
306
|
log(`Importing from ${target}: ${loaded.plans.length} plan(s), ${loaded.credentials.length} credential(s), ${loaded.labels.length} label(s), ${loaded.fixtures.length} fixture(s)`)
|
|
245
|
-
const res = await runImport({ apiUrl, apiKey, ...loaded, dryRun, log })
|
|
307
|
+
const res = await runImport({ apiUrl, apiKey, projectId, ...loaded, dryRun, log })
|
|
246
308
|
if (!res.ok && !res.dryRun) process.exit(1)
|
|
247
309
|
}
|
|
248
310
|
|
|
@@ -342,6 +404,11 @@ async function main() {
|
|
|
342
404
|
|
|
343
405
|
checkForUpdate() // best-effort "update available" notice (cached, non-blocking)
|
|
344
406
|
|
|
407
|
+
if (flags.version || args[0] === "version") {
|
|
408
|
+
log(currentVersion() || "unknown")
|
|
409
|
+
return
|
|
410
|
+
}
|
|
411
|
+
|
|
345
412
|
if (flags.help || args.length === 0 || args[0] === "help") {
|
|
346
413
|
log(HELP)
|
|
347
414
|
return
|
|
@@ -356,6 +423,9 @@ async function main() {
|
|
|
356
423
|
if (args[1] === "list") return cmdPlansList(flags)
|
|
357
424
|
if (args[1] === "create") return cmdPlansCreate(flags)
|
|
358
425
|
return errExit("usage: testlab plans <list|create>")
|
|
426
|
+
case "projects":
|
|
427
|
+
if (args[1] === "list") return cmdProjectsList(flags)
|
|
428
|
+
return errExit("usage: testlab projects list")
|
|
359
429
|
case "credentials":
|
|
360
430
|
if (args[1] === "set") return cmdCredentialsSet(flags, args)
|
|
361
431
|
if (args[1] === "list") return cmdCredentialsList(flags)
|
package/lib/examples.mjs
CHANGED
|
@@ -71,6 +71,7 @@ JSON (under "plans"):
|
|
|
71
71
|
"agentType": "functional", // functional|accessibility|uiux|exploratory|performance|security
|
|
72
72
|
"devices": ["Desktop Chrome"], // or ["iPhone 15 Pro"]
|
|
73
73
|
"labels": ["smoke", "auth"],
|
|
74
|
+
"projectId": 12, // optional: assign to a project (else account-level)
|
|
74
75
|
"failOnPreStepFailure": true
|
|
75
76
|
}
|
|
76
77
|
Pre-steps (run another plan first, sharing browser state):
|
|
@@ -80,6 +81,7 @@ Pre-steps (run another plan first, sharing browser state):
|
|
|
80
81
|
{ "testPlanId": 42 } // an existing plan by id
|
|
81
82
|
]
|
|
82
83
|
Rules: name <=200 chars; prompt <=32KB; <=25 labels; <=25 preSteps.
|
|
84
|
+
Projects: omit projectId to import account-level. Run "testlab projects list" for ids; "testlab import --project <id|name>" (or --project none) sets it for the whole import, and is auto-picked when you have exactly one.
|
|
83
85
|
|
|
84
86
|
═══════════════════════════════════════════════════════════════════════════
|
|
85
87
|
IMPORT BUNDLE — create everything at once: testlab import ./bundle.json
|
package/lib/import.mjs
CHANGED
|
@@ -90,6 +90,7 @@ export async function runImport({
|
|
|
90
90
|
labels = [],
|
|
91
91
|
fixtures = [],
|
|
92
92
|
plans = [],
|
|
93
|
+
projectId = null,
|
|
93
94
|
dryRun = false,
|
|
94
95
|
log = console.log,
|
|
95
96
|
}) {
|
|
@@ -187,6 +188,7 @@ export async function runImport({
|
|
|
187
188
|
cookies: plan.cookies,
|
|
188
189
|
headers: plan.headers,
|
|
189
190
|
failOnPreStepFailure: plan.failOnPreStepFailure,
|
|
191
|
+
projectId: plan.projectId ?? projectId, // per-plan id wins, else the resolved import-wide project
|
|
190
192
|
preSteps: normalizePreSteps(plan.preSteps, refToId),
|
|
191
193
|
}
|
|
192
194
|
const r = await apiFetch(apiUrl, apiKey, "POST", "/api/v1/test-plans", payload)
|