test desktop private protocol file flows

This commit is contained in:
tianma-if committed 2026-09-09 11:19:12 +08:00
1 parent a42922dbd0
commit b82719abdb
11 files changed
+392 -17

No files matched your search

+25
View File
@@ -353,6 +353,16 @@ jobs:
fi
bun run verify:packaged-desktop-startup -- "release/desktop/$unpacked_directory/EdgeEver.app/Contents/MacOS/EdgeEver"
- name: Verify packaged macOS private protocol file flows
env:
EDGE_EVER_DESKTOP_ARCH: ${{ matrix.arch }}
run: |
unpacked_directory="mac-${{ matrix.arch }}"
if [[ "${{ matrix.arch }}" == "x64" ]]; then
unpacked_directory="mac"
fi
bun run verify:desktop-protocol-e2e -- "release/desktop/$unpacked_directory/EdgeEver.app/Contents/MacOS/EdgeEver"
- name: Verify desktop installer
env:
EDGE_EVER_DESKTOP_ARCH: ${{ matrix.arch }}
@@ -495,6 +505,9 @@ jobs:
- name: Verify packaged Windows first launch
run: bun run verify:packaged-desktop-startup -- release/desktop/win-unpacked/EdgeEver.exe
- name: Verify packaged Windows private protocol file flows
run: bun run verify:desktop-protocol-e2e -- release/desktop/win-unpacked/EdgeEver.exe
- name: Verify that Preview executables are unsigned
shell: pwsh
run: |
@@ -635,6 +648,18 @@ jobs:
test -n "$executable"
xvfb-run -a bun run verify:packaged-desktop-startup -- "$executable"
- name: Verify packaged Linux private protocol file flows
run: |
executable=""
for candidate in release/desktop/linux-unpacked/EdgeEver release/desktop/linux-unpacked/edgeever; do
if [[ -x "$candidate" ]]; then
executable="$candidate"
break
fi
done
test -n "$executable"
xvfb-run -a bun run verify:desktop-protocol-e2e -- "$executable"
- name: Create Linux checksum
run: |
version="$(node -p "JSON.parse(require('fs').readFileSync('apps/desktop/package.json', 'utf8')).version")"
@@ -300,6 +300,16 @@ export const AiAssistantDialog = ({
clearResult();
};
const handleComposerChange = (value: string) => {
customInstructionEditedRef.current = true;
if (!initializedForOpen) {
setSelectedPromptId(null);
setAction("custom");
}
setCustomInstruction(value);
clearResult();
};
const runGeneration = async (
request: Parameters<typeof api.streamAiGeneration>[0],
{ preserveOutput = false }: { preserveOutput?: boolean } = {},
@@ -459,12 +469,22 @@ export const AiAssistantDialog = ({
parameterKind: "none",
resultMode: "both",
}),
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: ["ai-prompts"] });
onSuccess: ({ prompt }) => {
queryClient.setQueryData(
["ai-prompts", i18n.resolvedLanguage],
(current: typeof prompts | undefined) => [
...(current ?? []).filter((item) => item.id !== prompt.id),
prompt,
],
);
setSelectedPromptId(prompt.id);
setAction(prompt.action);
customInstructionEditedRef.current = false;
setSaveDialogOpen(false);
setSaveName("");
setSaveDescription("");
setPromptFeedback(t("aiAssistant.promptSaved"));
void queryClient.invalidateQueries({ queryKey: ["ai-prompts"] });
},
});
@@ -722,14 +742,10 @@ export const AiAssistantDialog = ({
className="min-h-24 resize-y rounded-md border border-slate-200 bg-white px-3 py-2 text-sm font-normal text-slate-900 outline-none focus:border-emerald-400 focus:ring-2 focus:ring-emerald-500/15"
value={customInstruction}
onChange={(event) => {
customInstructionEditedRef.current = true;
setCustomInstruction(event.target.value);
clearResult();
handleComposerChange(event.target.value);
}}
onCompositionEnd={(event) => {
customInstructionEditedRef.current = true;
setCustomInstruction(event.currentTarget.value);
clearResult();
handleComposerChange(event.currentTarget.value);
}}
onKeyDown={(event) => {
if (
@@ -33,6 +33,14 @@ describe("image resource menu positioning", () => {
expectOutsideImage(position, image);
});
test("clears the adjustment controls before an image has measurable height", async () => {
const image = { x: 300, y: 200, width: 500, height: 0 };
const position = await positionMenu(image);
expect(position.placement).toBe("bottom-end");
expect(position.y).toBeGreaterThanOrEqual(image.y + 56);
expectOutsideImage(position, image);
});
test("flips above an image near the bottom of the viewport", async () => {
const image = { x: 300, y: 700, width: 500, height: 60 };
const position = await positionMenu(image);
@@ -5,7 +5,7 @@ export const imageResourceMenuPosition: Partial<ComputePositionConfig> = {
placement: "bottom-end",
strategy: "fixed",
middleware: [
offset(8),
offset(({ rects }) => Math.max(8, 56 - rects.reference.height)),
flip({ padding: 12 }),
shift({ padding: 12 }),
],
+1
View File
@@ -66,6 +66,7 @@
"verify:desktop-renderer": "bun scripts/verify-desktop-renderer.mjs",
"verify:renderer-origin-migration": "bun run --cwd apps/desktop verify-origin-migration",
"verify:desktop-cross-version-startup": "bun scripts/verify-desktop-cross-version-startup.mjs",
"verify:desktop-protocol-e2e": "bun scripts/verify-desktop-protocol-e2e.mjs",
"verify:packaged-desktop-startup": "bun scripts/verify-packaged-desktop-startup.mjs",
"verify:desktop-package": "bun scripts/verify-desktop-package.mjs",
"build:worker": "bun scripts/build-cloudflare-worker.mjs",
+3
View File
@@ -1,7 +1,9 @@
import { defineConfig, devices } from "@playwright/test";
import { E2E_STORAGE_STATE_PATH } from "./tests/e2e/global-setup";
export default defineConfig({
testDir: "./tests/e2e",
globalSetup: "./tests/e2e/global-setup.ts",
fullyParallel: false,
workers: 1,
timeout: 45_000,
@@ -11,6 +13,7 @@ export default defineConfig({
use: {
baseURL: "http://127.0.0.1:5173",
locale: "zh-CN",
storageState: E2E_STORAGE_STATE_PATH,
screenshot: "only-on-failure",
trace: "retain-on-failure",
},
+9
View File
@@ -5,6 +5,7 @@ const workflow = readFileSync(new URL("../.github/workflows/desktop-build.yml",
const mobileWorkflow = readFileSync(new URL("../.github/workflows/mobile-build.yml", import.meta.url), "utf8");
const desktopPackageVerifier = readFileSync(new URL("./verify-desktop-package.mjs", import.meta.url), "utf8");
const packagedStartupVerifier = readFileSync(new URL("./verify-packaged-desktop-startup.mjs", import.meta.url), "utf8");
const protocolE2eVerifier = readFileSync(new URL("./verify-desktop-protocol-e2e.mjs", import.meta.url), "utf8");
const cargoConfig = readFileSync(new URL("../.cargo/config.toml", import.meta.url), "utf8");
const desktopBuilderConfig = readFileSync(new URL("../apps/desktop/electron-builder.yml", import.meta.url), "utf8");
@@ -83,6 +84,7 @@ describe("desktop release workflow", () => {
expect(step("Verify packaged macOS cross-version startup")).toContain("needs.release-plan.outputs.previous_tag");
expect(step("Verify packaged macOS cross-version startup")).toContain("verify:desktop-cross-version-startup");
expect(step("Verify packaged macOS first launch")).not.toContain("if: matrix.shared_validation");
expect(step("Verify packaged macOS private protocol file flows")).toContain("verify:desktop-protocol-e2e");
});
test("reports timings after builds without instrumenting native build steps", () => {
@@ -109,6 +111,12 @@ describe("desktop release workflow", () => {
expect(step("Verify packaged Windows cross-version startup")).toContain("Start-Process");
expect(workflow).toContain("name: Verify packaged Windows first launch");
expect(workflow).toContain("verify:packaged-desktop-startup");
expect(step("Verify packaged Windows private protocol file flows")).toContain("verify:desktop-protocol-e2e");
expect(protocolE2eVerifier).toContain('origin !== "edgeever-app://app"');
expect(protocolE2eVerifier).toContain('fetch("edgeever-staged://" + pending.id)');
expect(protocolE2eVerifier).toContain('const url = "edgeever-resource://resource/${cachedResourceId}"');
expect(protocolE2eVerifier).toContain('DOM.setFileInputFiles');
expect(protocolE2eVerifier).toContain('Browser.setDownloadBehavior');
expect(packagedStartupVerifier).toContain('new Set(["renderer.origin-ready", "sidecar.ready", "renderer.bootstrap-ready"])');
expect(packagedStartupVerifier).toContain('"renderer.origin-ready"');
expect(packagedStartupVerifier).toContain('startsWith("edgeever-app://app/")');
@@ -136,6 +144,7 @@ describe("desktop release workflow", () => {
expect(workflow).toContain("name: Run packaged Linux sidecar integration tests");
expect(workflow).toContain("name: Verify packaged Linux first launch");
expect(workflow).toContain("xvfb-run -a bun run verify:packaged-desktop-startup");
expect(step("Verify packaged Linux private protocol file flows")).toContain("xvfb-run -a bun run verify:desktop-protocol-e2e");
expect(workflow).toContain("SHA256SUMS-linux.txt");
expect(workflow).toContain("name: Audit Linux Preview asset");
expect(workflow).toContain("needs: [release-plan, desktop, windows, linux]");
+282
View File
@@ -0,0 +1,282 @@
import { spawn, spawnSync } from "node:child_process";
import { createServer } from "node:net";
import { existsSync, statSync } from "node:fs";
import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join, resolve } from "node:path";
const requestedExecutablePath = process.argv[2]?.trim();
const executablePath = requestedExecutablePath ? resolve(requestedExecutablePath) : "";
if (!executablePath || !existsSync(executablePath) || !statSync(executablePath).isFile()) {
throw new Error(`Packaged desktop executable is missing: ${executablePath || "<not provided>"}`);
}
const fixtureBytes = Buffer.from("EdgeEver private protocol E2E fixture.\n", "utf8");
const fixtureText = fixtureBytes.toString("utf8");
const cachedResourceId = "protocol-e2e-resource";
const downloadFilename = "edgeever-protocol-e2e.txt";
const testDirectory = await mkdtemp(join(tmpdir(), "edgeever-protocol-e2e-"));
const userDataDirectory = join(testDirectory, "profile");
const resourceCacheDirectory = join(userDataDirectory, "resource-cache");
const inputFixturePath = join(testDirectory, downloadFilename);
const downloadedPath = join(testDirectory, "downloads", downloadFilename);
const processOutput = [];
let child = null;
let cdp = null;
const wait = (milliseconds) => new Promise((resolveWait) => setTimeout(resolveWait, milliseconds));
const availablePort = () => new Promise((resolvePort, reject) => {
const server = createServer();
server.unref();
server.once("error", reject);
server.listen(0, "127.0.0.1", () => {
const address = server.address();
if (!address || typeof address === "string") {
server.close();
reject(new Error("Could not reserve a DevTools port"));
return;
}
server.close((error) => error ? reject(error) : resolvePort(address.port));
});
});
const stopProcess = async (processToStop) => {
if (!processToStop || processToStop.exitCode !== null || !processToStop.pid) return;
if (process.platform === "win32") {
spawnSync("taskkill", ["/PID", String(processToStop.pid), "/T", "/F"], { stdio: "ignore", windowsHide: true });
} else {
processToStop.kill("SIGTERM");
}
await Promise.race([
new Promise((resolveClosed) => processToStop.once("close", resolveClosed)),
wait(5_000),
]);
};
const findRendererTarget = async (port) => {
const deadline = Date.now() + (Number(process.env.EDGE_EVER_DESKTOP_E2E_TIMEOUT_MS) || 45_000);
let lastError = null;
while (Date.now() < deadline) {
if (child?.exitCode !== null) break;
try {
const response = await fetch(`http://127.0.0.1:${port}/json/list`);
if (!response.ok) throw new Error(`DevTools target request failed (${response.status})`);
const targets = await response.json();
const target = targets.find((candidate) =>
candidate.type === "page" && String(candidate.url || "").startsWith("edgeever-app://app/index.html"));
if (target?.webSocketDebuggerUrl) return target;
} catch (error) {
lastError = error;
}
await wait(200);
}
throw new Error([
"Could not find the packaged edgeever-app renderer target.",
lastError instanceof Error && lastError.message,
processOutput.length > 0 && `Process output:\n${processOutput.join("")}`,
].filter(Boolean).join("\n\n"));
};
const connectCdp = (webSocketUrl) => new Promise((resolveConnection, rejectConnection) => {
const socket = new WebSocket(webSocketUrl);
const pending = new Map();
let nextId = 1;
const timeout = setTimeout(() => rejectConnection(new Error("DevTools WebSocket connection timed out")), 10_000);
const connection = {
close: () => socket.close(),
send(method, params = {}) {
const id = nextId++;
return new Promise((resolveCommand, rejectCommand) => {
pending.set(id, { resolveCommand, rejectCommand });
socket.send(JSON.stringify({ id, method, params }));
});
},
};
socket.addEventListener("open", () => {
clearTimeout(timeout);
resolveConnection(connection);
}, { once: true });
socket.addEventListener("error", () => {
clearTimeout(timeout);
rejectConnection(new Error("DevTools WebSocket connection failed"));
}, { once: true });
socket.addEventListener("close", () => {
for (const { rejectCommand } of pending.values()) rejectCommand(new Error("DevTools WebSocket closed"));
pending.clear();
});
socket.addEventListener("message", (event) => {
const message = JSON.parse(String(event.data));
if (!message.id) return;
const command = pending.get(message.id);
if (!command) return;
pending.delete(message.id);
if (message.error) command.rejectCommand(new Error(`${message.error.message} (${message.error.code})`));
else command.resolveCommand(message.result);
});
});
const evaluate = async (expression) => {
const result = await cdp.send("Runtime.evaluate", { expression, awaitPromise: true, returnByValue: true });
if (result.exceptionDetails) {
throw new Error(result.exceptionDetails.exception?.description || result.exceptionDetails.text || "Renderer evaluation failed");
}
return result.result?.value;
};
const waitForDownloadedFile = async () => {
const deadline = Date.now() + 15_000;
while (Date.now() < deadline) {
if (existsSync(downloadedPath)) return;
await wait(100);
}
throw new Error(`Resource download did not create ${downloadedPath}`);
};
try {
await mkdir(resourceCacheDirectory, { recursive: true });
await mkdir(join(testDirectory, "downloads"), { recursive: true });
await writeFile(inputFixturePath, fixtureBytes);
await writeFile(join(userDataDirectory, "installation-confirmed"), "e2e");
await writeFile(join(resourceCacheDirectory, `${cachedResourceId}.bin`), fixtureBytes);
await writeFile(join(resourceCacheDirectory, `${cachedResourceId}.json`), JSON.stringify({ contentType: "text/plain; charset=utf-8" }));
const port = await availablePort();
child = spawn(executablePath, [
`--user-data-dir=${userDataDirectory}`,
`--remote-debugging-port=${port}`,
"--remote-allow-origins=*",
], {
env: { ...process.env, EDGE_EVER_DISABLE_AUTO_UPDATE: "1" },
stdio: ["ignore", "pipe", "pipe"],
windowsHide: true,
});
child.stdout?.on("data", (chunk) => processOutput.push(chunk.toString()));
child.stderr?.on("data", (chunk) => processOutput.push(chunk.toString()));
const target = await findRendererTarget(port);
cdp = await connectCdp(target.webSocketDebuggerUrl);
await cdp.send("Runtime.enable");
await cdp.send("DOM.enable");
await cdp.send("Page.enable");
const appProtocol = await evaluate(`(async () => {
const response = await fetch(new URL("extensions/registry.json", window.location.href), { cache: "no-store" });
const registry = await response.json();
return {
origin: window.location.origin,
secureContext: window.isSecureContext,
status: response.status,
contentType: response.headers.get("content-type"),
hasEntries: Array.isArray(registry.entries),
desktopBridge: Boolean(window.edgeeverDesktop?.isAvailable),
};
})()`);
if (appProtocol.origin !== "edgeever-app://app") throw new Error(`Unexpected app origin: ${appProtocol.origin}`);
if (!appProtocol.secureContext) throw new Error("The private app origin is not a secure context");
if (!appProtocol.desktopBridge) throw new Error("The packaged desktop bridge is unavailable");
if (appProtocol.status !== 200 || !appProtocol.contentType?.includes("application/json") || !appProtocol.hasEntries) {
throw new Error(`Bundled marketplace fetch failed: ${JSON.stringify(appProtocol)}`);
}
await evaluate(`(() => {
const input = document.createElement("input");
input.id = "edgeever-protocol-file-input";
input.type = "file";
document.body.append(input);
})()`);
const documentNode = await cdp.send("DOM.getDocument");
const inputNode = await cdp.send("DOM.querySelector", {
nodeId: documentNode.root.nodeId,
selector: "#edgeever-protocol-file-input",
});
await cdp.send("DOM.setFileInputFiles", { nodeId: inputNode.nodeId, files: [inputFixturePath] });
const staged = await evaluate(`(async () => {
const input = document.querySelector("#edgeever-protocol-file-input");
const file = input?.files?.[0];
if (!file || !window.edgeeverDesktop) throw new Error("File input or desktop bridge is unavailable");
const pending = await window.edgeeverDesktop.beginStagedResource({
memoId: "protocol-e2e-memo",
name: file.name,
type: file.type,
size: file.size,
});
await window.edgeeverDesktop.appendStagedResource(pending.id, await file.arrayBuffer());
await window.edgeeverDesktop.completeStagedResource(pending.id);
const response = await fetch("edgeever-staged://" + pending.id);
const bridged = await window.edgeeverDesktop.readStagedResource(pending.id);
return {
id: pending.id,
filename: file.name,
fileText: await file.text(),
fetchStatus: response.status,
fetchText: await response.text(),
bridgeText: new TextDecoder().decode(bridged.bytes),
};
})()`);
if (
staged.filename !== downloadFilename
|| staged.fileText !== fixtureText
|| staged.fetchStatus !== 200
|| staged.fetchText !== fixtureText
|| staged.bridgeText !== fixtureText
) {
throw new Error(`Staged resource round trip failed: ${JSON.stringify(staged)}`);
}
const cachedResource = await evaluate(`(async () => {
const url = "edgeever-resource://resource/${cachedResourceId}";
const full = await fetch(url);
const range = await fetch(url, { headers: { Range: "bytes=9-15" } });
return {
fullStatus: full.status,
fullText: await full.text(),
rangeStatus: range.status,
rangeText: await range.text(),
contentRange: range.headers.get("content-range"),
};
})()`);
if (
cachedResource.fullStatus !== 200
|| cachedResource.fullText !== fixtureText
|| cachedResource.rangeStatus !== 206
|| cachedResource.rangeText !== fixtureText.slice(9, 16)
|| cachedResource.contentRange !== `bytes 9-15/${fixtureBytes.byteLength}`
) {
throw new Error(`Cached resource protocol failed: ${JSON.stringify(cachedResource)}`);
}
await cdp.send("Browser.setDownloadBehavior", {
behavior: "allow",
downloadPath: join(testDirectory, "downloads"),
eventsEnabled: true,
});
await evaluate(`(() => {
const url = new URL("edgeever-resource://resource/${cachedResourceId}");
url.searchParams.set("download", "${downloadFilename}");
const anchor = document.createElement("a");
anchor.href = url.href;
anchor.download = "${downloadFilename}";
document.body.append(anchor);
anchor.click();
anchor.remove();
})()`);
await waitForDownloadedFile();
if (!(await readFile(downloadedPath)).equals(fixtureBytes)) throw new Error("Downloaded resource bytes changed");
await evaluate(`window.edgeeverDesktop.removeStagedResource(${JSON.stringify(staged.id)})`);
console.log(JSON.stringify({
ok: true,
executablePath,
origin: appProtocol.origin,
checks: ["app-assets", "file-input", "staged-resource", "cached-resource-range", "download"],
}));
} catch (error) {
const details = processOutput.length > 0 ? `\n\nProcess output:\n${processOutput.join("")}` : "";
throw new Error(`${error instanceof Error ? error.stack || error.message : String(error)}${details}`);
} finally {
cdp?.close();
await stopProcess(child);
await rm(testDirectory, { recursive: true, force: true });
}
+16 -6
View File
@@ -1,4 +1,4 @@
import { expect, test, type APIRequestContext, type Page } from "@playwright/test";
import { expect, test, type APIRequestContext, type Locator, type Page } from "@playwright/test";
type Prompt = {
id: string;
@@ -94,6 +94,18 @@ const selectAction = async (dialog: ReturnType<Page["getByRole"]>, optionName: s
await dialog.page().getByRole("option", { name: optionName, exact: true }).click();
};
const focusParagraphEnd = async (editor: Locator) => {
await editor.locator(":scope > p").last().evaluate((paragraph) => {
const range = document.createRange();
range.selectNodeContents(paragraph);
range.collapse(false);
const selection = window.getSelection();
selection?.removeAllRanges();
selection?.addRange(range);
(paragraph.closest("[contenteditable='true']") as HTMLElement | null)?.focus();
});
};
test.describe("AI custom prompts", () => {
let notebookId: string;
let notebookName: string;
@@ -178,7 +190,7 @@ test.describe("AI custom prompts", () => {
]);
expect(initialBox).not.toBeNull();
expect(handleBox).not.toBeNull();
expect(handleBox!.y).toBeCloseTo(initialBox!.y, 0);
expect(Math.abs(handleBox!.y - initialBox!.y)).toBeLessThanOrEqual(1);
expect(handleBox!.height).toBeGreaterThanOrEqual(60);
const viewport = page.viewportSize();
expect(viewport).not.toBeNull();
@@ -424,8 +436,7 @@ test.describe("AI custom prompts", () => {
const editor = page.locator(".ProseMirror[contenteditable='true']");
await expect(editor).toBeVisible();
await editor.click();
await page.keyboard.press("End");
await focusParagraphEnd(editor);
await page.keyboard.press("Enter");
const emptyParagraph = editor.locator("p").last();
await expect(emptyParagraph).toHaveClass(/is-empty/);
@@ -473,8 +484,7 @@ test.describe("AI custom prompts", () => {
await page.getByRole("button", { name: "返回上一页", exact: true }).click();
const editor = page.locator(".ProseMirror[contenteditable='true']");
await expect(editor).toBeVisible();
await editor.click();
await page.keyboard.press("End");
await focusParagraphEnd(editor);
await page.keyboard.press("Enter");
const emptyParagraph = editor.locator("p").last();
await expect(emptyParagraph).toHaveAttribute("data-placeholder", "/ 浏览命令 · @ 引用笔记");
+19
View File
@@ -0,0 +1,19 @@
import { request, type FullConfig } from "@playwright/test";
export const E2E_STORAGE_STATE_PATH = "test-results/e2e-storage-state.json";
export default async function globalSetup(config: FullConfig) {
const baseURL = config.projects[0]?.use.baseURL;
if (typeof baseURL !== "string") throw new Error("Playwright E2E baseURL is missing");
const context = await request.newContext({ baseURL });
try {
const response = await context.get("/api/v1/auth/session");
if (!response.ok()) {
throw new Error(`E2E session initialization failed: HTTP ${response.status()}`);
}
await context.storageState({ path: E2E_STORAGE_STATE_PATH });
} finally {
await context.dispose();
}
}
+4 -2
View File
@@ -64,9 +64,11 @@ test("keeps the caret and rebases edits made while autosave sync is in flight",
const marker = `sync-race-${Date.now()}`;
const notebooksResponse = await page.request.get("/api/v1/notebooks");
expect(notebooksResponse.ok()).toBe(true);
const notebooks = await notebooksResponse.json() as { notebooks: Array<{ id: string }> };
const notebooks = await notebooksResponse.json() as { notebooks: Array<{ id: string; name: string }> };
const notebookId = notebooks.notebooks[0]?.id;
const notebookName = notebooks.notebooks[0]?.name;
expect(notebookId).toBeTruthy();
expect(notebookName).toBeTruthy();
const createResponse = await page.request.post("/api/v1/memos", {
data: {
@@ -99,7 +101,7 @@ test("keeps the caret and rebases edits made while autosave sync is in flight",
try {
await page.goto("/");
await page.getByRole("button", { name: "全部笔记", exact: true }).click();
await page.getByRole("button", { name: new RegExp(notebookName!) }).click();
await page.getByPlaceholder("搜索笔记").fill(marker);
await page.locator(`[data-memo-id="${memoId}"]`).locator("button").first().click();
await page.getByRole("button", { name: "清空搜索", exact: true }).click();