@test-lab-ai/cli 0.2.10 → 0.2.12

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 CHANGED
@@ -130,16 +130,20 @@ and `testlab skills update` re-installs the bundled version on demand.
130
130
 
131
131
  ### Example prompts
132
132
 
133
- With the skill installed, talk to your agent in plain language; it writes the plan
134
- and runs the CLI. For a feature you just built:
133
+ With the skill installed, invoke it and describe the test in plain language. In
134
+ Claude Code: `/test-lab-plan`; in Codex or Cursor, just mention test-lab.
135
135
 
136
- - "Write a test-lab test for the signup flow at https://app.example.com/signup and create it in my account."
137
- - "Add a test-lab smoke test for our /login page (the test creds are already in test-lab), then import it."
136
+ Create a test for a feature you just built:
138
137
 
139
- To import tests you already have:
138
+ ```
139
+ /test-lab-plan write a test for the signup flow at https://app.example.com/signup, then create it in my account
140
+ ```
141
+
142
+ Import tests you already have:
140
143
 
141
- - "Convert the Playwright specs in ./tests/e2e into test-lab plans and import them."
142
- - "Turn our Cucumber .feature files into test-lab tests and create them in the Checkout project."
144
+ ```
145
+ /test-lab-plan convert the Playwright specs in ./tests/e2e into test-lab plans and import them
146
+ ```
143
147
 
144
148
  ## For AI agents
145
149
 
package/bin/testlab.mjs CHANGED
@@ -47,10 +47,13 @@ Usage:
47
47
  testlab labels list List your labels
48
48
  testlab data list List your data fixtures
49
49
  testlab data create -f fixture.json Create a data fixture ({{data.<fixture>.<field>}})
50
+ testlab scripts upload <file> --plan <id> Upload a local Playwright script for a plan
51
+ (skips paid AI generation; --device optional)
50
52
  testlab examples Print the full JSON reference for every
51
53
  resource (designed for AI agents)
52
- testlab skills install [--agent ...] Install the test-lab-plan skill (auto-detects
53
- Claude/Codex/Cursor; --agent claude|codex|cursor|all)
54
+ testlab skills install [--agent ...] Install the test-lab skills (test-lab-plan +
55
+ test-lab-script; auto-detects Claude/Codex/Cursor;
56
+ --agent claude|codex|cursor|all)
54
57
  testlab skills update Refresh installed skills (also auto-runs after a CLI upgrade)
55
58
 
56
59
  Options:
@@ -79,6 +82,8 @@ function parse() {
79
82
  global: { type: "boolean" },
80
83
  agent: { type: "string" },
81
84
  project: { type: "string" },
85
+ plan: { type: "string" },
86
+ device: { type: "string" },
82
87
  version: { type: "boolean", short: "v" },
83
88
  help: { type: "boolean", short: "h" },
84
89
  },
@@ -153,9 +158,25 @@ async function cmdLogin(flags) {
153
158
 
154
159
  async function cmdWhoami(flags) {
155
160
  const { apiKey, apiUrl } = requireAuth(flags)
156
- const r = await verifyKey(apiUrl, apiKey)
161
+ // The two requests are independent (apiFetch never throws), so fire
162
+ // them together — whoami is interactive and the serial form doubled
163
+ // its wall-clock. Identity endpoint shipped after the CLI's first
164
+ // release: older servers 404 on /me and we just skip those lines.
165
+ const [r, me] = await Promise.all([
166
+ verifyKey(apiUrl, apiKey),
167
+ apiFetch(apiUrl, apiKey, "GET", "/api/v1/me"),
168
+ ])
157
169
  if (!r.ok) errExit(`not authenticated (${r.status}): ${r.json?.error || ""}`)
158
170
  log(`✓ Authenticated to ${apiUrl}`)
171
+ if (me.ok && me.json) {
172
+ if (me.json.email) log(` Email: ${me.json.email}`)
173
+ const accountLabel =
174
+ me.json.accountType === "organization"
175
+ ? `${me.json.organization?.name || "organization"} (organization)`
176
+ : "personal"
177
+ log(` Account: ${accountLabel}`)
178
+ if (me.json.plan) log(` Plan: ${me.json.plan}`)
179
+ }
159
180
  log(` Test plans in account: ${r.json?.total ?? "?"}`)
160
181
  }
161
182
 
@@ -345,6 +366,54 @@ async function cmdDataCreate(flags) {
345
366
  log(`✓ Created data fixture: ${r.json.fixture.key}`)
346
367
  }
347
368
 
369
+ // Upload a locally-written Playwright .spec.ts for an existing plan, skipping
370
+ // paid AI generation. The server validates it (allow-list + intent review),
371
+ // wraps it in the trusted harness, and stores it as the plan's saved script.
372
+ // On rejection the per-line issues are printed so an agent can self-correct.
373
+ async function cmdScriptsUpload(flags, args) {
374
+ const file = args[2]
375
+ if (!file) errExit("usage: testlab scripts upload <file.spec.ts> --plan <planId> [--device <device>]")
376
+ const planId = flags.plan ? parseInt(flags.plan, 10) : NaN
377
+ if (!Number.isInteger(planId)) errExit("--plan <planId> is required (the numeric test-plan id; see `testlab plans list`)")
378
+ const { apiKey, apiUrl } = requireAuth(flags)
379
+
380
+ let script
381
+ try {
382
+ script = fs.readFileSync(file, "utf8")
383
+ } catch (e) {
384
+ errExit(`could not read ${file}: ${e.message}`)
385
+ }
386
+
387
+ // Omit the device when --device isn't passed so the server resolves it to the
388
+ // plan's first configured device. Hardcoding "Desktop Chrome" here would 400
389
+ // on any plan whose device isn't Desktop Chrome (the server validates it).
390
+ const device = flags.device
391
+ const r = await apiFetch(apiUrl, apiKey, "POST", "/api/v1/scripts/upload", { script, planId, device })
392
+
393
+ // 422 = the script was understood but rejected (allow-list or intent review).
394
+ // Print the structured issues so a human or agent can fix and retry.
395
+ if (r.status === 422 && Array.isArray(r.json?.issues)) {
396
+ log(`✗ Script rejected (${r.json.issues.length} issue${r.json.issues.length === 1 ? "" : "s"}):`)
397
+ for (const i of r.json.issues) {
398
+ const loc = i.line ? `L${i.line}${i.column ? ":" + i.column : ""} ` : ""
399
+ log(` ${loc}[${i.rule}] ${i.message}`)
400
+ }
401
+ process.exit(1)
402
+ }
403
+ if (r.status === 422 && r.json?.reason) {
404
+ log(`✗ Script rejected by security review: ${r.json.reason}`)
405
+ process.exit(1)
406
+ }
407
+ if (!r.ok) errExit(`${r.status}: ${r.json?.error || "upload failed"}`)
408
+
409
+ const steps = r.json?.stepCount
410
+ // Show the device the SERVER resolved (it defaults an omitted device to the
411
+ // plan's first), not the CLI-local flag which is undefined when --device is omitted.
412
+ const resolvedDevice = r.json?.device || device || "the plan's default device"
413
+ log(`✓ Uploaded ${file} → plan #${planId} (${steps != null ? `${steps} step${steps === 1 ? "" : "s"}, ` : ""}${resolvedDevice})`)
414
+ log(` This plan now runs your script instead of AI generation.`)
415
+ }
416
+
348
417
  function cmdExamples() {
349
418
  log(EXAMPLES_TEXT)
350
419
  }
@@ -480,6 +549,9 @@ async function main() {
480
549
  if (args[1] === "list") return cmdDataList(flags)
481
550
  if (args[1] === "create") return cmdDataCreate(flags)
482
551
  return errExit("usage: testlab data <list|create>")
552
+ case "scripts":
553
+ if (args[1] === "upload") return cmdScriptsUpload(flags, args)
554
+ return errExit("usage: testlab scripts upload <file.spec.ts> --plan <planId> [--device <device>]")
483
555
  case "examples":
484
556
  return cmdExamples()
485
557
  case "skills":
package/lib/config.mjs CHANGED
@@ -36,6 +36,8 @@ export function saveConfig(cfg) {
36
36
  export function resolveAuth(flags) {
37
37
  const cfg = loadConfig()
38
38
  const apiKey = flags.key || process.env.TESTLAB_API_KEY || cfg.apiKey || null
39
- const apiUrl = (flags["api-url"] || cfg.apiUrl || DEFAULT_API_URL).replace(/\/+$/, "")
39
+ const apiUrl = (
40
+ flags["api-url"] || process.env.TESTLAB_API_URL || cfg.apiUrl || DEFAULT_API_URL
41
+ ).replace(/\/+$/, "")
40
42
  return { apiKey, apiUrl }
41
43
  }
package/lib/skills.mjs CHANGED
@@ -26,7 +26,7 @@ import path from "node:path"
26
26
  import { fileURLToPath } from "node:url"
27
27
 
28
28
  // Skills published by test-lab. Add new skill directory names here.
29
- export const TESTLAB_SKILLS = ["test-lab-plan"]
29
+ export const TESTLAB_SKILLS = ["test-lab-plan", "test-lab-script"]
30
30
 
31
31
  // Agents this CLI can install into.
32
32
  export const AGENTS = ["claude", "codex", "cursor"]
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@test-lab-ai/cli",
3
- "version": "0.2.10",
3
+ "version": "0.2.12",
4
4
  "description": "Import existing test plans into test-lab.ai from the command line (or an AI agent).",
5
5
  "type": "module",
6
6
  "bin": {
@@ -245,6 +245,7 @@ Rules: get secret VALUES from the user (the CLI stores them encrypted, never ech
245
245
 
246
246
  ## Going further
247
247
 
248
+ - **Write the Playwright yourself and upload it** (skip paid AI generation) instead of describing a flow — use the **test-lab-script** skill (`testlab scripts upload`). Best when the user already has a `.spec.ts` or wants exact control.
248
249
  - **Create plans (and their credentials, labels, and data) directly** instead of pasting — the `@test-lab-ai/cli`. See "Creating it with the CLI" above.
249
250
  - **Two ways to drive this skill** (author a test while you build a feature, or import tests you already have) — see `examples/workflows.md`.
250
251
  - **Variable syntax in depth** (pre-steps, pipeline inputs, devices) — see `references/syntax.md`.
@@ -0,0 +1,163 @@
1
+ ---
2
+ name: test-lab-script
3
+ description: Write and upload a Playwright .spec.ts to test-lab.ai to skip paid AI script generation. Use this skill when the user wants to hand-write a Playwright test (or already has one) and attach it to an EXISTING test-lab.ai test plan via the CLI (`testlab scripts upload`), instead of describing a flow in English for the AI to generate. Trigger on "upload a Playwright script to test-lab", "write the Playwright myself", "I already have a .spec.ts", "skip AI generation / save credits", "attach my own test script", "testlab scripts upload". For describing a flow in plain English so the platform generates the Playwright, use the test-lab-plan skill instead. Outputs a .spec.ts that passes the upload allow-list (no imports, no lifecycle hooks, Playwright + safe JS only) plus the exact upload command.
4
+ allowed-tools:
5
+ - Read
6
+ - Glob
7
+ - Grep
8
+ - WebFetch
9
+ - Bash
10
+ - Write
11
+ ---
12
+
13
+ # test-lab.ai uploaded scripts
14
+
15
+ [test-lab.ai](https://test-lab.ai) is an AI QA platform. Normally you describe a flow in plain English and the platform's AI generates the Playwright that runs it (that is the `test-lab-plan` skill). This skill is the other path: you **write the Playwright `.spec.ts` yourself** and upload it to an existing plan, so the plan runs your exact script and skips paid generation.
16
+
17
+ Use this skill when the user says they want to write the Playwright themselves, already have a `.spec.ts`, or wants to save generation credits. If they instead want to describe a flow and let the AI build it, use **test-lab-plan**.
18
+
19
+ ## The one thing to understand first
20
+
21
+ You write **only the test steps**. You do **not** write the scaffolding. When you upload, the platform throws away everything except your step bodies and your top-level shared variables, then re-wraps them in its own harness that:
22
+
23
+ - imports Playwright and sets up the browser, context, tracing, and teardown for you,
24
+ - opens a shared page named `sharedPage`, already navigated to the plan's start URL, before your first step,
25
+ - runs your steps **serially**, in order, sharing that one page and browser context.
26
+
27
+ So your file must **not** contain `import` lines or `beforeAll`/`afterAll` hooks. If you write them, they are ignored (the harness owns them). What you write is a sequence of `test(...)` steps that drive `sharedPage`.
28
+
29
+ This also means the script runs in a **restricted environment**: Playwright and ordinary JavaScript only. No Node APIs (`fs`, `process`, `require`), no network clients (`fetch`, the `request` fixture), no `page.evaluate`. You drive the application the way a user would: through the page. The full allow / deny list is in `references/playwright-api.md` and is summarized below.
30
+
31
+ ## Workflow
32
+
33
+ Follow these in order.
34
+
35
+ ### 1. Confirm the target plan exists and is reachable
36
+
37
+ An upload attaches to an **existing** plan by numeric id. Before writing anything:
38
+
39
+ - `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.
40
+ - `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.
41
+
42
+ If there is no suitable plan yet, create one first (in the dashboard, or with the **test-lab-plan** skill + `testlab import`). The plan's English prompt stops driving runs the moment a script is uploaded: your script replaces AI generation for that plan and device.
43
+
44
+ ### 2. Know the flow and its checks
45
+
46
+ Same discipline as a good test plan: one user journey, explicit start, observable assertions. 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.
47
+
48
+ ### 3. Write the steps against `sharedPage`
49
+
50
+ Use the Template below. Each step is a `test("Step N: ...", async (...) => { ... })` block that acts on `sharedPage` and asserts with `expect`. The browser already sits on the start URL when Step 1 begins, so go straight into the flow. Keep one logical action + its checks per step; steps run in order and share state.
51
+
52
+ ### 4. Plug in credentials and per-run data through fixtures
53
+
54
+ Never inline a real password, token, or email. Pull sensitive values from the `credentials` fixture, and unique-per-run values from the `run` fixture, by **destructuring them in the step's parameters**:
55
+
56
+ ```ts
57
+ test("Step 1: Sign in", async ({ credentials }) => {
58
+ await sharedPage.getByLabel("Email").fill(credentials.testEmail);
59
+ await sharedPage.getByLabel("Password").fill(credentials.testPassword);
60
+ await sharedPage.getByRole("button", { name: "Sign in" }).click();
61
+ });
62
+ ```
63
+
64
+ The real values are injected at run time and never appear in your file. Configure the credential keys on the plan first (dashboard → Credentials, or `testlab credentials`). For a fresh value each run (a unique email, an order ref), use the `run` fixture (e.g. `run.shortId`); run `testlab examples` for the exact fixture shape. A bare `credentials` / `run` / `page` reference (not destructured) is rejected with a message telling you to add it to the step parameters.
65
+
66
+ ### 5. Self-check, then upload
67
+
68
+ Run the Self-check list below. Then write the file and upload:
69
+
70
+ ```bash
71
+ testlab scripts upload login.spec.ts --plan 1234
72
+ ```
73
+
74
+ - `--device` is optional; omit it and the server uses the plan's first configured device. If you pass one it must match a device configured on the plan, or the upload is rejected.
75
+ - On success you see `✓ Uploaded … → plan #1234 (N steps, <device>)` and the plan now runs your script instead of AI generation.
76
+ - On rejection the CLI prints the issues to fix. Allow-list problems come as `L<line>:<col> [<rule>] <message>` lines (the `[rule]` → fix map is in `references/playwright-api.md`); a security-review or device-mismatch rejection prints a plain reason instead. Fix what each says and re-upload. Don't try to "trick" the validator; rewrite the step to drive the page.
77
+
78
+ ## Template
79
+
80
+ A passing uploaded script looks like this. Note: no imports, no `beforeAll`/`afterAll`, steps drive `sharedPage`, shared state is a top-level `let`.
81
+
82
+ ```ts
83
+ // Optional cross-step state (top-level let/const is allowed and carried over).
84
+ let orderRef = "";
85
+
86
+ test("Step 1: Sign in", async ({ credentials }) => {
87
+ // sharedPage is already on the plan's start URL.
88
+ await sharedPage.getByLabel("Email").fill(credentials.testEmail);
89
+ await sharedPage.getByLabel("Password").fill(credentials.testPassword);
90
+ await sharedPage.getByRole("button", { name: "Sign in" }).click();
91
+ await expect(sharedPage.getByRole("navigation")).toContainText("Dashboard");
92
+ });
93
+
94
+ test("Step 2: Place an order", async ({ run }) => {
95
+ orderRef = `TEST-${run.shortId}`;
96
+ await sharedPage.getByRole("link", { name: "New order" }).click();
97
+ await sharedPage.getByLabel("Reference").fill(orderRef);
98
+ await sharedPage.getByRole("button", { name: "Submit" }).click();
99
+ });
100
+
101
+ test("Step 3: Confirm it was created", async () => {
102
+ await expect(sharedPage.getByRole("heading")).toHaveText("Order created");
103
+ await expect(sharedPage.getByText(orderRef)).toBeVisible();
104
+ });
105
+ ```
106
+
107
+ You may wrap the steps in `test.describe.serial("...", () => { ... })` if you prefer; it is optional (the harness already runs them serially). Top-level `let`/`const`/`function` declarations are carried over so steps can share them.
108
+
109
+ ### What you can write inside a step
110
+
111
+ - **Playwright:** `sharedPage` actions (`goto`, `click`, `fill`, `getByRole`/`getByLabel`/`getByText`/`locator`, `waitForURL`, `waitForLoadState`, …) and `expect(...)` matchers (`toBeVisible`, `toHaveText`, `toHaveURL`, …).
112
+ - **Plain JavaScript:** variables, `if`/`for`/`while`/`try`, arrow and named functions, destructuring, `async`/`await`, template literals, regex, `Promise.all([...])`.
113
+ - **Safe built-ins:** `JSON`, `Math`, `Date`, `RegExp`, `Number`, `String`, `Array`, `Object`, `Set`, `Map`, `console`, `Intl`, and similar pure helpers.
114
+ - **Fixtures**, by destructuring step parameters: `page`, `context`, `browser`, `run`, `credentials`, `pipeline`, `testInfo`, `browserName`. (`sharedPage`, `context`, and `expect` are already available without destructuring.)
115
+
116
+ ### What you must not write (and the fix)
117
+
118
+ | Don't | Why / Fix |
119
+ |---|---|
120
+ | `import ...` / `export ...` | The harness provides imports. Delete them. |
121
+ | `test.beforeAll` / `afterAll` / `beforeEach` | The harness owns lifecycle. Put setup in Step 1 against `sharedPage`. |
122
+ | `process`, `require`, `fs`, `Buffer`, `__dirname` | Node host APIs are unavailable. Drive the app through the page. |
123
+ | `fetch`, `XMLHttpRequest`, `WebSocket`, the `request` fixture | No direct network clients. Trigger requests via UI actions and assert the visible result. |
124
+ | `page.evaluate` / `$eval` / `waitForFunction` / `addInitScript` / `page.route` | In-page code execution and network interception are unavailable. Use locators + `expect`. |
125
+ | `eval`, `Function(...)`, `globalThis`, `Reflect`, `Proxy` | Not available. Write the logic directly. |
126
+ | `window`, `document`, `navigator`, `location` | Step bodies run in Node, not the browser. Use Playwright locators (`sharedPage.getBy…`). |
127
+ | `el["some" + x]` (computed key) or `.constructor` / `.__proto__` | Use dot access, `.nth(i)`, or `.at(i)`; don't touch prototype/constructor. |
128
+ | Downloading a file to disk via Node | Not available in an uploaded step. Assert the download was offered (e.g. a `download` event or the link/state) through the page. |
129
+
130
+ The complete lists and the per-rule fixes are in `references/playwright-api.md`.
131
+
132
+ ## Self-check (apply before upload)
133
+
134
+ 1. **No `import`/`export` lines** anywhere in the file.
135
+ 2. **No `beforeAll`/`afterAll`/`beforeEach`.** Setup lives in Step 1 against `sharedPage`.
136
+ 3. **At least one `test("...", async (...) => { ... })` step**, and each does one logical action + its checks.
137
+ 4. **Steps drive `sharedPage`** for continuity (a destructured `page` is a fresh, un-navigated page – only use it for a deliberately isolated check).
138
+ 5. **Every assertion is real.** `expect` targets DOM text / state that actually exists (read the source where you could).
139
+ 6. **No inlined secrets.** Passwords / tokens / real emails come from `{ credentials }`; unique-per-run values from `{ run }`. Each is destructured in the step that uses it.
140
+ 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
+ 8. **No computed member keys or prototype access** (`x[expr]`, `.constructor`, `.__proto__`).
142
+ 9. **The plan id is right** (`testlab plans list`) and the plan has a target URL.
143
+
144
+ If any item fails, fix it before uploading – it will be rejected server-side anyway, and fixing first saves a round-trip.
145
+
146
+ ## Anti-patterns (fix before upload)
147
+
148
+ | Anti-pattern | Why it's wrong | Fix |
149
+ |---|---|---|
150
+ | File starts with `import { test, expect } from "@playwright/test"` | The harness injects these and strips any imports you write (a leftover import is ignored, not run), so they're dead weight. | Delete all import lines. |
151
+ | `test.beforeAll(async () => { await page.goto(...) })` | Lifecycle is owned by the platform and discarded. | Do first-step setup inside `test("Step 1: …")` on `sharedPage`. |
152
+ | Using a destructured `page` across steps and wondering why state resets | Destructured `page` is a fresh page per step, not the shared one. | Use `sharedPage` for a continuous flow. |
153
+ | `const data = await page.evaluate(() => window.__STATE__)` | In-page execution is unavailable. | Assert on rendered DOM via locators + `expect`. |
154
+ | `await fetch("/api/orders")` to seed data | No network client in a step. | Drive the seeding through the UI, or set it up as a plan pre-step. |
155
+ | `password: "hunter2"` inlined | Secrets in scripts leak into reports and version control. | `async ({ credentials }) => …` and `credentials.password`. |
156
+ | Re-implementing login in every script | Wasted steps; brittle. | If the plan has a login pre-step, start after it; otherwise keep login as Step 1 only. |
157
+
158
+ ## Relationship to test-lab-plan
159
+
160
+ - **test-lab-plan** – describe a flow in English; the AI generates and maintains the Playwright. Best when you want low effort or don't have a script.
161
+ - **test-lab-script** (this skill) – you own the Playwright; upload it verbatim. Best when you already have a script, need exact control, or want to save generation credits.
162
+
163
+ 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.
@@ -0,0 +1,74 @@
1
+ # Uploaded-script API reference
2
+
3
+ The precise allow / deny surface for an uploaded `.spec.ts`, plus what each rejection `[rule]` means and how to fix it. The validator is **default-deny**: ordinary computation is allowed, only *capabilities* are denied. When in doubt, drive the application through `sharedPage` and assert with `expect`.
4
+
5
+ ## Available without declaring
6
+
7
+ These names can be used in any step body without destructuring or declaring them:
8
+
9
+ - `sharedPage` – the Playwright `Page` shared across all steps, already navigated to the plan's start URL before Step 1. Use this for a continuous flow.
10
+ - `context` – the shared `BrowserContext`.
11
+ - `expect` – Playwright assertions.
12
+ - `test` – to declare steps (and optionally `test.describe.serial(...)` to group them).
13
+ - Pure JS built-ins: `JSON`, `Math`, `Date`, `RegExp`, `Number`, `String`, `Boolean`, `Array`, `Object`, `Set`, `Map`, `WeakMap`, `WeakSet`, `Symbol`, `Promise`, `parseInt`, `parseFloat`, `isNaN`, `isFinite`, `encodeURIComponent`, `decodeURIComponent`, `encodeURI`, `decodeURI`, `console`, `Error`, `TypeError`, `RangeError`, `Intl`, `undefined`, `NaN`, `Infinity`.
14
+
15
+ ## Fixtures (destructure in the step parameters)
16
+
17
+ A step may inject any of these by destructuring them from its first parameter (`async ({ ... }) => {}`), or `testInfo` as the second parameter:
18
+
19
+ `page`, `context`, `browser`, `run`, `credentials`, `pipeline`, `testInfo`, `browserName`
20
+
21
+ ```ts
22
+ test("Step 1: ...", async ({ page, credentials, run }, testInfo) => { ... });
23
+ ```
24
+
25
+ - `credentials.<key>` – values configured on the plan; injected at run time, never written in the file.
26
+ - `run.shortId` (and other `run` fields) – per-run values, e.g. to build a unique email or reference. Run `testlab examples` for the full shape.
27
+ - `page` (destructured) – a **fresh** page in a separate fixture context, **not** `sharedPage`. State does not carry between steps on it. Use `sharedPage` unless you deliberately want an isolated page.
28
+ - **Not** available as a fixture: `request` (the Playwright `APIRequestContext`). There is no direct HTTP client in an uploaded step; drive requests through the UI.
29
+
30
+ ## Playwright surface you can use
31
+
32
+ Anything on `sharedPage` / `page` / locators / `expect` **except** the denied method names below. The common, fully-allowed set:
33
+
34
+ - Navigation: `goto`, `goBack`, `reload`, `waitForURL`, `waitForLoadState`.
35
+ - Locators: `locator`, `getByRole`, `getByText`, `getByLabel`, `getByPlaceholder`, `getByTestId`, `getByTitle`, `getByAltText`, `filter`, `nth`, `first`, `last`.
36
+ - Actions: `click`, `dblclick`, `fill`, `type`, `press`, `check`, `uncheck`, `selectOption`, `setInputFiles`, `hover`, `focus`, `scrollIntoViewIfNeeded`, `dragTo`.
37
+ - State / waits: `waitFor`, `isVisible`, `isEnabled`, `textContent`, `innerText`, `inputValue`, `getAttribute`, `count`.
38
+ - Assertions: `expect(locator).toBeVisible()/toHaveText()/toContainText()/toHaveValue()/toHaveURL()/toHaveCount()/toBeEnabled()`, etc.
39
+
40
+ ## Denied capabilities
41
+
42
+ Denied by **identifier** (free-identifier rule):
43
+
44
+ `process`, `globalThis`, `global`, `Buffer`, `require`, `module`, `exports`, `__dirname`, `__filename`, `eval`, `Function`, `fetch`, `XMLHttpRequest`, `WebSocket`, `importScripts`, `Reflect`, `Proxy`, `WebAssembly`, `window`, `document`, `navigator`, `location`, `self`, `Deno`, `Bun` – and **any** name you didn't declare and that isn't listed above.
45
+
46
+ Denied by **member / method name** (denied-api rule), on any receiver:
47
+
48
+ `evaluate`, `evaluateHandle`, `evaluateAll`, `$eval`, `$$eval`, `waitForFunction`, `exposeFunction`, `exposeBinding`, `addInitScript`, `addScriptTag`, `route`, `routeFromHAR`, `unroute`, `unrouteAll`, `request`.
49
+
50
+ Denied **member access** (dangerous-member rule):
51
+
52
+ `constructor`, `__proto__`, `prototype`, `getPrototypeOf`, `setPrototypeOf`, `__defineGetter__`, `__defineSetter__`, `__lookupGetter__`, `__lookupSetter__`.
53
+
54
+ Also: dynamic `import()` is denied, and computed member access with a non-literal key (`obj[expr]`) is denied. Static top-level `import`/`export` lines are **stripped** (the harness injects its own imports), so they're ignored rather than run – delete them to keep the file clean.
55
+
56
+ ## Rejection rules → fix
57
+
58
+ When `testlab scripts upload` prints `L<line>:<col> [<rule>] <message>`, this is what each rule means:
59
+
60
+ | `[rule]` | Meaning | Fix |
61
+ |---|---|---|
62
+ | `parse-error` | The file is not valid TypeScript/Playwright. | Fix the syntax at the reported line. |
63
+ | `no-steps` | No `test(...)` step was found. | Wrap actions in `test("Step 1: ...", async ({ ... }) => { ... })`. |
64
+ | `import-in-step` | An `import`/`export` appears inside validated step/declaration code (a normal top-level import is stripped, not flagged). | Don't import; the harness injects Playwright. |
65
+ | `dynamic-import` | A dynamic `import()` call. | Remove it; uploaded steps cannot load modules. |
66
+ | `denied-api` | A method like `evaluate`/`route`/`request`. | Use locators + `expect` (drive the UI); remove network interception / in-page eval. |
67
+ | `dangerous-member` | `.constructor` / `.__proto__` / `.prototype` etc. | Access the value directly; don't walk the prototype chain. |
68
+ | `computed-member` | `x[expr]` with a non-literal key. | Use dot access, `.nth(i)`, or `.at(i)`. |
69
+ | `denied-fixture` | A step destructured a fixture that isn't allowed (e.g. `request`). | Use only `page`, `context`, `browser`, `run`, `credentials`, `pipeline`, `testInfo`, `browserName`; drive HTTP through the page. |
70
+ | `free-identifier` | A name that is neither a safe global, an allowed fixture, nor declared by you (e.g. `process`, `fetch`, a bare `credentials`). | Remove the host/browser global, or – if it's a fixture – add it to the step parameters (`async ({ credentials }) => ...`), or declare your own variable. |
71
+
72
+ ## The page model, in one line
73
+
74
+ `sharedPage` persists across steps (the normal case); a destructured `page` is fresh per step. Pick `sharedPage` for a journey, `page` only for an intentionally isolated probe.