Skip to content
Merged
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 playwright.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@ export default defineConfig({
use: {
/* Base URL to use in actions like `await page.goto('')`. */
baseURL: testBaseUrl,
/* The preview copy button writes to the clipboard. */
permissions: ["clipboard-read", "clipboard-write"],
},

/* Configure projects for major browsers */
Expand Down
24 changes: 0 additions & 24 deletions tests/_support/browser-mocks/typst-state-module.d.ts

This file was deleted.

37 changes: 0 additions & 37 deletions tests/_support/browser-mocks/typst-state.ts

This file was deleted.

59 changes: 47 additions & 12 deletions tests/_support/browser-mocks/typst.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,45 @@
import { typstMockState } from "/pptypst/__test__/typst-state.js";
/**
* Browser mock for `@myriaddreamin/typst.ts`.
*
* `TypstMock` (see ../typst-mock.ts) serves this in place of the real
* WASM-backed library. Recorded compiler/renderer calls and the SVG the
* renderer returns live on `window.__typstMock` so the Node-side helper can
* read and tweak them via `page.evaluate`.
*/

export type TypstMockState = {
rendererInitOptions: { hasGetModule: boolean }[];
addSourceCalls: { path: string; source: string }[];
compileCalls: { mainFilePath: string }[];
renderSvgCalls: {
format: string;
artifactContent: number[];
data_selection: Record<string, boolean>;
}[];
previewSvg: string;
};

declare global {
interface Window {
__typstMock?: TypstMockState;
}
}

const DEFAULT_PREVIEW_SVG = [
'<svg xmlns="http://www.w3.org/2000/svg" width="160" height="40">',
'<text x="0" y="20" fill="#000">integral preview</text>',
"</svg>",
].join("");

const state: TypstMockState = {
rendererInitOptions: [],
addSourceCalls: [],
compileCalls: [],
renderSvgCalls: [],
previewSvg: DEFAULT_PREVIEW_SVG,
};

window.__typstMock = state;

type CompilerInitOptions = { beforeBuild: unknown[]; getModule: unknown };
type CompileOptions = { mainFilePath: string };
Expand All @@ -8,12 +49,6 @@ type RenderSvgOptions = {
data_selection: Record<string, boolean>;
};

const previewSvg = [
'<svg xmlns="http://www.w3.org/2000/svg" width="160" height="40">',
'<text x="0" y="20" fill="#000">integral preview</text>',
"</svg>",
].join("");

export function createTypstCompiler() {
return {
init(options: CompilerInitOptions) {
Expand All @@ -25,10 +60,10 @@ export function createTypstCompiler() {
return Promise.resolve();
},
addSource(path: string, source: string) {
typstMockState.addSourceCalls.push({ path, source });
state.addSourceCalls.push({ path, source });
},
compile(options: CompileOptions) {
typstMockState.compileCalls.push(options);
state.compileCalls.push(options);
return Promise.resolve({ diagnostics: [], result: new Uint8Array([1, 2, 3]) });
},
};
Expand All @@ -37,16 +72,16 @@ export function createTypstCompiler() {
export function createTypstRenderer() {
return {
init(options: { getModule: unknown }) {
typstMockState.rendererInitOptions.push({ hasGetModule: typeof options.getModule === "function" });
state.rendererInitOptions.push({ hasGetModule: typeof options.getModule === "function" });
return Promise.resolve();
},
renderSvg(options: RenderSvgOptions) {
typstMockState.renderSvgCalls.push({
state.renderSvgCalls.push({
format: options.format,
artifactContent: Array.from(options.artifactContent),
data_selection: options.data_selection,
});
return Promise.resolve(previewSvg);
return Promise.resolve(state.previewSvg);
},
};
}
55 changes: 29 additions & 26 deletions tests/_support/typst-mock.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,12 @@
import type { Page } from "@playwright/test";
import path from "node:path";
import { compileBrowserMock } from "./transpile-browser-mock";
import type { TypstMockState } from "./browser-mocks/typst";

export type TypstMockCalls = {
addSourceCalls: { path: string; source: string }[];
compileCalls: { mainFilePath: string }[];
renderSvgCalls: {
format: string;
artifactContent: number[];
data_selection: Record<string, boolean>;
}[];
};

const stateModuleUrl = "/pptypst/__test__/typst-state.js";
export type TypstMockCalls = Pick<
TypstMockState,
"addSourceCalls" | "compileCalls" | "renderSvgCalls"
>;

function browserMockPath(fileName: string) {
return path.join(process.cwd(), "tests", "_support", "browser-mocks", fileName);
Expand All @@ -28,7 +22,6 @@ export class TypstMock {

/** Routes only the Typst modules that web/src/typst.ts and font-cache.ts import. */
async install() {
await this.routeModule("**/__test__/typst-state.js", "typst-state.ts");
await this.routeModule("**/@myriaddreamin_typst__ts.js*", "typst.ts");
await this.routeModule("**/@myriaddreamin_typst__ts_dist_esm_options__init__mjs.js*", "typst-options.ts");
await this.routeModule("**/@myriaddreamin_typst__ts_dist_esm_fs_package__node__mjs.js*", "typst-package-registry.ts");
Expand All @@ -37,24 +30,34 @@ export class TypstMock {
await this.routeModule("**/typst_ts_renderer_bg.wasm?*", "typst-wasm-url.ts");
}

/** Waits for the mocked renderer init call, which means the Typst wrapper initialized. */
/** Resolves once the app has initialized the mocked renderer. */
async waitUntilReady() {
await this.page.waitForFunction(async (moduleUrl) => {
const stateModule = await import(moduleUrl) as {
typstMockReady: () => boolean;
};
return stateModule.typstMockReady();
}, stateModuleUrl);
await this.page.waitForFunction(
() => window.__typstMock?.rendererInitOptions.length === 1,
);
}

/** Returns the Typst compiler and renderer calls recorded in the browser. */
/** Snapshot of the compiler/renderer calls recorded so far. */
async calls(): Promise<TypstMockCalls> {
return this.page.evaluate(async (moduleUrl) => {
const stateModule = await import(moduleUrl) as {
typstMockCalls: () => TypstMockCalls;
};
return stateModule.typstMockCalls();
}, stateModuleUrl);
return this.page.evaluate(() => {
const state = window.__typstMock;
if (!state) {
throw new Error("Typst mock has not been initialized yet.");
}
const { addSourceCalls, compileCalls, renderSvgCalls } = state;
return { addSourceCalls, compileCalls, renderSvgCalls };
});
}

/** Overrides the SVG the mocked renderer returns for subsequent preview renders. */
async setPreviewSvg(svg: string) {
await this.page.evaluate((value) => {
const state = window.__typstMock;
if (!state) {
throw new Error("Typst mock has not been initialized yet.");
}
state.previewSvg = value;
}, svg);
}

private async routeModule(url: string, fileName: string) {
Expand Down
38 changes: 38 additions & 0 deletions tests/pages/powerpoint-page.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ export type OfficeSnapshot = {

type OfficeMockWindow = Window & typeof globalThis & {
__pptypstOfficeSeed?: OfficeMockSeed;
__pptypstClipboardWriteTypes?: string[];
__pptypstOfficeMock: {
reset: (_seed?: OfficeMockSeed) => void;
selectShapes: (_slideId: string, _shapeIds: string[]) => Promise<void>;
Expand Down Expand Up @@ -123,6 +124,10 @@ export class PowerPointPage {
await this.page.locator("#fillColor").fill(fillColor);
}

async setPreviewTypstFillEnabled(enabled: boolean) {
await this.page.locator("#previewFillEnabled").setChecked(enabled);
}

async insertOrUpdate() {
await this.page.locator("#insertBtn").click();
}
Expand All @@ -131,6 +136,39 @@ export class PowerPointPage {
await this.page.locator("#bulkUpdateBtn").click();
}

async copyPreviewSvg(options: { invertColors?: boolean } = {}) {
await this.page.locator("#previewCopyBtn").click({
modifiers: options.invertColors ? ["Shift"] : [],
});
}

async readClipboardText(): Promise<string> {
return this.page.evaluate(() => navigator.clipboard.readText());
}

async recordClipboardWrites() {
await this.page.evaluate(() => {
const appWindow = window as OfficeMockWindow;
const originalWriteText = navigator.clipboard.writeText.bind(navigator.clipboard);
Object.defineProperty(navigator.clipboard, "write", {
configurable: true,
value: async (items: ClipboardItem[]) => {
appWindow.__pptypstClipboardWriteTypes = items.flatMap(item => item.types);
const firstItem = items[0];
const textBlob = await firstItem.getType("text/plain");
await originalWriteText(await textBlob.text());
},
});
});
}

async clipboardWriteTypes(): Promise<string[]> {
return this.page.evaluate(() => {
const appWindow = window as OfficeMockWindow;
return appWindow.__pptypstClipboardWriteTypes || [];
});
}

async selectShapes(slideId: string, shapeIds: string[]) {
await this.page.evaluate(
async ({ selectedSlideId, selectedShapeIds }) => {
Expand Down
57 changes: 57 additions & 0 deletions tests/preview.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,3 +31,60 @@ test("previews Typst math expressions", async ({ powerPointPage, typstMock }) =>
},
]);
});

test("copies the preview SVG with optional inverted colors", async ({ powerPointPage }) => {
await powerPointPage.previewExpression("integral_a^b f(x) dif x");
await powerPointPage.expectPreviewVisible();

await powerPointPage.recordClipboardWrites();
await powerPointPage.copyPreviewSvg();
await expect.poll(() => powerPointPage.readClipboardText()).toContain('fill="#000000"');
const copiedSvg = await powerPointPage.readClipboardText();
await expect.poll(() => powerPointPage.clipboardWriteTypes()).toEqual([
"image/svg+xml",
"text/plain",
]);
expect(copiedSvg).toContain("<svg");
expect(copiedSvg).toContain("integral preview");
expect(copiedSvg).toContain('fill="#000000"');
expect(copiedSvg).not.toContain('style="width: 100%');

await powerPointPage.copyPreviewSvg({ invertColors: true });
await expect.poll(() => powerPointPage.readClipboardText()).toContain('fill="#ffffff"');
const invertedSvg = await powerPointPage.readClipboardText();
expect(invertedSvg).toContain("integral preview");
expect(invertedSvg).toContain('fill="#ffffff"');
expect(invertedSvg).not.toContain('fill="#000000"');
});

test("copies preview SVGs with alpha fills normalized for compatibility",
async ({ powerPointPage, typstMock }) => {
await typstMock.setPreviewSvg([
'<svg xmlns="http://www.w3.org/2000/svg" width="196.39001" height="93.14115">',
'<path fill="#ff000032" stroke="#000" d="M 0 0 L 10 0 L 10 10 Z"/>',
'<path fill="#0000ff32" stroke="#000" d="M 20 0 L 30 0 L 30 10 Z"/>',
'<path style="fill: #00ff0080; stroke: #00000080" d="M 40 0 L 50 0 L 50 10 Z"/>',
"</svg>",
].join(""));

await powerPointPage.setFillColor(null);
await powerPointPage.setPreviewTypstFillEnabled(true);
await powerPointPage.previewExpression("compatibility check");
await powerPointPage.expectPreviewVisible();

await powerPointPage.copyPreviewSvg();
await expect.poll(() => powerPointPage.readClipboardText())
.toContain('fill-opacity="0.19607843137254902"');

const copiedSvg = await powerPointPage.readClipboardText();
expect(copiedSvg).toContain('fill="#ff0000"');
expect(copiedSvg).toContain('fill="#0000ff"');
expect(copiedSvg).toContain('fill-opacity="0.19607843137254902"');
expect(copiedSvg).toContain("fill: rgb(0, 255, 0);");
expect(copiedSvg).toContain("fill-opacity: 0.5;");
expect(copiedSvg).toContain("stroke-opacity: 0.5;");
expect(copiedSvg).not.toContain("#ff000032");
expect(copiedSvg).not.toContain("#0000ff32");
expect(copiedSvg).not.toContain("#00ff0080");
expect(copiedSvg).not.toContain("#00000080");
});
Loading