@asrunjs/asrun 0.13.0 → 0.14.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.
- package/.claude/skills/exploring-cases/SKILL.md +26 -11
- package/.claude/skills/exploring-cases/references/handoff-card.js +81 -7
- package/.claude/skills/exploring-cases/references/open-questions.md +2 -2
- package/.claude/skills/exploring-cases/references/readiness.md +6 -0
- package/.claude/skills/exploring-cases/references/user-handoff.md +60 -27
- package/AGENTS.md +3 -2
- package/README.md +1 -1
- package/dist/lib/desktop-notify.d.ts +38 -0
- package/dist/lib/desktop-notify.js +158 -0
- package/dist/lib/paths.d.ts +9 -2
- package/dist/lib/paths.js +32 -5
- package/dist/lib/pdf.js +23 -3
- package/dist/scripts/check-cases.js +3 -3
- package/dist/scripts/cli.js +9 -2
- package/dist/scripts/doctor.js +39 -1
- package/dist/scripts/notify-test.d.ts +1 -0
- package/dist/scripts/notify-test.js +48 -0
- package/dist/ui/server/actions.js +4 -2
- package/dist/ui/server/ai-stream.d.ts +7 -1
- package/dist/ui/server/ai-stream.js +26 -7
- package/dist/ui/server/jobs.d.ts +10 -0
- package/dist/ui/server/jobs.js +41 -1
- package/dist/ui/shared/ai-commands.js +4 -2
- package/dist/ui/shared/types.d.ts +2 -0
- package/docs/agents/case-format.md +2 -1
- package/docs/ai-workflow.md +11 -7
- package/docs/cases.md +5 -5
- package/docs/commands.md +20 -1
- package/docs/install.md +1 -1
- package/docs/troubleshooting.md +17 -0
- package/docs/web-ui.md +7 -4
- package/package.json +2 -2
- package/templates/.claude/commands/explore-case.md +3 -2
- package/templates/package.json +1 -1
- package/ui/dist/assets/{dist-C46EmZuI.js → dist-BlFnpAOk.js} +1 -1
- package/ui/dist/assets/{dist-DrFzCwMc.js → dist-BwXTr_k4.js} +1 -1
- package/ui/dist/assets/{dist-DGzg2gyA.js → dist-BzDmxZ9V.js} +1 -1
- package/ui/dist/assets/{dist-_0g6iC9K.js → dist-C1uAVsF-.js} +1 -1
- package/ui/dist/assets/{dist-DMiOs9Qj.js → dist-CjFzi6xl.js} +1 -1
- package/ui/dist/assets/{dist-D9GmSkjm.js → dist-D7YBl9nc.js} +1 -1
- package/ui/dist/assets/{dist-BB2cF3XN.js → dist-DxeJFPaO.js} +1 -1
- package/ui/dist/assets/{dist-P9roUi1O.js → dist-DxlXH2E-.js} +1 -1
- package/ui/dist/assets/{dist-Dy45nXf8.js → dist-fpvlcqX7.js} +1 -1
- package/ui/dist/assets/{dist-Db_XhDui.js → dist-jGvx4PBr.js} +1 -1
- package/ui/dist/assets/{index-BN4ucmmn.js → index-Djsj43vv.js} +11 -11
- package/ui/dist/index.html +1 -1
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: exploring-cases
|
|
3
|
-
description: Explores the real pages for one case document (cases/<id>.md) before its spec is generated, for
|
|
3
|
+
description: Explores the real pages for one case document (cases/<id>.md) before its spec is generated, for the profiles named with --profiles or, without it, for at least one logged-in profile per domain. Checks the case against profiles.yaml and the pages, fixes wording mistakes, records what the pages show, turns unknowns into answerable TODO blocks, and, in an attended run, lets the person do the steps that write data or need a choice while it waits and records a general recipe under that step's `- Recipe:` lines. Also writes a local exploration note (locators and data-call paths) under `.explore/` for `/gen-spec` to verify instead of rediscovering. Ends with RESULT ready or needs-input. Use when asked to explore, review, check, clean up or narrow down a case, or for /explore-case. Its open-question rules are shared with generating-specs.
|
|
4
4
|
---
|
|
5
5
|
|
|
6
6
|
# Exploring a case document
|
|
@@ -54,10 +54,12 @@ one exception is a handoff in an attended run, where waiting for the person is t
|
|
|
54
54
|
before exploring and report `RESULT: needs-input` ("pass the environment").
|
|
55
55
|
- Optionally `--attended`: a person is at the machine and the browser window is visible, so handoffs may happen.
|
|
56
56
|
Without it nothing waits, and a step that needs a person becomes a `[blocking]` TODO.
|
|
57
|
-
- Optionally `--profiles a,b`: explore only these profiles (ids from `profiles.yaml`, comma-separated)
|
|
58
|
-
least one logged-in profile per domain.
|
|
59
|
-
|
|
60
|
-
|
|
57
|
+
- Optionally `--profiles a,b`: explore only these profiles (ids from `profiles.yaml`, comma-separated): **every id in
|
|
58
|
+
the list**. Without it, at least one logged-in profile per domain. A different host is a different domain, even when
|
|
59
|
+
two profiles serve the same product on another address, so the list is never collapsed into one profile. The
|
|
60
|
+
selection is never widened: a profile it leaves out is not opened and nothing about it is written into the case.
|
|
61
|
+
Runs add up: a later run with other profiles adds their observations and `## Values` entries next to the ones
|
|
62
|
+
recorded before.
|
|
61
63
|
- Optionally `--hints <file>` (or free text after the arguments): what the person knows or wants checked. Read it
|
|
62
64
|
before the static review. It guides this run; after the work, a reusable instruction is written into `## Rules` by
|
|
63
65
|
the gate in `references/case-rules.md`, and a one-off clue stays in the report.
|
|
@@ -90,14 +92,21 @@ one exception is a handoff in an attended run, where waiting for the person is t
|
|
|
90
92
|
in this session at all (no `browser_*` / `generator_*` tool) or the server reports that it failed to start, do not
|
|
91
93
|
fall back to ad-hoc Playwright scripts: stop, tell the user which MCP server is missing and to run `pnpm asrun
|
|
92
94
|
doctor`, make no observations, and end with `RESULT: needs-input`.
|
|
93
|
-
Explore each profile `--profiles` names that is logged in, and, without `--profiles`,
|
|
94
|
-
|
|
95
|
+
Explore each profile `--profiles` names that is logged in, and, without `--profiles`, one logged-in profile per
|
|
96
|
+
domain. Never explore a profile the selection leaves out, and never add a profile to fill a gap. Profiles that
|
|
95
97
|
are selected but not logged in are skipped and named in the report; a human runs `pnpm signin <profile-id>`.
|
|
98
|
+
**One profile at a time, one session each.** Open it with
|
|
99
|
+
`generator_setup_page({project: '<profile-id>', seedFile: 'tests/seed.spec.ts', plan: ''})`; never call it without
|
|
100
|
+
`project` (the MCP then opens whichever project it likes). Before the first read, check that `page.url()` starts
|
|
101
|
+
with that profile's `baseURL` in `profiles.yaml`: if it does not, the wrong project is open, so open the right one
|
|
102
|
+
and record nothing from the wrong page. A different host is a different domain even when both profiles serve the
|
|
103
|
+
same product, so two named profiles mean two sessions, never one. Finish every selected profile before you start
|
|
104
|
+
writing the case.
|
|
96
105
|
- Do the read-only steps yourself: open, look, read, hover, switch tabs, open and close dialogs and menus.
|
|
97
|
-
- **A step that writes or needs a choice is done by the person**, per `references/user-handoff.md`: announce it
|
|
98
|
-
|
|
99
|
-
happened from its `recorded` result. In a run that
|
|
100
|
-
instead.
|
|
106
|
+
- **A step that writes or needs a choice is done by the person**, per `references/user-handoff.md`: announce it
|
|
107
|
+
with one sentence plus one action per entry of `steps` and the finish condition in `finish`, run the wait loop
|
|
108
|
+
(the card records what the person does), then write down what happened from its `recorded` result. In a run that
|
|
109
|
+
is not attended, write a `TODO [blocking]` under the step instead.
|
|
101
110
|
- Close any modal that opens by itself without confirming it (for example the follow-up modal that opens after
|
|
102
111
|
publishing: close it and confirm nothing).
|
|
103
112
|
- When a confirm dialog appears while closing an email composer, choose "Discard" (see `AGENTS.md`).
|
|
@@ -157,6 +166,12 @@ one exception is a handoff in an attended run, where waiting for the person is t
|
|
|
157
166
|
line of every profile you explored must say `fresh`), and fix every FAIL. If
|
|
158
167
|
`case:check` fails with a sandbox error (`listen EPERM`), run
|
|
159
168
|
`node --import tsx <framework>/scripts/check-cases.ts` instead; it checks the same thing.
|
|
169
|
+
**A selected profile that is logged in gets its own note.** The note, the target card's `Reference
|
|
170
|
+
<profile-id>` and at least one observation marked with that profile id are the proof: before you report, compare the
|
|
171
|
+
selection with `.explore/notes/<case-id>/` and with the profile ids the case now names. A profile with no note that
|
|
172
|
+
was not skipped for its login was not explored: write a `TODO [blocking]` under the first step it affects (a person
|
|
173
|
+
re-runs `/explore-case` with that profile) and say so in the report; never report a run as complete for a profile
|
|
174
|
+
that was not opened. A profile skipped because it is not logged in gets no note and is named as skipped instead.
|
|
160
175
|
9. **Report** in the user's language: what was changed (wording fixes, observations added, TODOs closed or narrowed),
|
|
161
176
|
what was explored (profile ids), which profiles `--profiles` left out (and that a run with them covers the rest),
|
|
162
177
|
which steps the person did and what was recorded for them, what the exploration note covers (and what it does not),
|
|
@@ -4,8 +4,25 @@ async (page) => {
|
|
|
4
4
|
const ctx = page.context();
|
|
5
5
|
const cfg = ctx.__handoffCfg || {};
|
|
6
6
|
const message = cfg.message || 'Do the step, then press Done';
|
|
7
|
+
// The steps are the person's actions, one per line and numbered by the card: a handoff that needs several things
|
|
8
|
+
// never becomes one sentence nobody can follow (see user-handoff.md, "Before the handoff").
|
|
9
|
+
const steps = (Array.isArray(cfg.steps) ? cfg.steps : [])
|
|
10
|
+
.filter((step) => typeof step === 'string' && step.trim())
|
|
11
|
+
.map((step) => step.trim());
|
|
7
12
|
const copy = Array.isArray(cfg.copy) ? cfg.copy : [];
|
|
8
13
|
const doneText = cfg.doneText || null;
|
|
14
|
+
// The finish condition gets its own block. `doneText` is the same promise as a text the card can watch for, so an
|
|
15
|
+
// older handoff that only set it still says when the step is done.
|
|
16
|
+
const finish = typeof cfg.finish === 'string' && cfg.finish.trim()
|
|
17
|
+
? cfg.finish.trim()
|
|
18
|
+
: doneText ? `"${doneText}" appears` : null;
|
|
19
|
+
// The finish condition usually quotes the text it waits for ("Unfavorite Article"). A handoff that left `doneText`
|
|
20
|
+
// out gets that text as its signal, so the card ends by itself; the check below drops it when the page already shows
|
|
21
|
+
// it, because a condition that holds before the person acts must never end the handoff at once.
|
|
22
|
+
const quoted = finish
|
|
23
|
+
? ((finish.match(/["“]([^"”]{2,})["”]/) || finish.match(/'([^']{2,})'/)) || [])[1]
|
|
24
|
+
: undefined;
|
|
25
|
+
let watchedText = doneText || (quoted ? quoted.trim() : null);
|
|
9
26
|
const maxMs = cfg.maxMs || 600000;
|
|
10
27
|
await ctx.grantPermissions(['clipboard-read', 'clipboard-write']).catch(() => {});
|
|
11
28
|
|
|
@@ -55,7 +72,18 @@ async (page) => {
|
|
|
55
72
|
}
|
|
56
73
|
}
|
|
57
74
|
|
|
58
|
-
|
|
75
|
+
/** True when the page's own text holds `text`: the card's text is never part of it (it quotes the condition). */
|
|
76
|
+
const pageHasText = (p, text) => p.evaluate((needle) => {
|
|
77
|
+
const lower = needle.toLowerCase();
|
|
78
|
+
if (!document.body) return false;
|
|
79
|
+
if (document.body.innerText.toLowerCase().includes(lower)) return true;
|
|
80
|
+
// Sites that render inside their own shadow roots are not in innerText.
|
|
81
|
+
return Array.from(document.querySelectorAll('*')).some((el) =>
|
|
82
|
+
el.id !== '__ASRUN_panel' && el.shadowRoot &&
|
|
83
|
+
el.shadowRoot.textContent.toLowerCase().includes(lower));
|
|
84
|
+
}, text).catch(() => false);
|
|
85
|
+
|
|
86
|
+
const paint = (p) => p.evaluate(({message, steps, finish, copy, autoEnd}) => {
|
|
59
87
|
if (document.getElementById('__ASRUN_panel')) return;
|
|
60
88
|
const KEY = '__ASRUN_panel_state';
|
|
61
89
|
let state = {minimized: false, left: null, top: null};
|
|
@@ -74,24 +102,55 @@ async (page) => {
|
|
|
74
102
|
host.style.cssText = 'all:initial;position:fixed;z-index:2147483647;left:16px;bottom:16px';
|
|
75
103
|
const root = host.attachShadow({mode: 'open'});
|
|
76
104
|
root.innerHTML = '<style>' +
|
|
77
|
-
'.card{width:
|
|
105
|
+
'.card{width:340px;max-width:calc(100vw - 32px);background:#1e3340;color:#fff;font:13px/1.4 system-ui,sans-serif;border-radius:8px;box-shadow:0 6px 20px rgba(0,0,0,.35);overflow:hidden}' +
|
|
78
106
|
'.card.min{width:auto}.card.ok{background:#15803d}' +
|
|
79
107
|
'.bar{display:flex;align-items:center;gap:8px;padding:7px 8px 7px 12px;background:#b45309;cursor:grab;user-select:none;touch-action:none}' +
|
|
80
108
|
'.ok .bar{background:#166534;align-items:flex-start;padding-top:9px}.bar b{flex:1;min-width:0;font-size:12px;letter-spacing:.04em;white-space:nowrap}.ok .bar b{white-space:normal;line-height:1.35}.grip{display:inline-flex;opacity:.8}' +
|
|
81
109
|
'button{all:unset;box-sizing:border-box;cursor:pointer;display:inline-flex;align-items:center;justify-content:center;gap:4px;padding:3px 8px;border-radius:4px;background:rgba(255,255,255,.18);color:#fff;font:600 12px system-ui,sans-serif}' +
|
|
82
110
|
'button:hover{background:rgba(255,255,255,.3)}' +
|
|
83
|
-
|
|
111
|
+
// The steps scroll on their own: a long list never pushes the finish condition or the buttons out of the card.
|
|
112
|
+
'.body{display:flex;flex-direction:column}.min .body,.ok .body{display:none}' +
|
|
113
|
+
'.content{display:flex;flex-direction:column;gap:9px;padding:10px 12px 0}' +
|
|
114
|
+
'.summary{font-weight:600;white-space:pre-wrap;overflow-wrap:anywhere}' +
|
|
115
|
+
'.steps{margin:0;padding:0 2px 0 1.6em;display:flex;flex-direction:column;gap:6px;max-height:min(30vh,240px);overflow:auto;scrollbar-width:thin;scrollbar-color:rgba(255,255,255,.35) transparent}' +
|
|
116
|
+
'.steps li{padding-left:2px;overflow-wrap:anywhere}.steps li::marker{color:#fbbf24;font-weight:700}' +
|
|
117
|
+
// A list longer than its box fades out at the bottom instead of cutting a line in half, so nobody reads it as the end.
|
|
118
|
+
'.steps.scrolly{-webkit-mask-image:linear-gradient(to bottom,#000 calc(100% - 16px),transparent);mask-image:linear-gradient(to bottom,#000 calc(100% - 16px),transparent)}' +
|
|
119
|
+
'.finish{margin:9px 12px 0;border-left:4px solid #22c55e;background:rgba(34,197,94,.18);border-radius:4px;padding:7px 10px;display:flex;flex-direction:column;gap:3px}' +
|
|
120
|
+
'.finish .label{font-size:10px;font-weight:700;letter-spacing:.1em;color:#86efac}' +
|
|
121
|
+
'.finish .cond{font-weight:600;line-height:1.35;overflow-wrap:anywhere}' +
|
|
122
|
+
'.finish .also{font-size:11px;opacity:.8}' +
|
|
84
123
|
'.mini-done{display:none}.min .mini-done{display:inline-flex}' +
|
|
85
124
|
'.chip{display:flex;align-items:center;gap:6px;background:rgba(0,0,0,.28);padding:4px 4px 4px 8px;border-radius:4px}' +
|
|
86
125
|
'.chip span{flex:1;font:12px ui-monospace,Menlo,monospace;word-break:break-all;user-select:all}' +
|
|
87
|
-
'.row{display:flex;gap:8px}.row .primary{background:#15803d}.row .primary:hover{background:#16a34a}' +
|
|
126
|
+
'.row{display:flex;gap:8px;padding:12px}.row .primary{background:#15803d}.row .primary:hover{background:#16a34a}' +
|
|
88
127
|
'</style>' +
|
|
89
128
|
'<div class="card" id="card"><div class="bar" id="bar" title="Drag to move"><span class="grip">' + ICON.move + '</span><b id="title">YOUR TURN</b>' +
|
|
90
129
|
'<button class="mini-done" data-a="done" aria-label="AsRun handoff: done">Done</button><button id="toggle" title="Minimize or expand" aria-label="AsRun handoff: minimize or expand"></button></div>' +
|
|
91
|
-
'<div class="body"><div id="msg"></div><
|
|
130
|
+
'<div class="body"><div class="content"><div id="msg" class="summary"></div><ol id="steps" class="steps"></ol><div id="chips"></div></div>' +
|
|
131
|
+
'<div id="finish" class="finish" style="display:none"><div class="label">DONE WHEN</div><div id="cond" class="cond"></div>' +
|
|
132
|
+
'<div id="also" class="also"></div></div>' +
|
|
133
|
+
'<div class="row">' +
|
|
92
134
|
'<button class="primary" data-a="done" aria-label="AsRun handoff: done">Done</button><button data-a="skip" aria-label="AsRun handoff: cannot do this">Cannot do this</button></div></div></div>';
|
|
93
135
|
const card = root.getElementById('card'), bar = root.getElementById('bar'), toggle = root.getElementById('toggle');
|
|
136
|
+
// Every text goes in through `textContent`: the message comes from an agent, never from the site under test.
|
|
94
137
|
root.getElementById('msg').textContent = message;
|
|
138
|
+
const list = root.getElementById('steps');
|
|
139
|
+
if (steps.length) {
|
|
140
|
+
for (const step of steps) {
|
|
141
|
+
const item = document.createElement('li');
|
|
142
|
+
item.textContent = step;
|
|
143
|
+
list.appendChild(item);
|
|
144
|
+
}
|
|
145
|
+
} else list.style.display = 'none';
|
|
146
|
+
if (finish) {
|
|
147
|
+
root.getElementById('cond').textContent = finish;
|
|
148
|
+
// Say which way the wait can end: a card that can never notice the condition must not suggest that it will.
|
|
149
|
+
root.getElementById('also').textContent = autoEnd
|
|
150
|
+
? 'The card ends by itself when this shows. Done also works.'
|
|
151
|
+
: 'Press Done when you are finished.';
|
|
152
|
+
root.getElementById('finish').style.display = 'flex';
|
|
153
|
+
}
|
|
95
154
|
const chips = root.getElementById('chips');
|
|
96
155
|
chips.style.cssText = 'display:' + (copy.length ? 'flex' : 'none') + ';flex-direction:column;gap:6px';
|
|
97
156
|
for (const text of copy) {
|
|
@@ -115,6 +174,10 @@ async (page) => {
|
|
|
115
174
|
card.classList.toggle('min', state.minimized);
|
|
116
175
|
toggle.innerHTML = state.minimized ? ICON.up : ICON.down;
|
|
117
176
|
root.getElementById('title').textContent = state.minimized ? 'YOUR TURN' : 'YOUR TURN: control is yours';
|
|
177
|
+
// The minimized pill hides the steps and the finish condition: keep both one hover away, so nothing is missed.
|
|
178
|
+
const hidden = state.minimized ? [message, ...steps, ...(finish ? ['Done when: ' + finish] : [])].join('\n') : null;
|
|
179
|
+
bar.title = hidden || 'Drag to move';
|
|
180
|
+
bar.setAttribute('aria-label', hidden || 'AsRun handoff: drag to move');
|
|
118
181
|
};
|
|
119
182
|
const place = () => {
|
|
120
183
|
if (state.left === null) return;
|
|
@@ -147,10 +210,21 @@ async (page) => {
|
|
|
147
210
|
apply();
|
|
148
211
|
document.documentElement.appendChild(host);
|
|
149
212
|
place();
|
|
213
|
+
// Only a list longer than its box gets the fade; a short one must not look cut off. Measured after the card is in
|
|
214
|
+
// the document: before that every height is zero. The fade goes away at the end of the list, where nothing is left.
|
|
215
|
+
const markScroll = () =>
|
|
216
|
+
list.classList.toggle('scrolly', list.scrollHeight - list.clientHeight - list.scrollTop > 1);
|
|
217
|
+
list.addEventListener('scroll', markScroll);
|
|
218
|
+
markScroll();
|
|
150
219
|
if (!document.title.startsWith('YOUR TURN | ')) document.title = 'YOUR TURN | ' + document.title;
|
|
151
|
-
}, {message, copy}).catch(() => {});
|
|
220
|
+
}, {message, steps, finish, copy, autoEnd: Boolean(watchedText)}).catch(() => {});
|
|
152
221
|
|
|
153
222
|
for (const p of ctx.pages()) await p.evaluate(() => { window.__handoff = null; }).catch(() => {});
|
|
223
|
+
// A text the handoff only implies (from its finish condition) is dropped when the page already shows it: it would
|
|
224
|
+
// end the handoff before the person does anything. An explicit `doneText` keeps its meaning as the agent gave it.
|
|
225
|
+
if (!doneText && watchedText) {
|
|
226
|
+
for (const p of ctx.pages()) if (await pageHasText(p, watchedText)) { watchedText = null; break; }
|
|
227
|
+
}
|
|
154
228
|
const started = Date.now();
|
|
155
229
|
let outcome = null;
|
|
156
230
|
try {
|
|
@@ -159,7 +233,7 @@ async (page) => {
|
|
|
159
233
|
await paint(p);
|
|
160
234
|
const flag = await p.evaluate(() => window.__handoff || null).catch(() => null);
|
|
161
235
|
if (flag) outcome = flag;
|
|
162
|
-
else if (
|
|
236
|
+
else if (watchedText && await pageHasText(p, watchedText)) outcome = 'condition';
|
|
163
237
|
}
|
|
164
238
|
if (!outcome) await page.waitForTimeout(500);
|
|
165
239
|
}
|
|
@@ -45,8 +45,8 @@ Rules for writing it:
|
|
|
45
45
|
It needs no TODO block. See "Marker or question, never both" below.
|
|
46
46
|
|
|
47
47
|
## Where the block hangs
|
|
48
|
-
The parent line is the sentence the question is about: the panel shows it on its own,
|
|
49
|
-
|
|
48
|
+
The parent line is the sentence the question is about: the panel shows it on its own, under "The case says", so it
|
|
49
|
+
has to make sense without the rest of the document.
|
|
50
50
|
- **The immediate line, not an ancestor.** Hang the block under the one sentence that makes the claim. Under a
|
|
51
51
|
nested bullet, indent it under that bullet, not under the top-level one above it; when the claim only makes sense
|
|
52
52
|
with an umbrella sentence, write the claim itself as a top-level bullet and hang the block there.
|
|
@@ -136,6 +136,12 @@ in the same `browser_run_code_unsafe` call that waits for the download), run
|
|
|
136
136
|
images** and say in the card where the logo really is on each page, which pages have it and which do not, and what the
|
|
137
137
|
text shows. The rendered pages are the card's `Reference`. If the logo is not where the case says, that is a question.
|
|
138
138
|
|
|
139
|
+
When `pnpm pdf:render` itself fails, the install is broken — a PDF check would fail the same way once the spec runs, so
|
|
140
|
+
there is nothing to explore: end with `needs-input` and name the error (`pnpm asrun doctor` says which part is
|
|
141
|
+
missing). **Never substitute another renderer** (Ghostscript, an image converter, a preview from the browser): the
|
|
142
|
+
baseline a spec compares against is produced by `expectPdf` and pdf.js, so a page drawn by anything else is not a
|
|
143
|
+
`Reference` and the card would describe a picture that never appears again.
|
|
144
|
+
|
|
139
145
|
While exploring, do the same by hand: click, wait for the download event, see the file finish, read its size and its first
|
|
140
146
|
bytes. Record "(observed: <file name>, <size>, valid <kind>)".
|
|
141
147
|
|
|
@@ -23,15 +23,19 @@ perform this step; run /explore-case with --attended") and goes on with the rest
|
|
|
23
23
|
that nobody watches.
|
|
24
24
|
|
|
25
25
|
## Before the handoff
|
|
26
|
-
1. Say what the
|
|
27
|
-
|
|
28
|
-
|
|
26
|
+
1. Say in one sentence what the handoff is about (`message`), and put what to do in `steps`, **one action per entry, in
|
|
27
|
+
the order the person does it**. The card numbers them. Never chain actions in one entry ("pick items, then press
|
|
28
|
+
Next"): a step is one thing to click, type or choose. More than about five steps is two handoffs, not one long card.
|
|
29
|
+
2. Name the finish condition in `finish`, in the words of the page ("'Published' appears at the top of the page",
|
|
30
|
+
without a closing full stop), and keep it out of the steps: the card puts it in its own highlighted "DONE WHEN"
|
|
31
|
+
block. `doneText` is the same condition as a text the card can watch for (see "Finish condition").
|
|
32
|
+
3. Put every text the user has to type into `copy` (the name of a document, a title), so it can be
|
|
29
33
|
copied with one click. Build a created name with the project's test-data prefix (default `[asrun]`, printed by
|
|
30
34
|
`pnpm asrun rules`) and a run stamp: `<prefix> <base> (<YYYY-MM-DD HH-mm-ss>, UTC)`, the same shape
|
|
31
35
|
`testDataName()` builds. The stamp is chosen now, not recorded in the recipe.
|
|
32
|
-
|
|
36
|
+
4. Never ask the user to send email (production: never; other environments: only when `email` is `true` for the
|
|
33
37
|
profile). Never ask for login or 2FA. Name everything the user creates `<prefix> <something> (<run stamp>)`.
|
|
34
|
-
|
|
38
|
+
5. Do not start a recording yourself: the card starts and stops it (see "Recording"). Never call
|
|
35
39
|
`browser_start_recording` or `browser_stop_recording`: the `playwright-test` MCP lists them but cannot run them
|
|
36
40
|
(`devtools` tools; see `tools.md`).
|
|
37
41
|
|
|
@@ -39,9 +43,14 @@ that nobody watches.
|
|
|
39
43
|
Run it with one `browser_run_code_unsafe` call. A single call may wait for minutes (verified: 150 s without a
|
|
40
44
|
timeout), so no polling from outside is needed. The loop:
|
|
41
45
|
|
|
42
|
-
- **Shows a small floating card, not a bar.** It
|
|
43
|
-
|
|
44
|
-
|
|
46
|
+
- **Shows a small floating card, not a bar.** It reads top to bottom: one sentence about the handoff, the steps as a
|
|
47
|
+
numbered list, a line per text to copy, and the finish condition in its own green "DONE WHEN" block at the bottom,
|
|
48
|
+
with a line under it that says whether the card ends by itself. A list longer than its box scrolls (and fades at the
|
|
49
|
+
edge), while the finish condition and the buttons stay in view.
|
|
50
|
+
It sits in the bottom-left corner and only the card takes clicks, so nothing of the page is covered or blocked. The
|
|
51
|
+
user can **drag** it by its title bar and **minimize** it to a small pill that still has a Done button (its tooltip
|
|
52
|
+
keeps the steps and the finish condition). Position and minimized state survive a page reload (kept in
|
|
53
|
+
`sessionStorage`).
|
|
45
54
|
- **Click to copy.** Every text in `copy` (for example the `<prefix> <name> (<run stamp>)` the user has to type) is shown on its
|
|
46
55
|
own line with a copy icon; one click puts it on the clipboard and the icon shows "Copied". Pass only text that the
|
|
47
56
|
case says to type; leave `copy` empty when there is none.
|
|
@@ -72,9 +81,14 @@ badge, no count in the tab title and no notification (a HINT in the job log says
|
|
|
72
81
|
// browser_run_code_unsafe({intent: 'set the handoff card options', code: ...})
|
|
73
82
|
async (page) => {
|
|
74
83
|
page.context().__handoffCfg = {
|
|
75
|
-
message: '<what
|
|
84
|
+
message: '<what this handoff is for, one sentence>',
|
|
85
|
+
steps: [ // one action per entry, in order: the card numbers them
|
|
86
|
+
'<first thing to do>',
|
|
87
|
+
'<next thing to do>',
|
|
88
|
+
],
|
|
89
|
+
finish: '<what the page shows when it is done, in the page\'s own words, no full stop>',
|
|
76
90
|
copy: [], // texts to type, e.g. ['[autotest] published document (2026-11-09 12-34-56)']
|
|
77
|
-
doneText:
|
|
91
|
+
doneText: '<the exact text that appears when it is done>', // so the card ends by itself (see below)
|
|
78
92
|
maxMs: 600000,
|
|
79
93
|
};
|
|
80
94
|
return 'cfg set';
|
|
@@ -89,26 +103,44 @@ badge, no count in the tab title and no notification (a HINT in the job log says
|
|
|
89
103
|
the recording was unavailable (`recording: false`; `recordingError` says why).
|
|
90
104
|
|
|
91
105
|
Check once, after the card shows, that it looks right: take a screenshot and see a title bar with a move icon, a
|
|
92
|
-
chevron button at its right
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
106
|
+
chevron button at its right, the message as one line, the steps numbered, the finish condition in its own green
|
|
107
|
+
"DONE WHEN" block saying "The card ends by itself when this shows", and, when `copy` is not empty, a line per text
|
|
108
|
+
with a copy icon. The Done button must be visible without scrolling. Playwright's own recording toolbar at the top of
|
|
109
|
+
the page is expected and is not part of the card. If the card is not right (or says "Press Done when you are
|
|
110
|
+
finished"), fix `doneText` before you hand the step over.
|
|
111
|
+
|
|
112
|
+
Tell the user in the chat (the terminal or the UI job log) as well: "Your turn in the browser window: <task>. Done
|
|
113
|
+
when <finish condition>. Press Done on the card (bottom-left; drag it if it is in the way) when you finish." Name the
|
|
114
|
+
finish condition there too, not only on the card. When the run was started from the web UI, the UI notices the handoff
|
|
115
|
+
by itself (it sees the `__handoffCfg` call and the `handoff-card.js` call, whether the browser tool is called directly
|
|
116
|
+
or through the CLI's code mode, as OpenCode's `execute` tool does) and shows the "Your turn" badge, a count in the tab
|
|
117
|
+
title and a desktop notification saying what to do and when it is done — this is why the message and the finish
|
|
118
|
+
condition in `__handoffCfg` must be plain string literals. The UI server shows that notification itself; **never send
|
|
119
|
+
one yourself** (`osascript`, `notify-send`, a toast): a CLI that refuses a shell call leaves a failed call in the job
|
|
120
|
+
log, and the person gets nothing either way.
|
|
104
121
|
|
|
105
122
|
## Finish condition
|
|
106
|
-
Pick one that the page can show, before the handoff, and write it into the
|
|
123
|
+
Pick one that the page can show, before the handoff, and write it into `finish`, where the card shows it alone:
|
|
107
124
|
- a text that appears (a "Saved" toast, the title of the next step) or one that goes away;
|
|
108
125
|
- a URL that the page reaches;
|
|
109
126
|
- an element that appears or disappears (an editor that closes).
|
|
110
127
|
|
|
111
|
-
|
|
128
|
+
**Also give `doneText`: the exact text that is not on the page yet and shows up when the step is done** (the new
|
|
129
|
+
button label, the toast, the heading of the next screen). Without it the card cannot notice the condition and waits for
|
|
130
|
+
the person to press Done, which is the one thing a handoff must not need every time. Only a condition that carries no
|
|
131
|
+
text at all (a URL, an empty editor) may leave it out.
|
|
132
|
+
|
|
133
|
+
- Both must agree: nobody should read "DONE WHEN ..." on the card while `doneText` watches for something else.
|
|
134
|
+
- Quote the *new* text, not the one that is already there: `doneText: 'Unfavorite Article'`, never `'Favorite Article'`
|
|
135
|
+
when the button already reads that.
|
|
136
|
+
- If `finish` quotes a text and `doneText` is missing, the card uses that quoted text as its signal — but only when the
|
|
137
|
+
page did not show it when the card appeared, so a condition that already held can never end the handoff at once.
|
|
138
|
+
Relying on that is a mistake: set `doneText` yourself.
|
|
139
|
+
- The block says which way the wait ends: "The card ends by itself when this shows. Done also works." when the card can
|
|
140
|
+
notice the condition, "Press Done when you are finished." when it cannot. Read that line on the screenshot: a
|
|
141
|
+
handoff that will never end by itself is a handoff to fix, not to run.
|
|
142
|
+
- The Done button is always there (also on the minimized card) as the fallback, and "Cannot do this" lets the user give
|
|
143
|
+
up a step.
|
|
112
144
|
|
|
113
145
|
## After the handoff
|
|
114
146
|
- The card turns green and says control is back ("Done: control is back with Playwright. Do not touch the page.")
|
|
@@ -116,9 +148,10 @@ The Done button is always there (also on the minimized card) as the fallback, an
|
|
|
116
148
|
- `outcome: 'timeout'` or `'skip'`: do not guess. Write a `TODO [blocking]` under the step and go on with what does
|
|
117
149
|
not depend on it.
|
|
118
150
|
- Take a snapshot, write down what the user did from the card's `recorded` (see "Recording"), then continue read-only.
|
|
119
|
-
- At the end of the exploration hand off once more when the user created data
|
|
120
|
-
|
|
121
|
-
|
|
151
|
+
- At the end of the exploration hand off once more when the user created data, with the same shape as any other
|
|
152
|
+
handoff: `message` "Clean up the test items this run created", `steps` = "Open the list", "Delete the items that
|
|
153
|
+
start with <prefix> and carry this run's timestamp", `finish` = "the list holds no item of this run any more". The
|
|
154
|
+
case must clean up after itself, and so must the exploration.
|
|
122
155
|
|
|
123
156
|
## Recording
|
|
124
157
|
The card records what the person does: it starts the recording when it shows and stops it on every way out (Done,
|
package/AGENTS.md
CHANGED
|
@@ -77,8 +77,9 @@ These apply to every agent that drives a browser to explore pages for cases or s
|
|
|
77
77
|
- **B1 — Read-only for the agent.** Open, look, read, hover, switch tabs, open and close dialogs and menus.
|
|
78
78
|
Everything that writes is the person's (see F3).
|
|
79
79
|
- **B2 — Waiting for a person.** Only in an attended run. Announce the task in the browser window (a small draggable
|
|
80
|
-
card
|
|
81
|
-
when control is back. The card starts and stops the
|
|
80
|
+
card that lists the steps as a numbered list, shows the finish condition in its own block, has a Done button and
|
|
81
|
+
click-to-copy for texts to type), wait, and tell the person when control is back. The card starts and stops the
|
|
82
|
+
recording itself and returns `recorded`; never call
|
|
82
83
|
`browser_start_recording` or `browser_stop_recording`: the `playwright-test` MCP lists many browser tools it cannot
|
|
83
84
|
run (`devtools`, `storage`, `network`, `pdf`, `vision`, and the `skillOnly` ones such as `browser_network_clear`),
|
|
84
85
|
and the substitutes for each are in `<framework>/.claude/skills/exploring-cases/references/tools.md`. Record what
|
package/README.md
CHANGED
|
@@ -29,7 +29,7 @@ AsRun is a devDependency, not a service. Your profiles, cases, specs and baselin
|
|
|
29
29
|
|
|
30
30
|
## Requirements
|
|
31
31
|
|
|
32
|
-
- **Node.js 22+** and **pnpm**.
|
|
32
|
+
- **Node.js 22.13+** and **pnpm**.
|
|
33
33
|
- **ffmpeg** to join the recordings of one test into a video. `pnpm go` installs it with Homebrew or winget; on Linux
|
|
34
34
|
run `sudo apt install ffmpeg`. When it is missing, `pnpm go` / `pnpm asrun init` install the bundled `ffmpeg-static`
|
|
35
35
|
into the workspace; if neither works, the report lists the videos separately.
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/** One notification, already reduced to what a person reads at a glance. */
|
|
2
|
+
export interface DesktopNotification {
|
|
3
|
+
title: string;
|
|
4
|
+
body: string;
|
|
5
|
+
/** Ask for the platform's attention sound. Ignored where the platform has none. */
|
|
6
|
+
sound?: boolean;
|
|
7
|
+
}
|
|
8
|
+
/** The command that shows a notification: spawned directly, never through a shell. */
|
|
9
|
+
export interface NotifyCommand {
|
|
10
|
+
file: string;
|
|
11
|
+
args: string[];
|
|
12
|
+
}
|
|
13
|
+
/** What came of one attempt, for `pnpm asrun notify-test` and for the hint a failure leaves in a job log. */
|
|
14
|
+
export interface NotifyOutcome {
|
|
15
|
+
/** The command, as a person would type it; null when this platform has no notifier at all. */
|
|
16
|
+
command: string | null;
|
|
17
|
+
ok: boolean;
|
|
18
|
+
/** Why nothing was shown, when it was not. */
|
|
19
|
+
reason?: string;
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* The command one notification runs on this platform. Pure: the text is escaped here, so a message with quotes,
|
|
23
|
+
* Chinese or a colon cannot break the command. Null only for a platform with no notifier.
|
|
24
|
+
*/
|
|
25
|
+
export declare function notificationCommand(platform: NodeJS.Platform, notification: DesktopNotification): NotifyCommand | null;
|
|
26
|
+
/** A command as a person would type it, cut short: a toast script is hundreds of characters long. */
|
|
27
|
+
export declare function describeCommand(command: NotifyCommand): string;
|
|
28
|
+
/**
|
|
29
|
+
* Shows one notification and waits for the command to end. Used by `pnpm asrun notify-test`, which has to report
|
|
30
|
+
* whether the operating system took it; the server uses `notifyDesktop` instead.
|
|
31
|
+
*/
|
|
32
|
+
export declare function sendNotification(notification: DesktopNotification): Promise<NotifyOutcome>;
|
|
33
|
+
/**
|
|
34
|
+
* Shows one notification without waiting for it. The same text is sent once per few seconds, so two events of one
|
|
35
|
+
* handoff (the options call and the card) do not ring twice. `onError` says why nothing was shown, for a hint in the
|
|
36
|
+
* job log; it is never called on success.
|
|
37
|
+
*/
|
|
38
|
+
export declare function notifyDesktop(notification: DesktopNotification, onError?: (reason: string) => void): void;
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import { spawnTarget } from './command.js';
|
|
3
|
+
/** A notification holds a line or two; the rest would be cut by the desktop anyway. */
|
|
4
|
+
const MAX_TEXT = 240;
|
|
5
|
+
/** The same notification twice in a row (a handoff reported by two events) is shown once. */
|
|
6
|
+
const DEDUPE_MS = 5_000;
|
|
7
|
+
/** A notifier that has not finished by now is a broken one, not a slow one. */
|
|
8
|
+
const TIMEOUT_MS = 10_000;
|
|
9
|
+
/** What went out recently, so the same text is not shown again by the next event of the same handoff. */
|
|
10
|
+
const recent = new Map();
|
|
11
|
+
/** A notification's text is one line: a line break in the body is read as the end of the notification. */
|
|
12
|
+
function oneLine(value) {
|
|
13
|
+
return value
|
|
14
|
+
.replace(/[\r\n\t\f\v]+/g, ' ')
|
|
15
|
+
.replace(/ {2,}/g, ' ')
|
|
16
|
+
.trim()
|
|
17
|
+
.slice(0, MAX_TEXT);
|
|
18
|
+
}
|
|
19
|
+
/** A string in an AppleScript literal: a backslash escapes, so both it and the quote have to be escaped themselves. */
|
|
20
|
+
const appleScriptText = (value) => value.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
|
|
21
|
+
/** XML text of a Windows toast: the five characters that would end the element early. */
|
|
22
|
+
const xmlText = (value) => value
|
|
23
|
+
.replace(/&/g, '&')
|
|
24
|
+
.replace(/</g, '<')
|
|
25
|
+
.replace(/>/g, '>')
|
|
26
|
+
.replace(/"/g, '"')
|
|
27
|
+
.replace(/'/g, ''');
|
|
28
|
+
/** A PowerShell single-quoted literal: only a single quote is special inside it, and it is doubled. */
|
|
29
|
+
const powerShellText = (value) => value.replace(/'/g, "''");
|
|
30
|
+
/**
|
|
31
|
+
* Windows toast through Windows PowerShell (not pwsh: it has no WinRT). The app id is the one Windows PowerShell
|
|
32
|
+
* itself registers, so an unregistered toast does not silently disappear on a machine with nothing else installed.
|
|
33
|
+
*/
|
|
34
|
+
function toastScript(title, body, sound) {
|
|
35
|
+
const toast = '<toast><visual><binding template="ToastGeneric">' +
|
|
36
|
+
`<text>${xmlText(title)}</text><text>${xmlText(body)}</text>` +
|
|
37
|
+
'</binding></visual>' +
|
|
38
|
+
(sound ? '<audio src="ms-winsoundevent:Notification.Reminder"/>' : '<audio silent="true"/>') +
|
|
39
|
+
'</toast>';
|
|
40
|
+
return [
|
|
41
|
+
"$ErrorActionPreference='Stop'",
|
|
42
|
+
'[Windows.UI.Notifications.ToastNotificationManager, Windows.UI.Notifications, ContentType=WindowsRuntime] > $null',
|
|
43
|
+
'[Windows.Data.Xml.Dom.XmlDocument, Windows.Data.Xml.Dom.XmlDocument, ContentType=WindowsRuntime] > $null',
|
|
44
|
+
'$xml=New-Object Windows.Data.Xml.Dom.XmlDocument',
|
|
45
|
+
`$xml.LoadXml('${powerShellText(toast)}')`,
|
|
46
|
+
'$toast=[Windows.UI.Notifications.ToastNotification]::new($xml)',
|
|
47
|
+
"[Windows.UI.Notifications.ToastNotificationManager]::CreateToastNotifier(" +
|
|
48
|
+
"'{1AC14E77-02E7-4E5D-B744-2EB1AE5198B7}\\WindowsPowerShell\\v1.0\\powershell.exe').Show($toast)",
|
|
49
|
+
].join(';');
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* The command one notification runs on this platform. Pure: the text is escaped here, so a message with quotes,
|
|
53
|
+
* Chinese or a colon cannot break the command. Null only for a platform with no notifier.
|
|
54
|
+
*/
|
|
55
|
+
export function notificationCommand(platform, notification) {
|
|
56
|
+
const title = oneLine(notification.title) || 'AsRun';
|
|
57
|
+
const body = oneLine(notification.body);
|
|
58
|
+
if (platform === 'darwin') {
|
|
59
|
+
const script = `display notification "${appleScriptText(body)}" with title "${appleScriptText(title)}"` +
|
|
60
|
+
(notification.sound ? ' sound name "Glass"' : '');
|
|
61
|
+
return { file: 'osascript', args: ['-e', script] };
|
|
62
|
+
}
|
|
63
|
+
if (platform === 'win32') {
|
|
64
|
+
return {
|
|
65
|
+
file: 'powershell',
|
|
66
|
+
args: ['-NoProfile', '-NonInteractive', '-Command', toastScript(title, body, notification.sound === true)],
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
if (platform === 'linux' || platform === 'freebsd' || platform === 'openbsd') {
|
|
70
|
+
return { file: 'notify-send', args: ['-a', 'AsRun', title, body] };
|
|
71
|
+
}
|
|
72
|
+
return null;
|
|
73
|
+
}
|
|
74
|
+
/** One argument as it would be typed in a shell, for the line `notify-test` prints (never used to run anything). */
|
|
75
|
+
const quote = (value) => (/^[\w@=,.:/~+-]+$/.test(value) ? value : JSON.stringify(value));
|
|
76
|
+
/** A command as a person would type it, cut short: a toast script is hundreds of characters long. */
|
|
77
|
+
export function describeCommand(command) {
|
|
78
|
+
const line = [command.file, ...command.args.map(quote)].join(' ');
|
|
79
|
+
return line.length > 200 ? `${line.slice(0, 197)}...` : line;
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* Shows one notification and waits for the command to end. Used by `pnpm asrun notify-test`, which has to report
|
|
83
|
+
* whether the operating system took it; the server uses `notifyDesktop` instead.
|
|
84
|
+
*/
|
|
85
|
+
export async function sendNotification(notification) {
|
|
86
|
+
const command = notificationCommand(process.platform, notification);
|
|
87
|
+
if (!command)
|
|
88
|
+
return { command: null, ok: false, reason: `${process.platform} has no desktop notification` };
|
|
89
|
+
const display = describeCommand(command);
|
|
90
|
+
const target = spawnTarget(command.file, command.args);
|
|
91
|
+
if (!target)
|
|
92
|
+
return { command: display, ok: false, reason: `${command.file} was not found on PATH` };
|
|
93
|
+
return await new Promise((resolve) => {
|
|
94
|
+
let stderr = '';
|
|
95
|
+
let settled = false;
|
|
96
|
+
let timer;
|
|
97
|
+
const done = (outcome) => {
|
|
98
|
+
if (settled)
|
|
99
|
+
return;
|
|
100
|
+
settled = true;
|
|
101
|
+
if (timer)
|
|
102
|
+
clearTimeout(timer);
|
|
103
|
+
resolve(outcome);
|
|
104
|
+
};
|
|
105
|
+
let child;
|
|
106
|
+
try {
|
|
107
|
+
child = spawn(target.file, target.args, {
|
|
108
|
+
stdio: ['ignore', 'ignore', 'pipe'],
|
|
109
|
+
windowsHide: true,
|
|
110
|
+
...(target.throughShell && { windowsVerbatimArguments: true }),
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
catch (error) {
|
|
114
|
+
resolve({ command: display, ok: false, reason: error.message });
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
timer = setTimeout(() => {
|
|
118
|
+
child.kill();
|
|
119
|
+
done({ command: display, ok: false, reason: `no answer after ${TIMEOUT_MS / 1000} s` });
|
|
120
|
+
}, TIMEOUT_MS);
|
|
121
|
+
timer.unref();
|
|
122
|
+
child.stderr?.on('data', (chunk) => {
|
|
123
|
+
stderr += chunk.toString();
|
|
124
|
+
});
|
|
125
|
+
child.on('error', (error) => done({ command: display, ok: false, reason: error.message }));
|
|
126
|
+
child.on('close', (code) => {
|
|
127
|
+
const detail = stderr.trim().split('\n').filter(Boolean).at(-1);
|
|
128
|
+
done(code === 0
|
|
129
|
+
? { command: display, ok: true }
|
|
130
|
+
: { command: display, ok: false, reason: detail ?? `exit code ${code}` });
|
|
131
|
+
});
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
/**
|
|
135
|
+
* Shows one notification without waiting for it. The same text is sent once per few seconds, so two events of one
|
|
136
|
+
* handoff (the options call and the card) do not ring twice. `onError` says why nothing was shown, for a hint in the
|
|
137
|
+
* job log; it is never called on success.
|
|
138
|
+
*/
|
|
139
|
+
export function notifyDesktop(notification, onError) {
|
|
140
|
+
const key = `${notification.title}\u0000${notification.body}`;
|
|
141
|
+
const now = Date.now();
|
|
142
|
+
for (const [seen, at] of recent)
|
|
143
|
+
if (now - at > DEDUPE_MS)
|
|
144
|
+
recent.delete(seen);
|
|
145
|
+
if (recent.has(key))
|
|
146
|
+
return;
|
|
147
|
+
recent.set(key, now);
|
|
148
|
+
void sendNotification(notification).then((outcome) => {
|
|
149
|
+
if (outcome.ok || !onError)
|
|
150
|
+
return;
|
|
151
|
+
try {
|
|
152
|
+
onError(outcome.reason ?? 'the notification command failed');
|
|
153
|
+
}
|
|
154
|
+
catch {
|
|
155
|
+
// A caller that cannot report the failure is not worth failing the notification for.
|
|
156
|
+
}
|
|
157
|
+
});
|
|
158
|
+
}
|