@test-lab-ai/cli 0.2.12 → 0.2.14
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/bin/testlab.mjs +124 -29
- package/lib/api.mjs +60 -19
- package/lib/skills.mjs +17 -0
- package/package.json +1 -1
- package/skills/test-lab-script/SKILL.md +15 -9
package/bin/testlab.mjs
CHANGED
|
@@ -23,7 +23,7 @@ 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, installedSkillLocations } from "../lib/skills.mjs"
|
|
26
|
+
import { TESTLAB_SKILLS, AGENTS, detectAgents, installSkill, installedSkillLocations, installedSkillTargets } from "../lib/skills.mjs"
|
|
27
27
|
import { checkForUpdate, currentVersion, previousRunVersion } from "../lib/update-check.mjs"
|
|
28
28
|
|
|
29
29
|
const log = (...a) => console.log(...a)
|
|
@@ -49,6 +49,8 @@ Usage:
|
|
|
49
49
|
testlab data create -f fixture.json Create a data fixture ({{data.<fixture>.<field>}})
|
|
50
50
|
testlab scripts upload <file> --plan <id> Upload a local Playwright script for a plan
|
|
51
51
|
(skips paid AI generation; --device optional)
|
|
52
|
+
testlab scripts get <id> [--out <file>] Download a plan's active script to fix + re-upload
|
|
53
|
+
(prints to stdout, or writes --out <file>; --device optional)
|
|
52
54
|
testlab examples Print the full JSON reference for every
|
|
53
55
|
resource (designed for AI agents)
|
|
54
56
|
testlab skills install [--agent ...] Install the test-lab skills (test-lab-plan +
|
|
@@ -84,6 +86,7 @@ function parse() {
|
|
|
84
86
|
project: { type: "string" },
|
|
85
87
|
plan: { type: "string" },
|
|
86
88
|
device: { type: "string" },
|
|
89
|
+
out: { type: "string", short: "o" },
|
|
87
90
|
version: { type: "boolean", short: "v" },
|
|
88
91
|
help: { type: "boolean", short: "h" },
|
|
89
92
|
},
|
|
@@ -370,10 +373,19 @@ async function cmdDataCreate(flags) {
|
|
|
370
373
|
// paid AI generation. The server validates it (allow-list + intent review),
|
|
371
374
|
// wraps it in the trusted harness, and stores it as the plan's saved script.
|
|
372
375
|
// On rejection the per-line issues are printed so an agent can self-correct.
|
|
376
|
+
// Strict positive-integer plan id from a CLI arg/flag. parseInt is too lenient
|
|
377
|
+
// ("12abc" -> 12, "12.9" -> 12), which would silently fetch/replace the WRONG
|
|
378
|
+
// plan; require all-digits and > 0 so a typo errors instead.
|
|
379
|
+
function parsePlanIdArg(raw) {
|
|
380
|
+
if (typeof raw !== "string" || !/^\d+$/.test(raw.trim())) return NaN
|
|
381
|
+
const n = parseInt(raw.trim(), 10)
|
|
382
|
+
return n > 0 ? n : NaN
|
|
383
|
+
}
|
|
384
|
+
|
|
373
385
|
async function cmdScriptsUpload(flags, args) {
|
|
374
386
|
const file = args[2]
|
|
375
387
|
if (!file) errExit("usage: testlab scripts upload <file.spec.ts> --plan <planId> [--device <device>]")
|
|
376
|
-
const planId =
|
|
388
|
+
const planId = parsePlanIdArg(flags.plan)
|
|
377
389
|
if (!Number.isInteger(planId)) errExit("--plan <planId> is required (the numeric test-plan id; see `testlab plans list`)")
|
|
378
390
|
const { apiKey, apiUrl } = requireAuth(flags)
|
|
379
391
|
|
|
@@ -388,7 +400,9 @@ async function cmdScriptsUpload(flags, args) {
|
|
|
388
400
|
// plan's first configured device. Hardcoding "Desktop Chrome" here would 400
|
|
389
401
|
// on any plan whose device isn't Desktop Chrome (the server validates it).
|
|
390
402
|
const device = flags.device
|
|
391
|
-
|
|
403
|
+
// Idempotent on the server (saved_scripts upsert ON CONFLICT), so a transient
|
|
404
|
+
// 5xx is safe to retry — a successful retry overwrites, it never duplicates.
|
|
405
|
+
const r = await apiFetch(apiUrl, apiKey, "POST", "/api/v1/scripts/upload", { script, planId, device }, { retries: 3 })
|
|
392
406
|
|
|
393
407
|
// 422 = the script was understood but rejected (allow-list or intent review).
|
|
394
408
|
// Print the structured issues so a human or agent can fix and retry.
|
|
@@ -404,7 +418,20 @@ async function cmdScriptsUpload(flags, args) {
|
|
|
404
418
|
log(`✗ Script rejected by security review: ${r.json.reason}`)
|
|
405
419
|
process.exit(1)
|
|
406
420
|
}
|
|
407
|
-
if (!r.ok)
|
|
421
|
+
if (!r.ok) {
|
|
422
|
+
// A 5xx (or network error) survived the retries — almost always a transient
|
|
423
|
+
// server blip, not a rejected script. Tell the user it's safe to re-run:
|
|
424
|
+
// the upload is idempotent, so a retry can't create a duplicate. (A genuinely
|
|
425
|
+
// bad script comes back as 422 and is handled above, never here.)
|
|
426
|
+
if (r.status === 0 || r.status >= 500) {
|
|
427
|
+
errExit(
|
|
428
|
+
`${r.status || "network"}: ${r.json?.error || "upload failed"}\n` +
|
|
429
|
+
` This is usually a transient server error, not a rejected script. ` +
|
|
430
|
+
`Re-run the same command — the upload is idempotent, so it won't create a duplicate.`,
|
|
431
|
+
)
|
|
432
|
+
}
|
|
433
|
+
errExit(`${r.status}: ${r.json?.error || "upload failed"}`)
|
|
434
|
+
}
|
|
408
435
|
|
|
409
436
|
const steps = r.json?.stepCount
|
|
410
437
|
// Show the device the SERVER resolved (it defaults an omitted device to the
|
|
@@ -414,6 +441,44 @@ async function cmdScriptsUpload(flags, args) {
|
|
|
414
441
|
log(` This plan now runs your script instead of AI generation.`)
|
|
415
442
|
}
|
|
416
443
|
|
|
444
|
+
async function cmdScriptsGet(flags, args) {
|
|
445
|
+
const planId = parsePlanIdArg(args[2] ?? flags.plan)
|
|
446
|
+
if (!Number.isInteger(planId)) {
|
|
447
|
+
errExit("usage: testlab scripts get <planId> [--device <device>] [--out <file>]")
|
|
448
|
+
}
|
|
449
|
+
const { apiKey, apiUrl } = requireAuth(flags)
|
|
450
|
+
|
|
451
|
+
const qs = new URLSearchParams({ planId: String(planId) })
|
|
452
|
+
if (flags.device) qs.set("device", String(flags.device))
|
|
453
|
+
const r = await apiFetch(apiUrl, apiKey, "GET", `/api/v1/scripts?${qs.toString()}`)
|
|
454
|
+
|
|
455
|
+
// 404 carries a human message (no script / wrong device); surface it verbatim.
|
|
456
|
+
if (!r.ok) errExit(`${r.status}: ${r.json?.error || "failed to fetch script"}`)
|
|
457
|
+
const script = r.json?.script
|
|
458
|
+
if (typeof script !== "string") errExit("server returned no script")
|
|
459
|
+
|
|
460
|
+
const device = r.json?.device || flags.device || "the plan's default device"
|
|
461
|
+
const steps = r.json?.stepCount
|
|
462
|
+
const source = r.json?.source
|
|
463
|
+
const meta = `${steps != null ? `${steps} step${steps === 1 ? "" : "s"}, ` : ""}${device}${source ? `, ${source}` : ""}`
|
|
464
|
+
const withNl = script.endsWith("\n") ? script : script + "\n"
|
|
465
|
+
|
|
466
|
+
if (flags.out) {
|
|
467
|
+
try {
|
|
468
|
+
fs.writeFileSync(flags.out, withNl, "utf8")
|
|
469
|
+
} catch (e) {
|
|
470
|
+
errExit(`could not write ${flags.out}: ${e.message}`)
|
|
471
|
+
}
|
|
472
|
+
log(`✓ Saved plan #${planId} script → ${flags.out} (${meta})`)
|
|
473
|
+
log(` Edit it, then re-upload: testlab scripts upload ${flags.out} --plan ${planId}${flags.device ? ` --device "${flags.device}"` : ""}`)
|
|
474
|
+
} else {
|
|
475
|
+
// Status line to stderr so a plain `> file` redirect captures only the
|
|
476
|
+
// script (pipeable), while the human still sees what they fetched.
|
|
477
|
+
process.stderr.write(`# plan #${planId} script (${meta})\n`)
|
|
478
|
+
process.stdout.write(withNl)
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
|
|
417
482
|
function cmdExamples() {
|
|
418
483
|
log(EXAMPLES_TEXT)
|
|
419
484
|
}
|
|
@@ -462,44 +527,73 @@ function cmdSkillsList() {
|
|
|
462
527
|
log(`\nInstall with: testlab skills install [name] [--agent ${AGENTS.join("|")}|all]`)
|
|
463
528
|
}
|
|
464
529
|
|
|
465
|
-
|
|
466
|
-
|
|
530
|
+
// Install EVERY current skill at every location where any test-lab skill is
|
|
531
|
+
// already present — refreshing existing copies AND adding skills shipped since
|
|
532
|
+
// the user last installed, so an update delivers the latest skill SET (not just
|
|
533
|
+
// fresher copies of the old set). `installedSkillTargets()` is the union of
|
|
534
|
+
// (agent, scope) homes; a brand-new skill has no install locations of its own
|
|
535
|
+
// yet, which is exactly why the old per-skill loop never picked it up. Returns
|
|
536
|
+
// one result per (skill, location), with `isNew` flagging a first-time install.
|
|
537
|
+
async function refreshInstalledSkills() {
|
|
538
|
+
const had = new Set()
|
|
467
539
|
for (const name of TESTLAB_SKILLS) {
|
|
468
|
-
for (const loc of installedSkillLocations(name)) {
|
|
540
|
+
for (const loc of installedSkillLocations(name)) had.add(`${name}:${loc.agent}:${loc.global}`)
|
|
541
|
+
}
|
|
542
|
+
const results = []
|
|
543
|
+
for (const { agent, global } of installedSkillTargets()) {
|
|
544
|
+
for (const name of TESTLAB_SKILLS) {
|
|
545
|
+
const isNew = !had.has(`${name}:${agent}:${global}`)
|
|
469
546
|
try {
|
|
470
|
-
const res = await installSkill(name,
|
|
471
|
-
|
|
472
|
-
log(`✓ ${name} → ${loc.agent}${loc.global ? " (global)" : ""}: ${res.dest}`)
|
|
547
|
+
const res = await installSkill(name, agent, { global })
|
|
548
|
+
results.push({ name, agent, global, isNew, ok: true, dest: res.dest })
|
|
473
549
|
} catch (e) {
|
|
474
|
-
|
|
550
|
+
results.push({ name, agent, global, isNew, ok: false, error: e.message })
|
|
475
551
|
}
|
|
476
552
|
}
|
|
477
553
|
}
|
|
478
|
-
|
|
479
|
-
|
|
554
|
+
return results
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
async function cmdSkillsUpdate() {
|
|
558
|
+
const results = await refreshInstalledSkills()
|
|
559
|
+
if (results.length === 0) {
|
|
560
|
+
log("No installed skills found here. Run `testlab skills install` first.")
|
|
561
|
+
return
|
|
562
|
+
}
|
|
563
|
+
for (const r of results) {
|
|
564
|
+
const where = `${r.agent}${r.global ? " (global)" : ""}`
|
|
565
|
+
if (!r.ok) log(` ✗ ${r.name} (${where}): ${r.error}`)
|
|
566
|
+
else log(`${r.isNew ? "+ installed" : "✓ refreshed"} ${r.name} → ${where}: ${r.dest}`)
|
|
567
|
+
}
|
|
568
|
+
const ok = results.filter((r) => r.ok)
|
|
569
|
+
const added = ok.filter((r) => r.isNew).length
|
|
570
|
+
const bits = []
|
|
571
|
+
if (ok.length - added) bits.push(`${ok.length - added} refreshed`)
|
|
572
|
+
if (added) bits.push(`${added} newly installed`)
|
|
573
|
+
log(`\n${bits.join(", ") || "Nothing to update"}. Restart your agent to load the changes.`)
|
|
480
574
|
}
|
|
481
575
|
|
|
482
|
-
// After a CLI upgrade, refresh
|
|
483
|
-
// per version bump (best effort). Skipped in CI / when
|
|
576
|
+
// After a CLI upgrade, refresh installed skills in place AND add any newly
|
|
577
|
+
// shipped ones — runs once per version bump (best effort). Skipped in CI / when
|
|
578
|
+
// NO_UPDATE_NOTIFIER is set.
|
|
484
579
|
async function maybeRefreshSkillsOnUpgrade() {
|
|
485
580
|
if (process.env.CI || process.env.NO_UPDATE_NOTIFIER) return
|
|
486
581
|
const ver = currentVersion()
|
|
487
582
|
const prev = previousRunVersion(ver)
|
|
488
583
|
if (!prev || prev === ver) return
|
|
489
|
-
let
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
refreshed++
|
|
495
|
-
} catch {
|
|
496
|
-
/* best effort — never break the actual command */
|
|
497
|
-
}
|
|
498
|
-
}
|
|
499
|
-
}
|
|
500
|
-
if (refreshed > 0) {
|
|
501
|
-
process.stderr.write(`↻ CLI upgraded ${prev} → ${ver}; refreshed ${refreshed} installed skill location(s).\n`)
|
|
584
|
+
let ok
|
|
585
|
+
try {
|
|
586
|
+
ok = (await refreshInstalledSkills()).filter((r) => r.ok)
|
|
587
|
+
} catch {
|
|
588
|
+
return // best effort — never break the actual command
|
|
502
589
|
}
|
|
590
|
+
if (ok.length === 0) return
|
|
591
|
+
const added = ok.filter((r) => r.isNew).length
|
|
592
|
+
const refreshed = ok.length - added
|
|
593
|
+
const bits = []
|
|
594
|
+
if (refreshed) bits.push(`refreshed ${refreshed}`)
|
|
595
|
+
if (added) bits.push(`installed ${added} new`)
|
|
596
|
+
process.stderr.write(`↻ CLI upgraded ${prev} → ${ver}; ${bits.join(", ")} skill location(s).\n`)
|
|
503
597
|
}
|
|
504
598
|
|
|
505
599
|
async function main() {
|
|
@@ -551,7 +645,8 @@ async function main() {
|
|
|
551
645
|
return errExit("usage: testlab data <list|create>")
|
|
552
646
|
case "scripts":
|
|
553
647
|
if (args[1] === "upload") return cmdScriptsUpload(flags, args)
|
|
554
|
-
|
|
648
|
+
if (args[1] === "get") return cmdScriptsGet(flags, args)
|
|
649
|
+
return errExit("usage: testlab scripts <upload <file> --plan <id> | get <id> [--out <file>]>")
|
|
555
650
|
case "examples":
|
|
556
651
|
return cmdExamples()
|
|
557
652
|
case "skills":
|
package/lib/api.mjs
CHANGED
|
@@ -1,31 +1,72 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Tiny fetch wrapper for the test-lab public API. Uses global fetch (Node 18+).
|
|
3
3
|
* Returns { ok, status, json } — json is the parsed body (or { raw } on non-JSON).
|
|
4
|
+
*
|
|
5
|
+
* Transient resilience: a 5xx or a network error is retried with linear backoff.
|
|
6
|
+
* Retries are SAFE only for idempotent calls, so they default ON for GET and OFF
|
|
7
|
+
* for writes — a write opts in explicitly via `opts.retries` (used by the
|
|
8
|
+
* idempotent script upload, which the server upserts ON CONFLICT). This is what
|
|
9
|
+
* stops a momentary empty-body 500 (a Worker hiccup or an edge rate-limit that
|
|
10
|
+
* short-circuits before the app's JSON error handler) from surfacing as a hard
|
|
11
|
+
* "500: upload failed" when a one-second retry would have gone through.
|
|
4
12
|
*/
|
|
5
|
-
export async function apiFetch(apiUrl, apiKey, method, pathname, body) {
|
|
13
|
+
export async function apiFetch(apiUrl, apiKey, method, pathname, body, opts = {}) {
|
|
14
|
+
const retries = Number.isInteger(opts.retries) ? opts.retries : method === "GET" ? 2 : 0
|
|
15
|
+
const backoffMs = Number.isInteger(opts.backoffMs) ? opts.backoffMs : 800
|
|
16
|
+
|
|
6
17
|
const headers = {}
|
|
7
18
|
if (apiKey) headers["Authorization"] = `Bearer ${apiKey}`
|
|
8
19
|
if (body !== undefined) headers["Content-Type"] = "application/json"
|
|
9
20
|
|
|
10
|
-
let
|
|
11
|
-
|
|
12
|
-
res = await fetch(`${apiUrl}${pathname}`, {
|
|
13
|
-
method,
|
|
14
|
-
headers,
|
|
15
|
-
body: body !== undefined ? JSON.stringify(body) : undefined,
|
|
16
|
-
})
|
|
17
|
-
} catch (e) {
|
|
18
|
-
return { ok: false, status: 0, json: { error: `network error: ${e.message}` } }
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
const text = await res.text()
|
|
22
|
-
let json = null
|
|
23
|
-
if (text) {
|
|
21
|
+
for (let attempt = 0; ; attempt++) {
|
|
22
|
+
let res
|
|
24
23
|
try {
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
24
|
+
res = await fetch(`${apiUrl}${pathname}`, {
|
|
25
|
+
method,
|
|
26
|
+
headers,
|
|
27
|
+
body: body !== undefined ? JSON.stringify(body) : undefined,
|
|
28
|
+
})
|
|
29
|
+
} catch (e) {
|
|
30
|
+
if (attempt < retries) {
|
|
31
|
+
await sleep(backoffMs * (attempt + 1))
|
|
32
|
+
continue
|
|
33
|
+
}
|
|
34
|
+
return { ok: false, status: 0, json: { error: `network error: ${e.message}` } }
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// 5xx = transient (Worker error / edge rate-limit). Retry while budget remains.
|
|
38
|
+
if (res.status >= 500 && attempt < retries) {
|
|
39
|
+
await sleep(backoffMs * (attempt + 1))
|
|
40
|
+
continue
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const text = await res.text()
|
|
44
|
+
let json = null
|
|
45
|
+
if (text) {
|
|
46
|
+
try {
|
|
47
|
+
json = JSON.parse(text)
|
|
48
|
+
} catch {
|
|
49
|
+
json = { raw: text }
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// A failed response with no usable error body (the empty-body 5xx case) would
|
|
54
|
+
// otherwise reach the caller as `json: null` and print a blank reason.
|
|
55
|
+
// Synthesize an actionable message keyed off the status instead.
|
|
56
|
+
if (!res.ok && (!json || (!json.error && !json.issues && !json.reason))) {
|
|
57
|
+
json = {
|
|
58
|
+
...(json || {}),
|
|
59
|
+
error:
|
|
60
|
+
res.status >= 500
|
|
61
|
+
? `server error (HTTP ${res.status}) with no response body — usually transient`
|
|
62
|
+
: `request failed (HTTP ${res.status})`,
|
|
63
|
+
}
|
|
28
64
|
}
|
|
65
|
+
|
|
66
|
+
return { ok: res.ok, status: res.status, json }
|
|
29
67
|
}
|
|
30
|
-
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function sleep(ms) {
|
|
71
|
+
return new Promise((resolve) => setTimeout(resolve, ms))
|
|
31
72
|
}
|
package/lib/skills.mjs
CHANGED
|
@@ -70,6 +70,23 @@ export function installedSkillLocations(name) {
|
|
|
70
70
|
].filter((c) => fs.existsSync(c.path))
|
|
71
71
|
}
|
|
72
72
|
|
|
73
|
+
/**
|
|
74
|
+
* The distinct (agent, scope) locations where AT LEAST ONE test-lab skill is
|
|
75
|
+
* currently installed. `skills update` and the on-upgrade refresh install EVERY
|
|
76
|
+
* current skill at each of these, so a skill added in a later CLI release lands
|
|
77
|
+
* wherever the user already keeps test-lab skills — not just fresher copies of
|
|
78
|
+
* the skills that happened to be installed before.
|
|
79
|
+
*/
|
|
80
|
+
export function installedSkillTargets() {
|
|
81
|
+
const seen = new Map()
|
|
82
|
+
for (const name of TESTLAB_SKILLS) {
|
|
83
|
+
for (const loc of installedSkillLocations(name)) {
|
|
84
|
+
seen.set(`${loc.agent}:${loc.global}`, { agent: loc.agent, global: loc.global })
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
return [...seen.values()]
|
|
88
|
+
}
|
|
89
|
+
|
|
73
90
|
/** Resolve the install target (base dir + format) for one agent. */
|
|
74
91
|
export function agentTarget(agent, { global } = {}) {
|
|
75
92
|
const home = os.homedir()
|
package/package.json
CHANGED
|
@@ -32,18 +32,21 @@ This also means the script runs in a **restricted environment**: Playwright and
|
|
|
32
32
|
|
|
33
33
|
Follow these in order.
|
|
34
34
|
|
|
35
|
-
### 1.
|
|
35
|
+
### 1. Design the test as a plan prompt first (with test-lab-plan)
|
|
36
36
|
|
|
37
|
-
|
|
37
|
+
The plan's English **prompt is the spec; your script is one implementation of it.** Design the spec before you write a line of Playwright — that is what keeps the script honest and reviewable, and it is the fallback the platform runs if the script is ever cleared. Skipping this is how you end up with a script glued to a throwaway prompt that describes something else.
|
|
38
38
|
|
|
39
|
-
-
|
|
40
|
-
-
|
|
39
|
+
- **No plan yet?** Use the **test-lab-plan** skill to design it: one user journey, an explicit start URL, and a numbered `Verify that:` block of observable acceptance criteria. Create it (dashboard or `testlab import`), then attach your script.
|
|
40
|
+
- **Plan already exists?** Read its prompt — that *is* your spec. If the prompt is vague, wrong, or describes a different flow, fix it with test-lab-plan **before** writing the script. Never encode behaviour in the script that the prompt doesn't claim.
|
|
41
41
|
|
|
42
|
-
|
|
42
|
+
Keep the prompt a clean user-journey spec: **no setup, auth, or implementation detail in the prose** — logged-in state, injected cookies, environment config, "no login needed", framework internals: none of it belongs there. Setup lives in the plan's pre-step / project environment, not the prompt body (test-lab-plan enforces this). Your script then *implements* that spec: one `test("Step N: …")` per prompt action, and an `expect` for each numbered acceptance criterion.
|
|
43
43
|
|
|
44
|
-
### 2.
|
|
44
|
+
### 2. Confirm the plan is reachable and read the real UI
|
|
45
45
|
|
|
46
|
-
|
|
46
|
+
- `testlab whoami` – confirm the CLI is authenticated. If not, the user runs `testlab login` once or sets `TESTLAB_API_KEY`. If `testlab` is not found, use `npx @test-lab-ai/cli` instead.
|
|
47
|
+
- `testlab plans list` – find the plan id to upload to. The plan must already have a target URL (from its prompt or its project), because the script runs against that origin. Uploading a script replaces AI generation for that plan + device, so from then on the prompt's job is to be the spec you implemented and the fallback — keep the two in sync.
|
|
48
|
+
- **Read the real UI before asserting.** If you are in the target site's repo, read the relevant component / route so your locators and assertions match real DOM text and real success states, not guesses. If you are not in the repo, pull a snapshot with WebFetch or ask the user for the key screens. Anchor every `expect` to something real **and to a criterion in the prompt.**
|
|
49
|
+
- **Fixing a script the plan already runs?** Download the live one with `testlab scripts get <id> [--device] [--out <file>]` (works for uploaded *and* generated scripts) instead of guessing at it, then edit and re-upload. That is the full fetch → fix → `scripts upload` loop, no dashboard needed.
|
|
47
50
|
|
|
48
51
|
### 3. Write the steps against `sharedPage`
|
|
49
52
|
|
|
@@ -140,6 +143,7 @@ The complete lists and the per-rule fixes are in `references/playwright-api.md`.
|
|
|
140
143
|
7. **No Node / browser / network globals** (`process`, `fs`, `fetch`, `window`, `request`, …) and **no `page.evaluate`/`route`**. If you reached for one, rewrite the step to use the page.
|
|
141
144
|
8. **No computed member keys or prototype access** (`x[expr]`, `.constructor`, `.__proto__`).
|
|
142
145
|
9. **The plan id is right** (`testlab plans list`) and the plan has a target URL.
|
|
146
|
+
10. **The script implements the plan's prompt.** Every prompt action maps to a `test("Step N: …")`, and every numbered acceptance criterion maps to an `expect`. The prompt itself reads as a clean user journey — no setup/auth/implementation detail leaked into the prose (that belongs in the plan's pre-step / environment). If the prompt and the script disagree, fix the prompt with test-lab-plan first.
|
|
143
147
|
|
|
144
148
|
If any item fails, fix it before uploading – it will be rejected server-side anyway, and fixing first saves a round-trip.
|
|
145
149
|
|
|
@@ -157,7 +161,9 @@ If any item fails, fix it before uploading – it will be rejected server-side a
|
|
|
157
161
|
|
|
158
162
|
## Relationship to test-lab-plan
|
|
159
163
|
|
|
160
|
-
|
|
161
|
-
|
|
164
|
+
These **compose; they are not either/or.** test-lab-plan designs the spec (the plan's prompt); test-lab-script implements it (the Playwright). Even when you will hand-write the script, start with test-lab-plan so the spec is a clean, reviewable user journey and the script has a concrete contract to satisfy — see Workflow step 1.
|
|
165
|
+
|
|
166
|
+
- **test-lab-plan** – describe a flow in English; the AI generates and maintains the Playwright. Best when you want low effort, or as the spec step before you write your own script.
|
|
167
|
+
- **test-lab-script** (this skill) – you own the Playwright and upload it verbatim to implement that spec. Best when you need exact control, already have a script, or want to save generation credits.
|
|
162
168
|
|
|
163
169
|
An uploaded script can still be refined later with AI from the dashboard (it is tagged as uploaded vs generated). `testlab examples` is the canonical, always-current reference for fixture shapes and resource formats.
|