Files
edgeever/tests/desktop-note-window.integration.mjs
edgeever-updater[bot] eaf37b4aeb chore: update EdgeEver from v2.16.1
EdgeEver-Upstream-Commit: 6e79c1bd81a3678b1d0d01f5476564cfecdd40d8
2026-10-31 08:26:31 +00:00

431 lines
21 KiB
JavaScript

// Packaged desktop E2E for "open a note in a new window" (#67, #730).
// Usage: node tests/desktop-note-window.integration.mjs <packaged executable>
// Starts the test API (tests/desktop-note-window-api.mjs, under Bun) on a
// temporary SQLite database, launches the packaged app against it and drives
// the main window and note windows. The driver runs under Node because
// Playwright cannot open its Electron connection from Bun 1.3.
import assert from "node:assert/strict";
import { execFileSync, spawn } from "node:child_process";
import { desktopMenuCopy } from "../apps/desktop/src/main/desktop-menu.mjs";
import { _electron as electron, expect } from "@playwright/test";
import { mkdir, mkdtemp, readdir, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join, resolve } from "node:path";
assert.ok(process.argv[2], "Pass the locally built desktop executable as the first argument.");
const executablePath = resolve(process.argv[2]);
const only = process.argv[3]?.split(",") ?? null;
const directory = await mkdtemp(join(tmpdir(), "edgeever-note-window-e2e-"));
const profile = join(directory, "profile");
const results = [];
let application;
let server;
let page;
let note;
let baseUrl;
// Pausing storage simulates a sidecar that stops answering. Only this test's
// sidecar is matched, by its data directory.
const sidecarPids = () => {
try {
return execFileSync("pgrep", ["-f", `edgeever-sidecar --data-dir ${profile}`]).toString().trim().split("\n").filter(Boolean);
} catch {
return [];
}
};
const signalSidecars = (signal) => {
for (const pid of sidecarPids()) {
try { process.kill(Number(pid), signal); } catch {}
}
};
// Pausing storage relies on POSIX signals; Windows runs the other scenarios.
const pausesStorage = new Set(["D1", "E1"]);
// A timed-out scenario keeps running against the shared app, so later steps cannot trust its state.
let timedOut = false;
const step = async (name, run) => {
if (only && !only.includes(name.split(":")[0])) return;
if (timedOut) {
console.log(`SKIP ${name} (an earlier scenario timed out)`);
return;
}
if (process.platform === "win32" && pausesStorage.has(name.split(":")[0])) {
console.log(`SKIP ${name} (needs POSIX signals)`);
return;
}
const started = Date.now();
let timer;
try {
await Promise.race([
run(),
new Promise((_, reject) => {
timer = setTimeout(() => {
timedOut = true;
reject(new Error("step timeout"));
}, 180_000);
}),
]);
results.push({ name, ok: true, ms: Date.now() - started });
} catch (error) {
signalSidecars("SIGCONT");
results.push({ name, ok: false, error: String(error?.message || error).split("\n").slice(0, 6).join(" | ") });
} finally {
clearTimeout(timer);
}
const result = results.at(-1);
console.log(`${result.ok ? "PASS" : "FAIL"} ${name}${result.ok ? ` (${result.ms} ms)` : `: ${result.error}`}`);
};
const api = async (path, init) => {
const response = await fetch(`${baseUrl}${path}`, { ...init, headers: { "Content-Type": "application/json", ...(init?.headers ?? {}) } });
if (!response.ok) throw new Error(`${path} ${response.status}`);
return response.json();
};
let notebookId;
const createMemo = async (title, contentMarkdown) =>
(await api("/api/v1/memos", { method: "POST", body: JSON.stringify({ notebookId, title, contentMarkdown }) })).memo;
const serverMarkdown = async (id) => (await api(`/api/v1/memos/${id}`)).memo.contentMarkdown;
// An edit made on another device: through the API with its own edit session.
const editOnServer = async (id, suffix) => {
const { memo } = await api(`/api/v1/memos/${id}`);
const { editSession } = await api(`/api/v1/memos/${id}/edit-sessions`, { method: "POST" });
await api(`/api/v1/memos/${id}`, { method: "PATCH", body: JSON.stringify({
editSessionId: editSession.id,
expectedRevision: editSession.baseRevision,
expectedContentHash: editSession.baseContentHash,
contentMarkdown: `${memo.contentMarkdown}\n\n${suffix}`,
}) });
};
// Hosted Windows runners have a 1024x768 screen, which leaves the window below
// the 1024px desktop layout; a smaller scale factor gives it the desktop layout.
const displayArgs = process.platform === "win32" ? ["--force-device-scale-factor=0.75"] : [];
const launch = async () => {
application = await electron.launch({
executablePath,
args: [`--user-data-dir=${profile}`, ...displayArgs],
env: { ...process.env, EDGE_EVER_DISABLE_AUTO_UPDATE: "1" },
timeout: 60_000,
});
// Startup can briefly open a renderer-origin migration window before the main window.
const deadline = Date.now() + 60_000;
let main;
while (Date.now() < deadline && !main) {
main = application.windows().find((window) => window.url().startsWith("edgeever-app://app/index.html") && !window.url().includes("noteWindow="));
if (!main) await new Promise((resolveWait) => setTimeout(resolveWait, 100));
}
assert.ok(main, "The packaged main window must open");
await main.waitForSelector('[data-edgeever-renderer-ready="true"]', { state: "attached", timeout: 60_000 });
await main.evaluate(() => localStorage.setItem("edgeever.locale.preference", "en-US"));
await main.reload();
await main.waitForSelector('[data-edgeever-renderer-ready="true"]', { state: "attached", timeout: 60_000 });
page = main;
note = undefined;
// Wait until the first sync brought the test notebook into local storage.
// The sidecar can still be starting right after the reload.
await expect.poll(() => page.evaluate(async (id) => {
const { notebooks } = await window.edgeeverDesktop.sidecarRequest("notebook.list");
return notebooks.some((notebook) => notebook.id === id);
}, notebookId).catch(() => false), { timeout: 60_000 }).toBe(true);
// Search covers the selected notebook, and a seeded demo notebook is selected first.
await page.getByRole("button", { name: "All notes", exact: true }).click({ timeout: 30_000 }).catch(async (error) => {
await reportStartupState();
throw error;
});
};
// Print what the main window shows when startup does not reach the notes list.
const reportStartupState = async () => {
const state = await page.evaluate(() => ({
size: `${window.innerWidth}x${window.innerHeight}`,
locale: document.documentElement.lang,
buttons: [...document.querySelectorAll("button")].map((button) => button.getAttribute("aria-label") || button.innerText.trim()).filter(Boolean).slice(0, 40),
text: document.body.innerText.slice(0, 1500),
})).catch((error) => ({ error: String(error) }));
console.log("Main window at startup failure:", JSON.stringify(state, null, 2));
if (process.env.EDGE_EVER_E2E_SCREENSHOT) await page.screenshot({ path: process.env.EDGE_EVER_E2E_SCREENSHOT }).catch(() => undefined);
};
const noteWindowPage = async (memoId, timeout = 20_000) => {
const deadline = Date.now() + timeout;
while (Date.now() < deadline) {
const found = application.windows().find((window) => window.url().includes(`noteWindow=${memoId}`));
if (found) return found;
await new Promise((resolveWait) => setTimeout(resolveWait, 100));
}
return null;
};
const noteWindowCount = () => application.windows().filter((window) => window.url().includes("noteWindow=")).length;
const waitForNoteWindowClosed = async (memoId, timeout = 20_000) => {
const deadline = Date.now() + timeout;
while (Date.now() < deadline) {
if (!application.windows().some((window) => window.url().includes(`noteWindow=${memoId}`))) return;
await new Promise((resolveWait) => setTimeout(resolveWait, 100));
}
throw new Error("note window did not close");
};
const focusedUrl = () => application.evaluate(({ BrowserWindow }) => BrowserWindow.getFocusedWindow()?.webContents.getURL() ?? null);
const focusMain = () => application.evaluate(({ BrowserWindow }) => {
const main = BrowserWindow.getAllWindows().find((window) => !window.webContents.getURL().includes("noteWindow="));
main.show();
main.focus();
});
const syncNow = () => application.evaluate(({ BrowserWindow }) => {
BrowserWindow.getAllWindows().find((window) => !window.webContents.getURL().includes("noteWindow="))
.webContents.send("desktop:command", "sync-now");
});
const closeWindowLikeUser = (memoId) => application.evaluate(({ BrowserWindow }, id) => {
BrowserWindow.getAllWindows().find((window) => window.webContents.getURL().includes(`noteWindow=${id}`))?.close();
}, memoId);
// The application menu follows the OS language, not the app language.
const menuLabels = (key) => ["en-US", "zh-CN", "ja", "pl", "ru"].map((locale) => desktopMenuCopy(locale)[key]).filter(Boolean);
const clickMenuItem = (key) => application.evaluate(({ Menu }, labels) => {
const find = (items) => {
for (const item of items) {
if (labels.includes(item.label)) return item;
const nested = item.submenu && find(item.submenu.items);
if (nested) return nested;
}
return null;
};
const item = find(Menu.getApplicationMenu().items);
if (!item) throw new Error(`menu item ${labels[0]} missing`);
item.click();
}, menuLabels(key));
const noteEditor = () => note.locator(".ProseMirror[contenteditable='true']");
const mainEditor = () => page.locator(".ProseMirror[contenteditable='true']");
const typeAtEnd = async (target, editor, text) => {
await editor.click();
await target.keyboard.press("ControlOrMeta+End");
await target.keyboard.type(text);
};
const openInMain = async (memo) => {
await page.getByPlaceholder("Search notes").first().fill(memo.title);
const card = page.locator(`[data-memo-id="${memo.id}"]`).first();
await expect(card).toBeVisible({ timeout: 30_000 });
await card.locator("button").first().click();
await expect(mainEditor()).toContainText(memo.contentMarkdown.split("\n")[0], { timeout: 20_000 });
return mainEditor();
};
const chooseOpenInNewWindow = async () => {
await page.getByRole("button", { name: "More note actions" }).click({ timeout: 15_000 });
await page.getByRole("menuitem", { name: "Open in new window" }).click({ timeout: 15_000 });
};
const closeNotice = async () => {
await page.keyboard.press("Escape");
await page.getByRole("button", { name: /OK|Close|Got it/i }).first().click({ timeout: 2_000 }).catch(() => undefined);
};
const outboxStatuses = (memoId) => page.evaluate(async (id) => {
const { items } = await window.edgeeverDesktop.sidecarRequest("sync.outbox.list", { limit: 200, includeConflicts: true });
return items.filter((item) => item.entityId === id).map((item) => item.status);
}, memoId);
try {
server = spawn("bun", ["tests/desktop-note-window-api.mjs", directory], { stdio: ["pipe", "pipe", "inherit"] });
baseUrl = await new Promise((resolveUrl, reject) => {
let output = "";
server.on("error", reject);
server.on("exit", (code) => reject(new Error(`test API exited with code ${code}`)));
server.stdout.on("data", (chunk) => {
output += chunk;
const ready = output.match(/READY (\S+)/);
if (ready) resolveUrl(ready[1]);
});
});
notebookId = (await api("/api/v1/notebooks", { method: "POST", body: JSON.stringify({ name: "Note windows" }) })).notebook.id;
const memoA = await createMemo("Window A", "Alpha body");
const memoB = await createMemo("Window B", "Bravo body");
const memoC = await createMemo("Window C", "Charlie body");
const memoD = await createMemo("Window D", "Delta body");
const memoE = await createMemo("Window E", "Echo body");
await mkdir(profile, { recursive: true });
await writeFile(join(profile, "instance-url"), baseUrl);
await writeFile(join(profile, "installation-confirmed"), "test");
await launch();
await step("A1: unsaved edits move with the note to its new window", async () => {
await typeAtEnd(page, await openInMain(memoA), " typed-before-move");
await chooseOpenInNewWindow();
note = await noteWindowPage(memoA.id);
assert.ok(note, "note window opened");
await expect(noteEditor()).toContainText("typed-before-move", { timeout: 20_000 });
await expect(page.getByTestId("note-in-separate-window")).toBeVisible();
await expect(mainEditor()).toHaveCount(0);
assert.equal(noteWindowCount(), 1);
});
await step("A2: opening the note again from any entry focuses the same window", async () => {
assert.deepEqual(await page.evaluate((id) => window.edgeeverDesktop.openNoteWindow(id), memoA.id), { opened: false });
await focusMain();
await clickMenuItem("openNoteWindow");
await expect.poll(focusedUrl).toContain(`noteWindow=${memoA.id}`);
await focusMain();
await page.locator(`[data-memo-id="${memoA.id}"]`).first().locator("button").first().click();
await expect.poll(focusedUrl).toContain(`noteWindow=${memoA.id}`);
await focusMain();
await page.getByRole("button", { name: "Show window" }).click();
await expect.poll(focusedUrl).toContain(`noteWindow=${memoA.id}`);
assert.equal(noteWindowCount(), 1);
});
await step("A3: a note window cannot open windows, switch accounts or change the instance", async () => {
await assert.rejects(note.evaluate((id) => window.edgeeverDesktop.openNoteWindow(id), memoB.id));
await assert.rejects(note.evaluate(() => window.edgeeverDesktop.setAccountScope("someone-else")));
await assert.rejects(note.evaluate(() => window.edgeeverDesktop.setApiBaseUrl("http://127.0.0.1:1")));
assert.equal((await note.evaluate(() => window.edgeeverDesktop.noteWindows())).length, 0);
});
await step("B1: closing the note window saves pending edits and returns the note", async () => {
await typeAtEnd(note, noteEditor(), " typed-in-window");
await closeWindowLikeUser(memoA.id);
await waitForNoteWindowClosed(memoA.id);
await expect(page.getByTestId("note-in-separate-window")).toHaveCount(0, { timeout: 10_000 });
await expect(mainEditor()).toContainText("typed-in-window", { timeout: 20_000 });
await syncNow();
await expect.poll(() => serverMarkdown(memoA.id), { timeout: 60_000 }).toContain("typed-in-window");
assert.match(await serverMarkdown(memoA.id), /typed-before-move/);
});
await step("C1: two notes can each have their own window, and links open in the main window", async () => {
await chooseOpenInNewWindow();
note = await noteWindowPage(memoA.id);
await expect(noteEditor()).toContainText("typed-in-window", { timeout: 20_000 });
assert.equal(await note.evaluate((id) => window.edgeeverDesktop.openMemoInMainWindow(id), memoB.id), true);
await expect(mainEditor()).toContainText("Bravo body", { timeout: 20_000 });
await chooseOpenInNewWindow();
assert.ok(await noteWindowPage(memoB.id));
assert.equal(noteWindowCount(), 2);
// Closing right after opening must work too.
await closeWindowLikeUser(memoB.id);
await waitForNoteWindowClosed(memoB.id);
assert.equal(noteWindowCount(), 1);
});
await step("D1: if saving before the move fails, the note stays in the main window", async () => {
await closeWindowLikeUser(memoA.id);
await waitForNoteWindowClosed(memoA.id);
const editor = await openInMain(memoA);
signalSidecars("SIGSTOP");
try {
await typeAtEnd(page, editor, " typed-while-storage-down");
await chooseOpenInNewWindow();
await expect(page.getByText("couldn't be saved, so it stays in this window")).toBeVisible({ timeout: 60_000 });
assert.equal(noteWindowCount(), 0);
await expect(mainEditor()).toContainText("typed-while-storage-down");
} finally {
signalSidecars("SIGCONT");
}
await closeNotice();
});
await step("E1: if saving on close fails, the window stays open; closing again keeps the draft", async () => {
await chooseOpenInNewWindow();
note = await noteWindowPage(memoA.id, 60_000);
await expect(noteEditor()).toContainText("typed-while-storage-down", { timeout: 20_000 });
signalSidecars("SIGSTOP");
try {
await typeAtEnd(note, noteEditor(), " draft-only-edit");
await closeWindowLikeUser(memoA.id);
await expect(note.getByRole("alert")).toContainText("couldn't be saved", { timeout: 60_000 });
assert.equal(noteWindowCount(), 1);
await closeWindowLikeUser(memoA.id);
await waitForNoteWindowClosed(memoA.id);
} finally {
signalSidecars("SIGCONT");
}
await expect(mainEditor()).toContainText("draft-only-edit", { timeout: 30_000 });
});
await step("F1: quitting saves the open note window; windows are not restored", async () => {
await chooseOpenInNewWindow();
note = await noteWindowPage(memoA.id);
await expect(noteEditor()).toContainText("Alpha body", { timeout: 20_000 });
await typeAtEnd(note, noteEditor(), " typed-before-quit");
await application.evaluate(({ app }) => app.quit());
await application.waitForEvent("close", { timeout: 30_000 }).catch(() => undefined);
await launch();
assert.equal(noteWindowCount(), 0, "note windows are not restored after a restart");
await openInMain(memoA);
await expect(mainEditor()).toContainText("typed-before-quit", { timeout: 20_000 });
});
await step("G1: deleting the note in its window closes the window", async () => {
await chooseOpenInNewWindow();
note = await noteWindowPage(memoA.id);
await expect(noteEditor()).toBeVisible({ timeout: 20_000 });
await note.getByRole("button", { name: "More note actions" }).click({ timeout: 15_000 });
await note.getByRole("menuitem", { name: /Move to trash|Delete/ }).first().click({ timeout: 15_000 });
await note.getByRole("button", { name: /Move to trash|Delete/ }).last().click({ timeout: 3_000 }).catch(() => undefined);
await waitForNoteWindowClosed(memoA.id);
await expect(page.locator(`[data-memo-id="${memoA.id}"]`)).toHaveCount(0, { timeout: 30_000 });
});
await step("H1: a crashed note window gives the note back to the main window with a message", async () => {
await openInMain(memoD);
await chooseOpenInNewWindow();
note = await noteWindowPage(memoD.id);
await expect(noteEditor()).toContainText("Delta body", { timeout: 20_000 });
await application.evaluate(({ BrowserWindow }, id) => {
BrowserWindow.getAllWindows().find((window) => window.webContents.getURL().includes(`noteWindow=${id}`)).webContents.forcefullyCrashRenderer();
}, memoD.id);
await waitForNoteWindowClosed(memoD.id);
await expect(page.getByText("closed unexpectedly")).toBeVisible({ timeout: 20_000 });
await closeNotice();
await expect(page.getByTestId("note-in-separate-window")).toHaveCount(0, { timeout: 10_000 });
await expect(mainEditor()).toContainText("Delta body", { timeout: 20_000 });
});
await step("H2: a note the window cannot show is reported and leaves no window", async () => {
await assert.rejects(page.evaluate(() => window.edgeeverDesktop.openNoteWindow("memo_does_not_exist")), /note-unavailable/);
await waitForNoteWindowClosed("memo_does_not_exist");
assert.deepEqual(await page.evaluate(() => window.edgeeverDesktop.noteWindows()), []);
});
await step("I1: a change made on another device reaches the open note window after sync", async () => {
await openInMain(memoE);
await chooseOpenInNewWindow();
note = await noteWindowPage(memoE.id);
await expect(noteEditor()).toContainText("Echo body", { timeout: 20_000 });
await editOnServer(memoE.id, "remote-edit-one");
await syncNow();
await expect(noteEditor()).toContainText("remote-edit-one", { timeout: 40_000 });
});
await step("I2: a sync conflict reaches the note window as it reaches the main window", async () => {
await editOnServer(memoE.id, "remote-edit-two");
// Typing saves locally on the old revision; the main window's sync then conflicts.
await typeAtEnd(note, noteEditor(), " local-edit-two");
await note.waitForTimeout(4_000);
await syncNow();
await expect(noteEditor()).toContainText("remote-edit-two", { timeout: 40_000 });
await expect.poll(() => outboxStatuses(memoE.id), { timeout: 40_000 }).toContain("conflict");
});
} finally {
signalSidecars("SIGCONT");
let closeTimer;
await Promise.race([
application?.close().catch(() => undefined),
new Promise((resolveWait) => { closeTimer = setTimeout(resolveWait, 20_000); }),
]);
clearTimeout(closeTimer);
// Windows keeps the database locked until the API process has exited.
if (server && server.exitCode === null) {
const exited = new Promise((resolveExit) => server.once("exit", resolveExit));
server.stdin.end();
let killTimer;
await Promise.race([exited, new Promise((resolveWait) => { killTimer = setTimeout(resolveWait, 10_000); })]);
clearTimeout(killTimer);
if (server.exitCode === null) {
server.kill();
await exited;
}
}
await rm(directory, { recursive: true, force: true, maxRetries: 5, retryDelay: 500 });
}
const failed = results.filter((result) => !result.ok);
console.log(`Packaged note windows: ${results.length - failed.length}/${results.length} scenarios passed.`);
if (failed.length || !results.length) process.exit(1);