dsh-tabbit 0.2.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/README.md +106 -0
- package/README.zh-CN.md +107 -0
- package/cordis.patch.yml +3 -0
- package/index.js +352 -0
- package/installer.js +568 -0
- package/package.json +41 -0
- package/skills/tabbit-browser/SKILL.md +274 -0
- package/skills/tabbit-browser/agents/openai.yaml +4 -0
- package/skills/tabbit-browser/references/information-extraction.md +155 -0
- package/skills/tabbit-browser/references/interaction-helpers.md +103 -0
- package/skills/tabbit-browser/references/platform-invocation.md +45 -0
- package/skills/tabbit-browser/references/playwright-recipes.md +350 -0
- package/skills/tabbit-browser/references/runtime-recovery.md +95 -0
- package/update-check.js +177 -0
|
@@ -0,0 +1,350 @@
|
|
|
1
|
+
# Playwright recipes
|
|
2
|
+
|
|
3
|
+
Use these recipes inside a `tabbit-cli nodejs --task '<name>'` heredoc.
|
|
4
|
+
Each code block is an async function body: use it
|
|
5
|
+
directly without adding an async wrapper.
|
|
6
|
+
|
|
7
|
+
## Contents
|
|
8
|
+
|
|
9
|
+
- Navigate and inspect
|
|
10
|
+
- Inspect visible controls and visual targets
|
|
11
|
+
- Work with canvas-backed rich editors
|
|
12
|
+
- Fill and submit a form
|
|
13
|
+
- Extract and aggregate data
|
|
14
|
+
- Handle a popup or a possible popup
|
|
15
|
+
- Handle same-tab or new-tab navigation
|
|
16
|
+
- Handle JavaScript dialogs
|
|
17
|
+
- Work with iframes
|
|
18
|
+
- Download and upload files
|
|
19
|
+
- Repeat actions safely
|
|
20
|
+
- Capture evidence
|
|
21
|
+
- Correct common API mistakes
|
|
22
|
+
|
|
23
|
+
## Navigate and inspect
|
|
24
|
+
|
|
25
|
+
Wait for page content that establishes readiness rather than sleeping for a
|
|
26
|
+
fixed duration.
|
|
27
|
+
|
|
28
|
+
```js
|
|
29
|
+
await page.goto("https://example.com", {waitUntil: "domcontentloaded"});
|
|
30
|
+
const heading = page.getByRole("heading").first();
|
|
31
|
+
await heading.waitFor({state: "visible", timeout: 15000});
|
|
32
|
+
return {
|
|
33
|
+
url: page.url(),
|
|
34
|
+
title: await page.title(),
|
|
35
|
+
heading: (await heading.innerText()).trim(),
|
|
36
|
+
};
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
Use `mutation: "possible"` because navigation changes browser state.
|
|
40
|
+
|
|
41
|
+
## Inspect visible controls and visual targets
|
|
42
|
+
|
|
43
|
+
Use native Playwright. Prefer semantic locators when the target is known. For a
|
|
44
|
+
broad view, request an AI-mode ARIA snapshot and bound what you return:
|
|
45
|
+
|
|
46
|
+
```js
|
|
47
|
+
const snapshot = await page.ariaSnapshot({mode: "ai", depth: 20, boxes: true});
|
|
48
|
+
return snapshot.slice(0, 6000);
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
Use a fresh snapshot ref with `page.locator("aria-ref=e12")`. For iframe refs,
|
|
52
|
+
Playwright accepts the returned `f1e2` form. Use a new snapshot after navigation
|
|
53
|
+
or a substantial render. For canvas or coordinate-only surfaces, use native
|
|
54
|
+
`page.mouse` operations; the evaluation receipt automatically records compact
|
|
55
|
+
cross-frame and open-shadow-root hit diagnostics for mouse clicks.
|
|
56
|
+
|
|
57
|
+
## Work with canvas-backed rich editors
|
|
58
|
+
|
|
59
|
+
Canvas-backed editors often keep usable toolbar buttons and editor state in the
|
|
60
|
+
accessibility tree even when their visible DOM is flat or misleading. Inspect
|
|
61
|
+
that tree before using coordinates:
|
|
62
|
+
|
|
63
|
+
```js
|
|
64
|
+
const before = await page.ariaSnapshot({mode: "ai", depth: 20, boxes: true});
|
|
65
|
+
return before.slice(0, 6000);
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
Use a ref from that snapshot directly. ARIA refs may include a frame prefix such
|
|
69
|
+
as `f1e2`; native Playwright resolves either form:
|
|
70
|
+
|
|
71
|
+
```js
|
|
72
|
+
await page.locator("aria-ref=e12").click();
|
|
73
|
+
return (await page.ariaSnapshot({mode: "ai", depth: 20})).slice(0, 6000);
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
Take a new snapshot after each editor mode change because the next ARIA snapshot
|
|
77
|
+
replaces the old ref set. Check state flags such as `[pressed]`, `[expanded]`,
|
|
78
|
+
`[checked]`, and `[active]`. After inserting a table, verify that the active
|
|
79
|
+
textbox for following content is outside the `table` subtree before typing. Use
|
|
80
|
+
a screenshot only when the ARIA tree and visible surface still disagree.
|
|
81
|
+
|
|
82
|
+
## Fill and submit a form
|
|
83
|
+
|
|
84
|
+
Prefer labels and roles. Verify the resulting application state.
|
|
85
|
+
|
|
86
|
+
```js
|
|
87
|
+
await page.getByLabel("Email").fill("user@example.com");
|
|
88
|
+
await page.getByLabel("Password").fill("correct horse battery staple");
|
|
89
|
+
|
|
90
|
+
await Promise.all([
|
|
91
|
+
page.waitForURL(/dashboard/, {timeout: 15000}),
|
|
92
|
+
page.getByRole("button", {name: /sign in/i}).click(),
|
|
93
|
+
]);
|
|
94
|
+
|
|
95
|
+
const heading = page.getByRole("heading", {name: /dashboard/i});
|
|
96
|
+
await heading.waitFor({state: "visible"});
|
|
97
|
+
return {url: page.url(), signedIn: true};
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
If submission does not necessarily navigate, remove `waitForURL` and wait for a
|
|
101
|
+
success message or changed application element instead.
|
|
102
|
+
|
|
103
|
+
## Extract and aggregate data
|
|
104
|
+
|
|
105
|
+
Use locators for ordinary lists and tables. Aggregate inside the runtime rather
|
|
106
|
+
than returning an entire DOM snapshot.
|
|
107
|
+
|
|
108
|
+
```js
|
|
109
|
+
const rows = page.getByRole("table").getByRole("row");
|
|
110
|
+
const count = await rows.count();
|
|
111
|
+
const records = [];
|
|
112
|
+
|
|
113
|
+
for (let index = 1; index < count; index += 1) {
|
|
114
|
+
const cells = rows.nth(index).getByRole("cell");
|
|
115
|
+
records.push({
|
|
116
|
+
name: (await cells.nth(0).innerText()).trim(),
|
|
117
|
+
status: (await cells.nth(1).innerText()).trim(),
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const activeNames = records
|
|
122
|
+
.filter((record) => record.status === "Active")
|
|
123
|
+
.map((record) => record.name);
|
|
124
|
+
return {rowCount: records.length, activeNames};
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
For a large DOM-only computation, use one `page.evaluate()` and return an
|
|
128
|
+
aggregate. Pass data through the argument channel instead of relying on Node
|
|
129
|
+
closures:
|
|
130
|
+
|
|
131
|
+
```js
|
|
132
|
+
const minimum = 100;
|
|
133
|
+
return await page.evaluate(({minimum}) => {
|
|
134
|
+
const values = [...document.querySelectorAll("[data-price]")]
|
|
135
|
+
.map((element) => Number(element.getAttribute("data-price")));
|
|
136
|
+
return {
|
|
137
|
+
count: values.length,
|
|
138
|
+
aboveMinimum: values.filter((value) => value >= minimum).length,
|
|
139
|
+
};
|
|
140
|
+
}, {minimum});
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
## Handle a popup or a possible popup
|
|
144
|
+
|
|
145
|
+
When a popup is expected, install the waiter before clicking:
|
|
146
|
+
|
|
147
|
+
```js
|
|
148
|
+
const opener = page.getByRole("link", {name: /details/i});
|
|
149
|
+
const [popup] = await Promise.all([
|
|
150
|
+
context.waitForEvent("page", {timeout: 15000}),
|
|
151
|
+
opener.click(),
|
|
152
|
+
]);
|
|
153
|
+
await popup.waitForLoadState("domcontentloaded");
|
|
154
|
+
usePage(popup);
|
|
155
|
+
|
|
156
|
+
const result = {title: await page.title(), url: page.url()};
|
|
157
|
+
await page.close();
|
|
158
|
+
usePage(pages().find((candidate) => !candidate.isClosed()));
|
|
159
|
+
return result;
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
When a popup is only one possible outcome, start a bounded waiter and inspect
|
|
163
|
+
both outcomes. Retain the original page explicitly:
|
|
164
|
+
|
|
165
|
+
```js
|
|
166
|
+
const original = page;
|
|
167
|
+
const beforeUrl = original.url();
|
|
168
|
+
const popupPromise = context
|
|
169
|
+
.waitForEvent("page", {timeout: 8000})
|
|
170
|
+
.catch(() => null);
|
|
171
|
+
|
|
172
|
+
await original.getByRole("link", {name: /open report/i}).click();
|
|
173
|
+
const popup = await popupPromise;
|
|
174
|
+
|
|
175
|
+
if (popup) {
|
|
176
|
+
await popup.waitForLoadState("domcontentloaded");
|
|
177
|
+
const evidence = {kind: "popup", title: await popup.title(), url: popup.url()};
|
|
178
|
+
await popup.close();
|
|
179
|
+
usePage(original);
|
|
180
|
+
return evidence;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
await original.waitForLoadState("domcontentloaded").catch(() => {});
|
|
184
|
+
return {
|
|
185
|
+
kind: original.url() === beforeUrl ? "in-page" : "same-tab",
|
|
186
|
+
title: await original.title(),
|
|
187
|
+
url: original.url(),
|
|
188
|
+
};
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
Do not click first and install `waitForEvent("page")` afterward; the event may
|
|
192
|
+
already have fired.
|
|
193
|
+
|
|
194
|
+
## Handle same-tab or new-tab navigation
|
|
195
|
+
|
|
196
|
+
Use native Playwright and install the waiter before the action:
|
|
197
|
+
|
|
198
|
+
```js
|
|
199
|
+
await Promise.all([
|
|
200
|
+
page.waitForURL(/\/orders\/\d+$/),
|
|
201
|
+
page.getByRole("button", {name: /create order/i}).click(),
|
|
202
|
+
]);
|
|
203
|
+
return {title: await page.title(), url: page.url()};
|
|
204
|
+
```
|
|
205
|
+
|
|
206
|
+
For a popup, pair `context.waitForEvent("page")` with the click. Receipts report
|
|
207
|
+
new pages but do not replace the active `page`. Do not replace the requested
|
|
208
|
+
click with `page.goto(linkHref)`; that can skip application behavior. Close
|
|
209
|
+
obsolete task-created pages with native `popup.close()`.
|
|
210
|
+
|
|
211
|
+
## Handle JavaScript dialogs
|
|
212
|
+
|
|
213
|
+
Attach the handler before the triggering action. Dialog callbacks must not be
|
|
214
|
+
left pending because they block page JavaScript.
|
|
215
|
+
|
|
216
|
+
```js
|
|
217
|
+
let message = null;
|
|
218
|
+
page.once("dialog", async (dialog) => {
|
|
219
|
+
message = dialog.message();
|
|
220
|
+
await dialog.accept();
|
|
221
|
+
});
|
|
222
|
+
await page.getByRole("button", {name: /delete/i}).click();
|
|
223
|
+
await page.getByText(/deleted/i).waitFor({state: "visible"});
|
|
224
|
+
return {accepted: true, message};
|
|
225
|
+
```
|
|
226
|
+
|
|
227
|
+
Use `dialog.dismiss()` when cancellation is the requested behavior.
|
|
228
|
+
|
|
229
|
+
## Work with iframes
|
|
230
|
+
|
|
231
|
+
Use `frameLocator()` instead of trying to query iframe contents from the parent
|
|
232
|
+
document.
|
|
233
|
+
|
|
234
|
+
```js
|
|
235
|
+
const payment = page.frameLocator('iframe[title="Payment"]');
|
|
236
|
+
await payment.getByLabel("Card number").fill("4242 4242 4242 4242");
|
|
237
|
+
await payment.getByRole("button", {name: /pay/i}).click();
|
|
238
|
+
await page.getByText(/payment complete/i).waitFor({state: "visible"});
|
|
239
|
+
return {paid: true};
|
|
240
|
+
```
|
|
241
|
+
|
|
242
|
+
## Download and upload files
|
|
243
|
+
|
|
244
|
+
Install the download waiter before clicking and save into the task artifact
|
|
245
|
+
directory:
|
|
246
|
+
|
|
247
|
+
```js
|
|
248
|
+
const [download] = await Promise.all([
|
|
249
|
+
page.waitForEvent("download", {timeout: 15000}),
|
|
250
|
+
page.getByRole("button", {name: /export/i}).click(),
|
|
251
|
+
]);
|
|
252
|
+
const output = artifactPath("export.csv");
|
|
253
|
+
await download.saveAs(output);
|
|
254
|
+
return {
|
|
255
|
+
artifact: output,
|
|
256
|
+
suggestedFilename: download.suggestedFilename(),
|
|
257
|
+
failure: await download.failure(),
|
|
258
|
+
};
|
|
259
|
+
```
|
|
260
|
+
|
|
261
|
+
Chromium's built-in PDF viewer may open a page without emitting a download.
|
|
262
|
+
Fetch the rendered link through the BrowserContext request client, validate the
|
|
263
|
+
signature, and save it as an artifact:
|
|
264
|
+
|
|
265
|
+
```js
|
|
266
|
+
const link = page.getByRole("link", {name: /view pdf/i});
|
|
267
|
+
const pdfUrl = new URL(await link.getAttribute("href"), page.url()).href;
|
|
268
|
+
const response = await context.request.get(pdfUrl);
|
|
269
|
+
assert(response.ok(), `PDF request failed: ${response.status()}`);
|
|
270
|
+
const body = await response.body();
|
|
271
|
+
assert.equal(body.subarray(0, 5).toString(), "%PDF-");
|
|
272
|
+
const output = artifactPath("paper.pdf");
|
|
273
|
+
await (await import("node:fs/promises")).writeFile(output, body);
|
|
274
|
+
return {artifact: output, bytes: body.length, url: pdfUrl};
|
|
275
|
+
```
|
|
276
|
+
|
|
277
|
+
For a normal file input, use `setInputFiles()`:
|
|
278
|
+
|
|
279
|
+
```js
|
|
280
|
+
await page.getByLabel("Upload document").setInputFiles("/absolute/input.pdf");
|
|
281
|
+
await page.getByText(/upload complete/i).waitFor({state: "visible"});
|
|
282
|
+
return {uploaded: true};
|
|
283
|
+
```
|
|
284
|
+
|
|
285
|
+
For a file chooser opened by a button:
|
|
286
|
+
|
|
287
|
+
```js
|
|
288
|
+
const [chooser] = await Promise.all([
|
|
289
|
+
page.waitForEvent("filechooser"),
|
|
290
|
+
page.getByRole("button", {name: /choose file/i}).click(),
|
|
291
|
+
]);
|
|
292
|
+
await chooser.setFiles("/absolute/input.pdf");
|
|
293
|
+
await page.getByText(/upload complete/i).waitFor({state: "visible"});
|
|
294
|
+
return {uploaded: true};
|
|
295
|
+
```
|
|
296
|
+
|
|
297
|
+
## Repeat actions safely
|
|
298
|
+
|
|
299
|
+
Re-resolve locators each iteration because frameworks may replace DOM nodes.
|
|
300
|
+
Verify progress and bound every loop.
|
|
301
|
+
|
|
302
|
+
```js
|
|
303
|
+
const clicked = [];
|
|
304
|
+
for (let index = 0; index < 5; index += 1) {
|
|
305
|
+
const items = page.getByRole("list", {name: /news/i}).getByRole("link");
|
|
306
|
+
assert.ok(await items.count() > index, `missing news item ${index + 1}`);
|
|
307
|
+
const item = items.nth(index);
|
|
308
|
+
const title = (await item.innerText()).trim();
|
|
309
|
+
|
|
310
|
+
const original = page;
|
|
311
|
+
const [popup] = await Promise.all([
|
|
312
|
+
context.waitForEvent("page", {timeout: 10000}),
|
|
313
|
+
item.click(),
|
|
314
|
+
]);
|
|
315
|
+
await popup.waitForLoadState("domcontentloaded");
|
|
316
|
+
clicked.push({title, landingTitle: await popup.title(), url: popup.url()});
|
|
317
|
+
await popup.close();
|
|
318
|
+
usePage(original);
|
|
319
|
+
}
|
|
320
|
+
return {count: clicked.length, clicked};
|
|
321
|
+
```
|
|
322
|
+
|
|
323
|
+
If the page can reorder after each click, locate by captured title rather than
|
|
324
|
+
by index. Never use an unbounded loop waiting for a page condition.
|
|
325
|
+
|
|
326
|
+
## Capture evidence
|
|
327
|
+
|
|
328
|
+
Use a plain filename with `artifactPath()`:
|
|
329
|
+
|
|
330
|
+
```js
|
|
331
|
+
const output = artifactPath("final-state.png");
|
|
332
|
+
const screenshot = await page.screenshot({path: output, fullPage: true});
|
|
333
|
+
return {screenshot, title: await page.title(), url: page.url()};
|
|
334
|
+
```
|
|
335
|
+
|
|
336
|
+
Take screenshots as evidence or when visual state matters, not as the default
|
|
337
|
+
way to discover ordinary DOM controls.
|
|
338
|
+
|
|
339
|
+
## Correct common API mistakes
|
|
340
|
+
|
|
341
|
+
| Incorrect | Correct |
|
|
342
|
+
| --- | --- |
|
|
343
|
+
| `browser.pages()` | `context.pages()` or `pages()` |
|
|
344
|
+
| `document.querySelector(...)` in the Node body | `page.evaluate(() => document.querySelector(...))` |
|
|
345
|
+
| `expect(locator).toHaveValue(...)` | `await expect(locator).toHaveValue(...)` with the official Playwright Test `expect` |
|
|
346
|
+
| `page.waitForTimeout(3000)` after every action | wait for a locator, URL, event, response, or load state |
|
|
347
|
+
| click, then `waitForEvent("page")` | install the event waiter before the click with `Promise.all` |
|
|
348
|
+
| return a Locator/Page/JSHandle | return a small JSON-safe object |
|
|
349
|
+
| `page.goto(href)` when asked to click | call `locator.click()` and verify its result |
|
|
350
|
+
| create a new browser for the next step | reuse the same task and persistent `page`/`context` |
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
# Runtime receipts and recovery
|
|
2
|
+
|
|
3
|
+
Read this reference when an evaluation is queued, running, interrupted, timed
|
|
4
|
+
out, quarantined, or returns a resource handle.
|
|
5
|
+
|
|
6
|
+
## Receipt states
|
|
7
|
+
|
|
8
|
+
Every evaluation is serialized and identified by `requestId`.
|
|
9
|
+
|
|
10
|
+
- `succeeded`: use `result.value` or its resource handle.
|
|
11
|
+
- `failed`: inspect the error and correct the code only when no uncertain
|
|
12
|
+
mutation remains.
|
|
13
|
+
- `queued` or `running`: the operation is still live. Run `tabbit-cli
|
|
14
|
+
receipt --task '<name>' --request '<request-id>'`; do not submit the operation
|
|
15
|
+
again.
|
|
16
|
+
- `interrupted` with `mutationState: "possible"`: the action may already have
|
|
17
|
+
happened. Do not retry it under any request ID.
|
|
18
|
+
|
|
19
|
+
The CLI wait window controls how long the call waits for a receipt. Its expiry
|
|
20
|
+
is not an operation failure.
|
|
21
|
+
|
|
22
|
+
## Interrupted mutation procedure
|
|
23
|
+
|
|
24
|
+
1. Read the named task's receipt with the same request ID.
|
|
25
|
+
2. If still queued or running, continue polling the same receipt.
|
|
26
|
+
3. Run `tabbit-cli checkpoint --task '<name>'` after it settles. Check
|
|
27
|
+
`url`, `pageCount`,
|
|
28
|
+
`targetEpoch`, `documentGeneration`, and `mainFrameAttached`.
|
|
29
|
+
4. Inspect the application state with a new read-only evaluation.
|
|
30
|
+
5. Continue from observed state. Retry only when evidence proves the original
|
|
31
|
+
mutation did not occur.
|
|
32
|
+
|
|
33
|
+
Never clear uncertainty by switching browser automation backends. Within the
|
|
34
|
+
same Runtime Service generation, do not create a new task merely to evade a
|
|
35
|
+
quarantined or interrupted task.
|
|
36
|
+
|
|
37
|
+
## Runtime generation loss
|
|
38
|
+
|
|
39
|
+
`SERVICE_LOST`, `GENERATION_MISMATCH`, `BROWSER_RUNTIME_UNAVAILABLE`, or an
|
|
40
|
+
unknown task after a service reconnect means the Browser Runtime Service may
|
|
41
|
+
have restarted. A new generation never restores the old evaluator, pages, or
|
|
42
|
+
executable task state. Persisted receipts and resources are diagnostic records;
|
|
43
|
+
they do not make the old task executable again.
|
|
44
|
+
|
|
45
|
+
For an interrupted mutation:
|
|
46
|
+
|
|
47
|
+
1. Preserve any receipt already returned by the old generation.
|
|
48
|
+
2. Do not resubmit the mutation under either the old or a new request ID.
|
|
49
|
+
3. After the new generation is ready, create a task only for read-only
|
|
50
|
+
inspection of externally visible application state.
|
|
51
|
+
4. Retry the mutation only when that inspection proves it did not occur.
|
|
52
|
+
|
|
53
|
+
Do not start, stop, or restart the Runtime Service. Browser owns its process and
|
|
54
|
+
restart policy.
|
|
55
|
+
|
|
56
|
+
## Idempotent request IDs
|
|
57
|
+
|
|
58
|
+
Choose IDs that state intent and order:
|
|
59
|
+
|
|
60
|
+
```text
|
|
61
|
+
open-dashboard-01
|
|
62
|
+
filter-breached-02
|
|
63
|
+
submit-escalation-03
|
|
64
|
+
verify-escalation-04
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
Calling `evaluate` again with `submit-escalation-03` retrieves the existing
|
|
68
|
+
operation; it does not run new code. Never assign that ID to changed code.
|
|
69
|
+
|
|
70
|
+
## Large resources
|
|
71
|
+
|
|
72
|
+
Prefer returning a small aggregate. When the result is a resource handle:
|
|
73
|
+
|
|
74
|
+
1. Run `tabbit-cli resource --task '<name>' --resource '<id>' --offset 0`.
|
|
75
|
+
2. Append the returned slice.
|
|
76
|
+
3. Continue with exactly the returned `nextOffset`.
|
|
77
|
+
4. Stop when `eof` is true.
|
|
78
|
+
|
|
79
|
+
Do not request a slice length; the server fixes each slice at at most 8192
|
|
80
|
+
bytes. Avoid echoing the full resource into the final response when a compact
|
|
81
|
+
answer is sufficient.
|
|
82
|
+
|
|
83
|
+
## Cleanup
|
|
84
|
+
|
|
85
|
+
Run `tabbit-cli finish --task '<name>'` exactly once after verification
|
|
86
|
+
or when abandoning a failed task. A successful result is:
|
|
87
|
+
|
|
88
|
+
```json
|
|
89
|
+
{"taskId":"task-...","finished":true,"keep":false}
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
If `finish` fails because the Runtime Service generation disappeared, Browser
|
|
93
|
+
has already revoked that generation and closes its Browser sessions. Report
|
|
94
|
+
that task-level cleanup could not be confirmed; do not start a controller or a
|
|
95
|
+
fallback browser.
|
package/update-check.js
ADDED
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
import { mkdir, readFile, writeFile } from 'node:fs/promises'
|
|
2
|
+
import { homedir } from 'node:os'
|
|
3
|
+
import { dirname, join } from 'node:path'
|
|
4
|
+
|
|
5
|
+
const DAY_MS = 24 * 60 * 60 * 1000
|
|
6
|
+
const FETCH_TIMEOUT_MS = 1500
|
|
7
|
+
const CHANGELOG_MAX_CHARS = 500
|
|
8
|
+
// The raw CDN carries no GitHub API rate limit, which unauthenticated checks
|
|
9
|
+
// from shared egress IPs would otherwise exhaust within the hour.
|
|
10
|
+
const DEFAULT_CHANGELOG_URL = 'https://raw.githubusercontent.com/Tabbit-Browser/dsh-plugin/main/CHANGELOG.md'
|
|
11
|
+
const PACKAGE_URL = new URL('./package.json', import.meta.url)
|
|
12
|
+
|
|
13
|
+
let cachedLocalVersion
|
|
14
|
+
|
|
15
|
+
function numericVersion(version) {
|
|
16
|
+
const match = String(version ?? '').trim().match(/^v?(\d+(?:\.\d+)*)/i)
|
|
17
|
+
return match ? match[1].split('.').map(Number) : undefined
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function compareVersions(left, right) {
|
|
21
|
+
const leftParts = numericVersion(left)
|
|
22
|
+
const rightParts = numericVersion(right)
|
|
23
|
+
if (!leftParts || !rightParts) return undefined
|
|
24
|
+
const length = Math.max(leftParts.length, rightParts.length)
|
|
25
|
+
for (let index = 0; index < length; index += 1) {
|
|
26
|
+
const a = leftParts[index] ?? 0
|
|
27
|
+
const b = rightParts[index] ?? 0
|
|
28
|
+
if (a !== b) return a > b ? 1 : -1
|
|
29
|
+
}
|
|
30
|
+
return 0
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function flattenChangelog(text) {
|
|
34
|
+
return String(text ?? '').replace(/\s+/g, ' ').trim()
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function truncateChangelog(text) {
|
|
38
|
+
const value = flattenChangelog(text)
|
|
39
|
+
if (value.length <= CHANGELOG_MAX_CHARS) return value
|
|
40
|
+
return `${value.slice(0, CHANGELOG_MAX_CHARS - 1).trimEnd()}…`
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function parseLatestChangelog(markdown) {
|
|
44
|
+
const match = String(markdown ?? '').match(/^## +(v?\d+(?:\.\d+)+).*$/m)
|
|
45
|
+
if (!match) throw new Error('Latest changelog has no version heading.')
|
|
46
|
+
const version = numericVersion(match[1])?.join('.')
|
|
47
|
+
if (!version) throw new Error('Latest changelog heading has no usable version.')
|
|
48
|
+
const sectionStart = match.index + match[0].length
|
|
49
|
+
const nextSection = String(markdown).slice(sectionStart).search(/^## +/m)
|
|
50
|
+
const section = nextSection === -1
|
|
51
|
+
? String(markdown).slice(sectionStart)
|
|
52
|
+
: String(markdown).slice(sectionStart, sectionStart + nextSection)
|
|
53
|
+
return { version, changelog: truncateChangelog(section) }
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function defaultCacheFile(env = process.env, platform = process.platform) {
|
|
57
|
+
const base = env.XDG_CACHE_HOME
|
|
58
|
+
|| (platform === 'win32'
|
|
59
|
+
? env.LOCALAPPDATA || join(homedir(), 'AppData', 'Local')
|
|
60
|
+
: undefined)
|
|
61
|
+
|| join(homedir(), '.cache')
|
|
62
|
+
return join(base, 'tabbit-dsh', 'update-check.json')
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export async function readLocalVersion() {
|
|
66
|
+
if (cachedLocalVersion !== undefined) return cachedLocalVersion ?? undefined
|
|
67
|
+
try {
|
|
68
|
+
const parsed = JSON.parse(await readFile(PACKAGE_URL, 'utf8'))
|
|
69
|
+
cachedLocalVersion = typeof parsed.version === 'string' ? parsed.version : null
|
|
70
|
+
} catch {
|
|
71
|
+
cachedLocalVersion = null
|
|
72
|
+
}
|
|
73
|
+
return cachedLocalVersion ?? undefined
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export async function readCachedCheck(cacheFile) {
|
|
77
|
+
try {
|
|
78
|
+
const parsed = JSON.parse(await readFile(cacheFile, 'utf8'))
|
|
79
|
+
return parsed && typeof parsed === 'object' ? parsed : {}
|
|
80
|
+
} catch {
|
|
81
|
+
return {}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
async function writeCachedCheck(cacheFile, state) {
|
|
86
|
+
await mkdir(dirname(cacheFile), { recursive: true })
|
|
87
|
+
await writeFile(cacheFile, `${JSON.stringify(state, null, 2)}\n`, 'utf8')
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export async function fetchLatestChangelog({
|
|
91
|
+
url = process.env.TABBIT_PLUGIN_UPDATE_URL || DEFAULT_CHANGELOG_URL,
|
|
92
|
+
timeoutMs = FETCH_TIMEOUT_MS,
|
|
93
|
+
fetchImpl = fetch,
|
|
94
|
+
} = {}) {
|
|
95
|
+
const controller = new AbortController()
|
|
96
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs)
|
|
97
|
+
try {
|
|
98
|
+
const response = await fetchImpl(url, { signal: controller.signal })
|
|
99
|
+
if (!response.ok) throw new Error(`Update check failed with HTTP ${response.status}.`)
|
|
100
|
+
return parseLatestChangelog(await response.text())
|
|
101
|
+
} finally {
|
|
102
|
+
clearTimeout(timer)
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function isRecent(timestamp, now) {
|
|
107
|
+
return typeof timestamp === 'number' && now - timestamp < DAY_MS
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export function summarizeUpdate({
|
|
111
|
+
currentVersion,
|
|
112
|
+
latestVersion,
|
|
113
|
+
changelog,
|
|
114
|
+
dismissedVersion,
|
|
115
|
+
}) {
|
|
116
|
+
if (!currentVersion || !latestVersion) return { status: 'unknown', currentVersion }
|
|
117
|
+
if (compareVersions(latestVersion, currentVersion) !== 1 || latestVersion === dismissedVersion) {
|
|
118
|
+
return { status: 'current', currentVersion, latestVersion }
|
|
119
|
+
}
|
|
120
|
+
return { status: 'update-available', currentVersion, latestVersion, changelog }
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function summaryFromCache(currentVersion, cached) {
|
|
124
|
+
return summarizeUpdate({
|
|
125
|
+
currentVersion,
|
|
126
|
+
latestVersion: cached.latestVersion,
|
|
127
|
+
changelog: cached.changelog,
|
|
128
|
+
dismissedVersion: cached.dismissedVersion,
|
|
129
|
+
})
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
async function fetchAndCacheRelease({ currentVersion, cached, cacheFile, now, fetchRelease }) {
|
|
133
|
+
const state = { ...cached, lastAttemptAt: now }
|
|
134
|
+
try {
|
|
135
|
+
const release = await fetchRelease()
|
|
136
|
+
state.checkedAt = now
|
|
137
|
+
state.latestVersion = release.version
|
|
138
|
+
state.changelog = release.changelog
|
|
139
|
+
} catch {
|
|
140
|
+
// Keep lastAttemptAt so the failure stays silent for a day before retrying.
|
|
141
|
+
}
|
|
142
|
+
try {
|
|
143
|
+
await writeCachedCheck(cacheFile, state)
|
|
144
|
+
} catch {
|
|
145
|
+
// A read-only cache must not break the check itself.
|
|
146
|
+
}
|
|
147
|
+
return summaryFromCache(currentVersion, state)
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export async function checkPluginUpdate({
|
|
151
|
+
now = Date.now(),
|
|
152
|
+
cacheFile = defaultCacheFile(),
|
|
153
|
+
fetchRelease = fetchLatestChangelog,
|
|
154
|
+
readVersion = readLocalVersion,
|
|
155
|
+
force = false,
|
|
156
|
+
} = {}) {
|
|
157
|
+
const currentVersion = await readVersion()
|
|
158
|
+
const cached = await readCachedCheck(cacheFile)
|
|
159
|
+
if (!force) {
|
|
160
|
+
if (isRecent(cached.checkedAt, now) && cached.latestVersion) {
|
|
161
|
+
return summaryFromCache(currentVersion, cached)
|
|
162
|
+
}
|
|
163
|
+
if (isRecent(cached.lastAttemptAt, now)) {
|
|
164
|
+
return { status: 'unknown', currentVersion }
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
return fetchAndCacheRelease({ currentVersion, cached, cacheFile, now, fetchRelease })
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
export async function dismissUpdate(version, {
|
|
171
|
+
cacheFile = defaultCacheFile(),
|
|
172
|
+
} = {}) {
|
|
173
|
+
const cached = await readCachedCheck(cacheFile)
|
|
174
|
+
const state = { ...cached, dismissedVersion: String(version ?? '').trim() }
|
|
175
|
+
await writeCachedCheck(cacheFile, state)
|
|
176
|
+
return state
|
|
177
|
+
}
|