libretto 0.3.1 → 0.4.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 +17 -7
- package/dist/cli/commands/ai.js +3 -5
- package/dist/cli/commands/browser.js +23 -2
- package/dist/cli/commands/init.js +157 -114
- package/dist/cli/commands/snapshot.js +147 -26
- package/dist/cli/core/ai-config.js +38 -46
- package/dist/cli/core/api-snapshot-analyzer.js +74 -0
- package/dist/cli/core/browser.js +21 -4
- package/dist/cli/core/context.js +1 -1
- package/dist/cli/core/snapshot-analyzer.js +295 -104
- package/dist/cli/core/snapshot-api-config.js +137 -0
- package/dist/cli/index.js +1 -0
- package/dist/shared/condense-dom/condense-dom.cjs +462 -0
- package/dist/shared/condense-dom/condense-dom.d.cts +34 -0
- package/dist/shared/condense-dom/condense-dom.d.ts +34 -0
- package/dist/shared/condense-dom/condense-dom.js +438 -0
- package/dist/shared/llm/ai-sdk-adapter.cjs +5 -1
- package/dist/shared/llm/ai-sdk-adapter.js +5 -1
- package/dist/shared/llm/client.cjs +106 -27
- package/dist/shared/llm/client.d.cts +8 -1
- package/dist/shared/llm/client.d.ts +8 -1
- package/dist/shared/llm/client.js +89 -23
- package/dist/shared/llm/types.d.cts +4 -3
- package/dist/shared/llm/types.d.ts +4 -3
- package/dist/shared/state/session-state.cjs +8 -1
- package/dist/shared/state/session-state.d.cts +24 -18
- package/dist/shared/state/session-state.d.ts +24 -18
- package/dist/shared/state/session-state.js +7 -1
- package/package.json +39 -33
package/README.md
CHANGED
|
@@ -6,21 +6,18 @@ It is designed for engineering teams that automate workflows in web apps and wan
|
|
|
6
6
|
|
|
7
7
|
## Installation
|
|
8
8
|
|
|
9
|
-
Install Libretto in your project with your favorite package manager:
|
|
10
|
-
|
|
11
9
|
```bash
|
|
12
|
-
npm install libretto
|
|
13
|
-
yarn add libretto playwright zod
|
|
14
|
-
bun add libretto playwright zod
|
|
15
|
-
pnpm add libretto playwright zod
|
|
10
|
+
npm install --save-dev libretto
|
|
16
11
|
```
|
|
17
12
|
|
|
18
|
-
|
|
13
|
+
Chromium is downloaded automatically via a `postinstall` script. If postinstall scripts are disabled (e.g. `--ignore-scripts`, common in monorepos), run init manually:
|
|
19
14
|
|
|
20
15
|
```bash
|
|
21
16
|
npx libretto init
|
|
22
17
|
```
|
|
23
18
|
|
|
19
|
+
This installs the Chromium browser binary and optionally configures an AI subagent (Gemini, Claude, or Codex) that can analyze page snapshots without consuming the coding agent's context window.
|
|
20
|
+
|
|
24
21
|
## Usage
|
|
25
22
|
|
|
26
23
|
Libretto is usually used through prompts with the Libretto skill.
|
|
@@ -56,6 +53,19 @@ npx libretto help
|
|
|
56
53
|
npx libretto run ./integration.ts main
|
|
57
54
|
```
|
|
58
55
|
|
|
56
|
+
## The `.libretto/` directory
|
|
57
|
+
|
|
58
|
+
Libretto stores local runtime state in a `.libretto/` directory at your project root. Sensitive directories (`sessions/` and `profiles/`) are automatically git-ignored via `.libretto/.gitignore`.
|
|
59
|
+
|
|
60
|
+
- **`profiles/<domain>.json`** — Saved browser sessions (cookies, localStorage) for authenticated sites. Created via `npx libretto save <domain>`. Machine-local and never committed.
|
|
61
|
+
- **`sessions/<name>/`** — Per-session runtime state:
|
|
62
|
+
- `state.json` — Session metadata (debug port, PID, status)
|
|
63
|
+
- `logs.jsonl` — Structured session logs
|
|
64
|
+
- `network.jsonl` — Captured network requests (URLs, methods, headers, response status)
|
|
65
|
+
- `actions.jsonl` — Recorded user actions (clicks, fills, navigations)
|
|
66
|
+
- `snapshots/` — Screenshot PNGs and HTML snapshots captured via `npx libretto snapshot`
|
|
67
|
+
- **`ai.json`** — AI runtime configuration set via `npx libretto ai configure`.
|
|
68
|
+
|
|
59
69
|
## Authors
|
|
60
70
|
|
|
61
71
|
Maintained by the team at [Saffron Health](https://saffron.health).
|
package/dist/cli/commands/ai.js
CHANGED
|
@@ -2,16 +2,14 @@ import { runAiConfigure } from "../core/ai-config.js";
|
|
|
2
2
|
function registerAICommands(yargs) {
|
|
3
3
|
return yargs.command(
|
|
4
4
|
"ai configure [preset]",
|
|
5
|
-
"Configure AI
|
|
5
|
+
"Configure AI model for snapshot analysis",
|
|
6
6
|
(cmd) => cmd.option("clear", { type: "boolean", default: false }),
|
|
7
7
|
(argv) => {
|
|
8
|
-
const customPrefix = Array.isArray(argv["--"]) ? argv["--"] : [];
|
|
9
8
|
runAiConfigure({
|
|
10
9
|
clear: Boolean(argv.clear),
|
|
11
|
-
preset: argv.preset
|
|
12
|
-
customPrefix
|
|
10
|
+
preset: argv.preset
|
|
13
11
|
}, {
|
|
14
|
-
configureCommandName: "libretto
|
|
12
|
+
configureCommandName: "npx libretto ai configure"
|
|
15
13
|
});
|
|
16
14
|
}
|
|
17
15
|
);
|
|
@@ -16,6 +16,9 @@ function registerBrowserCommands(yargs, logger) {
|
|
|
16
16
|
}).option("headless", {
|
|
17
17
|
type: "boolean",
|
|
18
18
|
default: false
|
|
19
|
+
}).option("viewport", {
|
|
20
|
+
type: "string",
|
|
21
|
+
describe: "Viewport size as WIDTHxHEIGHT (e.g. 1920x1080)"
|
|
19
22
|
}),
|
|
20
23
|
async (argv) => {
|
|
21
24
|
const hasHeadedFlag = Boolean(argv.headed);
|
|
@@ -27,10 +30,28 @@ function registerBrowserCommands(yargs, logger) {
|
|
|
27
30
|
const url = argv.url;
|
|
28
31
|
if (!url) {
|
|
29
32
|
throw new Error(
|
|
30
|
-
"Usage: libretto-cli open <url> [--headless] [--session <name>]"
|
|
33
|
+
"Usage: libretto-cli open <url> [--headless] [--viewport WxH] [--session <name>]"
|
|
31
34
|
);
|
|
32
35
|
}
|
|
33
|
-
|
|
36
|
+
const viewportArg = argv.viewport;
|
|
37
|
+
let viewport;
|
|
38
|
+
if (viewportArg) {
|
|
39
|
+
const match = viewportArg.match(/^(\d+)x(\d+)$/i);
|
|
40
|
+
if (!match) {
|
|
41
|
+
throw new Error(
|
|
42
|
+
"Invalid --viewport format. Expected WIDTHxHEIGHT (e.g. 1920x1080)."
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
const w = Number(match[1]);
|
|
46
|
+
const h = Number(match[2]);
|
|
47
|
+
if (w < 1 || h < 1) {
|
|
48
|
+
throw new Error(
|
|
49
|
+
"Invalid --viewport dimensions. Width and height must be at least 1."
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
viewport = { width: w, height: h };
|
|
53
|
+
}
|
|
54
|
+
await runOpen(url, headed, String(argv.session), logger, { viewport });
|
|
34
55
|
}
|
|
35
56
|
).command(
|
|
36
57
|
"save [urlOrDomain]",
|
|
@@ -1,67 +1,169 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
1
|
+
import { createInterface } from "node:readline";
|
|
2
|
+
import { existsSync, readFileSync, appendFileSync, writeFileSync } from "node:fs";
|
|
3
3
|
import { spawnSync } from "node:child_process";
|
|
4
|
+
import { join } from "node:path";
|
|
4
5
|
import {
|
|
5
|
-
AI_CONFIG_PRESETS,
|
|
6
|
-
AiPresetSchema,
|
|
7
|
-
formatCommandPrefix,
|
|
8
6
|
readAiConfig
|
|
9
7
|
} from "../core/ai-config.js";
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
8
|
+
import { REPO_ROOT } from "../core/context.js";
|
|
9
|
+
import {
|
|
10
|
+
loadSnapshotEnv,
|
|
11
|
+
resolveSnapshotApiModel
|
|
12
|
+
} from "../core/snapshot-api-config.js";
|
|
13
|
+
import { hasProviderCredentials } from "../../shared/llm/client.js";
|
|
14
|
+
const PROVIDER_CHOICES = [
|
|
15
|
+
{
|
|
16
|
+
key: "1",
|
|
17
|
+
label: "OpenAI",
|
|
18
|
+
envVar: "OPENAI_API_KEY",
|
|
19
|
+
envHint: "Get your key at https://platform.openai.com/api-keys"
|
|
20
|
+
},
|
|
21
|
+
{
|
|
22
|
+
key: "2",
|
|
23
|
+
label: "Anthropic",
|
|
24
|
+
envVar: "ANTHROPIC_API_KEY",
|
|
25
|
+
envHint: "Get your key at https://console.anthropic.com/settings/keys"
|
|
26
|
+
},
|
|
27
|
+
{
|
|
28
|
+
key: "3",
|
|
29
|
+
label: "Google Gemini",
|
|
30
|
+
envVar: "GEMINI_API_KEY",
|
|
31
|
+
envHint: "Get your key at https://aistudio.google.com/apikey"
|
|
32
|
+
},
|
|
33
|
+
{
|
|
34
|
+
key: "4",
|
|
35
|
+
label: "Google Vertex AI",
|
|
36
|
+
envVar: "GOOGLE_CLOUD_PROJECT",
|
|
37
|
+
envHint: "Requires gcloud auth application-default login and a GCP project ID"
|
|
38
|
+
}
|
|
39
|
+
];
|
|
40
|
+
function promptUser(rl, question) {
|
|
41
|
+
return new Promise((resolve) => {
|
|
42
|
+
rl.question(question, (answer) => {
|
|
43
|
+
resolve(answer.trim());
|
|
44
|
+
});
|
|
45
|
+
});
|
|
13
46
|
}
|
|
14
|
-
function
|
|
47
|
+
function safeReadAiConfig() {
|
|
15
48
|
try {
|
|
16
|
-
|
|
17
|
-
if (!stats.isFile()) return false;
|
|
18
|
-
if (process.platform === "win32") {
|
|
19
|
-
const pathExt = process.env.PATHEXT ?? ".COM;.EXE;.BAT;.CMD";
|
|
20
|
-
const extensions = pathExt.split(";").map((ext) => ext.trim().toUpperCase()).filter(Boolean);
|
|
21
|
-
const fileExt = extname(filePath).toUpperCase();
|
|
22
|
-
return extensions.includes(fileExt);
|
|
23
|
-
}
|
|
24
|
-
accessSync(filePath, constants.X_OK);
|
|
25
|
-
return true;
|
|
49
|
+
return readAiConfig();
|
|
26
50
|
} catch {
|
|
27
|
-
return
|
|
51
|
+
return null;
|
|
28
52
|
}
|
|
29
53
|
}
|
|
30
|
-
function
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
const candidates = hasExtension ? [command] : extensions.map(
|
|
43
|
-
(ext) => ext.startsWith(".") ? `${command}${ext}` : `${command}.${ext}`
|
|
44
|
-
);
|
|
45
|
-
return pathEntries.some(
|
|
46
|
-
(dir) => candidates.some((candidate) => isRunnableFile(join(dir, candidate)))
|
|
54
|
+
function printSnapshotApiStatus() {
|
|
55
|
+
const config = safeReadAiConfig();
|
|
56
|
+
const selection = resolveSnapshotApiModel(config);
|
|
57
|
+
const envPath = join(REPO_ROOT, ".env");
|
|
58
|
+
console.log("\nSnapshot analysis:");
|
|
59
|
+
console.log(
|
|
60
|
+
" Libretto uses direct API calls for snapshot analysis when supported credentials are available."
|
|
61
|
+
);
|
|
62
|
+
console.log(` Credentials are loaded from process env and ${envPath}.`);
|
|
63
|
+
if (selection && hasProviderCredentials(selection.provider)) {
|
|
64
|
+
console.log(
|
|
65
|
+
` \u2713 Ready: ${selection.model} (${selection.source})`
|
|
47
66
|
);
|
|
67
|
+
console.log(" Snapshot objectives will use the API analyzer by default.");
|
|
68
|
+
console.log(" No further action required.");
|
|
69
|
+
return;
|
|
48
70
|
}
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
71
|
+
console.log(" \u2717 No snapshot API credentials detected.");
|
|
72
|
+
console.log(" Add one provider to .env:");
|
|
73
|
+
console.log(" OPENAI_API_KEY=...");
|
|
74
|
+
console.log(" ANTHROPIC_API_KEY=...");
|
|
75
|
+
console.log(" GEMINI_API_KEY=... # or GOOGLE_GENERATIVE_AI_API_KEY");
|
|
76
|
+
console.log(
|
|
77
|
+
" GOOGLE_CLOUD_PROJECT=... # plus application default credentials for Vertex"
|
|
54
78
|
);
|
|
79
|
+
console.log(
|
|
80
|
+
" Or run `npx libretto ai configure <provider>` to set a specific model."
|
|
81
|
+
);
|
|
82
|
+
console.log(" Run `npx libretto init` interactively to set up credentials.");
|
|
55
83
|
}
|
|
56
|
-
function
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
function printDifferentAnalyzerHint(prefix = " ") {
|
|
84
|
+
async function runInteractiveApiSetup() {
|
|
85
|
+
const config = safeReadAiConfig();
|
|
86
|
+
const selection = resolveSnapshotApiModel(config);
|
|
87
|
+
const envPath = join(REPO_ROOT, ".env");
|
|
88
|
+
console.log("\nSnapshot analysis setup:");
|
|
62
89
|
console.log(
|
|
63
|
-
|
|
90
|
+
" Libretto uses direct API calls for snapshot analysis."
|
|
64
91
|
);
|
|
92
|
+
console.log(` Credentials are loaded from process env and ${envPath}.`);
|
|
93
|
+
if (selection && hasProviderCredentials(selection.provider)) {
|
|
94
|
+
console.log(
|
|
95
|
+
` \u2713 Ready: ${selection.model} (${selection.source})`
|
|
96
|
+
);
|
|
97
|
+
console.log(" Snapshot objectives will use the API analyzer by default.");
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
console.log(" \u2717 No snapshot API credentials detected.\n");
|
|
101
|
+
const rl = createInterface({
|
|
102
|
+
input: process.stdin,
|
|
103
|
+
output: process.stdout
|
|
104
|
+
});
|
|
105
|
+
try {
|
|
106
|
+
console.log(" Which API provider would you like to use for snapshot analysis?\n");
|
|
107
|
+
for (const choice of PROVIDER_CHOICES) {
|
|
108
|
+
console.log(` ${choice.key}) ${choice.label}`);
|
|
109
|
+
}
|
|
110
|
+
console.log(" s) Skip for now\n");
|
|
111
|
+
const answer = await promptUser(rl, " Choice: ");
|
|
112
|
+
if (answer.toLowerCase() === "s" || !answer) {
|
|
113
|
+
console.log("\n Skipped. You can set up API credentials later by rerunning `npx libretto init`.");
|
|
114
|
+
console.log(" Or add credentials directly to your .env file:");
|
|
115
|
+
console.log(" OPENAI_API_KEY=...");
|
|
116
|
+
console.log(" ANTHROPIC_API_KEY=...");
|
|
117
|
+
console.log(" GEMINI_API_KEY=...");
|
|
118
|
+
console.log(
|
|
119
|
+
" Or run `npx libretto ai configure <provider>` to set a specific model."
|
|
120
|
+
);
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
const selected = PROVIDER_CHOICES.find((c) => c.key === answer);
|
|
124
|
+
if (!selected) {
|
|
125
|
+
console.log(`
|
|
126
|
+
Unknown choice "${answer}". Skipping API setup.`);
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
console.log(`
|
|
130
|
+
${selected.label} selected.`);
|
|
131
|
+
console.log(` ${selected.envHint}
|
|
132
|
+
`);
|
|
133
|
+
const apiKeyValue = await promptUser(rl, ` Enter your ${selected.envVar}: `);
|
|
134
|
+
if (!apiKeyValue) {
|
|
135
|
+
console.log("\n No value entered. Skipping API key setup.");
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
let envContent = "";
|
|
139
|
+
if (existsSync(envPath)) {
|
|
140
|
+
envContent = readFileSync(envPath, "utf-8");
|
|
141
|
+
}
|
|
142
|
+
const envLine = `${selected.envVar}=${apiKeyValue}`;
|
|
143
|
+
if (envContent.includes(`${selected.envVar}=`)) {
|
|
144
|
+
const updated = envContent.replace(
|
|
145
|
+
new RegExp(`^${selected.envVar}=.*$`, "m"),
|
|
146
|
+
() => envLine
|
|
147
|
+
);
|
|
148
|
+
writeFileSync(envPath, updated);
|
|
149
|
+
console.log(`
|
|
150
|
+
\u2713 Updated ${selected.envVar} in ${envPath}`);
|
|
151
|
+
} else {
|
|
152
|
+
const separator = envContent && !envContent.endsWith("\n") ? "\n" : "";
|
|
153
|
+
appendFileSync(envPath, `${separator}${envLine}
|
|
154
|
+
`);
|
|
155
|
+
console.log(`
|
|
156
|
+
\u2713 Added ${selected.envVar} to ${envPath}`);
|
|
157
|
+
}
|
|
158
|
+
loadSnapshotEnv();
|
|
159
|
+
process.env[selected.envVar] = apiKeyValue;
|
|
160
|
+
const newSelection = resolveSnapshotApiModel(safeReadAiConfig());
|
|
161
|
+
if (newSelection && hasProviderCredentials(newSelection.provider)) {
|
|
162
|
+
console.log(` \u2713 Snapshot API ready: ${newSelection.model}`);
|
|
163
|
+
}
|
|
164
|
+
} finally {
|
|
165
|
+
rl.close();
|
|
166
|
+
}
|
|
65
167
|
}
|
|
66
168
|
function installBrowsers() {
|
|
67
169
|
console.log("\nInstalling Playwright Chromium...");
|
|
@@ -77,69 +179,6 @@ function installBrowsers() {
|
|
|
77
179
|
);
|
|
78
180
|
}
|
|
79
181
|
}
|
|
80
|
-
function checkAiRuntimeConfiguration() {
|
|
81
|
-
let config = null;
|
|
82
|
-
let configReadError = null;
|
|
83
|
-
try {
|
|
84
|
-
config = readAiConfig();
|
|
85
|
-
} catch (error) {
|
|
86
|
-
configReadError = error instanceof Error ? error.message : String(error);
|
|
87
|
-
}
|
|
88
|
-
const availableCommands = detectAvailableAiRuntimeCommands();
|
|
89
|
-
console.log("\nAI runtime configuration:");
|
|
90
|
-
console.log(
|
|
91
|
-
" Libretto can use your coding agent as a subagent to analyze snapshots and other page signals."
|
|
92
|
-
);
|
|
93
|
-
console.log(
|
|
94
|
-
" This is optional, but it significantly improves page understanding and debugging performance."
|
|
95
|
-
);
|
|
96
|
-
if (configReadError) {
|
|
97
|
-
console.log(` \u2717 Could not read AI config: ${configReadError}`);
|
|
98
|
-
console.log(" Reconfigure with:");
|
|
99
|
-
printAiConfigureCommands(" ");
|
|
100
|
-
printDifferentAnalyzerHint(" ");
|
|
101
|
-
return;
|
|
102
|
-
}
|
|
103
|
-
if (config) {
|
|
104
|
-
const configuredCommand = config.commandPrefix[0];
|
|
105
|
-
if (!isCommandDefined(configuredCommand)) {
|
|
106
|
-
console.log(
|
|
107
|
-
` \u2717 Configured command not found: ${configuredCommand ?? "(empty)"}`
|
|
108
|
-
);
|
|
109
|
-
if (availableCommands.length > 0) {
|
|
110
|
-
console.log(
|
|
111
|
-
` Detected available commands: ${availableCommands.join(", ")}`
|
|
112
|
-
);
|
|
113
|
-
} else {
|
|
114
|
-
console.log(
|
|
115
|
-
" No codex, claude, or gemini analyzer command was detected on PATH."
|
|
116
|
-
);
|
|
117
|
-
}
|
|
118
|
-
console.log(" Reconfigure with:");
|
|
119
|
-
printAiConfigureCommands(" ");
|
|
120
|
-
printDifferentAnalyzerHint(" ");
|
|
121
|
-
return;
|
|
122
|
-
}
|
|
123
|
-
console.log(
|
|
124
|
-
` \u2713 Configured (${config.preset}): ${formatCommandPrefix(config.commandPrefix)}`
|
|
125
|
-
);
|
|
126
|
-
console.log(" Analysis commands are ready to use.");
|
|
127
|
-
printDifferentAnalyzerHint(" ");
|
|
128
|
-
return;
|
|
129
|
-
}
|
|
130
|
-
console.log(" \u2717 No AI config set.");
|
|
131
|
-
if (availableCommands.length > 0) {
|
|
132
|
-
console.log(
|
|
133
|
-
` Detected available commands: ${availableCommands.join(", ")}`
|
|
134
|
-
);
|
|
135
|
-
} else {
|
|
136
|
-
console.log(" No codex, claude, or gemini analyzer command was detected on PATH.");
|
|
137
|
-
}
|
|
138
|
-
console.log(" Configure one with:");
|
|
139
|
-
printAiConfigureCommands(" ");
|
|
140
|
-
printDifferentAnalyzerHint(" ");
|
|
141
|
-
console.log(" Optionally provide a custom command prefix with '-- ...'.");
|
|
142
|
-
}
|
|
143
182
|
function registerInitCommand(yargs) {
|
|
144
183
|
return yargs.command(
|
|
145
184
|
"init",
|
|
@@ -149,14 +188,18 @@ function registerInitCommand(yargs) {
|
|
|
149
188
|
default: false,
|
|
150
189
|
describe: "Skip Playwright Chromium installation"
|
|
151
190
|
}),
|
|
152
|
-
(argv) => {
|
|
191
|
+
async (argv) => {
|
|
153
192
|
console.log("Initializing libretto...\n");
|
|
154
193
|
if (!argv["skip-browsers"]) {
|
|
155
194
|
installBrowsers();
|
|
156
195
|
} else {
|
|
157
196
|
console.log("\nSkipping browser installation (--skip-browsers)");
|
|
158
197
|
}
|
|
159
|
-
|
|
198
|
+
if (process.stdin.isTTY) {
|
|
199
|
+
await runInteractiveApiSetup();
|
|
200
|
+
} else {
|
|
201
|
+
printSnapshotApiStatus();
|
|
202
|
+
}
|
|
160
203
|
console.log("\n\u2713 libretto init complete");
|
|
161
204
|
}
|
|
162
205
|
);
|
|
@@ -1,14 +1,69 @@
|
|
|
1
1
|
import { mkdirSync } from "node:fs";
|
|
2
2
|
import { connect, disconnectBrowser } from "../core/browser.js";
|
|
3
3
|
import { getSessionSnapshotRunDir } from "../core/context.js";
|
|
4
|
-
import {
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
} from "../core/
|
|
4
|
+
import { condenseDom } from "../../shared/condense-dom/condense-dom.js";
|
|
5
|
+
import { readSessionState } from "../core/session.js";
|
|
6
|
+
import { runApiInterpret } from "../core/api-snapshot-analyzer.js";
|
|
7
|
+
import { readAiConfig } from "../core/ai-config.js";
|
|
8
8
|
const DEFAULT_SNAPSHOT_CONTEXT = "No additional user context provided.";
|
|
9
|
+
const FALLBACK_SNAPSHOT_VIEWPORT = { width: 1280, height: 800 };
|
|
9
10
|
function generateSnapshotRunId() {
|
|
10
11
|
return `snapshot-${Date.now()}`;
|
|
11
12
|
}
|
|
13
|
+
function isZeroViewport(value) {
|
|
14
|
+
return typeof value === "number" && value <= 0;
|
|
15
|
+
}
|
|
16
|
+
function shouldForceSnapshotViewport(metrics) {
|
|
17
|
+
return isZeroViewport(metrics.configuredWidth) || isZeroViewport(metrics.configuredHeight) || isZeroViewport(metrics.innerWidth) || isZeroViewport(metrics.innerHeight);
|
|
18
|
+
}
|
|
19
|
+
function isZeroWidthScreenshotError(error) {
|
|
20
|
+
return error instanceof Error && error.message.includes("Cannot take screenshot with 0 width");
|
|
21
|
+
}
|
|
22
|
+
async function readSnapshotViewportMetrics(page) {
|
|
23
|
+
const configuredViewport = page.viewportSize();
|
|
24
|
+
let innerWidth = null;
|
|
25
|
+
let innerHeight = null;
|
|
26
|
+
try {
|
|
27
|
+
const innerViewport = await page.evaluate(() => ({
|
|
28
|
+
width: window.innerWidth,
|
|
29
|
+
height: window.innerHeight
|
|
30
|
+
}));
|
|
31
|
+
innerWidth = innerViewport.width;
|
|
32
|
+
innerHeight = innerViewport.height;
|
|
33
|
+
} catch {
|
|
34
|
+
}
|
|
35
|
+
return {
|
|
36
|
+
configuredWidth: configuredViewport?.width ?? null,
|
|
37
|
+
configuredHeight: configuredViewport?.height ?? null,
|
|
38
|
+
innerWidth,
|
|
39
|
+
innerHeight
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
function resolveSnapshotViewport(session, logger) {
|
|
43
|
+
const state = readSessionState(session, logger);
|
|
44
|
+
if (state?.viewport) {
|
|
45
|
+
logger.info("screenshot-viewport-from-session-state", {
|
|
46
|
+
session,
|
|
47
|
+
viewport: state.viewport
|
|
48
|
+
});
|
|
49
|
+
return state.viewport;
|
|
50
|
+
}
|
|
51
|
+
logger.info("screenshot-viewport-fallback", {
|
|
52
|
+
session,
|
|
53
|
+
reason: "no viewport in session state",
|
|
54
|
+
viewport: FALLBACK_SNAPSHOT_VIEWPORT
|
|
55
|
+
});
|
|
56
|
+
return FALLBACK_SNAPSHOT_VIEWPORT;
|
|
57
|
+
}
|
|
58
|
+
async function forceSnapshotViewport(page, viewport, logger, session, pageId, reason) {
|
|
59
|
+
await page.setViewportSize(viewport);
|
|
60
|
+
logger.warn("screenshot-viewport-forced", {
|
|
61
|
+
session,
|
|
62
|
+
pageId,
|
|
63
|
+
reason,
|
|
64
|
+
viewport
|
|
65
|
+
});
|
|
66
|
+
}
|
|
12
67
|
async function captureScreenshot(session, logger, pageId) {
|
|
13
68
|
logger.info("screenshot-start", { session, pageId });
|
|
14
69
|
const snapshotRunId = generateSnapshotRunId();
|
|
@@ -19,23 +74,81 @@ async function captureScreenshot(session, logger, pageId) {
|
|
|
19
74
|
requireSinglePage: true
|
|
20
75
|
});
|
|
21
76
|
try {
|
|
22
|
-
|
|
23
|
-
|
|
77
|
+
let title = null;
|
|
78
|
+
try {
|
|
79
|
+
title = await page.title();
|
|
80
|
+
} catch (error) {
|
|
81
|
+
logger.warn("screenshot-title-read-failed", {
|
|
82
|
+
session,
|
|
83
|
+
pageId,
|
|
84
|
+
error
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
let pageUrl = null;
|
|
88
|
+
try {
|
|
89
|
+
pageUrl = page.url();
|
|
90
|
+
} catch (error) {
|
|
91
|
+
logger.warn("screenshot-url-read-failed", {
|
|
92
|
+
session,
|
|
93
|
+
pageId,
|
|
94
|
+
error
|
|
95
|
+
});
|
|
96
|
+
}
|
|
24
97
|
const pngPath = `${snapshotRunDir}/page.png`;
|
|
25
98
|
const htmlPath = `${snapshotRunDir}/page.html`;
|
|
26
|
-
|
|
99
|
+
const condensedHtmlPath = `${snapshotRunDir}/page.condensed.html`;
|
|
100
|
+
const restoreViewport = resolveSnapshotViewport(session, logger);
|
|
101
|
+
const viewportMetrics = await readSnapshotViewportMetrics(page);
|
|
102
|
+
logger.info("screenshot-viewport-metrics", {
|
|
103
|
+
session,
|
|
104
|
+
pageId,
|
|
105
|
+
restoreViewport,
|
|
106
|
+
...viewportMetrics
|
|
107
|
+
});
|
|
108
|
+
await forceSnapshotViewport(
|
|
109
|
+
page,
|
|
110
|
+
restoreViewport,
|
|
111
|
+
logger,
|
|
112
|
+
session,
|
|
113
|
+
pageId,
|
|
114
|
+
shouldForceSnapshotViewport(viewportMetrics) ? "preflight-invalid-viewport" : "preflight-normalize-viewport"
|
|
115
|
+
);
|
|
116
|
+
try {
|
|
117
|
+
await page.screenshot({ path: pngPath });
|
|
118
|
+
} catch (error) {
|
|
119
|
+
if (!isZeroWidthScreenshotError(error)) {
|
|
120
|
+
throw error;
|
|
121
|
+
}
|
|
122
|
+
await forceSnapshotViewport(
|
|
123
|
+
page,
|
|
124
|
+
restoreViewport,
|
|
125
|
+
logger,
|
|
126
|
+
session,
|
|
127
|
+
pageId,
|
|
128
|
+
"retry-after-zero-width-screenshot-error"
|
|
129
|
+
);
|
|
130
|
+
await page.screenshot({ path: pngPath });
|
|
131
|
+
}
|
|
27
132
|
const htmlContent = await page.content();
|
|
28
133
|
const fs = await import("node:fs/promises");
|
|
29
134
|
await fs.writeFile(htmlPath, htmlContent);
|
|
135
|
+
const condenseResult = condenseDom(htmlContent);
|
|
136
|
+
await fs.writeFile(condensedHtmlPath, condenseResult.html);
|
|
30
137
|
logger.info("screenshot-success", {
|
|
31
138
|
session,
|
|
32
139
|
pageUrl,
|
|
33
140
|
title,
|
|
34
141
|
pngPath,
|
|
35
142
|
htmlPath,
|
|
36
|
-
|
|
143
|
+
condensedHtmlPath,
|
|
144
|
+
snapshotRunId,
|
|
145
|
+
domCondenseStats: {
|
|
146
|
+
originalLength: condenseResult.originalLength,
|
|
147
|
+
condensedLength: condenseResult.condensedLength,
|
|
148
|
+
reductions: condenseResult.reductions
|
|
149
|
+
}
|
|
37
150
|
});
|
|
38
|
-
return { pngPath, htmlPath, baseName: snapshotRunId };
|
|
151
|
+
return { pngPath, htmlPath, condensedHtmlPath, baseName: snapshotRunId };
|
|
39
152
|
} catch (err) {
|
|
40
153
|
let pageAlive = false;
|
|
41
154
|
let browserConnected = false;
|
|
@@ -49,7 +162,13 @@ async function captureScreenshot(session, logger, pageId) {
|
|
|
49
162
|
session,
|
|
50
163
|
pageAlive,
|
|
51
164
|
browserConnected,
|
|
52
|
-
pageUrl:
|
|
165
|
+
pageUrl: (() => {
|
|
166
|
+
try {
|
|
167
|
+
return page.url();
|
|
168
|
+
} catch {
|
|
169
|
+
return null;
|
|
170
|
+
}
|
|
171
|
+
})()
|
|
53
172
|
});
|
|
54
173
|
throw err;
|
|
55
174
|
} finally {
|
|
@@ -57,33 +176,35 @@ async function captureScreenshot(session, logger, pageId) {
|
|
|
57
176
|
}
|
|
58
177
|
}
|
|
59
178
|
async function runSnapshot(session, logger, pageId, objective, context) {
|
|
60
|
-
const { pngPath, htmlPath } = await captureScreenshot(session, logger, pageId);
|
|
61
|
-
console.log("Screenshot saved:");
|
|
62
|
-
console.log(` PNG: ${pngPath}`);
|
|
63
|
-
console.log(` HTML: ${htmlPath}`);
|
|
64
179
|
const normalizedObjective = objective?.trim();
|
|
65
180
|
const normalizedContext = context?.trim();
|
|
66
|
-
if (!normalizedObjective &&
|
|
67
|
-
console.log("Use --objective flag to analyze snapshots.");
|
|
68
|
-
return;
|
|
69
|
-
}
|
|
70
|
-
if (!normalizedObjective) {
|
|
181
|
+
if (!normalizedObjective && normalizedContext) {
|
|
71
182
|
throw new Error(
|
|
72
183
|
"Couldn't run analysis: --objective is required when providing --context."
|
|
73
184
|
);
|
|
74
185
|
}
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
186
|
+
const { pngPath, htmlPath, condensedHtmlPath } = await captureScreenshot(
|
|
187
|
+
session,
|
|
188
|
+
logger,
|
|
189
|
+
pageId
|
|
190
|
+
);
|
|
191
|
+
console.log("Screenshot saved:");
|
|
192
|
+
console.log(` PNG: ${pngPath}`);
|
|
193
|
+
console.log(` HTML: ${htmlPath}`);
|
|
194
|
+
console.log(` Condensed HTML: ${condensedHtmlPath}`);
|
|
195
|
+
if (!normalizedObjective) {
|
|
196
|
+
console.log("Use --objective flag to analyze snapshots.");
|
|
197
|
+
return;
|
|
79
198
|
}
|
|
80
|
-
|
|
199
|
+
const interpretArgs = {
|
|
81
200
|
objective: normalizedObjective,
|
|
82
201
|
session,
|
|
83
202
|
context: normalizedContext ?? DEFAULT_SNAPSHOT_CONTEXT,
|
|
84
203
|
pngPath,
|
|
85
|
-
htmlPath
|
|
86
|
-
|
|
204
|
+
htmlPath,
|
|
205
|
+
condensedHtmlPath
|
|
206
|
+
};
|
|
207
|
+
await runApiInterpret(interpretArgs, logger, readAiConfig());
|
|
87
208
|
}
|
|
88
209
|
function registerSnapshotCommands(yargs, logger) {
|
|
89
210
|
return yargs.command(
|