284 lines
8.4 KiB
TypeScript
284 lines
8.4 KiB
TypeScript
import type { Page } from "@playwright/test";
|
|
import { mkdir, rm, writeFile } from "node:fs/promises";
|
|
import { join } from "node:path";
|
|
import { expect, gotoSilverBulletPage, test } from "./fixtures.ts";
|
|
|
|
/** Run a Space Lua script in the live in-page runtime (headless mode). */
|
|
async function runLua(page: Page, script: string): Promise<unknown> {
|
|
return await page.evaluate(
|
|
(s) => (globalThis as any).sbRuntime.evalLuaScript(s),
|
|
script,
|
|
);
|
|
}
|
|
|
|
/** Invoke system.reboot() via the in-page runtime. Resolves when ready. */
|
|
async function reboot(page: Page): Promise<void> {
|
|
await runLua(page, "system.reboot()");
|
|
}
|
|
|
|
/** Run a SLIQ expression and return the resulting array. */
|
|
async function query(page: Page, sliq: string): Promise<unknown[]> {
|
|
const result = await runLua(page, `return query[[${sliq}]]`);
|
|
if (result === null || result === undefined) return [];
|
|
if (Array.isArray(result)) return result;
|
|
// An empty Lua table has length 0, so luaValueToJS serializes it as {} not
|
|
// []. Treat any empty plain object as an empty result set.
|
|
if (
|
|
typeof result === "object" &&
|
|
Object.keys(result as object).length === 0
|
|
) {
|
|
return [];
|
|
}
|
|
return result as unknown[];
|
|
}
|
|
|
|
/** Capture all console messages the page emits (live-updating array). */
|
|
function captureConsole(page: Page): {
|
|
messages: { type: string; text: string }[];
|
|
} {
|
|
const messages: { type: string; text: string }[] = [];
|
|
page.on("console", (msg) =>
|
|
messages.push({ type: msg.type(), text: msg.text() }),
|
|
);
|
|
return { messages };
|
|
}
|
|
|
|
/** Poll until `predicate()` is true or the timeout elapses. */
|
|
async function waitFor(
|
|
predicate: () => boolean,
|
|
timeoutMs = 15_000,
|
|
): Promise<void> {
|
|
const deadline = Date.now() + timeoutMs;
|
|
while (Date.now() < deadline) {
|
|
if (predicate()) return;
|
|
await new Promise((r) => setTimeout(r, 100));
|
|
}
|
|
throw new Error(`Condition not met within ${timeoutMs}ms`);
|
|
}
|
|
|
|
test.describe("system.reboot()", () => {
|
|
test.use({ spaceFiles: { "index.md": "# Index\nEntry point.\n" } });
|
|
|
|
test("out-of-band create: a new on-disk page is indexed after reboot", async ({
|
|
sbServer,
|
|
page,
|
|
}) => {
|
|
await gotoSilverBulletPage(page, sbServer);
|
|
|
|
// The page does not exist yet.
|
|
expect(
|
|
await query(
|
|
page,
|
|
`from index.tag "page" where name == "OutOfBand" select name`,
|
|
),
|
|
).toEqual([]);
|
|
|
|
// Create a markdown file directly on disk — no editor, no index event.
|
|
await writeFile(
|
|
join(sbServer.spaceDir, "OutOfBand.md"),
|
|
"# Out Of Band\nCreated directly on disk.\n",
|
|
);
|
|
|
|
await reboot(page);
|
|
|
|
// No sleep: the change must already be indexed.
|
|
expect(
|
|
await query(
|
|
page,
|
|
`from index.tag "page" where name == "OutOfBand" select name`,
|
|
),
|
|
).toEqual(["OutOfBand"]);
|
|
});
|
|
|
|
test("out-of-band modify: index reflects new on-disk content after reboot", async ({
|
|
sbServer,
|
|
page,
|
|
}) => {
|
|
const notePath = join(sbServer.spaceDir, "Note.md");
|
|
await writeFile(notePath, "# Note\noriginal content\n");
|
|
await gotoSilverBulletPage(page, sbServer);
|
|
|
|
// Original is indexed; the new tag is not present yet.
|
|
expect(
|
|
await query(
|
|
page,
|
|
`from index.tag "page" where name == "Note" select name`,
|
|
),
|
|
).toEqual(["Note"]);
|
|
expect(
|
|
await query(page, `from index.tag "rebootmodify" select name`),
|
|
).toEqual([]);
|
|
|
|
// Rewrite the file on disk, adding a frontmatter tag.
|
|
await writeFile(
|
|
notePath,
|
|
"---\ntags: rebootmodify\n---\n# Note\nupdated content\n",
|
|
);
|
|
await reboot(page);
|
|
|
|
// The page is now tagged from its updated on-disk content.
|
|
expect(
|
|
await query(page, `from index.tag "rebootmodify" select name`),
|
|
).toEqual(["Note"]);
|
|
});
|
|
|
|
test("out-of-band delete: removed file's objects are gone after reboot", async ({
|
|
sbServer,
|
|
page,
|
|
}) => {
|
|
const tempPath = join(sbServer.spaceDir, "Temp.md");
|
|
await writeFile(tempPath, "# Temp\ntemporary page\n");
|
|
await gotoSilverBulletPage(page, sbServer);
|
|
|
|
expect(
|
|
await query(
|
|
page,
|
|
`from index.tag "page" where name == "Temp" select name`,
|
|
),
|
|
).toEqual(["Temp"]);
|
|
|
|
// Remove the file directly on disk.
|
|
await rm(tempPath);
|
|
await reboot(page);
|
|
|
|
expect(
|
|
await query(
|
|
page,
|
|
`from index.tag "page" where name == "Temp" select name`,
|
|
),
|
|
).toEqual([]);
|
|
});
|
|
|
|
test("drain-before-return: query immediately after reboot sees the change (no sleep)", async ({
|
|
sbServer,
|
|
page,
|
|
}) => {
|
|
await gotoSilverBulletPage(page, sbServer);
|
|
await writeFile(join(sbServer.spaceDir, "Racy.md"), "# Racy\nrace check\n");
|
|
|
|
// Before reboot the index must NOT yet know about the new file —
|
|
// proving the post-reboot result is caused by reboot, not by the
|
|
// background fetchFileList interval. (Queried well within the 10s
|
|
// interval to keep this deterministic.)
|
|
expect(
|
|
await query(
|
|
page,
|
|
`from index.tag "page" where name == "Racy" select name`,
|
|
),
|
|
).toEqual([]);
|
|
|
|
await reboot(page);
|
|
|
|
// No sleep between reboot resolving and this query: awaitEmptyQueue
|
|
// must have blocked until indexing finished.
|
|
expect(
|
|
await query(
|
|
page,
|
|
`from index.tag "page" where name == "Racy" select name`,
|
|
),
|
|
).toEqual(["Racy"]);
|
|
});
|
|
|
|
test("script edits applied: a new Library Space Lua function is live after reboot", async ({
|
|
sbServer,
|
|
page,
|
|
}) => {
|
|
await gotoSilverBulletPage(page, sbServer);
|
|
|
|
// Function is not defined yet.
|
|
expect(await runLua(page, "return type(myRebootProbe)")).toBe("nil");
|
|
|
|
// Add a Library space-lua script directly on disk.
|
|
await mkdir(join(sbServer.spaceDir, "Library"), { recursive: true });
|
|
await writeFile(
|
|
join(sbServer.spaceDir, "Library", "Probe.md"),
|
|
'```space-lua\nfunction myRebootProbe()\n return "probe-ok"\nend\n```\n',
|
|
);
|
|
|
|
await reboot(page);
|
|
|
|
// The function is indexed and applied to the live Space Lua env.
|
|
expect(await runLua(page, "return myRebootProbe()")).toBe("probe-ok");
|
|
});
|
|
|
|
test("error surfacing: a Lua load error appears in the console after reboot", async ({
|
|
sbServer,
|
|
page,
|
|
}) => {
|
|
const consoleState = captureConsole(page);
|
|
await gotoSilverBulletPage(page, sbServer);
|
|
|
|
// Add a Library space-lua script with a syntax error.
|
|
await mkdir(join(sbServer.spaceDir, "Library"), { recursive: true });
|
|
await writeFile(
|
|
join(sbServer.spaceDir, "Library", "Broken.md"),
|
|
"```space-lua\nfunction broken( this is not valid lua\n```\n",
|
|
);
|
|
|
|
// reboot itself resolves cleanly — the failure is only in the logs.
|
|
await reboot(page);
|
|
|
|
await waitFor(() =>
|
|
consoleState.messages.some(
|
|
(m) =>
|
|
m.type === "error" &&
|
|
/Error (loading|evaluating|reloading) (Lua )?script/i.test(m.text),
|
|
),
|
|
);
|
|
});
|
|
|
|
test("no-op: reboot with nothing changed completes promptly and keeps the index intact", async ({
|
|
sbServer,
|
|
page,
|
|
}) => {
|
|
await gotoSilverBulletPage(page, sbServer);
|
|
|
|
const start = Date.now();
|
|
await reboot(page);
|
|
expect(Date.now() - start).toBeLessThan(15_000);
|
|
|
|
// The pre-existing index is still intact.
|
|
expect(
|
|
await query(
|
|
page,
|
|
`from index.tag "page" where name == "index" select name`,
|
|
),
|
|
).toEqual(["index"]);
|
|
});
|
|
|
|
test("in-flight concurrency: reboot converges while file operations are in flight", async ({
|
|
sbServer,
|
|
page,
|
|
}) => {
|
|
await gotoSilverBulletPage(page, sbServer);
|
|
await writeFile(
|
|
join(sbServer.spaceDir, "Concurrent.md"),
|
|
"# Concurrent\nconcurrent check\n",
|
|
);
|
|
|
|
// Drive operationCount > 0 (the deferred-fetchFileList path) by firing a
|
|
// burst of concurrent reads, and invoke system.reboot in the same batch.
|
|
await page.evaluate(async () => {
|
|
const client = (globalThis as any).client;
|
|
const esp = client.eventedSpacePrimitives;
|
|
const reads: Promise<unknown>[] = [];
|
|
for (let i = 0; i < 50; i++) {
|
|
reads.push(esp.readFile("index.md").catch(() => {}));
|
|
}
|
|
await Promise.all([
|
|
...reads,
|
|
client.clientSystem.localSyscall("system.reboot", []),
|
|
]);
|
|
});
|
|
|
|
// No sleep: despite the in-flight reads, the change must be indexed by
|
|
// the time reboot resolved.
|
|
expect(
|
|
await query(
|
|
page,
|
|
`from index.tag "page" where name == "Concurrent" select name`,
|
|
),
|
|
).toEqual(["Concurrent"]);
|
|
});
|
|
});
|