test desktop private protocol file flows
This commit is contained in:
1 parent
a42922dbd0
commit
b82719abdb
11 files changed
+392
-17
No files matched your search
@@ -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 }),
|
||||
],
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
|
||||
@@ -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]");
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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", "/ 浏览命令 · @ 引用笔记");
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
Reference in new issue
Block a user