tuya-platform-cli 0.1.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 +218 -0
- package/examples/rag-workflow.json +63 -0
- package/examples/simple-llm.json +37 -0
- package/package.json +40 -0
- package/src/cli.js +78 -0
- package/src/lib/analyze.js +213 -0
- package/src/lib/cdp-client.js +115 -0
- package/src/lib/chrome.js +115 -0
- package/src/lib/commands/auto-basic.js +482 -0
- package/src/lib/commands/configure.js +158 -0
- package/src/lib/commands/doctor.js +184 -0
- package/src/lib/commands/list-libraries.js +33 -0
- package/src/lib/commands/manual-record.js +82 -0
- package/src/lib/commands/publish.js +63 -0
- package/src/lib/commands/sample-branch-edges.js +391 -0
- package/src/lib/commands/sample-extra-nodes.js +204 -0
- package/src/lib/commands/sample-trial-inputs.js +173 -0
- package/src/lib/commands/shared.js +457 -0
- package/src/lib/config.js +204 -0
- package/src/lib/recorder.js +316 -0
- package/src/lib/report.js +309 -0
- package/src/lib/schema-builder.js +431 -0
- package/src/lib/selectors.js +12 -0
- package/src/lib/steps.js +50 -0
- package/src/lib/util.js +93 -0
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
import fs from "node:fs";
|
|
4
|
+
import { expandHome, timestampForFile } from "./util.js";
|
|
5
|
+
|
|
6
|
+
const DEFAULT_BASE_URL = "https://platform.tuya.com/ai/workflow";
|
|
7
|
+
|
|
8
|
+
function getDefaultChromePath() {
|
|
9
|
+
switch (process.platform) {
|
|
10
|
+
case "darwin":
|
|
11
|
+
return "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome";
|
|
12
|
+
case "win32":
|
|
13
|
+
return "C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe";
|
|
14
|
+
default:
|
|
15
|
+
return "/usr/bin/google-chrome";
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function getDefaultChromeProfileRoot() {
|
|
20
|
+
switch (process.platform) {
|
|
21
|
+
case "darwin":
|
|
22
|
+
return path.join(os.homedir(), "Library/Application Support/Google/Chrome");
|
|
23
|
+
case "win32":
|
|
24
|
+
return path.join(process.env.LOCALAPPDATA ?? os.homedir(), "Google/Chrome/User Data");
|
|
25
|
+
default:
|
|
26
|
+
return path.join(os.homedir(), ".config/google-chrome");
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function buildDefaultOutputDir(command) {
|
|
31
|
+
const segment = command === "manual" ? "tuya-platform-cli" : "live-run";
|
|
32
|
+
return path.join(process.cwd(), "artifacts", segment, timestampForFile());
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function requireArgValue(flag, next) {
|
|
36
|
+
if (next === undefined || next.startsWith("--")) {
|
|
37
|
+
throw new Error(`${flag} requires a value`);
|
|
38
|
+
}
|
|
39
|
+
return next;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function parseCliArgs(argv) {
|
|
43
|
+
const command =
|
|
44
|
+
argv[0] && !argv[0].startsWith("-")
|
|
45
|
+
? argv[0]
|
|
46
|
+
: "manual";
|
|
47
|
+
const tokens = command === "manual" ? argv : argv.slice(1);
|
|
48
|
+
|
|
49
|
+
const args = {
|
|
50
|
+
command,
|
|
51
|
+
baseUrl: DEFAULT_BASE_URL,
|
|
52
|
+
chromePath: getDefaultChromePath(),
|
|
53
|
+
chromeProfileRoot: getDefaultChromeProfileRoot(),
|
|
54
|
+
chromeProfileName: "Default",
|
|
55
|
+
remoteDebuggingPort: command === "manual" ? 9222 : 9440,
|
|
56
|
+
headless: false,
|
|
57
|
+
help: false,
|
|
58
|
+
redactAuth: false,
|
|
59
|
+
workflowName: null,
|
|
60
|
+
workflowId: null,
|
|
61
|
+
region: "AZ",
|
|
62
|
+
imagePath: null,
|
|
63
|
+
definitionFile: null,
|
|
64
|
+
projectCode: null,
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
for (let index = 0; index < tokens.length; index += 1) {
|
|
68
|
+
const token = tokens[index];
|
|
69
|
+
const next = tokens[index + 1];
|
|
70
|
+
|
|
71
|
+
switch (token) {
|
|
72
|
+
case "--base-url":
|
|
73
|
+
args.baseUrl = requireArgValue("--base-url", next);
|
|
74
|
+
index += 1;
|
|
75
|
+
break;
|
|
76
|
+
case "--chrome-path":
|
|
77
|
+
args.chromePath = expandHome(requireArgValue("--chrome-path", next));
|
|
78
|
+
index += 1;
|
|
79
|
+
break;
|
|
80
|
+
case "--chrome-profile-root":
|
|
81
|
+
args.chromeProfileRoot = expandHome(requireArgValue("--chrome-profile-root", next));
|
|
82
|
+
index += 1;
|
|
83
|
+
break;
|
|
84
|
+
case "--chrome-profile-name":
|
|
85
|
+
args.chromeProfileName = requireArgValue("--chrome-profile-name", next);
|
|
86
|
+
index += 1;
|
|
87
|
+
break;
|
|
88
|
+
case "--output-dir":
|
|
89
|
+
args.outputDir = expandHome(requireArgValue("--output-dir", next));
|
|
90
|
+
index += 1;
|
|
91
|
+
break;
|
|
92
|
+
case "--remote-debugging-port":
|
|
93
|
+
args.remoteDebuggingPort = Number(requireArgValue("--remote-debugging-port", next));
|
|
94
|
+
index += 1;
|
|
95
|
+
break;
|
|
96
|
+
case "--workflow-name":
|
|
97
|
+
args.workflowName = requireArgValue("--workflow-name", next);
|
|
98
|
+
index += 1;
|
|
99
|
+
break;
|
|
100
|
+
case "--workflow-id":
|
|
101
|
+
args.workflowId = requireArgValue("--workflow-id", next);
|
|
102
|
+
index += 1;
|
|
103
|
+
break;
|
|
104
|
+
case "--region":
|
|
105
|
+
args.region = requireArgValue("--region", next);
|
|
106
|
+
index += 1;
|
|
107
|
+
break;
|
|
108
|
+
case "--image-path":
|
|
109
|
+
args.imagePath = expandHome(requireArgValue("--image-path", next));
|
|
110
|
+
index += 1;
|
|
111
|
+
break;
|
|
112
|
+
case "--definition-file":
|
|
113
|
+
args.definitionFile = expandHome(requireArgValue("--definition-file", next));
|
|
114
|
+
index += 1;
|
|
115
|
+
break;
|
|
116
|
+
case "--project-code":
|
|
117
|
+
args.projectCode = requireArgValue("--project-code", next);
|
|
118
|
+
index += 1;
|
|
119
|
+
break;
|
|
120
|
+
case "--headless":
|
|
121
|
+
args.headless = true;
|
|
122
|
+
break;
|
|
123
|
+
case "--redact-auth":
|
|
124
|
+
args.redactAuth = true;
|
|
125
|
+
break;
|
|
126
|
+
case "--help":
|
|
127
|
+
case "-h":
|
|
128
|
+
args.help = true;
|
|
129
|
+
break;
|
|
130
|
+
default:
|
|
131
|
+
if (token.startsWith("--")) {
|
|
132
|
+
throw new Error(`Unknown argument: ${token}`);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const outputDir = args.outputDir ?? buildDefaultOutputDir(command);
|
|
138
|
+
|
|
139
|
+
const resolvedChromePath = path.resolve(args.chromePath);
|
|
140
|
+
if (!fs.existsSync(resolvedChromePath)) {
|
|
141
|
+
console.error(
|
|
142
|
+
`Warning: Chrome not found at ${resolvedChromePath}. Use --chrome-path to specify.`,
|
|
143
|
+
);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
return {
|
|
147
|
+
...args,
|
|
148
|
+
outputDir,
|
|
149
|
+
chromeProfileRoot: path.resolve(args.chromeProfileRoot),
|
|
150
|
+
chromePath: resolvedChromePath,
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
export function buildHelpText() {
|
|
155
|
+
return [
|
|
156
|
+
"Usage:",
|
|
157
|
+
" tuya-platform-cli <command> [options]",
|
|
158
|
+
" npm run tuya -- [options]",
|
|
159
|
+
"",
|
|
160
|
+
"Commands:",
|
|
161
|
+
" doctor Validate CDP, login state, workflow access, and trial-run preconditions",
|
|
162
|
+
" manual Launch isolated Chrome and record the 8 manual steps",
|
|
163
|
+
" auto-basic Attach to an existing Chrome CDP session and run the basic 8-step flow",
|
|
164
|
+
" sample-extra-nodes Attach to an existing Chrome CDP session and capture Loop/Monitor node samples",
|
|
165
|
+
" sample-branch-edges Attach to an existing Chrome CDP session and create a selector branch sample",
|
|
166
|
+
" sample-trial-inputs Attach to an existing Chrome CDP session and capture trial-run input samples",
|
|
167
|
+
" configure Read a JSON definition file and create/update a workflow via API",
|
|
168
|
+
" publish Publish a workflow's agent to the specified region",
|
|
169
|
+
" list-libraries List available knowledge libraries (returns libCode for SearchKnowledge nodes)",
|
|
170
|
+
"",
|
|
171
|
+
"Options:",
|
|
172
|
+
" --base-url <url> Target page URL",
|
|
173
|
+
" --chrome-path <path> Chrome binary path",
|
|
174
|
+
" --chrome-profile-root <path> Chrome user data root",
|
|
175
|
+
" --chrome-profile-name <name> Profile directory name (default: Default)",
|
|
176
|
+
" --output-dir <path> Artifact output directory",
|
|
177
|
+
" --remote-debugging-port <port> Chrome remote debugging port",
|
|
178
|
+
" --workflow-name <name> Workflow name prefix for sample commands",
|
|
179
|
+
" --workflow-id <id> Existing workflow ID for workflow-specific commands",
|
|
180
|
+
" --region <region> Workflow region for sample commands (default: AZ)",
|
|
181
|
+
" --image-path <path> Local image path for sample-trial-inputs",
|
|
182
|
+
" --definition-file <path> JSON workflow definition file for configure command",
|
|
183
|
+
" --project-code <code> Agent project code for publish command",
|
|
184
|
+
" --headless Run Chrome headless (manual only)",
|
|
185
|
+
" --redact-auth Redact auth headers (authorization, cookie) in output files",
|
|
186
|
+
" --help, -h Show this help",
|
|
187
|
+
"",
|
|
188
|
+
"Defaults:",
|
|
189
|
+
` base-url: ${DEFAULT_BASE_URL}`,
|
|
190
|
+
` chrome-path: ${getDefaultChromePath()}`,
|
|
191
|
+
` chrome-profile-root: ${getDefaultChromeProfileRoot()}`,
|
|
192
|
+
"",
|
|
193
|
+
"Examples:",
|
|
194
|
+
" node src/cli.js doctor --remote-debugging-port 9440 --workflow-id 5883 --region AZ",
|
|
195
|
+
" npm run tuya --",
|
|
196
|
+
" node src/cli.js auto-basic --remote-debugging-port 9440",
|
|
197
|
+
" node src/cli.js sample-extra-nodes --remote-debugging-port 9440 --region AZ",
|
|
198
|
+
" node src/cli.js sample-branch-edges --remote-debugging-port 9440 --region AZ",
|
|
199
|
+
" node src/cli.js sample-trial-inputs --remote-debugging-port 9440 --workflow-id 5883 --region AZ",
|
|
200
|
+
" node src/cli.js configure --definition-file workflow.json --remote-debugging-port 9440 --region AZ",
|
|
201
|
+
" node src/cli.js publish --workflow-id 5883 --region AZ --remote-debugging-port 9440",
|
|
202
|
+
" node src/cli.js list-libraries --remote-debugging-port 9440",
|
|
203
|
+
].join("\n");
|
|
204
|
+
}
|
|
@@ -0,0 +1,316 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import fs from "node:fs/promises";
|
|
3
|
+
import { chromium } from "playwright";
|
|
4
|
+
import { BrowserCdpClient, getBrowserWebSocketUrl } from "./cdp-client.js";
|
|
5
|
+
import { ensureDir, safeJsonParse, slugify, trimBody, writeJson, writeText } from "./util.js";
|
|
6
|
+
|
|
7
|
+
function buildAuthHeaders(headers = {}) {
|
|
8
|
+
const allowed = [
|
|
9
|
+
"authorization",
|
|
10
|
+
"cookie",
|
|
11
|
+
"x-auth-token",
|
|
12
|
+
"x-csrf-token",
|
|
13
|
+
"x-xsrf-token",
|
|
14
|
+
"x-requested-with",
|
|
15
|
+
"x-device-id",
|
|
16
|
+
];
|
|
17
|
+
|
|
18
|
+
return Object.fromEntries(
|
|
19
|
+
Object.entries(headers).filter(([key]) =>
|
|
20
|
+
allowed.includes(String(key).toLowerCase()),
|
|
21
|
+
),
|
|
22
|
+
);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const SENSITIVE_HEADERS = ["authorization", "cookie", "x-auth-token"];
|
|
26
|
+
|
|
27
|
+
function redactHeaders(headers) {
|
|
28
|
+
const result = { ...headers };
|
|
29
|
+
for (const key of Object.keys(result)) {
|
|
30
|
+
if (SENSITIVE_HEADERS.includes(key.toLowerCase())) {
|
|
31
|
+
result[key] = "***REDACTED***";
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
return result;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function looksLikeBundle(url, mimeType) {
|
|
38
|
+
return (
|
|
39
|
+
mimeType?.includes("javascript") ||
|
|
40
|
+
/\.js(\?|$)/i.test(url) ||
|
|
41
|
+
url.includes("/assets/")
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const BINARY_CONTENT_TYPES = /^(image|font|video|audio)\//i;
|
|
46
|
+
|
|
47
|
+
let authWarningShown = false;
|
|
48
|
+
|
|
49
|
+
export class StepRecorder {
|
|
50
|
+
constructor({ outputDir, remoteDebuggingPort, pageUrlIncludes = "platform.tuya.com/ai/workflow", redactAuth = false }) {
|
|
51
|
+
this.outputDir = outputDir;
|
|
52
|
+
this.remoteDebuggingPort = remoteDebuggingPort;
|
|
53
|
+
this.pageUrlIncludes = pageUrlIncludes;
|
|
54
|
+
this.redactAuth = redactAuth;
|
|
55
|
+
this.requests = new Map();
|
|
56
|
+
this.currentStep = null;
|
|
57
|
+
this.browser = null;
|
|
58
|
+
this.context = null;
|
|
59
|
+
this.page = null;
|
|
60
|
+
this.cdp = null;
|
|
61
|
+
this.sessions = new Set();
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
async initialize() {
|
|
65
|
+
const browserWsUrl = await getBrowserWebSocketUrl(this.remoteDebuggingPort);
|
|
66
|
+
this.cdp = new BrowserCdpClient(browserWsUrl);
|
|
67
|
+
await this.cdp.connect();
|
|
68
|
+
await this.bootstrapCdp();
|
|
69
|
+
|
|
70
|
+
this.browser = await chromium.connectOverCDP(
|
|
71
|
+
`http://127.0.0.1:${this.remoteDebuggingPort}`,
|
|
72
|
+
);
|
|
73
|
+
this.context =
|
|
74
|
+
this.browser.contexts()[0] ?? (await this.browser.newContext());
|
|
75
|
+
this.page =
|
|
76
|
+
this.context
|
|
77
|
+
.pages()
|
|
78
|
+
.find((page) => page.url().includes(this.pageUrlIncludes)) ??
|
|
79
|
+
this.context.pages().find((page) => page.url().startsWith("http")) ??
|
|
80
|
+
(await this.context.newPage());
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
async bootstrapCdp() {
|
|
84
|
+
this.cdp.on("Target.attachedToTarget", async (params, sessionId) => {
|
|
85
|
+
if (!sessionId) {
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
this.sessions.add(sessionId);
|
|
90
|
+
|
|
91
|
+
await Promise.allSettled([
|
|
92
|
+
this.cdp.send("Network.enable", {}, sessionId),
|
|
93
|
+
this.cdp.send("Page.enable", {}, sessionId),
|
|
94
|
+
this.cdp.send("Runtime.enable", {}, sessionId),
|
|
95
|
+
]);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
this.cdp.on("Network.requestWillBeSent", (params, sessionId) => {
|
|
99
|
+
if (!this.currentStep) {
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const existing = this.requests.get(params.requestId) ?? {
|
|
104
|
+
requestId: params.requestId,
|
|
105
|
+
stepId: this.currentStep.id,
|
|
106
|
+
targetSessionId: sessionId,
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
existing.url = params.request.url;
|
|
110
|
+
existing.method = params.request.method;
|
|
111
|
+
existing.requestHeaders = params.request.headers ?? {};
|
|
112
|
+
const authHeaders = buildAuthHeaders(params.request.headers);
|
|
113
|
+
if (Object.keys(authHeaders).length > 0 && !authWarningShown) {
|
|
114
|
+
console.error("Warning: auth headers (authorization, cookie, etc.) are being captured. Use --redact-auth to redact them.");
|
|
115
|
+
authWarningShown = true;
|
|
116
|
+
}
|
|
117
|
+
existing.authHeaders = this.redactAuth ? redactHeaders(authHeaders) : authHeaders;
|
|
118
|
+
existing.requestTimestamp = params.timestamp;
|
|
119
|
+
existing.resourceType = params.type ?? existing.resourceType ?? null;
|
|
120
|
+
existing.documentUrl = params.documentURL ?? null;
|
|
121
|
+
existing.initiator = params.initiator ?? null;
|
|
122
|
+
|
|
123
|
+
this.requests.set(params.requestId, existing);
|
|
124
|
+
this.capturePostData(params.requestId, sessionId).catch(() => {});
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
this.cdp.on("Network.responseReceived", (params) => {
|
|
128
|
+
const existing = this.requests.get(params.requestId);
|
|
129
|
+
if (!existing || !this.currentStep) {
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
existing.responseStatus = params.response.status;
|
|
134
|
+
existing.responseHeaders = params.response.headers ?? {};
|
|
135
|
+
existing.mimeType = params.response.mimeType ?? null;
|
|
136
|
+
existing.protocol = params.response.protocol ?? null;
|
|
137
|
+
existing.remoteIPAddress = params.response.remoteIPAddress ?? null;
|
|
138
|
+
existing.responseTimestamp = params.timestamp;
|
|
139
|
+
existing.responseUrl = params.response.url;
|
|
140
|
+
this.requests.set(params.requestId, existing);
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
this.cdp.on("Network.webSocketCreated", (params) => {
|
|
144
|
+
if (!this.currentStep) {
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
this.requests.set(params.requestId, {
|
|
149
|
+
requestId: params.requestId,
|
|
150
|
+
stepId: this.currentStep.id,
|
|
151
|
+
url: params.url,
|
|
152
|
+
method: "WEBSOCKET",
|
|
153
|
+
resourceType: "WebSocket",
|
|
154
|
+
authHeaders: {},
|
|
155
|
+
frames: [],
|
|
156
|
+
});
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
this.cdp.on("Network.webSocketFrameReceived", (params) => {
|
|
160
|
+
const existing = this.requests.get(params.requestId);
|
|
161
|
+
if (!existing) {
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
existing.frames ??= [];
|
|
166
|
+
existing.frames.push({
|
|
167
|
+
direction: "received",
|
|
168
|
+
opcode: params.response.opcode,
|
|
169
|
+
payloadData: trimBody(params.response.payloadData, 20_000),
|
|
170
|
+
});
|
|
171
|
+
this.requests.set(params.requestId, existing);
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
this.cdp.on("Network.webSocketFrameSent", (params) => {
|
|
175
|
+
const existing = this.requests.get(params.requestId);
|
|
176
|
+
if (!existing) {
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
existing.frames ??= [];
|
|
181
|
+
existing.frames.push({
|
|
182
|
+
direction: "sent",
|
|
183
|
+
opcode: params.response.opcode,
|
|
184
|
+
payloadData: trimBody(params.response.payloadData, 20_000),
|
|
185
|
+
});
|
|
186
|
+
this.requests.set(params.requestId, existing);
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
this.cdp.on("Network.loadingFinished", (params, sessionId) => {
|
|
190
|
+
if (!this.currentStep) {
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
this.captureResponseBody(params.requestId, sessionId).catch(() => {});
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
await this.cdp.send("Target.setDiscoverTargets", { discover: true });
|
|
198
|
+
await this.cdp.send("Target.setAutoAttach", {
|
|
199
|
+
autoAttach: true,
|
|
200
|
+
waitForDebuggerOnStart: false,
|
|
201
|
+
flatten: true,
|
|
202
|
+
});
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
async capturePostData(requestId, sessionId) {
|
|
206
|
+
if (!sessionId) {
|
|
207
|
+
return;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
const existing = this.requests.get(requestId);
|
|
211
|
+
if (!existing || existing.requestBody) {
|
|
212
|
+
return;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
try {
|
|
216
|
+
const response = await this.cdp.send(
|
|
217
|
+
"Network.getRequestPostData",
|
|
218
|
+
{ requestId },
|
|
219
|
+
sessionId,
|
|
220
|
+
);
|
|
221
|
+
existing.requestBody = trimBody(response.postData, 200_000);
|
|
222
|
+
this.requests.set(requestId, existing);
|
|
223
|
+
} catch {
|
|
224
|
+
// Some requests do not expose post data.
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
async captureResponseBody(requestId, sessionId) {
|
|
229
|
+
if (!sessionId) {
|
|
230
|
+
return;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
const existing = this.requests.get(requestId);
|
|
234
|
+
if (!existing || existing.responseBody !== undefined) {
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
if (BINARY_CONTENT_TYPES.test(existing.mimeType ?? "")) {
|
|
239
|
+
existing.responseBody = null;
|
|
240
|
+
existing.skippedBinaryBody = true;
|
|
241
|
+
this.requests.set(requestId, existing);
|
|
242
|
+
return;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
try {
|
|
246
|
+
const response = await this.cdp.send(
|
|
247
|
+
"Network.getResponseBody",
|
|
248
|
+
{ requestId },
|
|
249
|
+
sessionId,
|
|
250
|
+
);
|
|
251
|
+
existing.responseBody = trimBody(
|
|
252
|
+
response.base64Encoded
|
|
253
|
+
? Buffer.from(response.body, "base64").toString("utf8")
|
|
254
|
+
: response.body,
|
|
255
|
+
500_000,
|
|
256
|
+
);
|
|
257
|
+
existing.responseBodyJson = safeJsonParse(existing.responseBody);
|
|
258
|
+
this.requests.set(requestId, existing);
|
|
259
|
+
} catch {
|
|
260
|
+
// Some response bodies are unavailable for streaming or preflight requests.
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
async startStep(step) {
|
|
265
|
+
this.currentStep = step;
|
|
266
|
+
this.requests.clear();
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
async finishStep(step) {
|
|
270
|
+
const stepDir = path.join(this.outputDir, step.id);
|
|
271
|
+
const requestsDir = path.join(stepDir, "requests");
|
|
272
|
+
const bundlesDir = path.join(this.outputDir, "bundles");
|
|
273
|
+
const network = Array.from(this.requests.values()).sort((left, right) => {
|
|
274
|
+
return (left.requestTimestamp ?? 0) - (right.requestTimestamp ?? 0);
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
await ensureDir(requestsDir);
|
|
278
|
+
await ensureDir(bundlesDir);
|
|
279
|
+
|
|
280
|
+
for (const [index, request] of network.entries()) {
|
|
281
|
+
const fileName = `${String(index + 1).padStart(3, "0")}-${slugify(
|
|
282
|
+
request.method || "request",
|
|
283
|
+
)}-${slugify(request.url || request.requestId)}.json`;
|
|
284
|
+
|
|
285
|
+
await writeJson(path.join(requestsDir, fileName), request);
|
|
286
|
+
|
|
287
|
+
if (looksLikeBundle(request.url ?? "", request.mimeType) && request.responseBody) {
|
|
288
|
+
const bundleName = `${slugify(request.url)}.js`;
|
|
289
|
+
await writeText(path.join(bundlesDir, bundleName), request.responseBody);
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
await writeJson(path.join(stepDir, "network.json"), network);
|
|
294
|
+
|
|
295
|
+
if (this.page) {
|
|
296
|
+
try {
|
|
297
|
+
await this.page.screenshot({
|
|
298
|
+
path: path.join(stepDir, "screenshot.png"),
|
|
299
|
+
fullPage: true,
|
|
300
|
+
});
|
|
301
|
+
} catch {
|
|
302
|
+
// Screenshots are best-effort only.
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
this.currentStep = null;
|
|
307
|
+
return network;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
async close() {
|
|
311
|
+
await Promise.allSettled([
|
|
312
|
+
this.browser?.close(),
|
|
313
|
+
this.cdp?.close(),
|
|
314
|
+
]);
|
|
315
|
+
}
|
|
316
|
+
}
|