431 lines
21 KiB
JavaScript
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);
|