452 lines
15 KiB
TypeScript
452 lines
15 KiB
TypeScript
import { expect } from "@playwright/test";
|
|
import type { ChildProcess } from "node:child_process";
|
|
import { readFile, writeFile } from "node:fs/promises";
|
|
import { join } from "node:path";
|
|
import {
|
|
gotoSilverBulletPage,
|
|
mod,
|
|
spawnServerProcess,
|
|
test,
|
|
waitForServer,
|
|
} from "./fixtures.ts";
|
|
|
|
test.use({
|
|
spaceFiles: {
|
|
"index.md": "Hello world\n",
|
|
},
|
|
});
|
|
|
|
test("external disk edit appears live, preserves cursor, and is undoable", async ({
|
|
sbPage,
|
|
sbServer,
|
|
}) => {
|
|
const editor = sbPage.locator(".cm-content");
|
|
await expect(editor).toContainText("Hello world");
|
|
|
|
// Place the cursor at the end of "Hello world" (pos 11) and mark the JS
|
|
// context so we can prove no reload happened
|
|
await editor.click();
|
|
await sbPage.evaluate(() => {
|
|
const client = (globalThis as any).client;
|
|
client.editorView.dispatch({ selection: { anchor: 11 } });
|
|
(globalThis as any).__noReloadMarker = true;
|
|
});
|
|
|
|
// External program writes the file out-of-band
|
|
await writeFile(
|
|
join(sbServer.spaceDir, "index.md"),
|
|
"Hello world\nExternal line\n",
|
|
);
|
|
|
|
// Change appears via the push path (well under the 3s poll cadence)
|
|
await expect(editor).toContainText("External line", { timeout: 2500 });
|
|
|
|
// No page reload, cursor unmoved
|
|
const state = await sbPage.evaluate(() => ({
|
|
marker: (globalThis as any).__noReloadMarker === true,
|
|
cursor: (globalThis as any).client.editorView.state.selection.main.head,
|
|
}));
|
|
expect(state.marker).toBe(true);
|
|
expect(state.cursor).toBe(11);
|
|
|
|
// Presence artifacts render
|
|
await expect(sbPage.locator(".sb-external-edit").first()).toBeVisible();
|
|
await expect(sbPage.locator(".sb-external-caret")).toHaveAttribute(
|
|
"data-source",
|
|
"external",
|
|
);
|
|
|
|
// Undo reverts the external edit...
|
|
await editor.click();
|
|
await sbPage.keyboard.press(`${mod}+z`);
|
|
await expect(editor).not.toContainText("External line");
|
|
|
|
// ...and the revert autosaves back to disk
|
|
await expect
|
|
.poll(() => readFile(join(sbServer.spaceDir, "index.md"), "utf-8"), {
|
|
timeout: 5000,
|
|
})
|
|
.not.toContain("External line");
|
|
});
|
|
|
|
test("undo after moving the cursor past an external edit restores the user's cursor, not the pre-edit position", async ({
|
|
sbPage,
|
|
sbServer,
|
|
}) => {
|
|
const editor = sbPage.locator(".cm-content");
|
|
await expect(editor).toContainText("Hello world");
|
|
|
|
// Focus the editor once, up front, cursor at 0 (page load, untouched).
|
|
// From here on we never click again -- a click right before undo would
|
|
// itself move the cursor and mask the bug, exactly the trap the first
|
|
// test above falls into (it clicks immediately before pressing undo).
|
|
await editor.click();
|
|
await sbPage.evaluate(() => {
|
|
(globalThis as any).client.editorView.dispatch({
|
|
selection: { anchor: 0 },
|
|
});
|
|
});
|
|
|
|
// External program writes the file out-of-band while the cursor is still at 0.
|
|
await writeFile(
|
|
join(sbServer.spaceDir, "index.md"),
|
|
"Hello world\nExternal line\n",
|
|
);
|
|
await expect(editor).toContainText("External line", { timeout: 2500 });
|
|
|
|
// *Then* the user moves the cursor -- programmatically, not via another
|
|
// click, to keep this test isolated to undo's own selection handling.
|
|
await sbPage.evaluate(() => {
|
|
(globalThis as any).client.editorView.dispatch({
|
|
selection: { anchor: 6 },
|
|
});
|
|
});
|
|
const beforeUndo = await sbPage.evaluate(
|
|
() => (globalThis as any).client.editorView.state.selection.main.head,
|
|
);
|
|
expect(beforeUndo).toBe(6);
|
|
|
|
// Undo without clicking first.
|
|
await sbPage.keyboard.press(`${mod}+z`);
|
|
await expect(editor).not.toContainText("External line");
|
|
|
|
const afterUndo = await sbPage.evaluate(
|
|
() => (globalThis as any).client.editorView.state.selection.main.head,
|
|
);
|
|
expect(afterUndo).toBe(6);
|
|
|
|
await sbPage.evaluate(() => {
|
|
(globalThis as any).client.editorView.dispatch({
|
|
selection: { anchor: 3 },
|
|
});
|
|
});
|
|
await sbPage.keyboard.press(`${mod}+Shift+z`);
|
|
await expect(editor).toContainText("External line");
|
|
const afterRedo = await sbPage.evaluate(
|
|
() => (globalThis as any).client.editorView.state.selection.main.head,
|
|
);
|
|
expect(afterRedo).toBe(3);
|
|
});
|
|
|
|
test("external edit merges with unsaved local typing", async ({
|
|
sbPage,
|
|
sbServer,
|
|
}) => {
|
|
const editor = sbPage.locator(".cm-content");
|
|
await expect(editor).toContainText("Hello world");
|
|
|
|
// Type locally, then have the external program write (against the base) before
|
|
// autosave has necessarily flushed
|
|
await editor.click();
|
|
await sbPage.evaluate(() => {
|
|
(globalThis as any).client.editorView.dispatch({
|
|
changes: { from: 0, insert: "LOCAL " },
|
|
});
|
|
});
|
|
await writeFile(
|
|
join(sbServer.spaceDir, "index.md"),
|
|
"Hello world\nExternal line\n",
|
|
);
|
|
|
|
await expect(editor).toContainText("External line", { timeout: 2500 });
|
|
await expect(editor).toContainText("LOCAL Hello world");
|
|
|
|
// Merged result reaches disk
|
|
await expect
|
|
.poll(() => readFile(join(sbServer.spaceDir, "index.md"), "utf-8"), {
|
|
timeout: 5000,
|
|
})
|
|
.toContain("LOCAL");
|
|
});
|
|
|
|
test("undo isolates the external edit from the user's own prior typing", async ({
|
|
sbPage,
|
|
sbServer,
|
|
}) => {
|
|
const editor = sbPage.locator(".cm-content");
|
|
await expect(editor).toContainText("Hello world");
|
|
|
|
await editor.click();
|
|
await writeFile(
|
|
join(sbServer.spaceDir, "index.md"),
|
|
"Hello world\nExternal line\n",
|
|
);
|
|
await sbPage.evaluate(async () => {
|
|
const client = (globalThis as any).client;
|
|
client.editorView.dispatch({
|
|
changes: { from: 12, insert: "LOCAL\n" },
|
|
});
|
|
await client.contentManager.reloadPageContent();
|
|
});
|
|
|
|
await expect(editor).toContainText("External line");
|
|
await expect(editor).toContainText("LOCAL");
|
|
|
|
// First undo: only the external edit reverts. The user's own prior
|
|
// typing, a separate undo-history entry, must survive untouched -- this
|
|
// is the guarantee the brief calls "undo isolation."
|
|
await editor.click();
|
|
await sbPage.keyboard.press(`${mod}+z`);
|
|
await expect(editor).not.toContainText("External line");
|
|
await expect(editor).toContainText("LOCAL");
|
|
|
|
// Second undo: now the user's own typing reverts too, back to the
|
|
// pristine loaded content.
|
|
await sbPage.keyboard.press(`${mod}+z`);
|
|
await expect(editor).not.toContainText("LOCAL");
|
|
await expect(editor).toContainText("Hello world");
|
|
});
|
|
|
|
test("cursor position maps correctly around an external edit landing before it", async ({
|
|
sbPage,
|
|
sbServer,
|
|
}) => {
|
|
const editor = sbPage.locator(".cm-content");
|
|
await expect(editor).toContainText("Hello world");
|
|
|
|
// "Hello world\n": place the cursor between "wo" and "rld" (offset 8),
|
|
// strictly *before* where the external write below inserts text. This is
|
|
// the failure-prone direction test 1's cursor check can't reach.
|
|
await editor.click();
|
|
await sbPage.evaluate(() => {
|
|
(globalThis as any).client.editorView.dispatch({
|
|
selection: { anchor: 8 },
|
|
});
|
|
});
|
|
|
|
// An external write inserts a whole new line *before* "Hello world" -- the cursor's
|
|
// line shifts down by exactly the inserted prefix's length.
|
|
await writeFile(
|
|
join(sbServer.spaceDir, "index.md"),
|
|
"External line\nHello world\n",
|
|
);
|
|
await expect(editor).toContainText("External line", { timeout: 2500 });
|
|
|
|
const result = await sbPage.evaluate(() => {
|
|
const view = (globalThis as any).client.editorView;
|
|
const pos = view.state.selection.main.head;
|
|
return {
|
|
pos,
|
|
before: view.state.doc.sliceString(Math.max(0, pos - 8), pos),
|
|
after: view.state.doc.sliceString(pos, pos + 4),
|
|
};
|
|
});
|
|
|
|
// A bare numeric assertion here would be satisfied by an off-by-length
|
|
// bug that happens to compute the same coincidental number, so assert on
|
|
// the actual surrounding text as well: the cursor must still sit exactly
|
|
// between "wo" and "rld", now 14 characters ("External line\n") further in.
|
|
expect(result.pos).toBe(22);
|
|
expect(result.before).toBe("Hello wo");
|
|
expect(result.after).toBe("rld\n");
|
|
});
|
|
|
|
test("presence highlight and ghost caret expire after the TTL window", async ({
|
|
sbPage,
|
|
sbServer,
|
|
}) => {
|
|
const editor = sbPage.locator(".cm-content");
|
|
await expect(editor).toContainText("Hello world");
|
|
|
|
await writeFile(
|
|
join(sbServer.spaceDir, "index.md"),
|
|
"Hello world\nExternal line\n",
|
|
);
|
|
await expect(editor).toContainText("External line", { timeout: 2500 });
|
|
|
|
const highlight = sbPage.locator(".sb-external-edit");
|
|
const caret = sbPage.locator(".sb-external-caret");
|
|
await expect(highlight.first()).toBeVisible();
|
|
await expect(caret).toBeVisible();
|
|
|
|
// Decorations carry a ~5s TTL. Give the sweep real margin on both sides
|
|
// instead of asserting right at the boundary.
|
|
await expect(highlight).toHaveCount(0, { timeout: 8000 });
|
|
await expect(caret).toHaveCount(0, { timeout: 8000 });
|
|
});
|
|
|
|
test("forced reload applies a pending disk change instead of clobbering the merge base", async ({
|
|
page,
|
|
sbServer,
|
|
}) => {
|
|
// Block push entirely so only the explicit reload below can discover the
|
|
// disk change -- isolates loadPage()'s own merge-base handling (the bug
|
|
// Task 5's review found and fixed) from the live SSE/poll path this
|
|
// feature also adds, which would otherwise apply the change first and
|
|
// mask a regression here.
|
|
await page.route("**/.events", (route) => route.fulfill({ status: 404 }));
|
|
await gotoSilverBulletPage(page, sbServer);
|
|
|
|
const editor = page.locator(".cm-content");
|
|
await expect(editor).toContainText("Hello world");
|
|
|
|
await writeFile(
|
|
join(sbServer.spaceDir, "index.md"),
|
|
"Hello world\nExternal line\n",
|
|
);
|
|
|
|
// Sanity check: with push blocked, and well inside the ~3s file-watch
|
|
// poll window, nothing should have applied yet.
|
|
await expect(editor).not.toContainText("External line", { timeout: 500 });
|
|
|
|
// The exact method a page-reload command invokes under the hood
|
|
// (ContentManager.reloadEditor -> same-page loadPage()). Previously this
|
|
// set lastKnownDiskText to the freshly-read disk text *before* diffing
|
|
// against it, so the diff -- and the merge -- was always empty even
|
|
// though disk content had actually changed.
|
|
await page.evaluate(() => (globalThis as any).client.reloadEditor());
|
|
|
|
await expect(editor).toContainText("External line");
|
|
});
|
|
|
|
test("polling still delivers external edits when /.events is unavailable", async ({
|
|
page,
|
|
sbServer,
|
|
}) => {
|
|
let eventsRequestCount = 0;
|
|
await page.route("**/.events", (route) => {
|
|
eventsRequestCount++;
|
|
route.fulfill({ status: 404 });
|
|
});
|
|
const fallbackLogs: string[] = [];
|
|
page.on("console", (msg) => {
|
|
if (msg.text().includes("/.events unavailable")) {
|
|
fallbackLogs.push(msg.text());
|
|
}
|
|
});
|
|
|
|
await gotoSilverBulletPage(page, sbServer);
|
|
const editor = page.locator(".cm-content");
|
|
await expect(editor).toContainText("Hello world");
|
|
|
|
await writeFile(
|
|
join(sbServer.spaceDir, "index.md"),
|
|
"Hello world\nExternal line\n",
|
|
);
|
|
|
|
// No push channel available: this can only be the ~3s file-watch poll (or
|
|
// slower), so give it real margin above the push-path threshold used in
|
|
// the tests above.
|
|
await expect(editor).toContainText("External line", { timeout: 6000 });
|
|
|
|
// A 404 on the very first attempt is fatal-and-permanent by design (no
|
|
// retry loop hammering the server).
|
|
expect(fallbackLogs.length).toBeGreaterThan(0);
|
|
expect(eventsRequestCount).toBe(1);
|
|
});
|
|
|
|
test("reconnects with backoff after a transient connection failure", async ({
|
|
page,
|
|
sbServer,
|
|
}) => {
|
|
let attempts = 0;
|
|
await page.route("**/.events", (route) => {
|
|
attempts++;
|
|
if (attempts === 1) {
|
|
route.abort("connectionrefused");
|
|
} else {
|
|
route.continue();
|
|
}
|
|
});
|
|
|
|
await gotoSilverBulletPage(page, sbServer);
|
|
|
|
// The first connection attempt fails at the network level (not a fatal
|
|
// HTTP response), so the client's own backoff -- starting at 1s -- retries
|
|
// rather than giving up permanently.
|
|
await expect
|
|
.poll(() => attempts, { timeout: 5000 })
|
|
.toBeGreaterThanOrEqual(2);
|
|
|
|
// Prove it's not just a retried request but an actually-working stream:
|
|
// an external edit should still arrive via push, not just the poll
|
|
// fallback.
|
|
const editor = page.locator(".cm-content");
|
|
await expect(editor).toContainText("Hello world");
|
|
await writeFile(
|
|
join(sbServer.spaceDir, "index.md"),
|
|
"Hello world\nExternal line\n",
|
|
);
|
|
await expect(editor).toContainText("External line", { timeout: 2500 });
|
|
});
|
|
|
|
/**
|
|
* Spawns a replacement server on `port`, retrying if it fails to bind.
|
|
* `sbServer.stop()` just killed the previous process holding this exact
|
|
* port; the OS *usually* releases it in time for an immediate rebind
|
|
* (SO_REUSEADDR), but under CI load or a restrictive sandbox that's not
|
|
* guaranteed, so a bind failure here is a plausible flake rather than a
|
|
* real regression. Each attempt gets a bounded wait; a failed attempt is
|
|
* killed and retried after a short backoff.
|
|
*/
|
|
async function spawnReplacementServer(
|
|
spaceDir: string,
|
|
port: number,
|
|
url: string,
|
|
): Promise<ChildProcess> {
|
|
const maxAttempts = 3;
|
|
let lastError: unknown;
|
|
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
|
const proc = spawnServerProcess(spaceDir, port);
|
|
try {
|
|
await waitForServer(`${url}/.ping`, 5000);
|
|
return proc;
|
|
} catch (e) {
|
|
lastError = e;
|
|
await new Promise<void>((resolve) => {
|
|
proc.once("exit", () => resolve());
|
|
proc.kill("SIGKILL");
|
|
});
|
|
if (attempt < maxAttempts) {
|
|
await new Promise((r) => setTimeout(r, 300 * attempt));
|
|
}
|
|
}
|
|
}
|
|
throw new Error(
|
|
`Could not (re)bind replacement server to port ${port} after ${maxAttempts} attempts: ${lastError}`,
|
|
);
|
|
}
|
|
|
|
test("reconnect after a genuine server restart refreshes the file list (catch-up)", async ({
|
|
sbPage,
|
|
sbServer,
|
|
}) => {
|
|
const editor = sbPage.locator(".cm-content");
|
|
await expect(editor).toContainText("Hello world");
|
|
|
|
// Make sure the SSE stream is actually open before we sever it -- this
|
|
// test is specifically about dropping an *established* connection, unlike
|
|
// the transient-failure test above.
|
|
await sbPage.waitForFunction(
|
|
() => (globalThis as any).client?.realtimeEvents?.source?.readyState === 1,
|
|
undefined,
|
|
{ timeout: 5000 },
|
|
);
|
|
|
|
await sbServer.stop();
|
|
|
|
// Written while the server is down, so the only way the client can learn
|
|
// about it is the reconnect catch-up fetch.
|
|
await writeFile(join(sbServer.spaceDir, "NewPage.md"), "# New\n");
|
|
|
|
const proc = await spawnReplacementServer(
|
|
sbServer.spaceDir,
|
|
sbServer.port,
|
|
sbServer.url,
|
|
);
|
|
|
|
try {
|
|
await sbPage.waitForFunction(
|
|
() =>
|
|
(globalThis as any).client.clientSystem.allKnownFiles.has("NewPage.md"),
|
|
undefined,
|
|
{ timeout: 15_000 },
|
|
);
|
|
} finally {
|
|
await new Promise<void>((resolve) => {
|
|
proc.on("exit", () => resolve());
|
|
proc.kill("SIGKILL");
|
|
});
|
|
}
|
|
});
|