@asrunjs/asrun 0.13.0 → 0.15.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/commands/explore-case.md +8 -3
- package/.claude/skills/exploring-cases/SKILL.md +72 -14
- package/.claude/skills/exploring-cases/references/handoff-card.js +81 -7
- package/.claude/skills/exploring-cases/references/notes.md +42 -4
- 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/.claude/skills/generating-specs/SKILL.md +10 -3
- package/AGENTS.md +12 -4
- package/README.md +14 -2
- package/dist/lib/desktop-notify.d.ts +38 -0
- package/dist/lib/desktop-notify.js +158 -0
- package/dist/lib/explore-notes.d.ts +15 -0
- package/dist/lib/explore-notes.js +34 -1
- package/dist/lib/migrations.d.ts +43 -4
- package/dist/lib/migrations.js +39 -7
- package/dist/lib/paths.d.ts +9 -2
- package/dist/lib/paths.js +32 -5
- package/dist/lib/pdf.js +23 -3
- package/dist/lib/scaffold.d.ts +87 -0
- package/dist/lib/scaffold.js +220 -0
- package/dist/lib/term.d.ts +36 -0
- package/dist/lib/term.js +77 -0
- package/dist/lib/update-check.d.ts +1 -7
- package/dist/lib/update-check.js +3 -15
- package/dist/lib/upgrade-prompt.d.ts +84 -0
- package/dist/lib/upgrade-prompt.js +233 -0
- package/dist/migrations/index.d.ts +12 -4
- package/dist/migrations/index.js +40 -5
- package/dist/migrations/manifest.json +27 -1
- package/dist/scripts/build.js +4 -1
- package/dist/scripts/check-cases.js +3 -3
- package/dist/scripts/cli.js +11 -3
- package/dist/scripts/doctor.js +73 -4
- package/dist/scripts/explore-notes.js +19 -2
- package/dist/scripts/init.d.ts +6 -26
- package/dist/scripts/init.js +82 -101
- package/dist/scripts/migrate.js +84 -21
- package/dist/scripts/migrations-check.d.ts +1 -0
- package/dist/scripts/migrations-check.js +272 -0
- package/dist/scripts/migrations-freeze.d.ts +1 -0
- package/dist/scripts/migrations-freeze.js +126 -0
- package/dist/scripts/notify-test.d.ts +1 -0
- package/dist/scripts/notify-test.js +48 -0
- package/dist/scripts/upgrade.js +53 -10
- 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/app.js +4 -0
- 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 +8 -0
- package/docs/agents/case-format.md +8 -1
- package/docs/agents/framework-dev.md +44 -5
- package/docs/ai-workflow.md +27 -10
- package/docs/cases.md +5 -5
- package/docs/commands.md +73 -15
- package/docs/install.md +18 -5
- package/docs/troubleshooting.md +17 -0
- package/docs/web-ui.md +23 -8
- package/package.json +5 -2
- package/templates/.claude/commands/explore-case.md +9 -3
- package/templates/.claude/commands/upgrade-workspace.md +21 -0
- package/templates/AGENTS.md +1 -1
- package/templates/package.json +1 -1
- package/ui/dist/assets/{dist-C46EmZuI.js → dist-B4pM62Hc.js} +1 -1
- package/ui/dist/assets/{dist-DGzg2gyA.js → dist-BFADGd4p.js} +1 -1
- package/ui/dist/assets/{dist-P9roUi1O.js → dist-Bd-TmWTD.js} +1 -1
- package/ui/dist/assets/{dist-DMiOs9Qj.js → dist-Bt053MSo.js} +1 -1
- package/ui/dist/assets/{dist-DrFzCwMc.js → dist-CD9RixNE.js} +1 -1
- package/ui/dist/assets/{dist-_0g6iC9K.js → dist-ChACtLiA.js} +1 -1
- package/ui/dist/assets/{dist-D9GmSkjm.js → dist-CiW9sEPF.js} +1 -1
- package/ui/dist/assets/{dist-Db_XhDui.js → dist-CxEpzoZU.js} +1 -1
- package/ui/dist/assets/{dist-BB2cF3XN.js → dist-Dh7um7By.js} +1 -1
- package/ui/dist/assets/{dist-Dy45nXf8.js → dist-gHwKkZ6g.js} +1 -1
- package/ui/dist/assets/{index-BN4ucmmn.js → index-Ckax27uf.js} +158 -158
- package/ui/dist/index.html +1 -1
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
|
|
@@ -198,11 +199,13 @@ The framework folder is read-only in a project: never edit it. Its own layout an
|
|
|
198
199
|
Three steps, each learns one thing: the intent, the facts and inputs, the implementation.
|
|
199
200
|
1. `making-cases` (or `/make-case <notes>`) turns raw notes into case documents. It writes `cases/*.md` only and
|
|
200
201
|
never opens a browser. Check them with `pnpm case:check`.
|
|
201
|
-
2. `exploring-cases` (or `/explore-case <case-id> <env> [--attended] [--profiles a,b]`) explores the real pages for one
|
|
202
|
+
2. `exploring-cases` (or `/explore-case <case-id> <env> [--attended] [--profiles a,b] [--lead <profile-id>]`) explores the real pages for one
|
|
202
203
|
case. It edits that case file: wording, observations, TODO blocks, and, with `--attended`, the `- Recipe:` lines
|
|
203
204
|
of the steps that write or need a choice, and it writes a local note under `.explore/notes/` with the locators and
|
|
204
205
|
data-call paths it saw. `--profiles` narrows the run to those profiles; runs add up, so the case can be explored in
|
|
205
|
-
passes.
|
|
206
|
+
passes. `--lead` explores one profile in full and the rest only where they can differ (values, targets, the steps
|
|
207
|
+
that reach them), so a case with many profiles is not paid for once per profile; a profile whose page or flow
|
|
208
|
+
differs is explored in full instead, and the report says so.
|
|
206
209
|
3. `generating-specs` (or `/gen-spec <case-id> <env>`) turns the case into a spec. It replays the case, its
|
|
207
210
|
step recipes and the exploration note, and `## Values`, writes the spec (values stay in the case document), but never
|
|
208
211
|
runs the tests, never writes data and never creates baselines. There is no profile selection here: it covers every
|
|
@@ -215,6 +218,11 @@ before installing), `pnpm asrun init [--update] [--force]` writes or refreshes t
|
|
|
215
218
|
migrate` applies only the migrations (for a manual install), and `pnpm asrun reset` removes `node_modules` and the
|
|
216
219
|
files init wrote (edited files, cases, specs, `profiles.yaml` and `package.json` stay), so
|
|
217
220
|
`pnpm dlx @asrunjs/asrun init --install` can scaffold again.
|
|
221
|
+
A release can change a file the project owns (its `AGENTS.md`, `.mcp.json`, `cases/_template.md`) or ask for a manual
|
|
222
|
+
migration step; `pnpm asrun upgrade` ends with a `NEXT` block that names each one with its exact command.
|
|
223
|
+
`/upgrade-workspace` (or `pnpm asrun upgrade --prompt`, whose output it follows) does that work: it merges only the
|
|
224
|
+
scaffold text outside `## 2`/`## 3`, fixes case structure without changing what a case expects, runs the checks, and
|
|
225
|
+
leaves `pnpm asrun migrate --ack <id>` and the `init --ack <file>` confirmations to the person.
|
|
218
226
|
Do not hand-write specs unless asked. Do not edit `lib/` to make a single spec pass.
|
|
219
227
|
Every case step is wrapped in `test.step('<n>. <case step text>', ...)` so reports and traces show the
|
|
220
228
|
case's own steps (see the skill's `references/conventions.md`).
|
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.
|
|
@@ -58,6 +58,17 @@ generated files, applies the migrations and runs `doctor`. `--dry-run` shows the
|
|
|
58
58
|
that predates `upgrade` updates once by hand: `pnpm add -D @asrunjs/asrun@latest`, `pnpm asrun init --update`,
|
|
59
59
|
`pnpm asrun migrate`, `pnpm asrun doctor`.
|
|
60
60
|
|
|
61
|
+
When the release changes a file the project owns (`AGENTS.md`, `.mcp.json`, a case template) or records manual
|
|
62
|
+
migration steps, the upgrade ends with a `NEXT` block that names each one with its exact command. Hand that block to
|
|
63
|
+
an AI:
|
|
64
|
+
|
|
65
|
+
```bash
|
|
66
|
+
pnpm asrun upgrade --prompt # the task as Markdown; changes nothing
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
or run `/upgrade-workspace` in Claude Code / OpenCode, which does the same. The prompt is the same for every release —
|
|
70
|
+
it reads which files moved and which steps are open from the workspace and the installed package.
|
|
71
|
+
|
|
61
72
|
`init --update` alone refreshes only the generated files; it never overwrites `AGENTS.md`, `CLAUDE.md` or your skill
|
|
62
73
|
overrides. `doctor` checks profiles, generated files, the installed version, the migrations and the project rules.
|
|
63
74
|
`init --force` rewrites every file init writes (rules still need `--reset-rules`; `profiles.yaml` and `package.json`
|
|
@@ -102,7 +113,8 @@ exempts `@asrunjs/asrun` from pnpm's 24-hour release age, so a fresh release ins
|
|
|
102
113
|
writes what they show into the case and turns what it cannot know into questions. It also saves a local note of the
|
|
103
114
|
locators and data calls it saw. Answer the questions in the case file, or in the UI's **Answer** sheet, and run it
|
|
104
115
|
again. With many profiles, `--profiles a,b` opens only those; a later run with the others adds theirs, and the case
|
|
105
|
-
keeps both.
|
|
116
|
+
keeps both. `--lead <profile-id>` pays for the flow once: that profile is explored in full, the others only where
|
|
117
|
+
they can differ (their values, their target screenshots, the steps that reach them).
|
|
106
118
|
5. **Generate, run, read.** `/gen-spec header-logo staging` reads those notes, writes `tests/header-logo.spec.ts` and
|
|
107
119
|
fills the case's `## Values`. Then:
|
|
108
120
|
|
|
@@ -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
|
+
}
|
|
@@ -8,6 +8,9 @@
|
|
|
8
8
|
* A note holds only what the case does not: locators the agent confirmed on the page, the data-call paths of the
|
|
9
9
|
* actions that load something, and the match count of a target card's element. Expected values stay in the case's
|
|
10
10
|
* `## Values`, ready signals in the case's `Ready when:` lines, and assertions nowhere near this file.
|
|
11
|
+
*
|
|
12
|
+
* A `full` note (the default) covers every step. A `diff` note belongs to a profile that an `--lead` run explored only
|
|
13
|
+
* where it can differ, and names the lead profile in `basedOn` for the steps it did not open.
|
|
11
14
|
*/
|
|
12
15
|
export declare const NOTES_VERSION = 1;
|
|
13
16
|
export interface ExploreLocator {
|
|
@@ -38,6 +41,13 @@ export interface ExploreNote {
|
|
|
38
41
|
capturedAt: string;
|
|
39
42
|
/** Set when a later `/gen-spec` refreshed an entry it confirmed on the page. */
|
|
40
43
|
updatedAt?: string;
|
|
44
|
+
/**
|
|
45
|
+
* `full` (the default when absent) explored every step of the case; `diff` explored only the steps that can differ
|
|
46
|
+
* on this profile, and leans on `basedOn` for the rest. See `references/notes.md`, "Coverage".
|
|
47
|
+
*/
|
|
48
|
+
depth?: 'full' | 'diff';
|
|
49
|
+
/** The lead profile a `diff` note leans on for the steps it did not open. */
|
|
50
|
+
basedOn?: string;
|
|
41
51
|
steps: Record<string, ExploreNoteStep>;
|
|
42
52
|
targets?: Record<string, ExploreNoteTarget>;
|
|
43
53
|
}
|
|
@@ -64,6 +74,11 @@ export declare function validateExploreNote(value: unknown, expected: {
|
|
|
64
74
|
caseId: string;
|
|
65
75
|
profile: string;
|
|
66
76
|
}): string[];
|
|
77
|
+
/** `full` when the note explored every step, `diff` when it covered only the steps that can differ per profile. */
|
|
78
|
+
export declare function noteCoverage(note: ExploreNote): {
|
|
79
|
+
depth: 'full' | 'diff';
|
|
80
|
+
basedOn?: string;
|
|
81
|
+
};
|
|
67
82
|
/**
|
|
68
83
|
* Reads every note of a case (or one profile's), parses and validates it. A malformed file is reported, never thrown:
|
|
69
84
|
* the caller decides whether it matters, and generation treats an unreadable note as a missing one.
|
|
@@ -13,6 +13,9 @@ import { CASES_DIR, EXPLORE_DIR } from './paths.js';
|
|
|
13
13
|
* A note holds only what the case does not: locators the agent confirmed on the page, the data-call paths of the
|
|
14
14
|
* actions that load something, and the match count of a target card's element. Expected values stay in the case's
|
|
15
15
|
* `## Values`, ready signals in the case's `Ready when:` lines, and assertions nowhere near this file.
|
|
16
|
+
*
|
|
17
|
+
* A `full` note (the default) covers every step. A `diff` note belongs to a profile that an `--lead` run explored only
|
|
18
|
+
* where it can differ, and names the lead profile in `basedOn` for the steps it did not open.
|
|
16
19
|
*/
|
|
17
20
|
export const NOTES_VERSION = 1;
|
|
18
21
|
const HASH = /^[a-f0-9]{64}$/;
|
|
@@ -194,15 +197,45 @@ export function validateExploreNote(value, expected) {
|
|
|
194
197
|
if (value.env !== undefined && (typeof value.env !== 'string' || value.env.trim() === '')) {
|
|
195
198
|
problems.push('env must be a non-empty string when it is present');
|
|
196
199
|
}
|
|
200
|
+
if (value.depth !== undefined && value.depth !== 'full' && value.depth !== 'diff') {
|
|
201
|
+
problems.push('depth must be "full" or "diff" when it is present');
|
|
202
|
+
}
|
|
203
|
+
if (value.basedOn !== undefined) {
|
|
204
|
+
if (typeof value.basedOn !== 'string' || !PROFILE_FILE.test(value.basedOn)) {
|
|
205
|
+
problems.push('basedOn must be a profile id (letters, digits, ".", "_" and "-")');
|
|
206
|
+
}
|
|
207
|
+
else if (value.depth !== 'diff') {
|
|
208
|
+
problems.push('basedOn is only allowed on a note whose depth is "diff"');
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
if (value.depth === 'diff' && value.basedOn === undefined) {
|
|
212
|
+
problems.push('a diff note must name the lead profile in basedOn');
|
|
213
|
+
}
|
|
197
214
|
checkSteps(value.steps, problems);
|
|
198
215
|
if (value.targets !== undefined)
|
|
199
216
|
checkTargets(value.targets, problems);
|
|
200
|
-
const known = new Set([
|
|
217
|
+
const known = new Set([
|
|
218
|
+
'version',
|
|
219
|
+
'caseId',
|
|
220
|
+
'profile',
|
|
221
|
+
'env',
|
|
222
|
+
'caseSha256',
|
|
223
|
+
'capturedAt',
|
|
224
|
+
'updatedAt',
|
|
225
|
+
'depth',
|
|
226
|
+
'basedOn',
|
|
227
|
+
'steps',
|
|
228
|
+
'targets',
|
|
229
|
+
]);
|
|
201
230
|
for (const key of Object.keys(value))
|
|
202
231
|
if (!known.has(key))
|
|
203
232
|
problems.push(`${key} is not a known field`);
|
|
204
233
|
return problems;
|
|
205
234
|
}
|
|
235
|
+
/** `full` when the note explored every step, `diff` when it covered only the steps that can differ per profile. */
|
|
236
|
+
export function noteCoverage(note) {
|
|
237
|
+
return note.depth === 'diff' ? { depth: 'diff', basedOn: note.basedOn } : { depth: 'full' };
|
|
238
|
+
}
|
|
206
239
|
/**
|
|
207
240
|
* Reads every note of a case (or one profile's), parses and validates it. A malformed file is reported, never thrown:
|
|
208
241
|
* the caller decides whether it matters, and generation treats an unreadable note as a missing one.
|
package/dist/lib/migrations.d.ts
CHANGED
|
@@ -23,9 +23,9 @@ export interface MigrationContext {
|
|
|
23
23
|
* on any older release may still need it.
|
|
24
24
|
*/
|
|
25
25
|
export interface Migration {
|
|
26
|
-
/** stable and unique: `<version>-<slug>`,
|
|
26
|
+
/** stable and unique: `<version>-<slug>`, or `unreleased-<slug>` until the release names its version */
|
|
27
27
|
id: string;
|
|
28
|
-
/** the release that introduced the change;
|
|
28
|
+
/** the release that introduced the change; `unreleased` while it is still being prepared */
|
|
29
29
|
version: string;
|
|
30
30
|
/** one line for the person, in the release plan before the install and in the migration output after it */
|
|
31
31
|
summary: string;
|
|
@@ -37,6 +37,17 @@ export interface Migration {
|
|
|
37
37
|
details?: string;
|
|
38
38
|
/** steps only a person can do; they are printed and remembered until they are acknowledged */
|
|
39
39
|
manual?: string[];
|
|
40
|
+
/**
|
|
41
|
+
* The project files the change touches. A once-only scaffold file here (`AGENTS.md`, `.mcp.json`, ...) also advances
|
|
42
|
+
* that file's baseline when the migration is acknowledged, so one `migrate --ack` clears both reminders.
|
|
43
|
+
*/
|
|
44
|
+
files?: string[];
|
|
45
|
+
/**
|
|
46
|
+
* `true` for a release that touched project files but asks nothing of an existing workspace: documentation, a
|
|
47
|
+
* clarified contract, a new optional flag. The entry exists so `pnpm migrations:check` can tell "reviewed, nothing
|
|
48
|
+
* to do" from "forgotten"; the command reports it and moves on.
|
|
49
|
+
*/
|
|
50
|
+
advisory?: boolean;
|
|
40
51
|
/** true when the migration runs without asking after the install; the command still prints what it did */
|
|
41
52
|
auto?: boolean;
|
|
42
53
|
/** true when the workspace already looks migrated; a false answer skips `apply` */
|
|
@@ -46,6 +57,21 @@ export interface Migration {
|
|
|
46
57
|
}
|
|
47
58
|
/** A migration as it travels in `dist/migrations/manifest.json`: the same entry without its code. */
|
|
48
59
|
export type MigrationManifestEntry = Omit<Migration, 'detect' | 'apply'>;
|
|
60
|
+
/**
|
|
61
|
+
* `version` written by an entry that is still being prepared: the release it ships with names the version later, and
|
|
62
|
+
* release-please only knows it once the release pull request exists. Resolved to the installed release's own version,
|
|
63
|
+
* which is by definition the release that carries the entry's code.
|
|
64
|
+
*/
|
|
65
|
+
export declare const UNRELEASED = "unreleased";
|
|
66
|
+
/** True for an entry whose version is still `unreleased`. */
|
|
67
|
+
export declare function isUnreleased(version: string): boolean;
|
|
68
|
+
/**
|
|
69
|
+
* The registry with `unreleased` resolved to the installed release. Every consumer compares versions, so they must all
|
|
70
|
+
* see the resolved entries: a leftover sentinel would sort as a plain string and silently pick the wrong migrations.
|
|
71
|
+
*/
|
|
72
|
+
export declare function resolveMigrations<T extends {
|
|
73
|
+
version: string;
|
|
74
|
+
}>(entries: readonly T[], installedVersion: string): T[];
|
|
49
75
|
/**
|
|
50
76
|
* The migration state of a workspace, recorded in `.asrun.json`. `since` is the release the workspace was last
|
|
51
77
|
* migrated from, so only newer entries apply. It survives `init --update`, which is what keeps a manual
|
|
@@ -63,6 +89,10 @@ export interface WorkspaceMarker {
|
|
|
63
89
|
since?: unknown;
|
|
64
90
|
manual?: unknown;
|
|
65
91
|
};
|
|
92
|
+
/** The template each once-only project file is known to be aligned with (`lib/scaffold.ts`). */
|
|
93
|
+
scaffold?: unknown;
|
|
94
|
+
/** The release the workspace came from, before the last `init` wrote the current one. */
|
|
95
|
+
previous?: unknown;
|
|
66
96
|
[key: string]: unknown;
|
|
67
97
|
}
|
|
68
98
|
/** Where `pnpm build` writes the manifest, relative to the package root. */
|
|
@@ -88,8 +118,12 @@ export interface VersionedMigration {
|
|
|
88
118
|
* `since` is the release the workspace already matches, `to` is the release being installed.
|
|
89
119
|
*/
|
|
90
120
|
export declare function pendingMigrations<T extends VersionedMigration>(entries: readonly T[], since: string, to: string): T[];
|
|
91
|
-
/**
|
|
92
|
-
|
|
121
|
+
/**
|
|
122
|
+
* The manifest of a registry: the same entries, without the code that cannot travel in JSON. Versions are resolved
|
|
123
|
+
* first, so a published manifest never carries the `unreleased` sentinel: an older `upgrade` reads it out of the target
|
|
124
|
+
* tarball before installing anything, and has to compare real versions.
|
|
125
|
+
*/
|
|
126
|
+
export declare function toManifest(entries: readonly Migration[], installedVersion: string): MigrationManifestEntry[];
|
|
93
127
|
/** The marker of a workspace, or null when the file is missing or not a JSON object. */
|
|
94
128
|
export declare function readMarker(root: string): WorkspaceMarker | null;
|
|
95
129
|
/**
|
|
@@ -98,6 +132,11 @@ export declare function readMarker(root: string): WorkspaceMarker | null;
|
|
|
98
132
|
* starts at the installed version, so no historical entry runs on it.
|
|
99
133
|
*/
|
|
100
134
|
export declare function migrationState(marker: WorkspaceMarker | null, installedVersion: string): MigrationState;
|
|
135
|
+
/**
|
|
136
|
+
* Reads the marker, lets the caller change it and writes it back, keeping every other field. Every module that records
|
|
137
|
+
* something in `.asrun.json` goes through this one function, so two of them can never overwrite each other's block.
|
|
138
|
+
*/
|
|
139
|
+
export declare function updateMarker(root: string, mutate: (marker: WorkspaceMarker) => void): void;
|
|
101
140
|
/**
|
|
102
141
|
* Writes the migration state back into the marker and keeps every other field (init writes the rest). The workspace
|
|
103
142
|
* must already have a marker: `migrate` says so instead of creating one, because a workspace that was never
|
package/dist/lib/migrations.js
CHANGED
|
@@ -1,6 +1,23 @@
|
|
|
1
1
|
import fs from 'node:fs';
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import { MARKER } from './paths.js';
|
|
4
|
+
/**
|
|
5
|
+
* `version` written by an entry that is still being prepared: the release it ships with names the version later, and
|
|
6
|
+
* release-please only knows it once the release pull request exists. Resolved to the installed release's own version,
|
|
7
|
+
* which is by definition the release that carries the entry's code.
|
|
8
|
+
*/
|
|
9
|
+
export const UNRELEASED = 'unreleased';
|
|
10
|
+
/** True for an entry whose version is still `unreleased`. */
|
|
11
|
+
export function isUnreleased(version) {
|
|
12
|
+
return version === UNRELEASED;
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* The registry with `unreleased` resolved to the installed release. Every consumer compares versions, so they must all
|
|
16
|
+
* see the resolved entries: a leftover sentinel would sort as a plain string and silently pick the wrong migrations.
|
|
17
|
+
*/
|
|
18
|
+
export function resolveMigrations(entries, installedVersion) {
|
|
19
|
+
return entries.map((entry) => (entry.version === UNRELEASED ? { ...entry, version: installedVersion } : entry));
|
|
20
|
+
}
|
|
4
21
|
/** Where `pnpm build` writes the manifest, relative to the package root. */
|
|
5
22
|
export const MANIFEST_FILE = 'dist/migrations/manifest.json';
|
|
6
23
|
/** The same file inside an npm tarball, which prefixes every path with `package/`. */
|
|
@@ -53,9 +70,13 @@ export function pendingMigrations(entries, since, to) {
|
|
|
53
70
|
.filter((entry) => compareVersions(entry.version, since) > 0 && compareVersions(entry.version, to) <= 0)
|
|
54
71
|
.sort((a, b) => compareVersions(a.version, b.version) || a.id.localeCompare(b.id));
|
|
55
72
|
}
|
|
56
|
-
/**
|
|
57
|
-
|
|
58
|
-
|
|
73
|
+
/**
|
|
74
|
+
* The manifest of a registry: the same entries, without the code that cannot travel in JSON. Versions are resolved
|
|
75
|
+
* first, so a published manifest never carries the `unreleased` sentinel: an older `upgrade` reads it out of the target
|
|
76
|
+
* tarball before installing anything, and has to compare real versions.
|
|
77
|
+
*/
|
|
78
|
+
export function toManifest(entries, installedVersion) {
|
|
79
|
+
return resolveMigrations(entries, installedVersion).map(({ id, version, summary, breaking, appliesTo, details, manual, files, advisory, auto }) => ({
|
|
59
80
|
id,
|
|
60
81
|
version,
|
|
61
82
|
summary,
|
|
@@ -63,6 +84,8 @@ export function toManifest(entries) {
|
|
|
63
84
|
...(appliesTo !== undefined && { appliesTo }),
|
|
64
85
|
...(details !== undefined && { details }),
|
|
65
86
|
...(manual !== undefined && { manual }),
|
|
87
|
+
...(files !== undefined && { files }),
|
|
88
|
+
...(advisory !== undefined && { advisory }),
|
|
66
89
|
...(auto !== undefined && { auto }),
|
|
67
90
|
}));
|
|
68
91
|
}
|
|
@@ -93,14 +116,23 @@ export function migrationState(marker, installedVersion) {
|
|
|
93
116
|
: [];
|
|
94
117
|
return { since, manual: [...new Set(manual)] };
|
|
95
118
|
}
|
|
119
|
+
/**
|
|
120
|
+
* Reads the marker, lets the caller change it and writes it back, keeping every other field. Every module that records
|
|
121
|
+
* something in `.asrun.json` goes through this one function, so two of them can never overwrite each other's block.
|
|
122
|
+
*/
|
|
123
|
+
export function updateMarker(root, mutate) {
|
|
124
|
+
const file = path.join(root, MARKER);
|
|
125
|
+
const marker = readMarker(root) ?? {};
|
|
126
|
+
mutate(marker);
|
|
127
|
+
fs.writeFileSync(file, JSON.stringify(marker, null, 2) + '\n');
|
|
128
|
+
}
|
|
96
129
|
/**
|
|
97
130
|
* Writes the migration state back into the marker and keeps every other field (init writes the rest). The workspace
|
|
98
131
|
* must already have a marker: `migrate` says so instead of creating one, because a workspace that was never
|
|
99
132
|
* initialized has no release to migrate from.
|
|
100
133
|
*/
|
|
101
134
|
export function writeMigrationState(root, state) {
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
fs.writeFileSync(file, JSON.stringify(marker, null, 2) + '\n');
|
|
135
|
+
updateMarker(root, (marker) => {
|
|
136
|
+
marker.migrations = { since: state.since, manual: [...new Set(state.manual)] };
|
|
137
|
+
});
|
|
106
138
|
}
|
package/dist/lib/paths.d.ts
CHANGED
|
@@ -30,7 +30,14 @@ export declare const EXPLORE_DIR: string;
|
|
|
30
30
|
export declare const TEMPLATES_DIR: string;
|
|
31
31
|
export declare const SKILLS_DIR: string;
|
|
32
32
|
/**
|
|
33
|
-
* The
|
|
34
|
-
* install). Tools that need a package folder rather than a module (vite's config, pdfjs assets) use
|
|
33
|
+
* The folder of a package the framework needs at run time: the framework's own dependency, or the workspace's (a
|
|
34
|
+
* hoisted or linked install). Tools that need a package folder rather than a module (vite's config, pdfjs assets) use
|
|
35
|
+
* this.
|
|
36
|
+
*
|
|
37
|
+
* Node's own resolution comes first, because it is the same one every `import` in this package goes through: the
|
|
38
|
+
* folder and the module that is loaded can then never disagree. A hand-built path could not promise that — under
|
|
39
|
+
* pnpm's virtual store the framework sits in `<store>/node_modules/@asrunjs/asrun`, so the folder that holds its
|
|
40
|
+
* dependencies is one `node_modules`, not two, and appending a second one named the wrong folder and made every PDF
|
|
41
|
+
* check fail with "pdfjs-dist is not installed" while the package was installed.
|
|
35
42
|
*/
|
|
36
43
|
export declare function packageDir(packageName: string): string;
|
package/dist/lib/paths.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import fs from 'node:fs';
|
|
2
2
|
import path from 'node:path';
|
|
3
|
+
import { createRequire } from 'node:module';
|
|
3
4
|
/**
|
|
4
5
|
* Where the framework's own code and assets live: the package folder (`node_modules/@asrunjs/asrun`). The compiled files sit in
|
|
5
6
|
* `dist/lib`, so one level up from there is the package root again.
|
|
@@ -87,13 +88,39 @@ export const EXPLORE_DIR = path.join(ROOT, '.explore');
|
|
|
87
88
|
export const TEMPLATES_DIR = path.join(FRAMEWORK_ROOT, 'templates');
|
|
88
89
|
export const SKILLS_DIR = path.join(FRAMEWORK_ROOT, '.claude', 'skills');
|
|
89
90
|
/**
|
|
90
|
-
* The
|
|
91
|
-
* install). Tools that need a package folder rather than a module (vite's config, pdfjs assets) use
|
|
91
|
+
* The folder of a package the framework needs at run time: the framework's own dependency, or the workspace's (a
|
|
92
|
+
* hoisted or linked install). Tools that need a package folder rather than a module (vite's config, pdfjs assets) use
|
|
93
|
+
* this.
|
|
94
|
+
*
|
|
95
|
+
* Node's own resolution comes first, because it is the same one every `import` in this package goes through: the
|
|
96
|
+
* folder and the module that is loaded can then never disagree. A hand-built path could not promise that — under
|
|
97
|
+
* pnpm's virtual store the framework sits in `<store>/node_modules/@asrunjs/asrun`, so the folder that holds its
|
|
98
|
+
* dependencies is one `node_modules`, not two, and appending a second one named the wrong folder and made every PDF
|
|
99
|
+
* check fail with "pdfjs-dist is not installed" while the package was installed.
|
|
92
100
|
*/
|
|
93
101
|
export function packageDir(packageName) {
|
|
94
|
-
//
|
|
95
|
-
|
|
96
|
-
const dir = path.
|
|
102
|
+
// A bare name resolves to the package's entry point only; asking for the manifest is the portable way to its folder.
|
|
103
|
+
try {
|
|
104
|
+
const dir = path.dirname(createRequire(import.meta.url).resolve(`${packageName}/package.json`));
|
|
105
|
+
if (fs.existsSync(path.join(dir, 'package.json')))
|
|
106
|
+
return dir;
|
|
107
|
+
}
|
|
108
|
+
catch {
|
|
109
|
+
// Not resolvable from here (an `exports` map without "./package.json", or a package installed elsewhere): the
|
|
110
|
+
// folders that can hold an install answer instead.
|
|
111
|
+
}
|
|
112
|
+
// pnpm's virtual store already keeps a package's dependencies inside a `node_modules`, next to its scope folder;
|
|
113
|
+
// every other layout nests that folder inside the install root.
|
|
114
|
+
const store = path.dirname(path.dirname(FRAMEWORK_ROOT));
|
|
115
|
+
const candidates = [
|
|
116
|
+
// This package's own nested dependencies (an install strategy that nests instead of hoisting).
|
|
117
|
+
path.join(FRAMEWORK_ROOT, 'node_modules', packageName),
|
|
118
|
+
// The dependencies next to it: pnpm's store, or the root of a hoisted install.
|
|
119
|
+
path.basename(store) === 'node_modules' ? path.join(store, packageName) : path.join(store, 'node_modules', packageName),
|
|
120
|
+
// The workspace's own install: a peer dependency (`@playwright/test`) lives here.
|
|
121
|
+
path.join(ROOT, 'node_modules', packageName),
|
|
122
|
+
];
|
|
123
|
+
for (const dir of candidates) {
|
|
97
124
|
if (fs.existsSync(dir))
|
|
98
125
|
return dir;
|
|
99
126
|
}
|
package/dist/lib/pdf.js
CHANGED
|
@@ -1,13 +1,33 @@
|
|
|
1
1
|
import path from 'node:path';
|
|
2
|
+
import { createRequire } from 'node:module';
|
|
2
3
|
import { pathToFileURL } from 'node:url';
|
|
3
4
|
import { createCanvas } from '@napi-rs/canvas';
|
|
4
5
|
import { expect } from '@playwright/test';
|
|
5
6
|
import { packageDir } from './paths.js';
|
|
6
7
|
let pdfjs;
|
|
7
|
-
const loadPdfjs = () => (pdfjs ??= import('pdfjs-dist/legacy/build/pdf.mjs'))
|
|
8
|
-
//
|
|
8
|
+
const loadPdfjs = () => (pdfjs ??= import('pdfjs-dist/legacy/build/pdf.mjs').catch((error) => {
|
|
9
|
+
// The one dependency of this module. A raw ERR_MODULE_NOT_FOUND names this file and nothing a person can act on.
|
|
10
|
+
throw new Error(`The PDF renderer is unavailable: "pdfjs-dist" could not be loaded (${error.message}). The asrun install is ` +
|
|
11
|
+
'incomplete: run "pnpm install" in the workspace root, or install the framework again ("pnpm add -D ' +
|
|
12
|
+
'@asrunjs/asrun"). "pnpm asrun doctor" says which part is missing.');
|
|
13
|
+
}));
|
|
14
|
+
// pdf.js loads its fonts, cmaps and wasm files from the package folder, not from the consuming project. The folder is
|
|
15
|
+
// derived from the very module that is imported, so it cannot drift from the code that reads it: a hand-built path was
|
|
16
|
+
// one `node_modules` too deep under pnpm's virtual store, and every PDF check failed although the package was there.
|
|
17
|
+
// `packageDir` answers only when the module itself cannot be resolved (then the load above reports it).
|
|
9
18
|
let pdfjsDir;
|
|
10
|
-
|
|
19
|
+
function pdfjsRoot() {
|
|
20
|
+
if (pdfjsDir !== undefined)
|
|
21
|
+
return pdfjsDir;
|
|
22
|
+
try {
|
|
23
|
+
const build = createRequire(import.meta.url).resolve('pdfjs-dist/legacy/build/pdf.mjs');
|
|
24
|
+
return (pdfjsDir = path.resolve(path.dirname(build), '..', '..'));
|
|
25
|
+
}
|
|
26
|
+
catch {
|
|
27
|
+
return (pdfjsDir = packageDir('pdfjs-dist'));
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
const dirUrl = (dir) => pathToFileURL(path.join(pdfjsRoot(), dir)).href + '/';
|
|
11
31
|
/** A corner of the page. Defaults: 30% of the width and 12% of the height, enough for a logo and nothing else. */
|
|
12
32
|
export function corner(name, size = {}) {
|
|
13
33
|
const width = size.width ?? 0.3;
|