@test-lab-ai/cli 0.2.6 → 0.2.8
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/README.md +3 -0
- package/bin/testlab.mjs +112 -5
- package/lib/examples.mjs +2 -0
- package/lib/import.mjs +2 -0
- package/lib/skills.mjs +17 -0
- package/lib/update-check.mjs +17 -4
- 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/README.md
CHANGED
|
@@ -123,6 +123,9 @@ Cursor user rules are set in Cursor's Settings. Restart your agent to load it.
|
|
|
123
123
|
It installs from the public [`Test-Lab-ai/skills`](https://github.com/Test-Lab-ai/skills)
|
|
124
124
|
mirror, so you always get the latest version.
|
|
125
125
|
|
|
126
|
+
`testlab skills update` refreshes every installed copy to the latest, and the CLI
|
|
127
|
+
also refreshes them automatically the first time you run it after an upgrade.
|
|
128
|
+
|
|
126
129
|
## For AI agents
|
|
127
130
|
|
|
128
131
|
Run **`testlab examples`** for the complete JSON reference, or read `AGENTS.md`
|
package/bin/testlab.mjs
CHANGED
|
@@ -23,8 +23,8 @@ import { apiFetch } from "../lib/api.mjs"
|
|
|
23
23
|
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
|
-
import { TESTLAB_SKILLS, AGENTS, detectAgents, installSkill } from "../lib/skills.mjs"
|
|
27
|
-
import { checkForUpdate, currentVersion } from "../lib/update-check.mjs"
|
|
26
|
+
import { TESTLAB_SKILLS, AGENTS, detectAgents, installSkill, installedSkillLocations } from "../lib/skills.mjs"
|
|
27
|
+
import { checkForUpdate, currentVersion, previousRunVersion } 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
|
|
@@ -50,12 +51,14 @@ Usage:
|
|
|
50
51
|
resource (designed for AI agents)
|
|
51
52
|
testlab skills install [--agent ...] Install the test-lab-plan skill (auto-detects
|
|
52
53
|
Claude/Codex/Cursor; --agent claude|codex|cursor|all)
|
|
54
|
+
testlab skills update Refresh installed skills (also auto-runs after a CLI upgrade)
|
|
53
55
|
|
|
54
56
|
Options:
|
|
55
57
|
--key <tl_…> API key (else $TESTLAB_API_KEY or ~/.test-lab/config.json)
|
|
56
58
|
--stdin Read the value (key/credential) from stdin
|
|
57
59
|
--force (login) re-authenticate even if a stored key still works
|
|
58
60
|
--dry-run (import) validate + print plan order without writing
|
|
61
|
+
--project <id|name> (import/plans) assign plans to a project, or "none" for account-level
|
|
59
62
|
--version, -v Print the installed CLI version
|
|
60
63
|
|
|
61
64
|
Get a key at https://test-lab.ai/admin/settings/api-keys · run \`testlab examples\` for JSON shapes`
|
|
@@ -75,6 +78,7 @@ function parse() {
|
|
|
75
78
|
force: { type: "boolean" },
|
|
76
79
|
global: { type: "boolean" },
|
|
77
80
|
agent: { type: "string" },
|
|
81
|
+
project: { type: "string" },
|
|
78
82
|
version: { type: "boolean", short: "v" },
|
|
79
83
|
help: { type: "boolean", short: "h" },
|
|
80
84
|
},
|
|
@@ -185,7 +189,8 @@ async function cmdPlansCreate(flags) {
|
|
|
185
189
|
} else {
|
|
186
190
|
errExit("provide -f <plan.json>, or both --name and --prompt")
|
|
187
191
|
}
|
|
188
|
-
const
|
|
192
|
+
const projectId = plan.projectId ?? (await resolveProjectId(apiUrl, apiKey, flags))
|
|
193
|
+
const r = await apiFetch(apiUrl, apiKey, "POST", "/api/v1/test-plans", { ...plan, projectId })
|
|
189
194
|
if (!r.ok) errExit(`${r.status}: ${r.json?.error || ""}`)
|
|
190
195
|
log(`✓ Created plan #${r.json.testPlan.id}: ${r.json.testPlan.name}`)
|
|
191
196
|
}
|
|
@@ -231,6 +236,60 @@ async function cmdLabelsList(flags) {
|
|
|
231
236
|
log(`\n${labels.length} label(s)`)
|
|
232
237
|
}
|
|
233
238
|
|
|
239
|
+
// Resolve which project imported plans go into.
|
|
240
|
+
// --project none -> null (account-level, no project)
|
|
241
|
+
// --project <id|name> -> that project
|
|
242
|
+
// (no flag) -> 0 projects: null; 1 project: auto; many: prompt (TTY)
|
|
243
|
+
// or, for an agent/CI (no TTY), error listing the choices.
|
|
244
|
+
async function resolveProjectId(apiUrl, apiKey, flags) {
|
|
245
|
+
const flag = flags.project
|
|
246
|
+
if (flag === "none" || flag === "null") return null
|
|
247
|
+
const r = await apiFetch(apiUrl, apiKey, "GET", "/api/v1/projects")
|
|
248
|
+
if (!r.ok) errExit(`could not list projects (${r.status}): ${r.json?.error || ""}`)
|
|
249
|
+
const projects = r.json?.projects || []
|
|
250
|
+
if (flag) {
|
|
251
|
+
const match = /^\d+$/.test(flag)
|
|
252
|
+
? projects.find((p) => p.id === parseInt(flag, 10))
|
|
253
|
+
: projects.find((p) => p.name.toLowerCase() === flag.toLowerCase())
|
|
254
|
+
if (!match) {
|
|
255
|
+
errExit(`project "${flag}" not found. Available: ${projects.map((p) => p.name).join(", ") || "(none)"}`)
|
|
256
|
+
}
|
|
257
|
+
return match.id
|
|
258
|
+
}
|
|
259
|
+
if (projects.length === 0) return null
|
|
260
|
+
if (projects.length === 1) {
|
|
261
|
+
log(`Using project: ${projects[0].name} (#${projects[0].id})`)
|
|
262
|
+
return projects[0].id
|
|
263
|
+
}
|
|
264
|
+
if (!process.stdin.isTTY) {
|
|
265
|
+
errExit(
|
|
266
|
+
`You have ${projects.length} projects — pick one with --project <id|name> (or --project none):\n` +
|
|
267
|
+
projects.map((p) => ` ${p.id} ${p.name}`).join("\n")
|
|
268
|
+
)
|
|
269
|
+
}
|
|
270
|
+
log(`Which project should these plans go in?`)
|
|
271
|
+
projects.forEach((p, i) => log(` ${i + 1}. ${p.name} (#${p.id})`))
|
|
272
|
+
log(` 0. None (account-level)`)
|
|
273
|
+
const ans = await promptLine("Project [number]: ")
|
|
274
|
+
if (ans === "0") return null
|
|
275
|
+
const idx = parseInt(ans, 10)
|
|
276
|
+
if (idx >= 1 && idx <= projects.length) return projects[idx - 1].id
|
|
277
|
+
errExit("invalid selection")
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
async function cmdProjectsList(flags) {
|
|
281
|
+
const { apiKey, apiUrl } = requireAuth(flags)
|
|
282
|
+
const r = await apiFetch(apiUrl, apiKey, "GET", "/api/v1/projects")
|
|
283
|
+
if (!r.ok) errExit(`${r.status}: ${r.json?.error || ""}`)
|
|
284
|
+
const projects = r.json?.projects || []
|
|
285
|
+
if (projects.length === 0) {
|
|
286
|
+
log("No projects yet. Imported plans stay account-level unless you create a project in the dashboard.")
|
|
287
|
+
return
|
|
288
|
+
}
|
|
289
|
+
for (const p of projects) log(` #${p.id} ${p.name}`)
|
|
290
|
+
log(`\n${projects.length} project(s)`)
|
|
291
|
+
}
|
|
292
|
+
|
|
234
293
|
async function cmdImport(flags, args) {
|
|
235
294
|
const target = args[1]
|
|
236
295
|
if (!target) errExit("usage: testlab import <path> [--dry-run]")
|
|
@@ -243,8 +302,10 @@ async function cmdImport(flags, args) {
|
|
|
243
302
|
const dryRun = flags["dry-run"]
|
|
244
303
|
// --dry-run validates locally and never calls the API, so it doesn't need auth.
|
|
245
304
|
const { apiKey, apiUrl } = dryRun ? resolveAuth(flags) : requireAuth(flags)
|
|
305
|
+
// Resolve the target project (auto-pick / prompt / --project) for real imports only.
|
|
306
|
+
const projectId = dryRun || loaded.plans.length === 0 ? null : await resolveProjectId(apiUrl, apiKey, flags)
|
|
246
307
|
log(`Importing from ${target}: ${loaded.plans.length} plan(s), ${loaded.credentials.length} credential(s), ${loaded.labels.length} label(s), ${loaded.fixtures.length} fixture(s)`)
|
|
247
|
-
const res = await runImport({ apiUrl, apiKey, ...loaded, dryRun, log })
|
|
308
|
+
const res = await runImport({ apiUrl, apiKey, projectId, ...loaded, dryRun, log })
|
|
248
309
|
if (!res.ok && !res.dryRun) process.exit(1)
|
|
249
310
|
}
|
|
250
311
|
|
|
@@ -332,6 +393,46 @@ function cmdSkillsList() {
|
|
|
332
393
|
log(`\nInstall with: testlab skills install [name] [--agent ${AGENTS.join("|")}|all]`)
|
|
333
394
|
}
|
|
334
395
|
|
|
396
|
+
async function cmdSkillsUpdate() {
|
|
397
|
+
let refreshed = 0
|
|
398
|
+
for (const name of TESTLAB_SKILLS) {
|
|
399
|
+
for (const loc of installedSkillLocations(name)) {
|
|
400
|
+
try {
|
|
401
|
+
const res = await installSkill(name, loc.agent, { global: loc.global })
|
|
402
|
+
refreshed++
|
|
403
|
+
log(`✓ ${name} → ${loc.agent}${loc.global ? " (global)" : ""}: ${res.dest}`)
|
|
404
|
+
} catch (e) {
|
|
405
|
+
log(` ✗ ${name} (${loc.agent}): ${e.message}`)
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
if (refreshed === 0) log("No installed skills found here. Run `testlab skills install` first.")
|
|
410
|
+
else log(`\nRefreshed ${refreshed} location(s). Restart your agent to load the changes.`)
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
// After a CLI upgrade, refresh any already-installed skills in place — runs once
|
|
414
|
+
// per version bump (best effort). Skipped in CI / when NO_UPDATE_NOTIFIER is set.
|
|
415
|
+
async function maybeRefreshSkillsOnUpgrade() {
|
|
416
|
+
if (process.env.CI || process.env.NO_UPDATE_NOTIFIER) return
|
|
417
|
+
const ver = currentVersion()
|
|
418
|
+
const prev = previousRunVersion(ver)
|
|
419
|
+
if (!prev || prev === ver) return
|
|
420
|
+
let refreshed = 0
|
|
421
|
+
for (const name of TESTLAB_SKILLS) {
|
|
422
|
+
for (const loc of installedSkillLocations(name)) {
|
|
423
|
+
try {
|
|
424
|
+
await installSkill(name, loc.agent, { global: loc.global })
|
|
425
|
+
refreshed++
|
|
426
|
+
} catch {
|
|
427
|
+
/* best effort — never break the actual command */
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
if (refreshed > 0) {
|
|
432
|
+
process.stderr.write(`↻ CLI upgraded ${prev} → ${ver}; refreshed ${refreshed} installed skill location(s).\n`)
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
|
|
335
436
|
async function main() {
|
|
336
437
|
let parsed
|
|
337
438
|
try {
|
|
@@ -354,6 +455,8 @@ async function main() {
|
|
|
354
455
|
return
|
|
355
456
|
}
|
|
356
457
|
|
|
458
|
+
await maybeRefreshSkillsOnUpgrade()
|
|
459
|
+
|
|
357
460
|
switch (args[0]) {
|
|
358
461
|
case "login":
|
|
359
462
|
return cmdLogin(flags)
|
|
@@ -363,6 +466,9 @@ async function main() {
|
|
|
363
466
|
if (args[1] === "list") return cmdPlansList(flags)
|
|
364
467
|
if (args[1] === "create") return cmdPlansCreate(flags)
|
|
365
468
|
return errExit("usage: testlab plans <list|create>")
|
|
469
|
+
case "projects":
|
|
470
|
+
if (args[1] === "list") return cmdProjectsList(flags)
|
|
471
|
+
return errExit("usage: testlab projects list")
|
|
366
472
|
case "credentials":
|
|
367
473
|
if (args[1] === "set") return cmdCredentialsSet(flags, args)
|
|
368
474
|
if (args[1] === "list") return cmdCredentialsList(flags)
|
|
@@ -378,8 +484,9 @@ async function main() {
|
|
|
378
484
|
return cmdExamples()
|
|
379
485
|
case "skills":
|
|
380
486
|
if (args[1] === "install") return cmdSkillsInstall(flags, args)
|
|
487
|
+
if (args[1] === "update") return cmdSkillsUpdate()
|
|
381
488
|
if (args[1] === "list") return cmdSkillsList()
|
|
382
|
-
return errExit("usage: testlab skills <install|list> [name] [--global]")
|
|
489
|
+
return errExit("usage: testlab skills <install|update|list> [name] [--global]")
|
|
383
490
|
case "import":
|
|
384
491
|
return cmdImport(flags, args)
|
|
385
492
|
default:
|
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)
|
package/lib/skills.mjs
CHANGED
|
@@ -51,6 +51,23 @@ export function detectAgents() {
|
|
|
51
51
|
return [...found]
|
|
52
52
|
}
|
|
53
53
|
|
|
54
|
+
/**
|
|
55
|
+
* Where skill `name` is already installed — project (cwd) and global locations
|
|
56
|
+
* across all agents — filtered to the paths that actually exist. Used to
|
|
57
|
+
* refresh installed skills (on `skills update` and after a CLI upgrade).
|
|
58
|
+
*/
|
|
59
|
+
export function installedSkillLocations(name) {
|
|
60
|
+
const home = os.homedir()
|
|
61
|
+
const cwd = process.cwd()
|
|
62
|
+
return [
|
|
63
|
+
{ agent: "claude", global: false, path: path.join(cwd, ".claude", "skills", name) },
|
|
64
|
+
{ agent: "codex", global: false, path: path.join(cwd, ".agents", "skills", name) },
|
|
65
|
+
{ agent: "cursor", global: false, path: path.join(cwd, ".cursor", "rules", `${name}.mdc`) },
|
|
66
|
+
{ agent: "claude", global: true, path: path.join(home, ".claude", "skills", name) },
|
|
67
|
+
{ agent: "codex", global: true, path: path.join(home, ".agents", "skills", name) },
|
|
68
|
+
].filter((c) => fs.existsSync(c.path))
|
|
69
|
+
}
|
|
70
|
+
|
|
54
71
|
/** Resolve the install target (base dir + format) for one agent. */
|
|
55
72
|
export function agentTarget(agent, { global } = {}) {
|
|
56
73
|
const home = os.homedir()
|
package/lib/update-check.mjs
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* Best-effort "update available" notice.
|
|
3
3
|
*
|
|
4
4
|
* Design constraints: never block, slow, or break a command. It reads a cached
|
|
5
|
-
* latest-version (checked against the npm registry at most
|
|
5
|
+
* latest-version (checked against the npm registry at most every few hours), prints a
|
|
6
6
|
* one-line notice to STDERR when the running version is behind, and kicks off a
|
|
7
7
|
* background refresh for next time. Any failure (offline, slow/forbidden
|
|
8
8
|
* registry) is swallowed. Suppressed when stderr isn't a TTY (pipes / CI /
|
|
@@ -15,7 +15,7 @@ import path from "node:path"
|
|
|
15
15
|
const PKG = "@test-lab-ai/cli"
|
|
16
16
|
const REGISTRY = "https://registry.npmjs.org/@test-lab-ai%2Fcli"
|
|
17
17
|
const CACHE = path.join(os.homedir(), ".test-lab", "update-check.json")
|
|
18
|
-
const
|
|
18
|
+
const CHECK_TTL = 3 * 60 * 60 * 1000 // re-check npm at most every 3h
|
|
19
19
|
|
|
20
20
|
export function currentVersion() {
|
|
21
21
|
try {
|
|
@@ -70,16 +70,29 @@ export function checkForUpdate() {
|
|
|
70
70
|
const notice = updateNotice(current, cache.latest)
|
|
71
71
|
if (notice) process.stderr.write(notice + "\n")
|
|
72
72
|
|
|
73
|
-
if (!cache.lastCheck || Date.now() - cache.lastCheck >
|
|
73
|
+
if (!cache.lastCheck || Date.now() - cache.lastCheck > CHECK_TTL) {
|
|
74
74
|
const ctrl = new AbortController()
|
|
75
75
|
const timer = setTimeout(() => ctrl.abort(), 2000)
|
|
76
76
|
fetch(REGISTRY, { signal: ctrl.signal, headers: { Accept: "application/vnd.npm.install-v1+json" } })
|
|
77
77
|
.then((r) => (r.ok ? r.json() : null))
|
|
78
78
|
.then((j) => {
|
|
79
79
|
const latest = j && j["dist-tags"] && j["dist-tags"].latest
|
|
80
|
-
if (latest) writeCache({ lastCheck: Date.now(), latest })
|
|
80
|
+
if (latest) writeCache({ ...cache, lastCheck: Date.now(), latest })
|
|
81
81
|
})
|
|
82
82
|
.catch(() => {})
|
|
83
83
|
.finally(() => clearTimeout(timer))
|
|
84
84
|
}
|
|
85
85
|
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Record the running CLI version and return the version seen on the PREVIOUS
|
|
89
|
+
* run (null on first run). Lets the CLI notice "I was just upgraded" so it can
|
|
90
|
+
* refresh installed skills. Merges into the shared cache so it doesn't clobber
|
|
91
|
+
* the update-check state.
|
|
92
|
+
*/
|
|
93
|
+
export function previousRunVersion(current) {
|
|
94
|
+
const cache = readCache()
|
|
95
|
+
const prev = cache.cliVersion || null
|
|
96
|
+
if (prev !== current) writeCache({ ...cache, cliVersion: current })
|
|
97
|
+
return prev
|
|
98
|
+
}
|