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,115 @@
|
|
|
1
|
+
import WebSocket from "ws";
|
|
2
|
+
|
|
3
|
+
export class BrowserCdpClient {
|
|
4
|
+
constructor(websocketUrl) {
|
|
5
|
+
this.websocketUrl = websocketUrl;
|
|
6
|
+
this.socket = null;
|
|
7
|
+
this.nextId = 1;
|
|
8
|
+
this.pending = new Map();
|
|
9
|
+
this.eventListeners = new Map();
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
async connect() {
|
|
13
|
+
this.socket = new WebSocket(this.websocketUrl);
|
|
14
|
+
|
|
15
|
+
await new Promise((resolve, reject) => {
|
|
16
|
+
this.socket.once("open", resolve);
|
|
17
|
+
this.socket.once("error", reject);
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
this.socket.on("message", (rawData) => {
|
|
21
|
+
const message = JSON.parse(rawData.toString());
|
|
22
|
+
|
|
23
|
+
if (typeof message.id === "number") {
|
|
24
|
+
const pending = this.pending.get(message.id);
|
|
25
|
+
if (!pending) {
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
this.pending.delete(message.id);
|
|
30
|
+
|
|
31
|
+
if (message.error) {
|
|
32
|
+
pending.reject(new Error(message.error.message));
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
pending.resolve(message.result);
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
if (message.method) {
|
|
41
|
+
const listeners = this.eventListeners.get(message.method) ?? [];
|
|
42
|
+
for (const listener of listeners) {
|
|
43
|
+
listener(message.params ?? {}, message.sessionId ?? null);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
this.socket.on("close", () => {
|
|
49
|
+
for (const pending of this.pending.values()) {
|
|
50
|
+
pending.reject(new Error("CDP socket closed"));
|
|
51
|
+
}
|
|
52
|
+
this.pending.clear();
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
async send(method, params = {}, sessionId = null, timeoutMs = 30_000) {
|
|
57
|
+
const id = this.nextId;
|
|
58
|
+
this.nextId += 1;
|
|
59
|
+
|
|
60
|
+
const payload = { id, method, params };
|
|
61
|
+
if (sessionId) {
|
|
62
|
+
payload.sessionId = sessionId;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const promise = new Promise((resolve, reject) => {
|
|
66
|
+
this.pending.set(id, { resolve, reject });
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
this.socket.send(JSON.stringify(payload));
|
|
70
|
+
|
|
71
|
+
if (timeoutMs <= 0) {
|
|
72
|
+
return promise;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
let timer;
|
|
76
|
+
const timeout = new Promise((_, reject) => {
|
|
77
|
+
timer = setTimeout(() => {
|
|
78
|
+
this.pending.delete(id);
|
|
79
|
+
reject(new Error(`CDP timeout: ${method} (${timeoutMs}ms)`));
|
|
80
|
+
}, timeoutMs);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
return Promise.race([promise, timeout]).finally(() => clearTimeout(timer));
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
on(method, listener) {
|
|
87
|
+
const listeners = this.eventListeners.get(method) ?? [];
|
|
88
|
+
listeners.push(listener);
|
|
89
|
+
this.eventListeners.set(method, listeners);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
async close() {
|
|
93
|
+
if (!this.socket) {
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
await new Promise((resolve) => {
|
|
98
|
+
this.socket.once("close", resolve);
|
|
99
|
+
this.socket.close();
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export async function getBrowserWebSocketUrl(remoteDebuggingPort) {
|
|
105
|
+
const response = await fetch(
|
|
106
|
+
`http://127.0.0.1:${remoteDebuggingPort}/json/version`,
|
|
107
|
+
);
|
|
108
|
+
|
|
109
|
+
if (!response.ok) {
|
|
110
|
+
throw new Error(`CDP version endpoint failed: ${response.status}`);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const payload = await response.json();
|
|
114
|
+
return payload.webSocketDebuggerUrl;
|
|
115
|
+
}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import fs from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
import { spawn } from "node:child_process";
|
|
5
|
+
import { ensureDir, sleep } from "./util.js";
|
|
6
|
+
|
|
7
|
+
async function copyRecursive(sourcePath, targetPath) {
|
|
8
|
+
const stats = await fs.stat(sourcePath);
|
|
9
|
+
|
|
10
|
+
if (stats.isDirectory()) {
|
|
11
|
+
await fs.mkdir(targetPath, { recursive: true });
|
|
12
|
+
const entries = await fs.readdir(sourcePath);
|
|
13
|
+
for (const entry of entries) {
|
|
14
|
+
await copyRecursive(
|
|
15
|
+
path.join(sourcePath, entry),
|
|
16
|
+
path.join(targetPath, entry),
|
|
17
|
+
);
|
|
18
|
+
}
|
|
19
|
+
return;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
await fs.copyFile(sourcePath, targetPath);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export async function prepareProfileSnapshot({
|
|
26
|
+
chromeProfileRoot,
|
|
27
|
+
chromeProfileName,
|
|
28
|
+
}) {
|
|
29
|
+
const tempRoot = await fs.mkdtemp(
|
|
30
|
+
path.join(os.tmpdir(), "tuya-workflow-profile-"),
|
|
31
|
+
);
|
|
32
|
+
const tempProfileRoot = path.join(tempRoot, "ChromeUserData");
|
|
33
|
+
const tempProfileDir = path.join(tempProfileRoot, chromeProfileName);
|
|
34
|
+
const sourceProfileDir = path.join(chromeProfileRoot, chromeProfileName);
|
|
35
|
+
const sourceLocalState = path.join(chromeProfileRoot, "Local State");
|
|
36
|
+
|
|
37
|
+
await ensureDir(tempProfileRoot);
|
|
38
|
+
await copyRecursive(sourceProfileDir, tempProfileDir);
|
|
39
|
+
|
|
40
|
+
try {
|
|
41
|
+
await fs.copyFile(sourceLocalState, path.join(tempProfileRoot, "Local State"));
|
|
42
|
+
} catch {
|
|
43
|
+
// `Local State` is optional for some profile states.
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
return {
|
|
47
|
+
tempRoot,
|
|
48
|
+
tempProfileRoot,
|
|
49
|
+
tempProfileDir,
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export async function launchChrome({
|
|
54
|
+
chromePath,
|
|
55
|
+
baseUrl,
|
|
56
|
+
remoteDebuggingPort,
|
|
57
|
+
tempProfileRoot,
|
|
58
|
+
chromeProfileName,
|
|
59
|
+
outputDir,
|
|
60
|
+
headless,
|
|
61
|
+
}) {
|
|
62
|
+
const netLogPath = path.join(outputDir, "chrome-netlog.json");
|
|
63
|
+
const args = [
|
|
64
|
+
`--remote-debugging-port=${remoteDebuggingPort}`,
|
|
65
|
+
`--user-data-dir=${tempProfileRoot}`,
|
|
66
|
+
`--profile-directory=${chromeProfileName}`,
|
|
67
|
+
`--log-net-log=${netLogPath}`,
|
|
68
|
+
"--net-log-capture-mode=IncludeSensitive",
|
|
69
|
+
"--disable-background-networking",
|
|
70
|
+
"--disable-renderer-backgrounding",
|
|
71
|
+
"--disable-background-timer-throttling",
|
|
72
|
+
"--window-size=1600,1200",
|
|
73
|
+
];
|
|
74
|
+
|
|
75
|
+
if (headless) {
|
|
76
|
+
args.push("--headless=new");
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
args.push(baseUrl);
|
|
80
|
+
|
|
81
|
+
const chromeProcess = spawn(chromePath, args, {
|
|
82
|
+
stdio: "ignore",
|
|
83
|
+
detached: false,
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
chromeProcess.on("error", (error) => {
|
|
87
|
+
console.error(`Failed to launch Chrome: ${error.message}`);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
await sleep(2_500);
|
|
91
|
+
|
|
92
|
+
return {
|
|
93
|
+
chromeProcess,
|
|
94
|
+
netLogPath,
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export async function cleanupProfileSnapshot(tempRoot) {
|
|
99
|
+
if (!tempRoot) {
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
for (let attempt = 0; attempt < 5; attempt += 1) {
|
|
104
|
+
try {
|
|
105
|
+
await fs.rm(tempRoot, { recursive: true, force: true });
|
|
106
|
+
return;
|
|
107
|
+
} catch (error) {
|
|
108
|
+
if (attempt === 4) {
|
|
109
|
+
throw error;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
await sleep(500 * (attempt + 1));
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
}
|
|
@@ -0,0 +1,482 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { chromium } from "playwright";
|
|
3
|
+
import { STEPS } from "../steps.js";
|
|
4
|
+
import { ensureDir, sleep, slugify, writeJson, writeText } from "../util.js";
|
|
5
|
+
import { writeReport } from "../report.js";
|
|
6
|
+
import { writeStaticAnalysis } from "../analyze.js";
|
|
7
|
+
import { SELECTORS } from "../selectors.js";
|
|
8
|
+
|
|
9
|
+
const DEFAULT_PAGE_URL_INCLUDES = "platform.tuya.com/ai/workflow";
|
|
10
|
+
|
|
11
|
+
class PlaywrightStepRecorder {
|
|
12
|
+
constructor({ context, page, outputDir }) {
|
|
13
|
+
this.context = context;
|
|
14
|
+
this.page = page;
|
|
15
|
+
this.outputDir = outputDir;
|
|
16
|
+
this.activeStep = null;
|
|
17
|
+
this.requestMap = new Map();
|
|
18
|
+
this.requests = [];
|
|
19
|
+
this.counter = 1;
|
|
20
|
+
|
|
21
|
+
this.onRequest = this.onRequest.bind(this);
|
|
22
|
+
this.onResponse = this.onResponse.bind(this);
|
|
23
|
+
this.onRequestFailed = this.onRequestFailed.bind(this);
|
|
24
|
+
|
|
25
|
+
this.context.on("request", this.onRequest);
|
|
26
|
+
this.context.on("response", this.onResponse);
|
|
27
|
+
this.context.on("requestfailed", this.onRequestFailed);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
onRequest(request) {
|
|
31
|
+
if (!this.activeStep) {
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const headers = request.headers();
|
|
36
|
+
const entry = {
|
|
37
|
+
id: this.counter,
|
|
38
|
+
stepId: this.activeStep.id,
|
|
39
|
+
requestKey: String(this.counter),
|
|
40
|
+
method: request.method(),
|
|
41
|
+
url: request.url(),
|
|
42
|
+
resourceType: request.resourceType(),
|
|
43
|
+
requestHeaders: headers,
|
|
44
|
+
authHeaders: Object.fromEntries(
|
|
45
|
+
Object.entries(headers).filter(([key]) =>
|
|
46
|
+
["authorization", "cookie", "x-auth-token", "x-csrf-token", "x-requested-with"].includes(
|
|
47
|
+
key.toLowerCase(),
|
|
48
|
+
),
|
|
49
|
+
),
|
|
50
|
+
),
|
|
51
|
+
requestBody: request.postData() ?? null,
|
|
52
|
+
startedAt: new Date().toISOString(),
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
this.counter += 1;
|
|
56
|
+
this.requestMap.set(request, entry);
|
|
57
|
+
this.requests.push(entry);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
async onResponse(response) {
|
|
61
|
+
if (!this.activeStep) {
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const request = response.request();
|
|
66
|
+
const entry = this.requestMap.get(request);
|
|
67
|
+
if (!entry) {
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
entry.responseStatus = response.status();
|
|
72
|
+
entry.responseHeaders = await response.allHeaders().catch(() => ({}));
|
|
73
|
+
entry.finishedAt = new Date().toISOString();
|
|
74
|
+
|
|
75
|
+
try {
|
|
76
|
+
entry.responseBody = await response.text();
|
|
77
|
+
} catch {
|
|
78
|
+
entry.responseBody = null;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
onRequestFailed(request) {
|
|
83
|
+
if (!this.activeStep) {
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const entry = this.requestMap.get(request);
|
|
88
|
+
if (!entry) {
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
entry.failure = request.failure()?.errorText ?? "requestfailed";
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
async startStep(step) {
|
|
96
|
+
this.activeStep = step;
|
|
97
|
+
this.requestMap.clear();
|
|
98
|
+
this.requests = [];
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
async finishStep(step) {
|
|
102
|
+
await ensureDir(path.join(this.outputDir, step.id, "requests"));
|
|
103
|
+
const network = this.requests.map((entry) => ({ ...entry }));
|
|
104
|
+
const stepDir = path.join(this.outputDir, step.id);
|
|
105
|
+
const requestsDir = path.join(stepDir, "requests");
|
|
106
|
+
const bundlesDir = path.join(this.outputDir, "bundles");
|
|
107
|
+
await ensureDir(bundlesDir);
|
|
108
|
+
|
|
109
|
+
for (const [index, request] of network.entries()) {
|
|
110
|
+
const name = `${String(index + 1).padStart(3, "0")}-${slugify(request.method)}-${slugify(
|
|
111
|
+
request.url,
|
|
112
|
+
)}.json`;
|
|
113
|
+
await writeJson(path.join(requestsDir, name), request);
|
|
114
|
+
|
|
115
|
+
const contentType = String(request.responseHeaders?.["content-type"] ?? "");
|
|
116
|
+
if (
|
|
117
|
+
request.responseBody &&
|
|
118
|
+
(contentType.includes("javascript") || /\.js(\?|$)/i.test(request.url))
|
|
119
|
+
) {
|
|
120
|
+
await writeText(
|
|
121
|
+
path.join(bundlesDir, `${slugify(request.url)}.js`),
|
|
122
|
+
request.responseBody,
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
await writeJson(path.join(stepDir, "network.json"), network);
|
|
128
|
+
await this.page.screenshot({
|
|
129
|
+
path: path.join(stepDir, "screenshot.png"),
|
|
130
|
+
fullPage: true,
|
|
131
|
+
}).catch(() => {});
|
|
132
|
+
|
|
133
|
+
this.activeStep = null;
|
|
134
|
+
return network;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
async dispose() {
|
|
138
|
+
this.context.off("request", this.onRequest);
|
|
139
|
+
this.context.off("response", this.onResponse);
|
|
140
|
+
this.context.off("requestfailed", this.onRequestFailed);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
async function getBrowserContext(remoteDebuggingPort, pageUrlIncludes = DEFAULT_PAGE_URL_INCLUDES) {
|
|
145
|
+
const browser = await chromium.connectOverCDP(`http://127.0.0.1:${remoteDebuggingPort}`);
|
|
146
|
+
const context = browser.contexts()[0] ?? (await browser.newContext());
|
|
147
|
+
const page =
|
|
148
|
+
context.pages().find((candidate) => candidate.url().includes(pageUrlIncludes)) ??
|
|
149
|
+
context.pages().find((candidate) => candidate.url().startsWith("http")) ??
|
|
150
|
+
(await context.newPage());
|
|
151
|
+
|
|
152
|
+
return { browser, context, page };
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
async function dismissKnownModals(page) {
|
|
156
|
+
const modal = page.locator(SELECTORS.MODAL_WRAP).last();
|
|
157
|
+
if (!(await modal.isVisible().catch(() => false))) {
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const text = await modal.innerText().catch(() => "");
|
|
162
|
+
|
|
163
|
+
if (text.includes("你确定要删除该工作流吗")) {
|
|
164
|
+
const buttons = modal.locator("button");
|
|
165
|
+
const count = await buttons.count().catch(() => 0);
|
|
166
|
+
if (count > 0) {
|
|
167
|
+
await buttons.nth(count - 1).click({ force: true }).catch(() => {});
|
|
168
|
+
await page.waitForTimeout(3_000);
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
if (text.includes("当前不是最新版本")) {
|
|
174
|
+
const cancel = modal.getByRole("button", { name: /取消/ }).first();
|
|
175
|
+
if (await cancel.isVisible().catch(() => false)) {
|
|
176
|
+
await cancel.click();
|
|
177
|
+
await page.waitForTimeout(1_000);
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const closeButton = modal.getByRole("button", { name: /取消|关闭/ }).first();
|
|
183
|
+
if (await closeButton.isVisible().catch(() => false)) {
|
|
184
|
+
await closeButton.click().catch(() => {});
|
|
185
|
+
await page.waitForTimeout(1_000);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
async function cleanupExistingWorkflow(page, baseUrl, workflowName = "测试工作流") {
|
|
190
|
+
await page.goto(baseUrl, { waitUntil: "domcontentloaded" }).catch(() => {});
|
|
191
|
+
await page.waitForTimeout(5_000);
|
|
192
|
+
await dismissKnownModals(page);
|
|
193
|
+
|
|
194
|
+
const MAX_CLEANUP_ITERATIONS = 20;
|
|
195
|
+
for (let iteration = 0; iteration < MAX_CLEANUP_ITERATIONS; iteration += 1) {
|
|
196
|
+
const row = page.locator("tr", { hasText: workflowName }).first();
|
|
197
|
+
if (!(await row.isVisible().catch(() => false))) {
|
|
198
|
+
break;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
await row.hover();
|
|
202
|
+
await row.locator(SELECTORS.MORE_ICON).click();
|
|
203
|
+
await page.getByText("删除").last().click();
|
|
204
|
+
const confirm = page.getByRole("button", { name: /确认|确定/ }).last();
|
|
205
|
+
if (await confirm.isVisible().catch(() => false)) {
|
|
206
|
+
await confirm.click();
|
|
207
|
+
}
|
|
208
|
+
await page.waitForTimeout(4_000);
|
|
209
|
+
|
|
210
|
+
if (iteration === MAX_CLEANUP_ITERATIONS - 1) {
|
|
211
|
+
throw new Error(`cleanupExistingWorkflow: exceeded ${MAX_CLEANUP_ITERATIONS} iterations`);
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
async function createWorkflow(page, workflowName = "测试工作流") {
|
|
217
|
+
await page.getByRole("button", { name: "创建工作流" }).click();
|
|
218
|
+
await page.waitForTimeout(1_000);
|
|
219
|
+
const modal = page.locator(SELECTORS.MODAL_WRAP).last();
|
|
220
|
+
await modal.locator('input[placeholder*="工作流名称"]').fill(workflowName);
|
|
221
|
+
await modal.locator('textarea[placeholder*="工作流描述"]').fill("test");
|
|
222
|
+
await modal.locator(SELECTORS.SELECT_SELECTOR).first().click();
|
|
223
|
+
await page
|
|
224
|
+
.locator(SELECTORS.SELECT_DROPDOWN_OPTION)
|
|
225
|
+
.filter({ hasText: /美西/ })
|
|
226
|
+
.last()
|
|
227
|
+
.click();
|
|
228
|
+
await modal.getByRole("button", { name: /确定|确认/ }).click();
|
|
229
|
+
await page.waitForTimeout(5_000);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
async function openWorkflowEditor(page, workflowName = "测试工作流") {
|
|
233
|
+
const row = page.locator("tr", { hasText: workflowName }).first();
|
|
234
|
+
await row.hover();
|
|
235
|
+
await row.getByText("编辑").click({ force: true });
|
|
236
|
+
await page.waitForTimeout(8_000);
|
|
237
|
+
await dismissKnownModals(page);
|
|
238
|
+
await page.waitForTimeout(1_000);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
async function addIntentNode(page) {
|
|
242
|
+
await dismissKnownModals(page);
|
|
243
|
+
await page.getByRole("button", { name: "添加节点" }).click();
|
|
244
|
+
await page.waitForTimeout(1_000);
|
|
245
|
+
await page.locator(SELECTORS.NODE_PANEL_ITEM).filter({ hasText: "意图识别" }).click();
|
|
246
|
+
await page.waitForTimeout(2_000);
|
|
247
|
+
const node = page.locator(SELECTORS.FLOW_ACTIVITY_NODE).last();
|
|
248
|
+
await node.click({ force: true });
|
|
249
|
+
await page.waitForTimeout(1_000);
|
|
250
|
+
const speedMode = page.getByText("极速模式", { exact: true }).first();
|
|
251
|
+
if (await speedMode.isVisible().catch(() => false)) {
|
|
252
|
+
await speedMode.click();
|
|
253
|
+
}
|
|
254
|
+
const intentInput = page.locator('input[placeholder*="请输入用户意图的描述"]').first();
|
|
255
|
+
if (await intentInput.isVisible().catch(() => false)) {
|
|
256
|
+
await intentInput.fill("问候");
|
|
257
|
+
}
|
|
258
|
+
await page.waitForTimeout(8_000);
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
async function selectAllShortcut(page) {
|
|
262
|
+
const modifier = process.platform === "darwin" ? "Meta+A" : "Control+A";
|
|
263
|
+
await page.keyboard.press(modifier);
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
async function fillCodeMirror(editor, text, page) {
|
|
267
|
+
await editor.click();
|
|
268
|
+
await selectAllShortcut(page);
|
|
269
|
+
await page.keyboard.press("Backspace");
|
|
270
|
+
await page.keyboard.type(text, { delay: 20 });
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
async function addLlmNode(page) {
|
|
274
|
+
await dismissKnownModals(page);
|
|
275
|
+
await page.getByRole("button", { name: "添加节点" }).click();
|
|
276
|
+
await page.waitForTimeout(1_000);
|
|
277
|
+
await page.locator(SELECTORS.NODE_PANEL_ITEM).filter({ hasText: "大模型" }).click();
|
|
278
|
+
await page.waitForTimeout(2_500);
|
|
279
|
+
const node = page.locator(SELECTORS.FLOW_ACTIVITY_NODE).last();
|
|
280
|
+
await node.click({ force: true });
|
|
281
|
+
await page.waitForTimeout(1_000);
|
|
282
|
+
|
|
283
|
+
const editors = page.locator(SELECTORS.CODE_MIRROR_EDITOR);
|
|
284
|
+
if ((await editors.count()) >= 2) {
|
|
285
|
+
await fillCodeMirror(editors.nth(0), "你是一个友好的助手。", page);
|
|
286
|
+
await fillCodeMirror(editors.nth(1), "请回答用户的问题。", page);
|
|
287
|
+
}
|
|
288
|
+
await page.waitForTimeout(8_000);
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
async function triggerAutosave(page) {
|
|
292
|
+
const editors = page.locator(SELECTORS.CODE_MIRROR_EDITOR);
|
|
293
|
+
if ((await editors.count()) >= 2) {
|
|
294
|
+
await fillCodeMirror(editors.nth(1), "请回答用户的问题,并简洁回复。", page);
|
|
295
|
+
}
|
|
296
|
+
await page.waitForTimeout(8_000);
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
async function trialRun(page) {
|
|
300
|
+
await dismissKnownModals(page);
|
|
301
|
+
await page.getByRole("button", { name: "试运行" }).click();
|
|
302
|
+
await page.waitForTimeout(2_000);
|
|
303
|
+
|
|
304
|
+
const inputs = page.locator("input, textarea, [contenteditable='true']");
|
|
305
|
+
const count = await inputs.count();
|
|
306
|
+
for (let index = 0; index < count; index += 1) {
|
|
307
|
+
const input = inputs.nth(index);
|
|
308
|
+
const placeholder = (await input.getAttribute("placeholder").catch(() => "")) ?? "";
|
|
309
|
+
const text = (await input.innerText().catch(() => "")) ?? "";
|
|
310
|
+
if (placeholder.includes("USER_TEXT") || text.includes("USER_TEXT")) {
|
|
311
|
+
await input.click();
|
|
312
|
+
await selectAllShortcut(page);
|
|
313
|
+
await page.keyboard.type("你好");
|
|
314
|
+
break;
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
const dcSelector = page.locator(SELECTORS.SELECT_SELECTOR).filter({ hasText: /数据中心|美西|请选择/ }).first();
|
|
319
|
+
if (await dcSelector.isVisible().catch(() => false)) {
|
|
320
|
+
await dcSelector.click().catch(() => {});
|
|
321
|
+
await page
|
|
322
|
+
.locator(SELECTORS.SELECT_DROPDOWN_OPTION)
|
|
323
|
+
.filter({ hasText: /美西/ })
|
|
324
|
+
.last()
|
|
325
|
+
.click()
|
|
326
|
+
.catch(() => {});
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
const runButton = page.getByRole("button", { name: /^运行$/ }).last();
|
|
330
|
+
if (await runButton.isVisible().catch(() => false)) {
|
|
331
|
+
await runButton.click();
|
|
332
|
+
}
|
|
333
|
+
await page.waitForTimeout(20_000);
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
async function deleteWorkflow(page, baseUrl, workflowName = "测试工作流") {
|
|
337
|
+
await page.goto(baseUrl, { waitUntil: "domcontentloaded" }).catch(() => {});
|
|
338
|
+
await page.waitForTimeout(5_000);
|
|
339
|
+
const row = page.locator("tr", { hasText: workflowName }).first();
|
|
340
|
+
await row.hover();
|
|
341
|
+
await row.locator(SELECTORS.MORE_ICON).click();
|
|
342
|
+
await page.getByText("删除").last().click();
|
|
343
|
+
const confirm = page.getByRole("button", { name: /确认|确定/ }).last();
|
|
344
|
+
if (await confirm.isVisible().catch(() => false)) {
|
|
345
|
+
await confirm.click();
|
|
346
|
+
}
|
|
347
|
+
await page.waitForTimeout(5_000);
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
async function runStep(recorder, step, action, idleMs = 5_000) {
|
|
351
|
+
await recorder.startStep(step);
|
|
352
|
+
await action();
|
|
353
|
+
await sleep(idleMs);
|
|
354
|
+
return recorder.finishStep(step);
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
function requireStepRequests(stepId, network, urlMatchers) {
|
|
358
|
+
const matched = network.some((request) =>
|
|
359
|
+
urlMatchers.some((matcher) => matcher.test(String(request.url ?? ""))),
|
|
360
|
+
);
|
|
361
|
+
|
|
362
|
+
if (!matched) {
|
|
363
|
+
throw new Error(
|
|
364
|
+
`${stepId} failed: expected one of ${urlMatchers.map((item) => item.source).join(", ")}`,
|
|
365
|
+
);
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
export async function runAutoBasicCapture(config) {
|
|
370
|
+
await ensureDir(config.outputDir);
|
|
371
|
+
|
|
372
|
+
const { browser, context, page } = await getBrowserContext(config.remoteDebuggingPort);
|
|
373
|
+
const recorder = new PlaywrightStepRecorder({ context, page, outputDir: config.outputDir });
|
|
374
|
+
const workflowName = config.workflowName ?? "测试工作流";
|
|
375
|
+
|
|
376
|
+
try {
|
|
377
|
+
await page.bringToFront();
|
|
378
|
+
await cleanupExistingWorkflow(page, config.baseUrl, workflowName);
|
|
379
|
+
|
|
380
|
+
const stepNetworks = [];
|
|
381
|
+
|
|
382
|
+
stepNetworks.push({
|
|
383
|
+
step: STEPS[0],
|
|
384
|
+
network: await runStep(
|
|
385
|
+
recorder,
|
|
386
|
+
STEPS[0],
|
|
387
|
+
async () => {
|
|
388
|
+
await page.goto(config.baseUrl, { waitUntil: "domcontentloaded" }).catch(() => {});
|
|
389
|
+
await page.waitForTimeout(8_000);
|
|
390
|
+
},
|
|
391
|
+
1_000,
|
|
392
|
+
),
|
|
393
|
+
});
|
|
394
|
+
requireStepRequests(STEPS[0].id, stepNetworks[0].network, [/workflow\/list/i]);
|
|
395
|
+
|
|
396
|
+
stepNetworks.push({
|
|
397
|
+
step: STEPS[1],
|
|
398
|
+
network: await runStep(recorder, STEPS[1], async () => {
|
|
399
|
+
await createWorkflow(page, workflowName);
|
|
400
|
+
}),
|
|
401
|
+
});
|
|
402
|
+
requireStepRequests(STEPS[1].id, stepNetworks[1].network, [/addBasic/i]);
|
|
403
|
+
|
|
404
|
+
stepNetworks.push({
|
|
405
|
+
step: STEPS[2],
|
|
406
|
+
network: await runStep(recorder, STEPS[2], async () => {
|
|
407
|
+
await openWorkflowEditor(page, workflowName);
|
|
408
|
+
}, 2_000),
|
|
409
|
+
});
|
|
410
|
+
requireStepRequests(STEPS[2].id, stepNetworks[2].network, [/workflow\/get/i]);
|
|
411
|
+
|
|
412
|
+
stepNetworks.push({
|
|
413
|
+
step: STEPS[3],
|
|
414
|
+
network: await runStep(recorder, STEPS[3], async () => {
|
|
415
|
+
await addIntentNode(page);
|
|
416
|
+
}),
|
|
417
|
+
});
|
|
418
|
+
requireStepRequests(STEPS[3].id, stepNetworks[3].network, [/workflow\/update/i]);
|
|
419
|
+
|
|
420
|
+
stepNetworks.push({
|
|
421
|
+
step: STEPS[4],
|
|
422
|
+
network: await runStep(recorder, STEPS[4], async () => {
|
|
423
|
+
await addLlmNode(page);
|
|
424
|
+
}),
|
|
425
|
+
});
|
|
426
|
+
requireStepRequests(STEPS[4].id, stepNetworks[4].network, [/workflow\/update/i]);
|
|
427
|
+
|
|
428
|
+
stepNetworks.push({
|
|
429
|
+
step: STEPS[5],
|
|
430
|
+
network: await runStep(recorder, STEPS[5], async () => {
|
|
431
|
+
await triggerAutosave(page);
|
|
432
|
+
}),
|
|
433
|
+
});
|
|
434
|
+
requireStepRequests(STEPS[5].id, stepNetworks[5].network, [/workflow\/update/i]);
|
|
435
|
+
|
|
436
|
+
stepNetworks.push({
|
|
437
|
+
step: STEPS[6],
|
|
438
|
+
network: await runStep(recorder, STEPS[6], async () => {
|
|
439
|
+
await trialRun(page);
|
|
440
|
+
}, 2_000),
|
|
441
|
+
});
|
|
442
|
+
requireStepRequests(STEPS[6].id, stepNetworks[6].network, [
|
|
443
|
+
/run-pre-check/i,
|
|
444
|
+
/workflow\/test\/config/i,
|
|
445
|
+
/workflow\/test\/execute/i,
|
|
446
|
+
/workflow\/test\/progress/i,
|
|
447
|
+
]);
|
|
448
|
+
|
|
449
|
+
stepNetworks.push({
|
|
450
|
+
step: STEPS[7],
|
|
451
|
+
network: await runStep(recorder, STEPS[7], async () => {
|
|
452
|
+
await deleteWorkflow(page, config.baseUrl, workflowName);
|
|
453
|
+
}),
|
|
454
|
+
});
|
|
455
|
+
requireStepRequests(STEPS[7].id, stepNetworks[7].network, [/workflow\/delete/i]);
|
|
456
|
+
|
|
457
|
+
const workflowId =
|
|
458
|
+
page.url().match(/workflowId=([^&]+)/)?.[1] ??
|
|
459
|
+
page.url().match(/[?&]id=([^&]+)/)?.[1] ??
|
|
460
|
+
null;
|
|
461
|
+
|
|
462
|
+
await writeJson(path.join(config.outputDir, "summary.json"), {
|
|
463
|
+
generatedAt: new Date().toISOString(),
|
|
464
|
+
command: "auto-basic",
|
|
465
|
+
outputDir: config.outputDir,
|
|
466
|
+
workflowId,
|
|
467
|
+
steps: stepNetworks.map(({ step, network }) => ({
|
|
468
|
+
id: step.id,
|
|
469
|
+
title: step.title,
|
|
470
|
+
requestCount: network.length,
|
|
471
|
+
})),
|
|
472
|
+
});
|
|
473
|
+
|
|
474
|
+
await writeReport(config.outputDir, stepNetworks);
|
|
475
|
+
await writeStaticAnalysis(config.outputDir, stepNetworks);
|
|
476
|
+
|
|
477
|
+
console.log(JSON.stringify({ outputDir: config.outputDir, workflowId }, null, 2));
|
|
478
|
+
} finally {
|
|
479
|
+
await recorder.dispose();
|
|
480
|
+
await browser.close();
|
|
481
|
+
}
|
|
482
|
+
}
|