@ethlete/agent-rules 0.1.0-next.27 → 0.1.0-next.29
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/CHANGELOG.md +31 -0
- package/content/hooks/context-warning.py +32 -3
- package/content/mods/.claude-plugin/marketplace.json +11 -0
- package/content/mods/plugins/e2e-progress/.claude-plugin/plugin.json +6 -0
- package/content/mods/plugins/e2e-progress/hooks/hooks.json +1 -0
- package/content/mods/plugins/e2e-progress/hooks/register.tsx +442 -0
- package/content/mods/plugins/e2e-progress/tsconfig.json +3 -0
- package/content/mods/plugins/e2e-progress/types/index.d.ts +23 -0
- package/content/rules/agent-sessions-wake-up.md +11 -0
- package/content/skills/agent-sessions/SKILL.md +81 -0
- package/content/skills/agent-sessions/agent-sessions.mjs +460 -0
- package/content/skills/design-exploration/SKILL.md +11 -2
- package/content/skills/e2e-triage/SKILL.md +80 -0
- package/content/skills/handoff/SKILL.md +48 -2
- package/content/skills/sdk-update/SKILL.md +7 -0
- package/package.json +1 -1
- package/src/index.js +7 -0
- package/src/index.js.map +1 -1
- package/src/lib/config.d.ts +2 -0
- package/src/lib/config.js +5 -3
- package/src/lib/config.js.map +1 -1
- package/src/lib/plan.js +4 -0
- package/src/lib/plan.js.map +1 -1
- package/src/lib/sessions-command.d.ts +4 -0
- package/src/lib/sessions-command.js +16 -0
- package/src/lib/sessions-command.js.map +1 -0
- package/src/lib/targets/claude-hooks.d.ts +2 -0
- package/src/lib/targets/claude-hooks.js +4 -1
- package/src/lib/targets/claude-hooks.js.map +1 -1
- package/src/lib/targets/claude-mods.d.ts +20 -0
- package/src/lib/targets/claude-mods.js +47 -0
- package/src/lib/targets/claude-mods.js.map +1 -0
- package/src/lib/targets/hooks-shared.d.ts +1 -0
- package/src/lib/targets/hooks-shared.js +3 -2
- package/src/lib/targets/hooks-shared.js.map +1 -1
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: agent-sessions
|
|
3
|
+
description: Run agents in tmux sessions of one checkout, message them, wake idle ones, restart a session with a prompt, and claim paths so two agents do not overwrite each other. Read before you edit a shared file while another agent may be active, and when the user says "mailbox", "coordinate with the other agent", "claim a file", "hand off to the other agent", or "restart the session".
|
|
4
|
+
kind: skill
|
|
5
|
+
scope: both
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
# Agent sessions
|
|
9
|
+
|
|
10
|
+
One script, {%resource:agent-sessions.mjs%}, does three jobs for the agents of one checkout:
|
|
11
|
+
|
|
12
|
+
- **Sessions.** It runs each agent in a `tmux` session, and it can clear a session and start it with a prompt.
|
|
13
|
+
- **Messages.** Agents send each other messages through an append-only JSON-lines file, the mailbox. A dispatcher wakes an idle agent.
|
|
14
|
+
- **Claims.** An agent announces the paths it edits, so two agents do not overwrite each other.
|
|
15
|
+
|
|
16
|
+
Below, `<skill-dir>` is the directory this file sits in, and `<name>` is your own sender name. Where
|
|
17
|
+
the `ethlete-agents` package is a dependency, `npx ethlete-agents sessions <command>` runs the same
|
|
18
|
+
script, so you do not need `<skill-dir>`. The older name `npx ethlete-agents mailbox` works too.
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
node <skill-dir>/agent-sessions.mjs run <name> <command...>
|
|
22
|
+
node <skill-dir>/agent-sessions.mjs up <name>[=<command>]...
|
|
23
|
+
node <skill-dir>/agent-sessions.mjs restart <name> [--after <seconds>] <prompt...>
|
|
24
|
+
node <skill-dir>/agent-sessions.mjs down [<name>...]
|
|
25
|
+
node <skill-dir>/agent-sessions.mjs shell-install [<agent>...] [--file <path>] [--remove] [--dry-run]
|
|
26
|
+
node <skill-dir>/agent-sessions.mjs shell-init [<agent>...]
|
|
27
|
+
node <skill-dir>/agent-sessions.mjs send <name> [--to <agent>] '<message>'
|
|
28
|
+
node <skill-dir>/agent-sessions.mjs inbox <name> [--wait <seconds>]
|
|
29
|
+
node <skill-dir>/agent-sessions.mjs read [--last <n>] [--all]
|
|
30
|
+
node <skill-dir>/agent-sessions.mjs watch <name>
|
|
31
|
+
node <skill-dir>/agent-sessions.mjs dispatch
|
|
32
|
+
node <skill-dir>/agent-sessions.mjs claim <name> <path...> [--ttl <hours>] [--force]
|
|
33
|
+
node <skill-dir>/agent-sessions.mjs release <name> [path...]
|
|
34
|
+
node <skill-dir>/agent-sessions.mjs claims
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
## Quick start for a person
|
|
38
|
+
|
|
39
|
+
Nothing runs until you start it, so a person who does not use this does nothing. To try it:
|
|
40
|
+
|
|
41
|
+
1. Run `node <skill-dir>/agent-sessions.mjs shell-install`. It adds one function per agent to your shell file.
|
|
42
|
+
2. Open a new terminal. In the repo, type `claude` or `codex` as usual. The function starts the agent in a `tmux` session and starts the dispatcher.
|
|
43
|
+
3. Ask one agent to message the other. An idle agent wakes when a message carries `--to <agent>`.
|
|
44
|
+
4. Run `node <skill-dir>/agent-sessions.mjs down` to stop the dispatcher. Run `shell-install --remove` to undo step 1.
|
|
45
|
+
|
|
46
|
+
## Sessions
|
|
47
|
+
|
|
48
|
+
`run <name> <command...>` starts the command in the `tmux` session `<name>@<repo>`, or attaches if it runs already. It also starts the dispatcher when none runs. Session names carry the repo name, so `codex` in two repos never collides. If that session has a terminal attached already, `run` starts `<name>-2`, then `<name>-3`, and so on. A detached session is attached again. So a second `claude` terminal gets its own agent.
|
|
49
|
+
|
|
50
|
+
`up codex=codex gemini=gemini` starts a detached `tmux` session per agent, named like its sender name, and runs the command in it. It also starts a session named `mailbox-dispatch` that runs `dispatch`. A running session is left alone. Attach to a session with `tmux attach -t <name>`. Detach with `Ctrl-b`, then `d`. The session and its agent keep running. Use `down` to stop the dispatcher and the named sessions.
|
|
51
|
+
|
|
52
|
+
`restart <name> <prompt...>` types the clear command (`/clear`, or `/new` for a `codex` name) and then the prompt into the session `<name>@<repo>`, no sooner than `--after` seconds (default 5). It waits until that session ended its turn and its prompt holds no typed text, and gives up after 30 minutes. The command returns at once, so end your turn after it. An agent uses it to clear itself and resume from its saved work state. It refuses any session other than the caller's own (from `$TMUX_PANE`), and the error names the caller's own sender name. Background commands of the old session still report into the new one.
|
|
53
|
+
|
|
54
|
+
`shell-install` writes one shell function per agent into `~/.zshrc` or `~/.bashrc`, between two marker lines. A second run replaces the block, and `--remove` deletes it. Use `--file` for another startup file and `--dry-run` to preview. After a new terminal opens, typing `claude` or `codex` in a repo with this skill runs the agent through `run`. Elsewhere the function starts the plain command. Run `shell-install` only when the user asks: it changes a file the user owns. `shell-init` prints the same functions without writing a file.
|
|
55
|
+
|
|
56
|
+
Choose a `<name>` that no other agent uses, for example `agent-a` or `agent-b`. Two sessions with one name look like one agent.
|
|
57
|
+
|
|
58
|
+
## Messages
|
|
59
|
+
|
|
60
|
+
The mailbox file is `.ethlete/mailbox.jsonl` in the git root. The first write also creates `.ethlete/.gitignore`, so the file stays out of git. Set `AGENT_MAILBOX` to use another path. For two Claude Code sessions, the `SendMessage` tool is the better channel. The mailbox is for agents that cannot reach each other directly.
|
|
61
|
+
|
|
62
|
+
An idle agent cannot read the mailbox. One `dispatch` process outside every agent fixes this. It polls the mailbox for free. When a message carries `--to <agent>`, it waits a short grace period and then, if the agent has not read the message, types a wake-up line into the `tmux` session `<agent>@<repo>`, or a session named only `<agent>`. The agent then runs `inbox` and acts. A message without `--to` wakes nobody.
|
|
63
|
+
|
|
64
|
+
If the prompt of the agent holds typed text, the dispatcher waits until that text is sent or cleared. It checks again every 2 seconds. An open menu, for example a permission dialog, also makes it wait. If the agent is in the middle of a turn, the wake-up line waits as its next prompt. An agent in a `tmux` session does not need the `--wait` loop, which costs a turn at every timeout. If you wait for an answer in a session without `tmux`, run `inbox <name> --wait 120`: it blocks until a message arrives or 120 seconds pass. If it prints "No new messages.", run it again. A background `--wait` ends after one message, so it is not a lasting watch.
|
|
65
|
+
|
|
66
|
+
- `inbox` keeps one cursor per sender name, so it never repeats a message. The first call shows the last 20.
|
|
67
|
+
- `read` shows the last 20 messages. Use `--all` for the full history.
|
|
68
|
+
- `send` with no message reads it from standard input, so a long message needs no shell quoting.
|
|
69
|
+
- `watch` is optional. It runs until you stop it. In Claude Code, start it with the `Monitor` tool, so a new message wakes the session. Without such a feature, rely on `inbox`.
|
|
70
|
+
- Never rewrite or truncate the file. Only the script appends to it.
|
|
71
|
+
- Keep credentials and personal data out of the mailbox.
|
|
72
|
+
|
|
73
|
+
## Claims
|
|
74
|
+
|
|
75
|
+
1. At the start of a task, run `claims` and `inbox`. They show what other agents hold and said.
|
|
76
|
+
2. Before you edit a file, run `claim` with the exact paths. A directory path claims everything below it. `claim` also prints the messages you have not seen yet.
|
|
77
|
+
3. If `claim` reports a conflict, do not edit. Run `send` to ask the holder for a handoff, then wait.
|
|
78
|
+
4. After each larger step, run `inbox` again. Most agents cannot wake on new output, so you must pull messages yourself.
|
|
79
|
+
5. When you finish, run `release`. A claim also expires after 4 hours, or after `--ttl` hours.
|
|
80
|
+
|
|
81
|
+
A claim is advisory, not a lock. `--force` claims over another agent: use it only after the holder agreed. Silence is not approval. Acknowledge each handoff in words. No agent starts automatically. The user starts each one.
|
|
@@ -0,0 +1,460 @@
|
|
|
1
|
+
import { execFileSync, spawn, spawnSync } from 'node:child_process';
|
|
2
|
+
import { appendFileSync, existsSync, mkdirSync, readFileSync, watchFile, writeFileSync } from 'node:fs';
|
|
3
|
+
import { basename, dirname, join, relative, resolve } from 'node:path';
|
|
4
|
+
import { homedir } from 'node:os';
|
|
5
|
+
import { fileURLToPath } from 'node:url';
|
|
6
|
+
|
|
7
|
+
const root = (() => {
|
|
8
|
+
try {
|
|
9
|
+
return execFileSync('git', ['rev-parse', '--show-toplevel'], {
|
|
10
|
+
encoding: 'utf8',
|
|
11
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
12
|
+
}).trim();
|
|
13
|
+
} catch {
|
|
14
|
+
return process.cwd();
|
|
15
|
+
}
|
|
16
|
+
})();
|
|
17
|
+
const mailbox = process.env.AGENT_MAILBOX ?? join(root, '.ethlete/mailbox.jsonl');
|
|
18
|
+
const script = fileURLToPath(import.meta.url);
|
|
19
|
+
const hasRootScript = (name) => {
|
|
20
|
+
try {
|
|
21
|
+
return Boolean(JSON.parse(readFileSync(join(root, 'package.json'), 'utf8')).scripts?.[name]);
|
|
22
|
+
} catch {
|
|
23
|
+
return false;
|
|
24
|
+
}
|
|
25
|
+
};
|
|
26
|
+
const inboxCommand = (agent) => {
|
|
27
|
+
if (existsSync(join(root, 'node_modules/.bin/ethlete-agents'))) return `npx ethlete-agents sessions inbox ${agent}`;
|
|
28
|
+
if (hasRootScript('agents:sessions')) return `yarn agents:sessions inbox ${agent}`;
|
|
29
|
+
return `node ${script} inbox ${agent}`;
|
|
30
|
+
};
|
|
31
|
+
const DEFAULT_TTL_HOURS = 4;
|
|
32
|
+
const DEFAULT_LAST = 20;
|
|
33
|
+
const ENTER_DELAY_MS = 400;
|
|
34
|
+
const WAKE_GRACE_MS = 1500;
|
|
35
|
+
const DRAFT_RETRY_MS = 2000;
|
|
36
|
+
const RESTART_AFTER_SECONDS = 5;
|
|
37
|
+
const RESTART_CLEAR_MS = 2500;
|
|
38
|
+
const RESTART_POLL_MS = 2000;
|
|
39
|
+
const RESTART_WAIT_MS = 30 * 60 * 1000;
|
|
40
|
+
const repoSlug = basename(root).replace(/[.:]/g, '-');
|
|
41
|
+
const sessionFor = (agent) => `${agent}@${repoSlug}`;
|
|
42
|
+
const DISPATCH_SESSION = process.env.AGENT_MAILBOX_DISPATCH_SESSION ?? `mailbox-dispatch@${repoSlug}`;
|
|
43
|
+
|
|
44
|
+
const takeFlag = (args, name, hasValue) => {
|
|
45
|
+
const index = args.indexOf(name);
|
|
46
|
+
if (index === -1) return undefined;
|
|
47
|
+
const [flag, value] = args.splice(index, hasValue ? 2 : 1);
|
|
48
|
+
return hasValue ? value : flag;
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
const args = process.argv.slice(2);
|
|
52
|
+
const last = takeFlag(args, '--last', true);
|
|
53
|
+
const ttl = takeFlag(args, '--ttl', true);
|
|
54
|
+
const all = takeFlag(args, '--all', false);
|
|
55
|
+
const force = takeFlag(args, '--force', false);
|
|
56
|
+
const waitSeconds = takeFlag(args, '--wait', true);
|
|
57
|
+
const recipient = takeFlag(args, '--to', true);
|
|
58
|
+
const rcFile = takeFlag(args, '--file', true);
|
|
59
|
+
const remove = takeFlag(args, '--remove', false);
|
|
60
|
+
const afterSeconds = takeFlag(args, '--after', true);
|
|
61
|
+
const now = takeFlag(args, '--now', false);
|
|
62
|
+
const dryRun = takeFlag(args, '--dry-run', false);
|
|
63
|
+
const [command, sender, ...words] = args;
|
|
64
|
+
|
|
65
|
+
const help = `Shared agent mailbox: ${mailbox}
|
|
66
|
+
|
|
67
|
+
Send: node ${script} send <name> [--to <agent>] '<message>' (no message: read it from stdin)
|
|
68
|
+
Inbox: node ${script} inbox <name> [--wait <seconds>] (only the messages you have not seen yet;
|
|
69
|
+
--wait blocks until one arrives or the time ends)
|
|
70
|
+
Read: node ${script} read [--last <n>] [--all] (default: the last ${DEFAULT_LAST} messages)
|
|
71
|
+
Watch: node ${script} watch <name>
|
|
72
|
+
Claim: node ${script} claim <name> <path...> [--ttl <hours>] [--force]
|
|
73
|
+
Release: node ${script} release <name> [path...] (no path: release every claim of <name>)
|
|
74
|
+
Claims: node ${script} claims
|
|
75
|
+
Up: node ${script} up <name>[=<command>]... (start a tmux session per agent, plus one that runs dispatch)
|
|
76
|
+
Run: node ${script} run <name> <command...> (start or attach the agent in its tmux session; starts dispatch)
|
|
77
|
+
Install: node ${script} shell-install [<agent>...] [--file <path>] [--remove] [--dry-run]
|
|
78
|
+
(write the shell functions into ~/.zshrc or ~/.bashrc)
|
|
79
|
+
Shell: node ${script} shell-init [<agent>...] (print shell functions that wrap each agent with run; default: claude codex)
|
|
80
|
+
Restart: node ${script} restart <name> [--after <seconds>] <prompt...>
|
|
81
|
+
(type the clear command and then the prompt into the tmux session of <name>)
|
|
82
|
+
Down: node ${script} down [<name>...] (stop dispatch; stop the named sessions too)
|
|
83
|
+
Dispatch: node ${script} dispatch (wake the tmux session named <agent> for each message sent --to <agent>)
|
|
84
|
+
|
|
85
|
+
Protocol: append one JSON object per line; never rewrite or truncate.
|
|
86
|
+
Fields: timestamp (UTC), sender, message. A claim also has paths and expires.
|
|
87
|
+
Pick a sender name no other agent uses. Claim the paths you edit before you edit them.
|
|
88
|
+
A claim is advisory, not a lock: it expires after ${DEFAULT_TTL_HOURS} hours unless --ttl says otherwise.
|
|
89
|
+
Silence is not approval. Acknowledge handoffs explicitly.
|
|
90
|
+
Watch prints existing messages and then new messages from other senders every second.
|
|
91
|
+
Keep credentials and personal data out of the mailbox. No agent is started automatically.
|
|
92
|
+
`;
|
|
93
|
+
|
|
94
|
+
const tmux = (...tmuxArgs) => spawnSync('tmux', tmuxArgs, { encoding: 'utf8' });
|
|
95
|
+
const quote = (text) => `'${text.replaceAll("'", "'\\''")}'`;
|
|
96
|
+
const sessionExists = (name) => tmux('has-session', '-t', `=${name}`).status === 0;
|
|
97
|
+
const ONE_SHOT_FLAGS = new Set(['-p', '--print', '-v', '--version', '-h', '--help']);
|
|
98
|
+
const runsInSession = (agentArgs) =>
|
|
99
|
+
Boolean(process.stdin.isTTY && process.stdout.isTTY) && !agentArgs.some((arg) => ONE_SHOT_FLAGS.has(arg));
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Reads the last prompt line (`❯` for Claude Code, `›` for Codex) of the pane. Dim text is the empty
|
|
103
|
+
* prompt's placeholder, so it does not count. A menu cursor (`❯ 1. Yes`) also counts, which keeps a
|
|
104
|
+
* wake-up line from answering a permission dialog.
|
|
105
|
+
*/
|
|
106
|
+
const SGR = `${String.fromCharCode(27)}\\[([0-9;]*)m`;
|
|
107
|
+
const isBusy = (session) => /esc to inte/i.test(tmux('capture-pane', '-p', '-t', `=${session}:`).stdout ?? '');
|
|
108
|
+
|
|
109
|
+
/** Waits until the agent ended its turn and nobody types in its prompt, twice in a row; `false` after the time limit. */
|
|
110
|
+
const waitForQuiet = (session) => {
|
|
111
|
+
const end = Date.now() + RESTART_WAIT_MS;
|
|
112
|
+
let quiet = 0;
|
|
113
|
+
while (Date.now() < end) {
|
|
114
|
+
quiet = isBusy(session) || hasDraft(session) ? 0 : quiet + 1;
|
|
115
|
+
if (quiet === 2) return true;
|
|
116
|
+
sleep(RESTART_POLL_MS);
|
|
117
|
+
}
|
|
118
|
+
return false;
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
const hasDraft = (session) => {
|
|
122
|
+
const pane = tmux('capture-pane', '-p', '-e', '-t', `=${session}:`).stdout ?? '';
|
|
123
|
+
const prompt = pane
|
|
124
|
+
.split('\n')
|
|
125
|
+
.reverse()
|
|
126
|
+
.find((line) => /^\s*[❯›]/.test(line.replace(new RegExp(SGR, 'g'), '')));
|
|
127
|
+
if (!prompt) return false;
|
|
128
|
+
let dim = false;
|
|
129
|
+
const visible = prompt
|
|
130
|
+
.split(new RegExp(`(${SGR.replace('(', '(?:')})`))
|
|
131
|
+
.map((part) => {
|
|
132
|
+
const codes = part.match(new RegExp(`^${SGR}$`))?.[1].split(';');
|
|
133
|
+
if (!codes) return dim ? '' : part;
|
|
134
|
+
codes.forEach((code) => {
|
|
135
|
+
if (code === '2') dim = true;
|
|
136
|
+
if (code === '' || code === '0' || code === '22') dim = false;
|
|
137
|
+
});
|
|
138
|
+
return '';
|
|
139
|
+
})
|
|
140
|
+
.join('');
|
|
141
|
+
return visible.replace(/^\s*[❯›]/, '').trim().length > 0;
|
|
142
|
+
};
|
|
143
|
+
const isAttached = (name) => tmux('list-clients', '-t', `=${name}`).stdout.trim() !== '';
|
|
144
|
+
const freeSessionFor = (agent) => {
|
|
145
|
+
for (let number = 1; ; number++) {
|
|
146
|
+
const session = sessionFor(number === 1 ? agent : `${agent}-${number}`);
|
|
147
|
+
if (!sessionExists(session) || !isAttached(session)) return session;
|
|
148
|
+
}
|
|
149
|
+
};
|
|
150
|
+
const startSession = (name, shellCommand, quiet = false) => {
|
|
151
|
+
if (sessionExists(name)) return quiet ? undefined : process.stdout.write(`Session ${name} already runs.\n`);
|
|
152
|
+
const result = tmux('new-session', '-d', '-s', name, '-c', root, ...(shellCommand ? [shellCommand] : []));
|
|
153
|
+
if (result.status !== 0) return process.stderr.write(`Could not start ${name}: ${result.stderr.trim()}\n`);
|
|
154
|
+
if (!quiet) process.stdout.write(`Started ${name}. Attach with: tmux attach -t ${name}\n`);
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
const IGNORED = ['mailbox.jsonl', 'mailbox.cursor.*'];
|
|
158
|
+
|
|
159
|
+
const prepare = () => {
|
|
160
|
+
mkdirSync(dirname(mailbox), { recursive: true });
|
|
161
|
+
if (process.env.AGENT_MAILBOX) return;
|
|
162
|
+
const ignore = join(dirname(mailbox), '.gitignore');
|
|
163
|
+
const present = existsSync(ignore) ? readFileSync(ignore, 'utf8') : '';
|
|
164
|
+
const missing = IGNORED.filter((line) => !present.split('\n').includes(line));
|
|
165
|
+
if (missing.length)
|
|
166
|
+
writeFileSync(ignore, `${present && !present.endsWith('\n') ? `${present}\n` : present}${missing.join('\n')}\n`);
|
|
167
|
+
};
|
|
168
|
+
|
|
169
|
+
const append = (entry) => {
|
|
170
|
+
prepare();
|
|
171
|
+
appendFileSync(mailbox, `${JSON.stringify({ timestamp: new Date().toISOString(), ...entry })}\n`, 'utf8');
|
|
172
|
+
};
|
|
173
|
+
|
|
174
|
+
const parse = (line) => {
|
|
175
|
+
if (!line.trim()) return undefined;
|
|
176
|
+
try {
|
|
177
|
+
return JSON.parse(line);
|
|
178
|
+
} catch {
|
|
179
|
+
process.stderr.write('Ignoring malformed mailbox line.\n');
|
|
180
|
+
return undefined;
|
|
181
|
+
}
|
|
182
|
+
};
|
|
183
|
+
|
|
184
|
+
const entries = () => (existsSync(mailbox) ? readFileSync(mailbox, 'utf8').split('\n').map(parse).filter(Boolean) : []);
|
|
185
|
+
|
|
186
|
+
const show = (entry) => process.stdout.write(`[${entry.timestamp}] ${entry.sender}: ${entry.message}\n`);
|
|
187
|
+
|
|
188
|
+
const inboxCursor = (reader) => join(dirname(mailbox), `mailbox.cursor.${reader}`);
|
|
189
|
+
|
|
190
|
+
const unreadFor = (reader) => {
|
|
191
|
+
const cursor = inboxCursor(reader);
|
|
192
|
+
const history = entries();
|
|
193
|
+
const seen = existsSync(cursor) ? Number(readFileSync(cursor, 'utf8')) : history.length - DEFAULT_LAST;
|
|
194
|
+
return { count: history.length, unread: history.slice(Math.max(0, seen)).filter((entry) => entry.sender !== reader) };
|
|
195
|
+
};
|
|
196
|
+
|
|
197
|
+
const sleep = (ms) => Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
|
198
|
+
|
|
199
|
+
const showInbox = (reader, waitFor = 0) => {
|
|
200
|
+
const deadline = Date.now() + waitFor * 1000;
|
|
201
|
+
let { count, unread } = unreadFor(reader);
|
|
202
|
+
while (!unread.length && Date.now() < deadline) {
|
|
203
|
+
sleep(1000);
|
|
204
|
+
({ count, unread } = unreadFor(reader));
|
|
205
|
+
}
|
|
206
|
+
if (!unread.length) process.stdout.write('No new messages.\n');
|
|
207
|
+
unread.forEach(show);
|
|
208
|
+
prepare();
|
|
209
|
+
writeFileSync(inboxCursor(reader), String(count));
|
|
210
|
+
};
|
|
211
|
+
|
|
212
|
+
const toRepoPath = (path) => relative(root, resolve(path)).split('\\').join('/');
|
|
213
|
+
|
|
214
|
+
const overlaps = (a, b) => a === b || a.startsWith(`${b}/`) || b.startsWith(`${a}/`);
|
|
215
|
+
|
|
216
|
+
const activeClaims = () => {
|
|
217
|
+
const now = Date.now();
|
|
218
|
+
const claims = [];
|
|
219
|
+
for (const entry of entries()) {
|
|
220
|
+
if (entry.type === 'claim') {
|
|
221
|
+
for (const path of entry.paths) claims.push({ sender: entry.sender, path, expires: entry.expires });
|
|
222
|
+
} else if (entry.type === 'release') {
|
|
223
|
+
for (let index = claims.length - 1; index >= 0; index--) {
|
|
224
|
+
const claim = claims[index];
|
|
225
|
+
const released = !entry.paths?.length || entry.paths.some((path) => overlaps(path, claim.path));
|
|
226
|
+
if (claim.sender === entry.sender && released) claims.splice(index, 1);
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
return claims.filter((claim) => Date.parse(claim.expires) > now);
|
|
231
|
+
};
|
|
232
|
+
|
|
233
|
+
if (command === 'send' && sender) {
|
|
234
|
+
const message = words.length ? words.join(' ') : process.stdin.isTTY ? '' : readFileSync(0, 'utf8').trim();
|
|
235
|
+
if (!message) {
|
|
236
|
+
process.stderr.write('No message given.\n');
|
|
237
|
+
process.exitCode = 1;
|
|
238
|
+
} else {
|
|
239
|
+
append({ sender, message, ...(recipient ? { to: recipient } : {}) });
|
|
240
|
+
process.stdout.write(`Sent as ${sender}.\n`);
|
|
241
|
+
}
|
|
242
|
+
} else if (command === 'up') {
|
|
243
|
+
const withEnv = process.env.AGENT_MAILBOX ? `AGENT_MAILBOX=${quote(process.env.AGENT_MAILBOX)} ` : '';
|
|
244
|
+
startSession(DISPATCH_SESSION, `${withEnv}node ${quote(script)} dispatch`);
|
|
245
|
+
[sender, ...words].filter(Boolean).forEach((spec) => {
|
|
246
|
+
const [name, ...rest] = spec.split('=');
|
|
247
|
+
startSession(sessionFor(name), rest.join('=') || undefined);
|
|
248
|
+
});
|
|
249
|
+
} else if (command === 'run' && sender && words.length && !runsInSession(words.slice(1))) {
|
|
250
|
+
const [program, ...programArgs] = words;
|
|
251
|
+
process.exitCode = spawnSync(program, programArgs, { stdio: 'inherit' }).status ?? 1;
|
|
252
|
+
} else if (command === 'run' && sender && words.length) {
|
|
253
|
+
const withEnv = process.env.AGENT_MAILBOX ? `AGENT_MAILBOX=${quote(process.env.AGENT_MAILBOX)} ` : '';
|
|
254
|
+
if (!sessionExists(DISPATCH_SESSION))
|
|
255
|
+
startSession(DISPATCH_SESSION, `${withEnv}node ${quote(script)} dispatch`, true);
|
|
256
|
+
const session = freeSessionFor(sender);
|
|
257
|
+
const shellCommand = words.map(quote).join(' ');
|
|
258
|
+
if (process.env.TMUX) {
|
|
259
|
+
if (!sessionExists(session)) tmux('new-session', '-d', '-s', session, '-c', process.cwd(), shellCommand);
|
|
260
|
+
process.exitCode = spawnSync('tmux', ['switch-client', '-t', `=${session}`], { stdio: 'inherit' }).status ?? 1;
|
|
261
|
+
} else {
|
|
262
|
+
process.exitCode =
|
|
263
|
+
spawnSync('tmux', ['new-session', '-A', '-s', session, '-c', process.cwd(), shellCommand], { stdio: 'inherit' })
|
|
264
|
+
.status ?? 1;
|
|
265
|
+
}
|
|
266
|
+
} else if (command === 'shell-init' || command === 'shell-install') {
|
|
267
|
+
const agents = [sender, ...words].filter(Boolean);
|
|
268
|
+
const functions = (agents.length ? agents : ['claude', 'codex'])
|
|
269
|
+
.map(
|
|
270
|
+
(agent) => `${agent}() {
|
|
271
|
+
local root script
|
|
272
|
+
root=$(git rev-parse --show-toplevel 2>/dev/null) && script="$root/.agents/skills/ethlete-agent-sessions/agent-sessions.mjs"
|
|
273
|
+
if [ -n "$script" ] && [ -f "$script" ] && command -v tmux >/dev/null; then
|
|
274
|
+
node "$script" run ${agent} ${agent} "$@"
|
|
275
|
+
else
|
|
276
|
+
command ${agent} "$@"
|
|
277
|
+
fi
|
|
278
|
+
}
|
|
279
|
+
`,
|
|
280
|
+
)
|
|
281
|
+
.join('');
|
|
282
|
+
if (command === 'shell-init') {
|
|
283
|
+
process.stdout.write(functions);
|
|
284
|
+
} else {
|
|
285
|
+
const shell = basename(process.env.SHELL ?? '');
|
|
286
|
+
const file = rcFile ?? (shell === 'zsh' ? '.zshrc' : shell === 'bash' ? '.bashrc' : undefined);
|
|
287
|
+
if (!file) {
|
|
288
|
+
process.stderr.write('Unknown shell. Pass --file <path> to the startup file.\n');
|
|
289
|
+
process.exitCode = 1;
|
|
290
|
+
} else {
|
|
291
|
+
const path = rcFile ? resolve(rcFile) : join(homedir(), file);
|
|
292
|
+
const present = existsSync(path) ? readFileSync(path, 'utf8') : '';
|
|
293
|
+
const start = '# >>> ethlete agent-sessions >>>';
|
|
294
|
+
const end = '# <<< ethlete agent-sessions <<<';
|
|
295
|
+
const without = ['agent-mailbox', 'agent-sessions']
|
|
296
|
+
.reduce(
|
|
297
|
+
(text, name) =>
|
|
298
|
+
text.replace(new RegExp(`\\n?# >>> ethlete ${name} >>>[\\s\\S]*?# <<< ethlete ${name} <<<\\n?`), '\n'),
|
|
299
|
+
present,
|
|
300
|
+
)
|
|
301
|
+
.replace(/\n+$/, '\n');
|
|
302
|
+
const block = `${start}\n${functions}${end}\n`;
|
|
303
|
+
const next = remove ? without : `${without}\n${block}`;
|
|
304
|
+
if (dryRun) {
|
|
305
|
+
process.stdout.write(`Would write ${path}:\n${remove ? '(block removed)\n' : block}`);
|
|
306
|
+
} else {
|
|
307
|
+
writeFileSync(path, next);
|
|
308
|
+
process.stdout.write(
|
|
309
|
+
`${remove ? 'Removed the block from' : 'Wrote the block to'} ${path}. Open a new terminal.\n`,
|
|
310
|
+
);
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
} else if (command === 'restart' && sender && words.length) {
|
|
315
|
+
const session = [sessionFor(sender), sender].find(sessionExists);
|
|
316
|
+
const own = process.env.TMUX_PANE
|
|
317
|
+
? tmux('display-message', '-p', '-t', process.env.TMUX_PANE, '#{session_name}').stdout.trim()
|
|
318
|
+
: null;
|
|
319
|
+
if (!session) {
|
|
320
|
+
process.stderr.write(`No tmux session for ${sender}.\n`);
|
|
321
|
+
process.exitCode = 1;
|
|
322
|
+
} else if (own && own !== session) {
|
|
323
|
+
process.stderr.write(
|
|
324
|
+
`Refused: ${session} is not your session. You run in ${own}, so pass ${own.replace(`@${repoSlug}`, '')}.\n`,
|
|
325
|
+
);
|
|
326
|
+
process.exitCode = 1;
|
|
327
|
+
} else if (now) {
|
|
328
|
+
const type = (...keys) => tmux('send-keys', '-t', `=${session}:`, ...keys);
|
|
329
|
+
sleep(Number(afterSeconds ?? RESTART_AFTER_SECONDS) * 1000);
|
|
330
|
+
if (!waitForQuiet(session)) {
|
|
331
|
+
process.exitCode = 1;
|
|
332
|
+
} else {
|
|
333
|
+
type('-l', sender.startsWith('codex') ? '/new' : '/clear');
|
|
334
|
+
sleep(ENTER_DELAY_MS);
|
|
335
|
+
type('Enter');
|
|
336
|
+
sleep(RESTART_CLEAR_MS);
|
|
337
|
+
if (waitForQuiet(session)) {
|
|
338
|
+
type('-l', words.join(' '));
|
|
339
|
+
sleep(ENTER_DELAY_MS);
|
|
340
|
+
type('Enter');
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
} else {
|
|
344
|
+
const delay = Number(afterSeconds ?? RESTART_AFTER_SECONDS);
|
|
345
|
+
spawn(process.execPath, [script, 'restart', sender, '--now', '--after', String(delay), ...words], {
|
|
346
|
+
detached: true,
|
|
347
|
+
stdio: 'ignore',
|
|
348
|
+
}).unref();
|
|
349
|
+
process.stdout.write(
|
|
350
|
+
`Restarting ${session} once its turn ended and its prompt is empty, ${delay} seconds at the earliest. End your turn now.\n`,
|
|
351
|
+
);
|
|
352
|
+
}
|
|
353
|
+
} else if (command === 'down') {
|
|
354
|
+
[DISPATCH_SESSION, ...[sender, ...words].filter(Boolean).map(sessionFor)].forEach((name) => {
|
|
355
|
+
const result = tmux('kill-session', '-t', `=${name}`);
|
|
356
|
+
process.stdout.write(result.status === 0 ? `Stopped ${name}.\n` : `No session ${name}.\n`);
|
|
357
|
+
});
|
|
358
|
+
} else if (command === 'dispatch') {
|
|
359
|
+
prepare();
|
|
360
|
+
appendFileSync(mailbox, '');
|
|
361
|
+
let seen = entries().length;
|
|
362
|
+
const due = {};
|
|
363
|
+
const waiting = new Set();
|
|
364
|
+
const wake = (agent) => {
|
|
365
|
+
if (!unreadFor(agent).unread.length) return;
|
|
366
|
+
const text = `New mailbox message for ${agent}. Run: ${inboxCommand(agent)}`;
|
|
367
|
+
const session = [sessionFor(agent), agent].find(sessionExists);
|
|
368
|
+
if (!session) return process.stderr.write(`No tmux session for ${agent}.\n`);
|
|
369
|
+
if (hasDraft(session)) {
|
|
370
|
+
if (!waiting.has(agent)) process.stdout.write(`Waiting for the typed text in ${session}.\n`);
|
|
371
|
+
waiting.add(agent);
|
|
372
|
+
due[agent] = Date.now() + DRAFT_RETRY_MS;
|
|
373
|
+
return;
|
|
374
|
+
}
|
|
375
|
+
waiting.delete(agent);
|
|
376
|
+
spawnSync('tmux', ['send-keys', '-t', `=${session}:`, '-l', text]);
|
|
377
|
+
sleep(ENTER_DELAY_MS);
|
|
378
|
+
spawnSync('tmux', ['send-keys', '-t', `=${session}:`, 'Enter']);
|
|
379
|
+
process.stdout.write(`Woke ${session}.\n`);
|
|
380
|
+
};
|
|
381
|
+
const poll = () => {
|
|
382
|
+
const history = entries();
|
|
383
|
+
const fresh = history.slice(seen);
|
|
384
|
+
seen = history.length;
|
|
385
|
+
fresh
|
|
386
|
+
.filter((entry) => entry.to && entry.to !== entry.sender)
|
|
387
|
+
.forEach((entry) => (due[entry.to] ??= Date.now() + WAKE_GRACE_MS));
|
|
388
|
+
Object.keys(due)
|
|
389
|
+
.filter((agent) => due[agent] <= Date.now())
|
|
390
|
+
.forEach((agent) => {
|
|
391
|
+
delete due[agent];
|
|
392
|
+
wake(agent);
|
|
393
|
+
});
|
|
394
|
+
};
|
|
395
|
+
process.stdout.write(`Dispatching for ${resolve(mailbox)}\n`);
|
|
396
|
+
setInterval(poll, 1000);
|
|
397
|
+
} else if (command === 'inbox' && sender) {
|
|
398
|
+
showInbox(sender, Number(waitSeconds ?? 0));
|
|
399
|
+
} else if (command === 'read') {
|
|
400
|
+
const history = entries();
|
|
401
|
+
(all ? history : history.slice(-Number(last ?? DEFAULT_LAST))).forEach(show);
|
|
402
|
+
} else if (command === 'claims') {
|
|
403
|
+
const claims = activeClaims();
|
|
404
|
+
if (!claims.length) process.stdout.write('No active claims.\n');
|
|
405
|
+
claims.forEach((claim) => process.stdout.write(`${claim.path} ${claim.sender} until ${claim.expires}\n`));
|
|
406
|
+
} else if (command === 'claim' && sender && words.length) {
|
|
407
|
+
showInbox(sender);
|
|
408
|
+
const paths = words.map(toRepoPath);
|
|
409
|
+
const conflicts = activeClaims().filter(
|
|
410
|
+
(claim) => claim.sender !== sender && paths.some((path) => overlaps(path, claim.path)),
|
|
411
|
+
);
|
|
412
|
+
if (conflicts.length && !force) {
|
|
413
|
+
conflicts.forEach((claim) =>
|
|
414
|
+
process.stderr.write(`Claimed by ${claim.sender}: ${claim.path} until ${claim.expires}\n`),
|
|
415
|
+
);
|
|
416
|
+
process.stderr.write('Ask for a handoff, or pass --force to claim anyway.\n');
|
|
417
|
+
process.exitCode = 2;
|
|
418
|
+
} else {
|
|
419
|
+
const hours = ttl === undefined ? DEFAULT_TTL_HOURS : Number(ttl);
|
|
420
|
+
const expires = new Date(Date.now() + hours * 3_600_000).toISOString();
|
|
421
|
+
append({ sender, type: 'claim', paths, expires, message: `claims ${paths.join(', ')}` });
|
|
422
|
+
process.stdout.write(`Claimed ${paths.join(', ')} as ${sender} until ${expires}.\n`);
|
|
423
|
+
}
|
|
424
|
+
} else if (command === 'release' && sender) {
|
|
425
|
+
const paths = words.map(toRepoPath);
|
|
426
|
+
append({
|
|
427
|
+
sender,
|
|
428
|
+
type: 'release',
|
|
429
|
+
paths,
|
|
430
|
+
message: paths.length ? `releases ${paths.join(', ')}` : 'releases all claims',
|
|
431
|
+
});
|
|
432
|
+
process.stdout.write(`Released as ${sender}.\n`);
|
|
433
|
+
} else if (command === 'watch' && sender) {
|
|
434
|
+
prepare();
|
|
435
|
+
appendFileSync(mailbox, '');
|
|
436
|
+
let consumed = 0;
|
|
437
|
+
let pending = '';
|
|
438
|
+
const update = () => {
|
|
439
|
+
const contents = readFileSync(mailbox, 'utf8');
|
|
440
|
+
if (contents.length < consumed) {
|
|
441
|
+
process.stderr.write('Mailbox was truncated; restarting read.\n');
|
|
442
|
+
consumed = 0;
|
|
443
|
+
pending = '';
|
|
444
|
+
}
|
|
445
|
+
pending += contents.slice(consumed);
|
|
446
|
+
consumed = contents.length;
|
|
447
|
+
const lines = pending.split('\n');
|
|
448
|
+
pending = lines.pop() ?? '';
|
|
449
|
+
lines
|
|
450
|
+
.map(parse)
|
|
451
|
+
.filter((entry) => entry && entry.sender !== sender)
|
|
452
|
+
.forEach(show);
|
|
453
|
+
};
|
|
454
|
+
process.stdout.write(`Watching ${resolve(mailbox)} as ${sender}\n`);
|
|
455
|
+
update();
|
|
456
|
+
watchFile(mailbox, { interval: 1000 }, update);
|
|
457
|
+
} else {
|
|
458
|
+
process.stdout.write(help);
|
|
459
|
+
if (command && command !== '--help') process.exitCode = 1;
|
|
460
|
+
}
|
|
@@ -45,7 +45,7 @@ finished while the user is still talking about it.
|
|
|
45
45
|
check that a variant renders. In a repository with `et design`, take it with
|
|
46
46
|
`et design shot <layer>/<name> --states [--variants]`: one contact sheet of every state, and
|
|
47
47
|
every variant of the open sketch, with a label per cell; below each variant row, a diff row marks
|
|
48
|
-
the pixels that differ from the merged state in red, or says "no pixel change" (with ", but the markup differs" when only the markup changed, e.g. both sides miss the same content). It prints the PNG path last. `--scale 2` enlarges a small part; `--crop x,y,w,h` cuts each cell to that CSS-pixel box, to judge hairlines. `--interaction hover` forces an interaction on every cell. `--state a,b` draws only those states, to compare them side by side. With `--variants`, `--diff-props` compares each variant with the merged part drawn with the variant's state props, so changed props do not paint the diff red. It draws
|
|
48
|
+
the pixels that differ from the merged state in red, or says "no pixel change" (with ", but the markup differs" when only the markup changed, e.g. both sides miss the same content). It prints the PNG path last. `--scale 2` enlarges a small part; `--crop x,y,w,h` cuts each cell to that CSS-pixel box, to judge hairlines. `--interaction hover` forces an interaction on every cell. `--state a,b` draws only those states, to compare them side by side. With `--variants`, `--round r2` or `--variant i,j` draws only those variants next to the merged row, so the current round stays readable; it filters the picture, never the checks. With `--variants`, `--diff-props` compares each variant with the merged part drawn with the variant's state props, so changed props do not paint the diff red. It draws
|
|
49
49
|
undimmed, whatever Studio shows. Do not write a scratch script for batch shots.
|
|
50
50
|
|
|
51
51
|
If a second defect appears, add it to the open list and give it one line. Do not draw it.
|
|
@@ -115,12 +115,19 @@ answers. Variants are drawn at the same geometry, and one variant wins only agai
|
|
|
115
115
|
|
|
116
116
|
```bash
|
|
117
117
|
et design sketch new <layer>/<name> --variants a,b --by <model id> [--like <layer>/<name>] [--variant-note a=<text>]… [--product <p>] # each variant starts as a copy of the part
|
|
118
|
+
et design sketch new <layer>/<name> <layer>/<name>… --variants a --by <model id> # one change across several parts: one sketch, one ready, one merge
|
|
118
119
|
et design sketch report <layer>/<name> --variant b --by <model id> "phone" # tell Studio what you build now
|
|
119
120
|
et design sketch report <layer>/<name> --variant b --by <model id> --done # you stopped drawing b; Studio shows its frames
|
|
120
121
|
et design sketch check [<layer>/<name>] [--fix] # each variant laid over the product
|
|
121
122
|
et design sketch ready <layer>/<name> --by <model id> # now the user can pick
|
|
123
|
+
et design sketch reject <layer>/<name> --variant b [--reason <text>] --by <model id> # only when the user rejected b; no pick or merge until reopened
|
|
124
|
+
et design sketch reopen <layer>/<name> --variant b [--reason <text>] --by <model id> # only when the user takes b back; the rejection stays in the history
|
|
122
125
|
```
|
|
123
126
|
|
|
127
|
+
One change across several parts (an alias removal, a token rename) is one sketch with several targets: each
|
|
128
|
+
variant holds every target, and `ready` compares them all. `ready` and `preview` also name every part a `shared/`
|
|
129
|
+
edit reaches.
|
|
130
|
+
|
|
124
131
|
Variants are alternatives. A variant never stands for a state of another variant: a hidden or
|
|
125
132
|
collapsed mode of variant d is a further state of d, not a variant e. Before you draw a new control,
|
|
126
133
|
look for a control that already does the job (an active rail button that toggles its panel), and
|
|
@@ -160,7 +167,9 @@ variant imports it as `@shared/<path>`, and a shared file may import `@system/..
|
|
|
160
167
|
changed holds its own copy at `<variant>/shared/<path>`; `@shared/<path>` finds that copy first, and a merge
|
|
161
168
|
of that variant takes it. On a merge, `shared/` moves into
|
|
162
169
|
the target's folder as `shared/` with its imports made relative; when no part imports it, it is
|
|
163
|
-
dropped.
|
|
170
|
+
dropped. With several targets, each takes only the files it imports, directly or through another
|
|
171
|
+
shared file; a file nobody imports goes back to the target that held it, and the merge refuses one no
|
|
172
|
+
target held. `sketch new` refuses targets whose `shared/` folders hold one path with different contents. Only the target may import it: a merge refuses while another part of the picked variant
|
|
164
173
|
does, so move that code into the part. `sketch new` prints each file it puts in `shared/`, for example
|
|
165
174
|
the `shared/` folder of a merged page: edit it there, as part of the sketch, not in a variant copy.
|
|
166
175
|
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: e2e-triage
|
|
3
|
+
description: Work through a failed Playwright e2e run - read the failures file the @ethlete/cli failure reporter writes, group the failures by error, fix the largest group first, and re-run only the specs that failed. Read when an e2e run fails with more than a few tests, when an `e2e-failures.jsonl` file is present, or before you start a full e2e run after a change.
|
|
4
|
+
kind: skill
|
|
5
|
+
scope: consumer
|
|
6
|
+
requires: ['@ethlete/cli']
|
|
7
|
+
vars: [docsBaseUrl]
|
|
8
|
+
---
|
|
9
|
+
|
|
10
|
+
# Triage a failed e2e run
|
|
11
|
+
|
|
12
|
+
Many failed tests rarely mean many bugs. In one real run, a single change in a shared pipe failed 61
|
|
13
|
+
tests. After the fix, 158 of the first 159 retried tests passed. So find the shared cause first, and
|
|
14
|
+
never fix failures one test at a time from the top of the log.
|
|
15
|
+
|
|
16
|
+
## Before the run
|
|
17
|
+
|
|
18
|
+
1. Check that `playwright.config.ts` lists `@ethlete/cli/playwright/failure-reporter` in `reporter`.
|
|
19
|
+
If not, add it next to the reporter that is there: `[['list'], ['@ethlete/cli/playwright/failure-reporter']]`.
|
|
20
|
+
The reporter writes one JSON line per failed attempt while the run goes on. The guide is
|
|
21
|
+
{%docsBaseUrl%}/cli/e2e.
|
|
22
|
+
2. Run the specs of the files you changed first. Start the full suite only when they pass. A full
|
|
23
|
+
run takes long, and one broken shared file fails it everywhere.
|
|
24
|
+
|
|
25
|
+
## Read the failures
|
|
26
|
+
|
|
27
|
+
The file is `e2e-failures.jsonl` next to `playwright.config.ts`, unless `E2E_FAILURES_FILE` or the
|
|
28
|
+
`outputFile` option of the reporter names another path. You can read it while the run goes on.
|
|
29
|
+
|
|
30
|
+
Each line has `test`, `location`, `project`, `status`, `retry`, `seconds`, `error`, `detail` and `context`.
|
|
31
|
+
`error` holds the first lines of the message. `detail` holds the first later line with a URL, for errors whose first lines do not name it, or `null`. `context` is the path of Playwright's
|
|
32
|
+
`error-context.md`, which holds the page snapshot at the failure.
|
|
33
|
+
|
|
34
|
+
Do not parse the console log of the run. It mixes the output of parallel workers, and it has no end
|
|
35
|
+
line if the run stops.
|
|
36
|
+
|
|
37
|
+
## Group, then fix the largest group
|
|
38
|
+
|
|
39
|
+
1. Normalize the `error` text of each line. Remove line and column numbers, timeouts in ms, ids,
|
|
40
|
+
hashes and dates. Keep the matcher, the locator and the expected value.
|
|
41
|
+
2. Group the lines by the normalized text. Count the distinct spec files per group, not only the lines.
|
|
42
|
+
A group that spans many spec files points at shared code: a component, a pipe, a fixture or a
|
|
43
|
+
test helper.
|
|
44
|
+
3. Take the largest group. Read one `context` file of it, then the code the error points at. Find
|
|
45
|
+
one cause that explains the whole group.
|
|
46
|
+
4. Fix that cause. Then re-run only the spec files of that group.
|
|
47
|
+
|
|
48
|
+
A group whose errors are a connection refused (`ERR_CONNECTION_REFUSED`, `ECONNREFUSED`) on a local mock or
|
|
49
|
+
`webServer` port is an infrastructure group, not a test verdict. Check that the server is still up,
|
|
50
|
+
restart it, and re-run those tests before you read them as failures.
|
|
51
|
+
|
|
52
|
+
Small groups that differ only in a locator often have the same cause. Check them after the large group
|
|
53
|
+
is fixed, because a fix often clears them too.
|
|
54
|
+
|
|
55
|
+
## Re-run only what failed
|
|
56
|
+
|
|
57
|
+
Take the spec file from `location`, without the `:line`. Collect each file once, then run:
|
|
58
|
+
|
|
59
|
+
```bash
|
|
60
|
+
npx playwright test <file> <file> --project=<project> --no-deps
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
- Pass each file as its own argument. In zsh, an unquoted `$files` is one argument, and Playwright
|
|
64
|
+
then finds no tests.
|
|
65
|
+
- `--no-deps` skips the setup projects. Leave it out if the failure is in a setup project, or if the
|
|
66
|
+
setup state is gone.
|
|
67
|
+
- `--project` comes from the `project` field. Run one call per project.
|
|
68
|
+
|
|
69
|
+
## Retries and flaky tests
|
|
70
|
+
|
|
71
|
+
With `retries` on, a test that fails each attempt has one line per attempt (`retry` 0, 1, and so on).
|
|
72
|
+
A test with a line for `retry` 0 and no line for the last retry passed on a later attempt: it is flaky.
|
|
73
|
+
Report a flaky test, but fix the stable failures first.
|
|
74
|
+
|
|
75
|
+
## Rules
|
|
76
|
+
|
|
77
|
+
- Never skip a failed test, or mark it with `test.fail()` or `test.fixme()`, to make a run pass.
|
|
78
|
+
- Never change an assertion to match the wrong value the app shows now. A failed e2e test is a
|
|
79
|
+
signal first. If you think the test is wrong, say why, and ask before you change it.
|
|
80
|
+
- Report per group: the cause, the fix, the spec files you re-ran, and what still fails.
|