@tamsensedev/runner 0.1.0 → 0.3.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.
@@ -2,7 +2,7 @@
2
2
 
3
3
  Read this when the project has no Playwright or Cypress tests you can run for the change, but you still need to see the pages: screenshots, console errors, failed requests, horizontal overflow on narrow screens, and optionally an axe scan.
4
4
 
5
- The script below starts the app with the project's own command on a free port, waits for it, opens each page headless at a desktop and a phone width, runs optional steps you write, saves screenshots and a JSON summary to a temp folder, and stops the app and all its child processes at the end.
5
+ The script below starts the app with the project's own command on a free port, waits for it, opens each page headless at a desktop and a phone width, runs optional steps you write, saves screenshots and a JSON summary to a temp folder, and stops the app and all its child processes at the end. In a TAMsense run each page and width is also recorded as a replay for the task, named from `CONFIG.title` and `CONFIG.purpose` (see [recording.md](recording.md)).
6
6
 
7
7
  ## What it needs
8
8
 
@@ -14,7 +14,7 @@ The script below starts the app with the project's own command on a free port, w
14
14
  ## How to use it
15
15
 
16
16
  1. Make a new folder in the OS temp directory (`node -p "require('os').tmpdir()"`), for example `<tmp>/ui-check-<random>`. Never inside the repository.
17
- 2. Write the script there as `ui-check.mjs` and edit only the `CONFIG` block and the `steps` function.
17
+ 2. Write the script there as `ui-check.mjs` and edit only the `CONFIG` block and the `steps` function. Set `title` and `purpose` to what this check is and why ("Feature testing, attempt 2", "Checking the empty orders state after the fix"), and `video: true` when the pages have a canvas, a map, a video player or iframes from another site.
18
18
  3. Run `node <tmp>/ui-check-<random>/ui-check.mjs` with a timeout of a few minutes. It prints the summary; screenshots are in `CONFIG.outDir`.
19
19
  4. Open the screenshots with the Read tool and look at them. Describe in the report what each one shows; the paths alone mean nothing to the reader.
20
20
  5. Delete the folder when the report is written, unless the task asks you to keep artifacts.
@@ -41,13 +41,18 @@ const CONFIG = {
41
41
  widths: [1280, 390],
42
42
  startTimeoutMs: 120_000,
43
43
  navigationTimeoutMs: 30_000,
44
+ title: 'Feature testing',
45
+ purpose: 'Checking the changed screens at desktop and phone width',
46
+ video: false,
44
47
  }
45
48
 
46
- async function steps(page, { path, width }) {
49
+ async function steps(page, { path, width, mark }) {
47
50
  }
48
51
 
49
52
  const MAX_ITEMS = 20
53
+ const MAX_TITLE = 60
50
54
  const serverOutput = []
55
+ const recorder = process.env.TAMSENSE_RECORDER ? createRequire(import.meta.url)(process.env.TAMSENSE_RECORDER) : null
51
56
 
52
57
  function keepTail(chunk) {
53
58
  serverOutput.push(...String(chunk).split('\n').filter(Boolean))
@@ -162,9 +167,18 @@ async function runAxe(page, axeSource) {
162
167
  }
163
168
  }
164
169
 
170
+ async function openContext(browser, path, width) {
171
+ const options = { viewport: { width, height: 900 } }
172
+ if (!recorder) return browser.newContext(options)
173
+ const where = `, ${path} at ${width}px`
174
+ const title = `${CONFIG.title.slice(0, MAX_TITLE - where.length)}${where}`
175
+ return recorder.newRecordedContext(browser, { title, purpose: CONFIG.purpose, video: CONFIG.video, context: options })
176
+ }
177
+
165
178
  async function checkPage(browser, url, path, width, axeSource) {
166
- const context = await browser.newContext({ viewport: { width, height: 900 } })
179
+ const context = await openContext(browser, path, width)
167
180
  const page = await context.newPage()
181
+ const mark = label => recorder?.mark(page, label)
168
182
  const result = { path, width, consoleErrors: [], pageErrors: [], failedRequests: [], badResponses: [] }
169
183
  const add = (list, item) => { if (list.length < MAX_ITEMS) list.push(item) }
170
184
  page.on('console', message => { if (message.type() === 'error') add(result.consoleErrors, message.text()) })
@@ -175,7 +189,8 @@ async function checkPage(browser, url, path, width, axeSource) {
175
189
  const response = await page.goto(url, { waitUntil: 'load', timeout: CONFIG.navigationTimeoutMs })
176
190
  result.status = response?.status() ?? null
177
191
  await page.waitForLoadState('networkidle', { timeout: 5_000 }).catch(() => {})
178
- result.steps = await steps(page, { path, width })
192
+ await mark(`Opened ${path}`)
193
+ result.steps = await steps(page, { path, width, mark })
179
194
  result.title = await page.title()
180
195
  result.finalUrl = page.url()
181
196
  result.horizontalOverflowPx = await page.evaluate(() => document.documentElement.scrollWidth - document.documentElement.clientWidth)
@@ -186,7 +201,7 @@ async function checkPage(browser, url, path, width, axeSource) {
186
201
  } catch (error) {
187
202
  result.error = String(error.message).split('\n')[0]
188
203
  } finally {
189
- await context.close()
204
+ result.recording = recorder ? await recorder.closeRecorded(context) : await context.close()
190
205
  }
191
206
  return result
192
207
  }
@@ -236,15 +251,17 @@ main().catch(error => {
236
251
 
237
252
  ## Writing steps
238
253
 
239
- `steps` runs on every page and width before the screenshot. Use it to reach the state the task changed: open the dialog, fill and submit the form, switch the tab. Branch on `path` when pages need different steps. Whatever it returns is saved as `steps` in that page's summary (for example the focus order from a keyboard pass, see the `accessibility` skill). Use the same rules as in tests: role and label locators, web-first waits, no fixed sleeps (see [playwright-patterns.md](playwright-patterns.md)).
254
+ `steps` runs on every page and width before the screenshot. Use it to reach the state the task changed: open the dialog, fill and submit the form, switch the tab. Branch on `path` when pages need different steps. Call `await mark('...')` after each meaningful step: the marks show on the replay's timeline for the people on the task. Whatever it returns is saved as `steps` in that page's summary (for example the focus order from a keyboard pass, see the `accessibility` skill). Use the same rules as in tests: role and label locators, web-first waits, no fixed sleeps (see [playwright-patterns.md](playwright-patterns.md)).
240
255
 
241
256
  ```js
242
- async function steps(page, { path }) {
257
+ async function steps(page, { path, mark }) {
243
258
  if (path !== '/settings') return
244
259
  await page.getByRole('button', { name: 'Edit profile' }).click()
260
+ await mark('Opened the profile form')
245
261
  await page.getByLabel('Display name').fill('A very long display name that should wrap or truncate')
246
262
  await page.getByRole('button', { name: 'Save' }).click()
247
263
  await page.getByRole('status').waitFor()
264
+ await mark('Saved a long display name')
248
265
  }
249
266
  ```
250
267
 
@@ -253,6 +270,7 @@ Steps that write data only touch the local app and whatever local database it us
253
270
  ## Reading the summary
254
271
 
255
272
  - `server`: should be `stopped`. Anything else names a process you must stop yourself before finishing.
273
+ - `recording`: where each page's replay (and video) is. They are attached to the task when the run ends; `attach_replay` and `attach_video` attach one sooner.
256
274
  - `setupError`: the app or the browser did not start. Report it with the last lines of `serverOutputTail` (strip anything that looks like a secret). Do not retry with a different command unless the first one was clearly wrong, and never invent results.
257
275
  - `status` 4xx or 5xx, `pageErrors`, `consoleErrors`: real findings when they come from the changed code; noise from browser extensions does not exist here, but third-party scripts and dev-server warnings do. Say which is which.
258
276
  - `badResponses` and `failedRequests`: API calls the page needed that failed. A 401 or a redirect to a login page means the page needs a signed-in user (see the auth section of [playwright-patterns.md](playwright-patterns.md)).
@@ -0,0 +1,51 @@
1
+ # Starting a local copy of the app
2
+
3
+ Read this before you start the app for a check. In a TAMsense run the task folder is a fresh checkout: no dependencies, no `.env`, nothing running. You set up a local copy from this checkout yourself, every time, and you take it down at the end. The person's own setup, their databases and every shared server stay untouched.
4
+
5
+ ## The rules
6
+
7
+ 1. **This task folder only.** Everything you start comes from the checkout in the task folder. Never read or copy `.env` files, keys or settings from elsewhere on this computer, including the person's own checkout of the same project.
8
+ 2. **Find the local setup yourself.** Look in the README, compose files named local or dev (`docker-compose.local.yml`, `infra/compose/local.compose.yml`, `compose.dev.yaml`), `.env.example` and other env files meant for a local run (`.env.local.example`, `.env.development`), and the package scripts (`dev`, `db:migrate`, `db:seed`). The CI workflow often shows the exact services and commands.
9
+ 3. **Check every address before you start.** In the settings you use, databases, caches, queues and APIs must point at `localhost`, `127.0.0.1` or containers you start. A staging, shared dev or production address, or a cloud host you do not start, means stop (rule 7). Keys of outside services (payments, mail, analytics, storage, AI APIs) stay empty; never fill them with real ones. Check variables by name and address only; never print secrets.
10
+ 4. **Your own compose project on free ports.** Start containers as a separate compose project named `tamsense-task-<task id>`, so they never mix with the person's containers. Map them to free host ports with an override file in a temp folder, not by editing the project's files. Find a free port by asking the system (`node -e "const s=require('net').createServer().listen(0,()=>{console.log(s.address().port);s.close()})"`).
11
+ 5. **Never touch containers you did not start.** Do not stop, restart, remove or change other containers, volumes or networks, even when a port is taken: pick another port.
12
+ 6. **Clean up.** At the end run `docker compose -p tamsense-task-<task id> down -v` with the same files, stop the app by its process id, and delete the temp folder.
13
+ 7. **When it cannot run, stop.** If Docker is not running, the project has no local setup, or the settings point outside this computer, do not start anything and do not look for a workaround. In a check step, call `submit_verdict` with `passed: false` and `couldNotRun: true`, write the exact reason and what a person should do in the report ("Docker is not running: start Docker Desktop and run this step again"), and do not hand off. The task then waits on this step for a person. In a code step, say so in your report.
14
+
15
+ The browser adds a guard of its own: requests other than GET, HEAD and OPTIONS to an address outside this computer are aborted (see [recording.md](recording.md)). It does not replace rule 3: the app's server talks to its database and services directly.
16
+
17
+ ## Example
18
+
19
+ Task 40, a project with `infra/compose/local.compose.yml` (postgres and redis), `.env.example` pointing at `localhost:5432` and `localhost:6379`, and a `dev` script.
20
+
21
+ ```bash
22
+ docker info --format '{{.ServerVersion}}' # an error here means Docker is not running: stop (rule 7)
23
+ TMP=$(node -p "require('fs').mkdtempSync(require('path').join(require('os').tmpdir(), 'tamsense-task-40-'))")
24
+
25
+ # free host ports for the two services, found as in rule 4
26
+ cat > "$TMP/ports.override.yml" <<'EOF'
27
+ services:
28
+ postgres:
29
+ ports: !override
30
+ - "55432:5432"
31
+ redis:
32
+ ports: !override
33
+ - "56379:6379"
34
+ EOF
35
+
36
+ docker compose -p tamsense-task-40 -f infra/compose/local.compose.yml -f "$TMP/ports.override.yml" up -d postgres redis
37
+
38
+ pnpm install --frozen-lockfile
39
+ # settings from .env.example, with the ports changed to the free ones, passed as variables, not written into the repository
40
+ export DATABASE_URL=postgresql://postgres:postgres@localhost:55432/app REDIS_URL=redis://localhost:56379
41
+ pnpm db:migrate # migrations go to the local database only
42
+ BROWSER=none pnpm dev --port 53100 # the project's own dev script on a free port; note its process id
43
+
44
+ # ... the headless check against http://localhost:53100 ...
45
+
46
+ # stop the dev server by its process id, then
47
+ docker compose -p tamsense-task-40 -f infra/compose/local.compose.yml -f "$TMP/ports.override.yml" down -v
48
+ rm -rf "$TMP"
49
+ ```
50
+
51
+ Before `db:migrate`, look at the address once more: `DATABASE_URL` must name `localhost` and the port of your container. `!override` (Docker Compose 2.24 or newer) replaces the project's own port list instead of adding to it, so nothing binds the ports the person's own containers may use.
@@ -1,6 +1,6 @@
1
1
  # Playwright patterns: locators, waiting, flakes, auth, mocking
2
2
 
3
- Read this when you write, fix or review browser tests or the `steps` of a headless check. Examples are Playwright Test in TypeScript; translate to the project's language and runner. If the project uses Cypress, the same ideas apply (see the last section). Check the installed version (`npx playwright --version`) before using an API you are not sure exists in it.
3
+ Read this when you write, fix or review browser tests or the `steps` of a headless check. In a TAMsense run every browser context, also those of the project's tests, is recorded for the task; scripts of your own name their recordings as [recording.md](recording.md) shows. Examples are Playwright Test in TypeScript; translate to the project's language and runner. If the project uses Cypress, the same ideas apply (see the last section). Check the installed version (`npx playwright --version`) before using an API you are not sure exists in it.
4
4
 
5
5
  ## Locators
6
6
 
@@ -0,0 +1,58 @@
1
+ # Recording the browser for the task
2
+
3
+ Read this before any script that opens a browser during a TAMsense run. People on the task watch what your browser did: every page is recorded as a replay (the DOM, clicks and typing, played back in the TAMsense player) and attached to the task when the run ends.
4
+
5
+ ## What happens by itself
6
+
7
+ - When the `TAMSENSE_RECORDER` environment variable is set, every Node process you start loads the TAMsense recorder, and every Playwright browser context is recorded, also the ones opened by the project's own E2E tests. You cannot switch this off, and you do not need to.
8
+ - Requests from the tested site to the TAMsense tracker (`/api/events` with a `?key=` or sent to the TAMsense server) are answered by the recorder and never leave the browser, so your runs do not reach the product's analytics.
9
+ - Requests other than GET, HEAD and OPTIONS to an address outside this computer (anything but `localhost`, `*.localhost` and loopback addresses) are aborted, so a test run never writes to an outside service. The summary from `closeRecorded` counts them in `blockedExternalWrites`. If a flow you check needs such a write, it was set up against an outside service: say so instead of working around it.
10
+ - Password fields are masked in the replay.
11
+ - Recordings you did not attach yourself are attached when the run ends. Unnamed ones get the step name as their title, so name yours.
12
+
13
+ ## What you add in every browser script
14
+
15
+ Load the recorder and open each context through it, with a title and a purpose:
16
+
17
+ ```js
18
+ import { createRequire } from 'node:module'
19
+
20
+ const recorder = process.env.TAMSENSE_RECORDER ? createRequire(import.meta.url)(process.env.TAMSENSE_RECORDER) : null
21
+
22
+ const context = recorder
23
+ ? await recorder.newRecordedContext(browser, {
24
+ title: 'Checkout testing, attempt 2',
25
+ purpose: 'Paying with a saved card after the fix to the total',
26
+ context: { viewport: { width: 1280, height: 900 } },
27
+ })
28
+ : await browser.newContext({ viewport: { width: 1280, height: 900 } })
29
+ const page = await context.newPage()
30
+
31
+ await page.goto(url)
32
+ await recorder?.mark(page, 'Opened checkout')
33
+ await page.getByRole('button', { name: 'Pay' }).click()
34
+ await recorder?.mark(page, 'Paid, the receipt is shown')
35
+
36
+ const recorded = recorder ? await recorder.closeRecorded(context) : await context.close()
37
+ console.log(JSON.stringify(recorded, null, 2))
38
+ ```
39
+
40
+ - `title`: what it shows, at most 60 characters. Number design variants ("Design option 1", "Design option 2") and name test runs by their goal and attempt ("Feature testing", "Second attempt at feature testing"). Never generic names like "screenshot1" or "test".
41
+ - `purpose`: one line, at most 160 characters, on why it was made ("Comparing two layouts for the empty orders state").
42
+ - `mark(page, label)`: call it at each meaningful step (opened a screen, submitted a form, saw the error). Marks show on the replay's timeline, and a click on one jumps there.
43
+ - `closeRecorded(context)`: closes the context, finishes the files and returns where they are. Close every context this way, or at least with `context.close()`, before the script ends.
44
+ - Without `TAMSENSE_RECORDER` (not a TAMsense run) the script works as before.
45
+
46
+ ## When to add a video
47
+
48
+ A replay rebuilds the page from its DOM, so it cannot show pixels drawn by the page itself. Pass `video: true` to `newRecordedContext` as well when the screen has:
49
+
50
+ - a `<canvas>` (charts drawn on canvas, editors, games), a map, or a `<video>` player;
51
+ - iframes from another site (payment forms, embedded widgets);
52
+ - or when someone in the chat asks for a video.
53
+
54
+ `closeRecorded` warns when it saw a canvas, a video or a cross-origin iframe on a page recorded without video. Videos are larger (up to 200 MB each, 5 per run), so keep them short.
55
+
56
+ ## Attaching earlier
57
+
58
+ Everything is attached at the end of the run. To attach a recording sooner, or with a better title, call `attach_replay` (the `.rrweb.ndjson` file) or `attach_video` (the `.webm` file) with the path from `closeRecorded`, a title and a purpose. A run attaches at most 20 replays and 5 videos; the ones you named come first.
package/package.json CHANGED
@@ -1,45 +1,46 @@
1
- {
2
- "name": "@tamsensedev/runner",
3
- "type": "module",
4
- "version": "0.1.0",
5
- "description": "Connects your computer to TAMsense so it can work on your tasks",
6
- "author": "TAMsense",
7
- "license": "UNLICENSED",
8
- "homepage": "https://my.tamsense.com",
9
- "keywords": [
10
- "tamsense",
11
- "claude-code",
12
- "agents",
13
- "tasks"
14
- ],
15
- "publishConfig": {
16
- "access": "public"
17
- },
18
- "bin": {
19
- "tamsense-runner": "./dist/cli.js"
20
- },
21
- "files": [
22
- "dist",
23
- "library"
24
- ],
25
- "engines": {
26
- "node": ">=22"
27
- },
28
- "scripts": {
29
- "build": "tsup",
30
- "dev": "tsup --watch",
31
- "clean": "rimraf dist",
32
- "prepublishOnly": "tsup",
33
- "test": "node --experimental-strip-types --no-warnings --import ./test/resolve.mjs --test \"test/*.test.ts\""
34
- },
35
- "dependencies": {
36
- "@anthropic-ai/claude-agent-sdk": "^0.3.300",
37
- "zod": "^4.0.0"
38
- },
39
- "devDependencies": {
40
- "@types/node": "^22.0.0",
41
- "rimraf": "^6.0.1",
42
- "tsup": "^8.5.0",
43
- "typescript": "^5.5.4"
44
- }
45
- }
1
+ {
2
+ "name": "@tamsensedev/runner",
3
+ "type": "module",
4
+ "version": "0.3.0",
5
+ "description": "Connects your computer to TAMsense so it can work on your tasks",
6
+ "author": "TAMsense",
7
+ "license": "UNLICENSED",
8
+ "homepage": "https://my.tamsense.com",
9
+ "keywords": [
10
+ "tamsense",
11
+ "claude-code",
12
+ "agents",
13
+ "tasks"
14
+ ],
15
+ "publishConfig": {
16
+ "access": "public"
17
+ },
18
+ "bin": {
19
+ "tamsense-runner": "./dist/cli.js"
20
+ },
21
+ "files": [
22
+ "dist",
23
+ "library"
24
+ ],
25
+ "engines": {
26
+ "node": ">=22"
27
+ },
28
+ "scripts": {
29
+ "build": "tsup",
30
+ "dev": "tsup --watch",
31
+ "clean": "rimraf dist",
32
+ "prepublishOnly": "tsup",
33
+ "test": "node --experimental-strip-types --no-warnings --import ./test/resolve.mjs --test \"test/*.test.ts\""
34
+ },
35
+ "dependencies": {
36
+ "@anthropic-ai/claude-agent-sdk": "^0.3.300",
37
+ "rrweb": "2.0.0-alpha.20",
38
+ "zod": "^4.0.0"
39
+ },
40
+ "devDependencies": {
41
+ "@types/node": "^22.0.0",
42
+ "rimraf": "^6.0.1",
43
+ "tsup": "^8.5.0",
44
+ "typescript": "^5.5.4"
45
+ }
46
+ }