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.
@@ -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.
@@ -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
+ }