Skip to content
Open
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
2 changes: 2 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,8 @@ jobs:
- run: pnpm check
- name: Install Chromium for PDF touch regression tests
run: pnpm --filter @mdbase-reader/app exec playwright install --with-deps chromium
- name: Verify extension shared-session writes across separate bundles
run: pnpm --filter @mdbase-reader/extension test:browser
- name: Verify PDF gestures and mobile highlight persistence
run: pnpm --filter @mdbase-reader/app test:pdf-touch
- name: Verify imported SVG rendering in the HTML sandbox
Expand Down
4 changes: 3 additions & 1 deletion apps/extension/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,8 @@
"package": "MDBASE_ENV=${MDBASE_ENV:-production} pnpm build && MDBASE_ENV=${MDBASE_ENV:-production} node scripts/package-extension.mjs",
"manifest:mdbase": "node scripts/write-mdbase-manifest.mjs",
"test": "pnpm manifest:mdbase && vitest run --passWithNoTests",
"typecheck": "tsc -p tsconfig.json"
"typecheck": "tsc -p tsconfig.json",
"test:browser": "node scripts/shared-session-browser.mjs"
},
"dependencies": {
"@fontsource-variable/source-serif-4": "^5.3.1",
Expand All @@ -30,6 +31,7 @@
"react-dom": "^19.3.1"
},
"devDependencies": {
"@playwright/test": "1.64.1",
"@types/chrome": "^0.3.6",
"@types/react": "^19.3.0",
"@types/react-dom": "^19.3.0",
Expand Down
4 changes: 2 additions & 2 deletions apps/extension/scripts/extension-manifest.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -153,8 +153,8 @@ export function extensionManifest(environment) {
},
// One panel per window; it follows the window's active tab.
side_panel: { default_path: "capture.html" },
// Classic, with no SDK: the SDK runtime lives in the extension's pages.
background: { service_worker: "background.js" },
// The SDK requires a module URL, including in a non-hosting context.
background: { service_worker: "background.js", type: "module" },
options_ui: { page: "options.html", open_in_tab: true },
commands: {
_execute_action: {
Expand Down
28 changes: 28 additions & 0 deletions apps/extension/scripts/fixtures/shared-session.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
/* global __SESSION_HOST__ */
import { createMdbase } from "@mdbase-dev/sdk";

const origin = globalThis.location.origin;
const manifest = {
manifest_version: 1,
id: "dev.mdbase.reader.extension.session-test",
name: "Reader shared session test",
homepage: `${origin}/`,
redirect_uris: [`${origin}/`],
requirements: {
access: "full_collection",
contracts: [],
capabilities: { contract_version: 2, required: [], optional: [] },
},
};
const app = createMdbase(
{ id: manifest.id, name: manifest.name, origin, environment: "production", manifest },
{
session: { build: "reader-extension-session-test", host: __SESSION_HOST__ },
unsynced: true,
followVisibility: false,
},
);
globalThis.fixtureMdbase = app;
void app.start().catch((error) => {
globalThis.fixtureError = error.message;
});
184 changes: 184 additions & 0 deletions apps/extension/scripts/shared-session-browser.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,184 @@
import assert from "node:assert/strict";
import { createHash } from "node:crypto";
import { mkdir, mkdtemp, readFile, rm } from "node:fs/promises";
import { createServer } from "node:http";
import { extname, join, resolve } from "node:path";

import { chromium, expect } from "@playwright/test";
import { build } from "vite";

// Two separately bundled browser contexts, using the pinned SDK's original Worker,
// WASM and SQLite against one local unsynced replica. Registration is a fixture;
// no account, Connect service, collection grant or log service is contacted.
const root = resolve(import.meta.dirname, "..");
const scratch = resolve(root, "../../.tmp");
await mkdir(scratch, { recursive: true });
const directory = await mkdtemp(join(scratch, "extension-shared-session-"));
const dist = join(directory, "dist");
let browser;
let server;
const errors = [];
const unexpected = [];
function canonical(value) {
if (Array.isArray(value)) return `[${value.map(canonical).join(",")}]`;
if (value && typeof value === "object")
return `{${Object.keys(value)
.sort()
.map((key) => `${JSON.stringify(key)}:${canonical(value[key])}`)
.join(",")}}`;
return JSON.stringify(value);
}
try {
for (const [name, host] of [
["host", true],
["follower", false],
]) {
await build({
root,
configFile: false,
publicDir: false,
logLevel: "error",
define: { __SESSION_HOST__: JSON.stringify(host) },
worker: { format: "es" },
build: {
outDir: dist,
emptyOutDir: host,
target: "es2022",
rolldownOptions: {
input: resolve(root, "scripts/fixtures/shared-session.mjs"),
output: { entryFileNames: `${name}.js` },
},
},
});
}
server = createServer(async (request, response) => {
const path = new URL(request.url, "http://localhost").pathname;
if (["/host", "/follower"].includes(path)) {
response.setHeader("Content-Type", "text/html");
response.end(`<!doctype html><script type="module" src="${path}.js"></script>`);
return;
}
const file = resolve(dist, `.${path}`);
if (!file.startsWith(`${dist}/`)) {
response.writeHead(403).end();
return;
}
try {
response.setHeader(
"Content-Type",
extname(file) === ".wasm" ? "application/wasm" : "text/javascript",
);
response.end(await readFile(file));
} catch {
response.writeHead(404).end();
}
});
await new Promise((resolveListen) => server.listen(0, "127.0.0.1", resolveListen));
const origin = `http://127.0.0.1:${server.address().port}`;
browser = await chromium.launch();
const context = await browser.newContext();
await context.route("**/*", async (route) => {
const request = route.request();
const url = new URL(request.url());
if (url.origin === origin) return route.continue();
if (url.href === "https://connect.mdbase.dev/v1/apps/register") {
const { manifest } = request.postDataJSON();
const digest = createHash("sha256").update(canonical(manifest)).digest("hex");
return route.fulfill({
json: {
application: {
id: "55555555-5555-4555-8555-555555555555",
name: manifest.name,
manifest_digest: digest,
canonical_identity: `bundle:${manifest.id}:sha256:${digest}`,
family_identity: `bundle:${manifest.id}`,
distribution: "web",
application_declaration: manifest,
},
},
});
}
unexpected.push(url.origin + url.pathname);
return route.abort();
});
const follower = await context.newPage();
const host = await context.newPage();
const workers = [];
for (const page of [host, follower]) {
page.on("pageerror", (error) => errors.push(error.message));
page.on("worker", (worker) => workers.push(worker.url()));
}
await follower.goto(`${origin}/follower`);
await expect
.poll(() => follower.evaluate(() => globalThis.fixtureMdbase?.getSnapshot().status))
.toBe("starting");
assert.equal(workers.length, 0, "a host:false context never constructs a Worker");
await host.goto(`${origin}/host`);
for (const page of [host, follower]) {
await expect
.poll(
() =>
page.evaluate(() => ({
phase: globalThis.fixtureMdbase?.getSnapshot().phase.kind,
error: globalThis.fixtureError,
problem: globalThis.fixtureMdbase?.getSnapshot().phase.problem,
})),
{ timeout: 15000 },
)
.toEqual({ phase: "unsynced", error: undefined, problem: undefined });
}
assert.equal(workers.length, 1, "one SDK Worker serves both bundles");
const ids = await Promise.all(
[host, follower].map((page) =>
page.evaluate(() => globalThis.fixtureMdbase.getSnapshot().phase.connection.id),
),
);
assert.equal(ids[0], ids[1], "both contexts open the same replica");
await host.evaluate(async () => {
const records = globalThis.fixtureMdbase.getSnapshot().phase.connection.records;
await records.create({
path: "shared.md",
frontmatter: { title: "From the host" },
body: "Shared body",
});
});
const read = await follower.evaluate(async () => {
const records = globalThis.fixtureMdbase.getSnapshot().phase.connection.records;
const record = await records.get({ path: "shared.md" }, { include: { body: true } });
await records.update(record, { patch: { title: "From the follower" } });
return { title: record.frontmatter.title, body: record.body };
});
assert.deepEqual(read, { title: "From the host", body: "Shared body" });
assert.equal(
await host.evaluate(
async () =>
(
await globalThis.fixtureMdbase
.getSnapshot()
.phase.connection.records.get({ path: "shared.md" })
).frontmatter.title,
),
"From the follower",
);
await follower.close();
assert.equal(
await host.evaluate(
async () =>
(
await globalThis.fixtureMdbase
.getSnapshot()
.phase.connection.records.get({ path: "shared.md" })
).frontmatter.title,
),
"From the follower",
);
assert.deepEqual(errors, []);
assert.deepEqual(unexpected, []);
console.log(
"PASS: two separate bundles, one original SDK Worker and replica; writes visible in both directions; follower close leaves host usable.",
);
} finally {
await browser?.close();
if (server) await new Promise((resolveClose) => server.close(resolveClose));
await rm(directory, { recursive: true, force: true });
}
Loading
Loading