diff --git a/.github/workflows/desktop-build.yml b/.github/workflows/desktop-build.yml index 289c456d..47e37bd8 100644 --- a/.github/workflows/desktop-build.yml +++ b/.github/workflows/desktop-build.yml @@ -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")" diff --git a/apps/web/src/components/dialogs/AiAssistantDialog.tsx b/apps/web/src/components/dialogs/AiAssistantDialog.tsx index 00c60a30..555e7c8e 100644 --- a/apps/web/src/components/dialogs/AiAssistantDialog.tsx +++ b/apps/web/src/components/dialogs/AiAssistantDialog.tsx @@ -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[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 ( diff --git a/apps/web/src/components/editor/image-resource-menu-position.test.mjs b/apps/web/src/components/editor/image-resource-menu-position.test.mjs index 82a0040c..005044ad 100644 --- a/apps/web/src/components/editor/image-resource-menu-position.test.mjs +++ b/apps/web/src/components/editor/image-resource-menu-position.test.mjs @@ -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); diff --git a/apps/web/src/components/editor/image-resource-menu-position.ts b/apps/web/src/components/editor/image-resource-menu-position.ts index 62dae638..6c8fbbb1 100644 --- a/apps/web/src/components/editor/image-resource-menu-position.ts +++ b/apps/web/src/components/editor/image-resource-menu-position.ts @@ -5,7 +5,7 @@ export const imageResourceMenuPosition: Partial = { placement: "bottom-end", strategy: "fixed", middleware: [ - offset(8), + offset(({ rects }) => Math.max(8, 56 - rects.reference.height)), flip({ padding: 12 }), shift({ padding: 12 }), ], diff --git a/package.json b/package.json index 104d4816..74f7122b 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/playwright.config.ts b/playwright.config.ts index 0eb803fb..27aaba13 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -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", }, diff --git a/scripts/desktop-build-workflow.test.mjs b/scripts/desktop-build-workflow.test.mjs index 1e444415..2d4a9926 100644 --- a/scripts/desktop-build-workflow.test.mjs +++ b/scripts/desktop-build-workflow.test.mjs @@ -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]"); diff --git a/scripts/verify-desktop-protocol-e2e.mjs b/scripts/verify-desktop-protocol-e2e.mjs new file mode 100644 index 00000000..aba2cc8b --- /dev/null +++ b/scripts/verify-desktop-protocol-e2e.mjs @@ -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 || ""}`); +} + +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 }); +} diff --git a/tests/e2e/ai-custom-prompts.spec.ts b/tests/e2e/ai-custom-prompts.spec.ts index e0ce0e6f..a3fad130 100644 --- a/tests/e2e/ai-custom-prompts.spec.ts +++ b/tests/e2e/ai-custom-prompts.spec.ts @@ -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, 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", "/ 浏览命令 · @ 引用笔记"); diff --git a/tests/e2e/global-setup.ts b/tests/e2e/global-setup.ts new file mode 100644 index 00000000..1dc99688 --- /dev/null +++ b/tests/e2e/global-setup.ts @@ -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(); + } +} diff --git a/tests/e2e/memo-sync-race.spec.ts b/tests/e2e/memo-sync-race.spec.ts index 364b7c98..7a1ffd65 100644 --- a/tests/e2e/memo-sync-race.spec.ts +++ b/tests/e2e/memo-sync-race.spec.ts @@ -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();