Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 7 additions & 6 deletions apps/desktop/e2e/appearance.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,17 +56,18 @@ test("it is light by default, the sidebar's moon switches to dark, and the choic
expect(await theme(page)).toBe("light");
expect(await style(page, "body", "background-color")).toBe(LIGHT.bg);
expect(await style(page, "body", "color")).toBe(LIGHT.ink);
expect(await style(page, "body", "font-family")).toContain("iA Writer Mono");
expect(await style(page, "body", "font-family")).toContain("ui-sans-serif");
await page.screenshot({ path: join(__dirname, "../test-results/appearance-light.png") });

await page.getByRole("button", { name: "Switch to dark mode" }).click();
expect(await theme(page)).toBe("dark");
expect(await style(page, "body", "background-color")).toBe(DARK.bg);
expect(await style(page, "body", "color")).toBe(DARK.ink);
// Primary actions use the structure signal, with the neutral canvas preserved around them.
// Export is a quiet menu action; the neutral canvas is preserved around it.
await page.locator('summary[aria-label="Page actions"]').click();
const download = page.getByRole("button", { name: "Download PDF" });
await expect(download).toHaveCSS("background-color", DARK.structure);
await expect(download).toHaveCSS("color", DARK.structureInk);
await expect(download).toHaveCSS("background-color", "rgba(0, 0, 0, 0)");
await expect(download).toHaveCSS("color", DARK.ink);
await expect(page.getByRole("button", { name: "Switch to light mode" })).toBeVisible();
await page.screenshot({ path: join(__dirname, "../test-results/appearance-dark.png") });

Expand All @@ -83,7 +84,7 @@ test("the page's text and its diagram follow the theme at once, and the PDF does
const { app, page } = await launch();
try {
// The page body is in the app's type, not BlockNote's own.
expect(await style(page, ".page-editor .bn-editor", "font-family")).toContain("iA Writer Mono");
expect(await style(page, ".page-editor .bn-editor", "font-family")).toContain("ui-sans-serif");
expect(await labelFill(page)).toBe(LIGHT.bg);

await page.getByRole("button", { name: "Switch to dark mode" }).click();
Expand Down Expand Up @@ -112,7 +113,7 @@ test("Settings: Theme follows the system when asked to, and Type changes the fac
"aria-checked",
"true",
);
await expect(type.getByRole("radio", { name: "Mono" })).toHaveAttribute("aria-checked", "true");
await expect(type.getByRole("radio", { name: "Sans" })).toHaveAttribute("aria-checked", "true");

// System follows the machine, also while the app runs.
await page.emulateMedia({ colorScheme: "dark" });
Expand Down
3 changes: 2 additions & 1 deletion apps/desktop/e2e/backup-recovery.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { mkdtempSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { _electron as electron, expect, test } from "@playwright/test";
import { maximizeWindow } from "./helpers";
import { maximizeWindow, openPageActions } from "./helpers";

test("an automatic backup can be restored as a new page without replacing the current page", async () => {
test.setTimeout(60_000);
Expand All @@ -23,6 +23,7 @@ test("an automatic backup can be restored as a new page without replacing the cu
await page.reload();
await expect(page.getByLabel("Page title")).toHaveValue("Current");

await openPageActions(page);
await page.getByRole("button", { name: "Backups" }).click();
const dialog = page.getByRole("dialog", { name: "Automatic backups" });
await expect(dialog).toContainText("never replaces this one");
Expand Down
2 changes: 1 addition & 1 deletion apps/desktop/e2e/block-numbers.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -235,7 +235,7 @@ test("what the person says reaches the model as the reference the margin shows",
await expect(labels(page)).toHaveCount(6);
await page
.getByRole("group", { name: "Mode" })
.getByRole("button", { name: "Command" })
.getByRole("button", { name: "Change page" })
.click();
const bar = page.getByLabel("Dictate or type your notes");
await bar.fill("Cross out item 2 of block 6");
Expand Down
78 changes: 78 additions & 0 deletions apps/desktop/e2e/change-review.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import { mkdtempSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { _electron as electron, expect, test } from "@playwright/test";
import { startMockLmStudio } from "@lucid/mock-lmstudio";

test("a selected passage can be previewed, discarded, kept and undone", async () => {
const mock = await startMockLmStudio({ script: [] });
const app = await electron.launch({
args: [join(__dirname, "../out/main/index.js")],
env: {
...process.env,
LUCID_HOME: mkdtempSync(join(tmpdir(), "lucid-review-")),
LUCID_LLM_URL: mock.url,
},
});
try {
const page = await app.firstWindow();
await page.emulateMedia({ colorScheme: "light" });
await app.evaluate(({ BrowserWindow }) => BrowserWindow.getAllWindows()[0]?.setSize(1280, 820));
await page.evaluate(async () => {
const file = await window.lucid.pages.create("A considered change");
await window.lucid.pages.save({
...file,
doc: {
blocks: [
{
id: "keep",
type: "paragraph",
props: {},
content: [{ type: "text", text: "Keep the surrounding thought.", styles: {} }],
},
{
id: "change",
type: "paragraph",
props: {},
content: [{ type: "text", text: "An unnecessary paragraph.", styles: {} }],
},
],
},
});
});
await page.reload();
const select = async () => {
await page.getByText("An unnecessary paragraph.", { exact: true }).dblclick();
await page.getByRole("button", { name: "Change passage", exact: true }).click();
await expect(page.locator(".passage-source")).toContainText("An unnecessary paragraph.");
await expect(page.locator(".passage-source")).not.toContainText(
"Keep the surrounding thought.",
);
await page.getByLabel("Dictate or type your notes").fill("delete this block");
await page.getByLabel("Dictate or type your notes").press("Enter");
await expect(page.getByRole("button", { name: "Keep change", exact: true })).toBeVisible();
};
await select();
await expect(page.locator(".review-document")).toContainText("Keep the surrounding thought.");
await expect(page.locator(".review-document")).not.toContainText("An unnecessary paragraph.");
await page.getByRole("button", { name: "Original", exact: true }).click();
await expect(page.locator(".review-document")).toContainText("An unnecessary paragraph.");
await page.getByRole("button", { name: "Result", exact: true }).click();
await page.screenshot({ path: "test-results/change-review-wide.png" });
await page.getByRole("button", { name: "Discard", exact: true }).click();
await expect(page.locator(".page-editor")).toContainText("An unnecessary paragraph.");
await select();
await app.evaluate(({ BrowserWindow }) => BrowserWindow.getAllWindows()[0]?.setSize(820, 700));
await page.screenshot({ path: "test-results/change-review-narrow.png" });
await page.getByRole("button", { name: "Keep change", exact: true }).click();
await expect(page.locator(".review-document")).toHaveCount(0);
await expect(page.locator(".page-editor")).not.toContainText("An unnecessary paragraph.");
await expect(page.locator(".page-editor")).toContainText("Keep the surrounding thought.");
await page.locator(".bn-editor").click();
await page.keyboard.press("Meta+z");
await expect(page.locator(".page-editor")).toContainText("An unnecessary paragraph.");
} finally {
await app.close();
await mock.close();
}
});
17 changes: 14 additions & 3 deletions apps/desktop/e2e/command.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,9 @@ const say = async (page: Page, text: string) => {
await bar(page).press("Enter");
};
const modeButton = (page: Page, name: "Input" | "Command") =>
page.getByRole("group", { name: "Mode" }).getByRole("button", { name });
page
.getByRole("group", { name: "Mode" })
.getByRole("button", { name: name === "Input" ? "Add notes" : "Change page" });
/** The list item with this text: where the page draws a crossed-out option. */
const struck = (page: Page, text: string) =>
page
Expand All @@ -73,10 +75,10 @@ test("the mode pill, the ⌘/ shortcut and the spoken 'command mode' all switch
const { app, page } = await launch([]);
try {
await expect(modeButton(page, "Input")).toHaveAttribute("aria-pressed", "true");
await expect(bar(page)).toHaveAttribute("placeholder", /Speak or type your notes/);
await expect(bar(page)).toHaveAttribute("placeholder", /Type a thought/);
await modeButton(page, "Command").click();
await expect(modeButton(page, "Command")).toHaveAttribute("aria-pressed", "true");
await expect(bar(page)).toHaveAttribute("placeholder", /Say or type what to change/);
await expect(bar(page)).toHaveAttribute("placeholder", /Describe what to change/);
await page.keyboard.press("Meta+/");
await expect(modeButton(page, "Input")).toHaveAttribute("aria-pressed", "true");
await say(page, "Command mode.");
Expand Down Expand Up @@ -169,10 +171,19 @@ test("a command that throws something away waits for a yes: by button, or by voi
await modeButton(page, "Command").click();
await say(page, "clean up the comparison");
await expect(page.getByText("Apply this change?")).toBeVisible({ timeout: 20_000 });
await expect(page.locator(".page-editor .bn-editor")).toHaveAttribute(
"contenteditable",
"false",
);
await expect(page.getByLabel("Page title")).toHaveAttribute("readonly", "");
await expect(page.locator(".lucid-table")).toBeVisible();
await page.screenshot({ path: join(__dirname, "../test-results/app-proposal.png") });
await page.getByRole("button", { name: "Cancel" }).click();
await expect(page.getByText("Apply this change?")).toHaveCount(0);
await expect(page.locator(".page-editor .bn-editor")).toHaveAttribute(
"contenteditable",
"true",
);
await expect(page.locator(".lucid-table")).toBeVisible();

await say(page, "clean up the comparison");
Expand Down
14 changes: 8 additions & 6 deletions apps/desktop/e2e/dictation.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -232,13 +232,13 @@ async function wisprPaste(app: ElectronApplication, page: Page, words: string):
}
const requestsSent = () => mock?.requests.length ?? 0;

test("a paste goes to the input box and is sent, wherever the focus is: on a button, on nothing, on the read-only title", async () => {
test("paste on chrome goes to capture; paste in the editable title stays in the title", async () => {
test.setTimeout(120_000);
const script = Array.from({ length: 3 }, () => ({ kind: "reply" as const, content: notes }));
const { app, page } = await launch({ script });
try {
// 1. After clicking a button (focus is on it, which cannot take text).
await page.getByRole("button", { name: /^History/ }).focus();
await page.locator('summary[aria-label="Page actions"]').focus();
await wisprPaste(app, page, "Load balancer first thing. ");
await expect.poll(requestsSent, { timeout: 30_000 }).toBe(1);
const sentFirst = JSON.stringify(mock?.requests[0]?.body);
Expand All @@ -256,11 +256,13 @@ test("a paste goes to the input box and is sent, wherever the focus is: on a but
await expect.poll(requestsSent, { timeout: 30_000 }).toBe(2);
expect(JSON.stringify(mock?.requests[1]?.body)).toContain("Health checks keep traffic away");

// 3. With the cursor in the page title, which is read-only outside Edit mode.
// 3. The title is directly editable: preserve the user's intended paste target.
await expect(page.locator(".dictation")).toHaveAttribute("data-status", "done");
await page.getByLabel("Page title").fill("");
await page.getByLabel("Page title").focus();
await wisprPaste(app, page, "The database can become the bottleneck. ");
await expect.poll(requestsSent, { timeout: 30_000 }).toBe(3);
await expect(page.getByLabel("Page title")).toHaveValue("Load balancing");
await wisprPaste(app, page, "Database bottlenecks");
await expect(page.getByLabel("Page title")).toHaveValue("Database bottlenecks");
expect(requestsSent()).toBe(2);
} finally {
await app.close();
}
Expand Down
59 changes: 45 additions & 14 deletions apps/desktop/e2e/editing.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,15 +87,14 @@ test("one view that scales: the page, its text and the input box grow with the w
};
});

// The maximized window itself (what a person gets): everything fits, and a wide one shows the history by itself.
// The maximized window itself (what a person gets): everything fits, while History stays out of the way until asked for.
const maximized = await measure();
expect(maximized.overflow, "maximized: no horizontal scrolling").toBe(false);
expect(maximized.barBottom, "maximized: the input box is on screen").toBeLessThanOrEqual(
maximized.innerHeight,
);
expect(maximized.barRight).toBeLessThanOrEqual(maximized.innerWidth);
if (maximized.innerWidth >= 1500)
await expect(page.getByRole("complementary", { name: "History" })).toBeVisible();
await expect(page.getByRole("complementary", { name: "History" })).toBeHidden();
await page.screenshot({ path: join(__dirname, "../test-results/app-maximized.png") });

const sizes = [
Expand Down Expand Up @@ -140,37 +139,42 @@ test("one view that scales: the page, its text and the input box grow with the w
}
});

test("the page is read-only until Edit mode; ⌘E, Esc and 'edit mode' switch; the side panel and page list hide", async () => {
test("the page stays directly editable; legacy editing shortcuts and collapsible panels still work", async () => {
test.setTimeout(90_000);
const { app, page } = await openNewPage();
try {
const pill = (name: string) =>
page.getByRole("group", { name: "Mode" }).getByRole("button", { name });
const editable = () => page.locator(".page-editor .bn-editor").getAttribute("contenteditable");

// A new page starts in Edit mode.
await expect(pill("Edit")).toHaveAttribute("aria-pressed", "true");
await expect(pill("Edit")).toHaveCount(0);
await page.keyboard.type("typed by hand");
expect(await editable()).toBe("true");

// Esc goes back to taking notes: the page can no longer be typed in, and there is one input box.
// Esc focuses capture without making the document read-only.
await page.keyboard.press("Escape");
await expect(pill("Input")).toHaveAttribute("aria-pressed", "true");
expect(await editable()).toBe("false");
await expect(pill("Add notes")).toHaveAttribute("aria-pressed", "true");
expect(await editable()).toBe("true");
await expect(page.getByLabel("Dictate or type your notes")).toBeFocused();

// ⌘E toggles Edit mode, and the box gives way to a hint.
// Direct editing needs no mode button, even after capture was focused.
await page.locator(".page-editor .bn-editor").click();
await page.keyboard.press("End");
await page.keyboard.type(" directly");
await expect(page.locator(".page-editor")).toContainText("directly");

// Legacy shortcut focuses the page; the capture control remains available.
await page.keyboard.press("Meta+e");
await expect(pill("Edit")).toHaveAttribute("aria-pressed", "true");
await expect(page.getByText("Editing by hand.")).toBeVisible();
await expect(page.locator(".page-editor .bn-editor")).toBeFocused();
await expect(page.getByLabel("Dictate or type your notes")).toBeVisible();
expect(await editable()).toBe("true");
await page.keyboard.press("Meta+e");
await expect(pill("Input")).toHaveAttribute("aria-pressed", "true");
await expect(pill("Add notes")).toHaveAttribute("aria-pressed", "true");

// Saying "edit mode" does the same, without asking the model.
await page.getByLabel("Dictate or type your notes").fill("edit mode");
await page.getByLabel("Dictate or type your notes").press("Enter");
await expect(pill("Edit")).toHaveAttribute("aria-pressed", "true");
await expect(page.locator(".page-editor .bn-editor")).toBeFocused();
await page.keyboard.press("Escape");

// The panel on the right shows what was said, and hides.
Expand All @@ -188,3 +192,30 @@ test("the page is read-only until Edit mode; ⌘E, Esc and 'edit mode' switch; t
await app.close();
}
});

test("Insert exposes both builders and closes with Escape or a choice", async () => {
const { app, page } = await openNewPage();
try {
const insert = page.locator(".insert-menu > summary");
await insert.focus();
await page.keyboard.press("Enter");
await expect(page.locator(".insert-popover")).toBeVisible();
await page.keyboard.press("Escape");
await expect(page.locator(".insert-popover")).toBeHidden();
await expect(insert).toBeFocused();
for (const kind of ["Diagram", "Table"]) {
await insert.click();
await page
.locator(".insert-popover")
.getByRole("button", { name: kind, exact: true })
.click();
const workbench = page.getByRole("region", { name: `${kind} builder` });
await expect(workbench).toBeVisible();
await expect(page.locator(".insert-popover")).toBeHidden();
await workbench.getByRole("button", { name: "Cancel", exact: true }).click();
await expect(page.locator(".page-editor")).toBeVisible();
}
} finally {
await app.close();
}
});
3 changes: 2 additions & 1 deletion apps/desktop/e2e/export-markdown.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { tmpdir } from "node:os";
import { join } from "node:path";
import { COMMAND_DOC } from "@lucid/llm";
import { _electron as electron, expect, test } from "@playwright/test";
import { autoSaveDialog, maximizeWindow } from "./helpers";
import { autoSaveDialog, maximizeWindow, openPageActions } from "./helpers";

test("the page can be saved as Markdown, from the button and by voice", async () => {
test.setTimeout(60_000);
Expand All @@ -23,6 +23,7 @@ test("the page can be saved as Markdown, from the button and by voice", async ()
await expect(page.locator(".page-editor")).toContainText("MongoDB");

const path = await autoSaveDialog(app);
await openPageActions(page);
await page.getByRole("button", { name: "Markdown" }).click();
await expect(page.getByText(/^Saved page\.pdf/)).toBeVisible();
const md = readFileSync(path, "utf8");
Expand Down
1 change: 1 addition & 0 deletions apps/desktop/e2e/faults.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,7 @@ test("without Tectonic the PDF says how to install it, and Markdown still works"
await page.reload();
await expect(page.locator(".page-editor")).toContainText("MongoDB");

await page.locator('summary[aria-label="Page actions"]').click();
await page.getByRole("button", { name: "Download PDF" }).click();
const note = page.locator(".export-note");
await expect(note).toContainText("The PDF couldn’t be made", { timeout: 60_000 });
Expand Down
9 changes: 9 additions & 0 deletions apps/desktop/e2e/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,13 @@ import { expect, type ElectronApplication, type Page } from "@playwright/test";

const PDFTOTEXT = "/opt/homebrew/bin/pdftotext";

/** Opens the progressive-disclosure menu without closing it when it is already open. */
export async function openPageActions(page: Page): Promise<void> {
const menu = page.locator("details.workspace-menu");
if ((await menu.getAttribute("open")) === null)
await menu.locator('summary[aria-label="Page actions"]').click();
}

/** Answers the Save dialog with a file in a temp folder, so Download PDF can be driven without a person. */
export async function autoSaveDialog(app: ElectronApplication): Promise<string> {
const path = join(mkdtempSync(join(tmpdir(), "lucid-dl-")), "page.pdf");
Expand All @@ -21,6 +28,7 @@ export async function downloadPdf(
path: string,
timeoutMs = 90_000,
): Promise<string | undefined> {
await openPageActions(page);
await page.getByRole("button", { name: "Download PDF" }).click();
try {
await expect(page.getByText(/^Saved page\.pdf/)).toBeVisible({ timeout: timeoutMs });
Expand Down Expand Up @@ -77,6 +85,7 @@ export async function maximizeWindow(

/** Opens the History panel unless it is open already: a wide (maximized) window opens it by itself. */
export async function showHistory(page: Page): Promise<void> {
await openPageActions(page);
const button = page.getByRole("button", { name: /^History/ });
if ((await button.getAttribute("aria-pressed")) !== "true") await button.click();
await expect(page.getByRole("complementary", { name: "History" })).toBeVisible();
Expand Down
Loading
Loading