diff --git a/.config/vite.config.js b/.config/vite.config.js index ac7d8b5..081fc15 100644 --- a/.config/vite.config.js +++ b/.config/vite.config.js @@ -1,5 +1,6 @@ import { defineConfig } from "vite"; import fs from "fs"; +import process from "node:process"; const input = { main: "web/index.html", @@ -7,23 +8,29 @@ const input = { powerpoint: "web/powerpoint.html", }; -export default defineConfig(({ command }) => ({ - root: "web", - base: "/pptypst/", - build: { - outDir: "../build/", - emptyOutDir: true, - rollupOptions: { - input, - }, - }, - server: { - port: 3155, - ...(command === "serve" && { - https: { - key: fs.readFileSync("web/certs/localhost.key"), - cert: fs.readFileSync("web/certs/localhost.crt"), +function serverHttpsConfig() { + return { + key: fs.readFileSync("web/certs/localhost.key"), + cert: fs.readFileSync("web/certs/localhost.crt"), + }; +} + +export default defineConfig(({ command }) => { + const useHttps = command === "serve" && process.env.PPTYPST_USE_HTTPS !== "false"; + + return { + root: "web", + base: "/pptypst/", + build: { + outDir: "../build/", + emptyOutDir: true, + rollupOptions: { + input, }, - }), - }, -})); + }, + server: { + port: 3155, + ...(useHttps && { https: serverHttpsConfig() }), + }, + }; +}); diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..7740f96 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,38 @@ +name: Playwright Tests + +on: + push: + branches: [ main, next ] + pull_request: + branches: [ main, next ] + +jobs: + test: + environment: testing-review + timeout-minutes: 10 + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v6 + with: + persist-credentials: false + + - uses: actions/setup-node@v6 + with: + node-version: lts/* + + - name: Install dependencies + run: npm ci # ci: "clean install" + + - name: Install Playwright Chromium deps & browser + run: npx playwright install-deps chromium && npx playwright install chromium + + - name: Run Playwright tests + run: npx playwright test + + - uses: actions/upload-artifact@v7 + if: ${{ !cancelled() }} + with: + name: playwright-report + path: playwright-report/ + retention-days: 30 diff --git a/.gitignore b/.gitignore index 6ff7203..5a92e93 100644 --- a/.gitignore +++ b/.gitignore @@ -3,4 +3,11 @@ web/certs/* !web/certs/.gitkeep build/ tmp/ -manifest.prod.xml \ No newline at end of file +manifest.prod.xml + +# Playwright +/test-results/ +/playwright-report/ +/blob-report/ +/playwright/.cache/ +/playwright/.auth/ diff --git a/.vscode/extensions.json b/.vscode/extensions.json index 36ab3d7..8266b36 100644 --- a/.vscode/extensions.json +++ b/.vscode/extensions.json @@ -2,5 +2,6 @@ "recommendations": [ "dbaeumer.vscode-eslint", "streetsidesoftware.code-spell-checker", + "ms-playwright.playwright" ] } \ No newline at end of file diff --git a/DEV.md b/DEV.md index 50d0be6..7f2dbf9 100644 --- a/DEV.md +++ b/DEV.md @@ -37,6 +37,8 @@ mkcert -cert-file web/certs/localhost.crt -key-file web/certs/localhost.key loca If you're on WSL, follow [these steps](https://github.com/microsoft/WSL/issues/3161#issuecomment-451863149). For more background, see [this guide](https://240dc.com/wsl2-add-a-local-ssl-certificate-with-mkcert/), but rather execute the commands as shown in the first link. It's a bit tedious, but you will get there. +Playwright tests do not need those certificates. The local and CI test setup starts Vite over plain `http` because the Office APIs are mocked in that scenario. + ### Debug In PowerPoint, press `Ctrl+Shift+I` when the focus is on the Add-in task pane. This will open the dev console of the embedded web view where you can see network requests, the console output etc. @@ -47,6 +49,19 @@ In PowerPoint, press `Ctrl+Shift+I` when the focus is on the Add-in task pane. T npm run validate-manifest ``` +## Playwright Tests + +```sh +# Install necessary dependencies first +npx playwright install-deps chromium +npx playwright install chromium + +# Run tests (or even easier, just use the Playwright VSCode extension) +# Note that a dedicated test webserver will automatically be started for the tests, +# see the playwright.config.ts for details. +npm run test +``` + ## Test production-like environment All you have to do is sideload the `manifest.prod.xml` instead of the manifest used for local development `manifest.xml`. To do so, copy the `manifest.prod.xml` to some `tmp/` folder that you have added to the PowerPoint Trust Center (see above) and rename the file to `manifest.xml` such that PowerPoint recognizes it. @@ -58,3 +73,7 @@ If you have used another manifest beforehand, clear the office cache as describe ``` The production manifest has URLs configured to point to the site hosted on GitHub Pages. This way, you can see if everything works fine. All we ship to the PowerPoint Marketplace is the Manifest file in the end. + +## Useful links + +- [PowerPoint JS API](https://learn.microsoft.com/en-us/javascript/api/powerpoint) (Preview) & [v10](https://learn.microsoft.com/en-us/javascript/api/powerpoint?view=powerpoint-js-1.10) diff --git a/package-lock.json b/package-lock.json index a9bf3e9..0b3b6e9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,7 +16,9 @@ "@eslint/css": "^1.1", "@eslint/js": "^10.0.1", "@html-eslint/eslint-plugin": "^0.59", + "@playwright/test": "^1.58.2", "@stylistic/eslint-plugin": "^5.7", + "@types/node": "^25.5.0", "@types/office-js": "^1.0", "eslint": "^10.2", "globals": "^17.5", @@ -1472,6 +1474,17 @@ "node": ">=14.18.0" } }, + "node_modules/@inquirer/core/node_modules/@types/node": { + "version": "20.19.42", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.42.tgz", + "integrity": "sha512-5L7SUaFC1RyDraj2yRhyBzHTobyXHmohD100CChNtyPyleoq37Mqab5Gn8XEKI04dfN/oqPdpHk38MgcQWHbZg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "undici-types": "~6.21.0" + } + }, "node_modules/@inquirer/editor": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/@inquirer/editor/-/editor-3.0.1.tgz", @@ -4666,6 +4679,22 @@ "url": "https://github.com/sponsors/Boshen" } }, + "node_modules/@playwright/test": { + "version": "1.60.0", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.60.0.tgz", + "integrity": "sha512-O71yZIbAh/PxDMNGns37GHBIfrVkEVyn+AXyIa5dOTfb4/xNvRWV+Vv/NMbNCtODB/pO7vLlF2OTmMVLhmr7Ag==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.60.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/@protobufjs/aspromise": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", @@ -5051,9 +5080,9 @@ } }, "node_modules/@tybys/wasm-util": { - "version": "0.10.1", - "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", - "integrity": "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==", + "version": "0.10.2", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz", + "integrity": "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==", "dev": true, "license": "MIT", "optional": true, @@ -5142,13 +5171,13 @@ } }, "node_modules/@types/node": { - "version": "20.19.39", - "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.39.tgz", - "integrity": "sha512-orrrD74MBUyK8jOAD/r0+lfa1I2MO6I+vAkmAWzMYbCcgrN4lCrmK52gRFQq/JRxfYPfonkr4b0jcY7Olqdqbw==", + "version": "25.9.2", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.9.2.tgz", + "integrity": "sha512-G05zqtJhcDLb8uslf5EjCxXg9G1KQxiV8OS0R26IC//Eoyitzqe8z37I7cqvnZlrlSfgocQRfSn/AHBZJJFyGw==", "dev": true, "license": "MIT", "dependencies": { - "undici-types": "~6.21.0" + "undici-types": ">=7.24.0 <7.24.7" } }, "node_modules/@types/node-fetch": { @@ -5163,6 +5192,13 @@ "form-data": "^4.0.4" } }, + "node_modules/@types/node/node_modules/undici-types": { + "version": "7.24.6", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz", + "integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/office-js": { "version": "1.0.589", "resolved": "https://registry.npmjs.org/@types/office-js/-/office-js-1.0.589.tgz", @@ -7808,9 +7844,9 @@ } }, "node_modules/fsevents": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", - "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", "dev": true, "hasInstallScript": true, "license": "MIT", @@ -11068,6 +11104,38 @@ "node": ">=16.20.0" } }, + "node_modules/playwright": { + "version": "1.60.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.60.0.tgz", + "integrity": "sha512-hheHdokM8cdqCb0lcE3s+zT4t4W+vvjpGxsZlDnikarzx8tSzMebh3UiFtgqwFwnTnjYQcsyMF8ei2mCO/tpeA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.60.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.60.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.60.0.tgz", + "integrity": "sha512-9bW6zvX/m0lEbgTKJ6YppOKx8H3VOPBMOCFh2irXFOT4BbHgrx5hPjwJYLT40Lu+4qtD36qKc/Hn56StUW57IA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/portfinder": { "version": "1.0.38", "resolved": "https://registry.npmjs.org/portfinder/-/portfinder-1.0.38.tgz", @@ -12651,7 +12719,8 @@ "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/union": { "version": "0.5.0", @@ -12850,6 +12919,21 @@ } } }, + "node_modules/vite/node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/vscode-css-languageservice": { "version": "6.3.10", "resolved": "https://registry.npmjs.org/vscode-css-languageservice/-/vscode-css-languageservice-6.3.10.tgz", diff --git a/package.json b/package.json index 346459a..06c7b06 100644 --- a/package.json +++ b/package.json @@ -7,6 +7,7 @@ "build": "vite build --config .config/vite.config.js", "start": "office-addin-debugging start manifest.xml desktop", "stop": "office-addin-debugging stop manifest.xml", + "test": "npx playwright test", "validate": "office-addin-manifest validate manifest.xml", "validate-prod": "office-addin-manifest validate -p manifest.prod.xml", "generate-prod-manifest": "sed 's|https://localhost:3155/pptypst/|https://splines.github.io/pptypst/|g' manifest.xml > manifest.prod.xml" @@ -15,7 +16,9 @@ "@eslint/css": "^1.1", "@eslint/js": "^10.0.1", "@html-eslint/eslint-plugin": "^0.59", + "@playwright/test": "^1.58.2", "@stylistic/eslint-plugin": "^5.7", + "@types/node": "^25.5.0", "@types/office-js": "^1.0", "eslint": "^10.2", "globals": "^17.5", diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 0000000..b0365e8 --- /dev/null +++ b/playwright.config.ts @@ -0,0 +1,50 @@ +import { defineConfig, devices } from "@playwright/test"; +import process from "node:process"; + +const isCI = Boolean(process.env.CI); +const testHost = "127.0.0.1"; +const testPort = "3157"; +const testOrigin = `http://${testHost}:${testPort}`; +const testBaseUrl = `${testOrigin}/pptypst/`; + +/** + * See https://playwright.dev/docs/test-configuration. + */ +export default defineConfig({ + testDir: "./tests", + /* Run tests in files in parallel */ + fullyParallel: true, + /* Fail the build on CI if you accidentally left test.only in the source code. */ + forbidOnly: isCI, + /* Retry on CI only */ + retries: isCI ? 2 : 0, + /* Opt out of parallel tests on CI. */ + workers: isCI ? 1 : undefined, + /* Reporter to use. See https://playwright.dev/docs/test-reporters */ + reporter: "html", + /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ + use: { + /* Base URL to use in actions like `await page.goto('')`. */ + baseURL: testBaseUrl, + }, + + /* Configure projects for major browsers */ + projects: [ + { + name: "chromium", + use: { ...devices["Desktop Chrome"] }, + }, + ], + + /* Run your local dev server before starting the tests */ + webServer: { + command: `npm run dev -- --host ${testHost} --port ${testPort} --strictPort`, + env: { + ...process.env, + PPTYPST_USE_HTTPS: "false", + }, + url: `${testBaseUrl}powerpoint.html`, + reuseExistingServer: !isCI, + timeout: 120_000, + }, +}); diff --git a/tests/_support/browser-mocks/typst-memory-access-model.ts b/tests/_support/browser-mocks/typst-memory-access-model.ts new file mode 100644 index 0000000..8054ce1 --- /dev/null +++ b/tests/_support/browser-mocks/typst-memory-access-model.ts @@ -0,0 +1 @@ +export function MemoryAccessModel() {} diff --git a/tests/_support/browser-mocks/typst-options.ts b/tests/_support/browser-mocks/typst-options.ts new file mode 100644 index 0000000..f2fc060 --- /dev/null +++ b/tests/_support/browser-mocks/typst-options.ts @@ -0,0 +1,9 @@ +function option(name: string) { + return (...args: unknown[]) => ({ name, args }); +} + +export const disableDefaultFontAssets = option("disableDefaultFontAssets"); +export const loadFonts = option("loadFonts"); +export const preloadFontAssets = option("preloadFontAssets"); +export const withAccessModel = option("withAccessModel"); +export const withPackageRegistry = option("withPackageRegistry"); diff --git a/tests/_support/browser-mocks/typst-package-registry.ts b/tests/_support/browser-mocks/typst-package-registry.ts new file mode 100644 index 0000000..129372c --- /dev/null +++ b/tests/_support/browser-mocks/typst-package-registry.ts @@ -0,0 +1,8 @@ +export function NodeFetchPackageRegistry( + this: { accessModel: unknown; request: unknown }, + accessModel: unknown, + request: unknown, +) { + this.accessModel = accessModel; + this.request = request; +} diff --git a/tests/_support/browser-mocks/typst-state-module.d.ts b/tests/_support/browser-mocks/typst-state-module.d.ts new file mode 100644 index 0000000..134a919 --- /dev/null +++ b/tests/_support/browser-mocks/typst-state-module.d.ts @@ -0,0 +1,24 @@ +declare module "*__test__/typst-state.js" { + export const typstMockState: { + rendererInitOptions: { hasGetModule: boolean }[]; + addSourceCalls: { path: string; source: string }[]; + compileCalls: { mainFilePath: string }[]; + renderSvgCalls: { + format: string; + artifactContent: number[]; + data_selection: Record; + }[]; + }; + + export function typstMockReady(): boolean; + + export function typstMockCalls(): { + addSourceCalls: { path: string; source: string }[]; + compileCalls: { mainFilePath: string }[]; + renderSvgCalls: { + format: string; + artifactContent: number[]; + data_selection: Record; + }[]; + }; +} diff --git a/tests/_support/browser-mocks/typst-state.ts b/tests/_support/browser-mocks/typst-state.ts new file mode 100644 index 0000000..5f53dde --- /dev/null +++ b/tests/_support/browser-mocks/typst-state.ts @@ -0,0 +1,37 @@ +type AddSourceCall = { path: string; source: string }; +type CompileCall = { mainFilePath: string }; +type RenderSvgCall = { + format: string; + artifactContent: number[]; + data_selection: Record; +}; + +type TypstMockState = { + rendererInitOptions: { hasGetModule: boolean }[]; + addSourceCalls: AddSourceCall[]; + compileCalls: CompileCall[]; + renderSvgCalls: RenderSvgCall[]; +}; + +function freshState(): TypstMockState { + return { + rendererInitOptions: [], + addSourceCalls: [], + compileCalls: [], + renderSvgCalls: [], + }; +} + +export const typstMockState = freshState(); + +export function typstMockReady() { + return typstMockState.rendererInitOptions.length === 1; +} + +export function typstMockCalls() { + return { + addSourceCalls: structuredClone(typstMockState.addSourceCalls), + compileCalls: structuredClone(typstMockState.compileCalls), + renderSvgCalls: structuredClone(typstMockState.renderSvgCalls), + }; +} diff --git a/tests/_support/browser-mocks/typst-wasm-url.ts b/tests/_support/browser-mocks/typst-wasm-url.ts new file mode 100644 index 0000000..b314586 --- /dev/null +++ b/tests/_support/browser-mocks/typst-wasm-url.ts @@ -0,0 +1 @@ +export default "/mock/typst.wasm"; diff --git a/tests/_support/browser-mocks/typst.ts b/tests/_support/browser-mocks/typst.ts new file mode 100644 index 0000000..68578be --- /dev/null +++ b/tests/_support/browser-mocks/typst.ts @@ -0,0 +1,52 @@ +import { typstMockState } from "/pptypst/__test__/typst-state.js"; + +type CompilerInitOptions = { beforeBuild: unknown[]; getModule: unknown }; +type CompileOptions = { mainFilePath: string }; +type RenderSvgOptions = { + format: string; + artifactContent: Uint8Array; + data_selection: Record; +}; + +const previewSvg = [ + '', + 'integral preview', + "", +].join(""); + +export function createTypstCompiler() { + return { + init(options: CompilerInitOptions) { + if (typeof options.getModule !== "function") { + return Promise.reject( + new Error("Expected Typst compiler getModule option."), + ); + } + return Promise.resolve(); + }, + addSource(path: string, source: string) { + typstMockState.addSourceCalls.push({ path, source }); + }, + compile(options: CompileOptions) { + typstMockState.compileCalls.push(options); + return Promise.resolve({ diagnostics: [], result: new Uint8Array([1, 2, 3]) }); + }, + }; +} + +export function createTypstRenderer() { + return { + init(options: { getModule: unknown }) { + typstMockState.rendererInitOptions.push({ hasGetModule: typeof options.getModule === "function" }); + return Promise.resolve(); + }, + renderSvg(options: RenderSvgOptions) { + typstMockState.renderSvgCalls.push({ + format: options.format, + artifactContent: Array.from(options.artifactContent), + data_selection: options.data_selection, + }); + return Promise.resolve(previewSvg); + }, + }; +} diff --git a/tests/_support/fixtures.ts b/tests/_support/fixtures.ts new file mode 100644 index 0000000..5dd9a37 --- /dev/null +++ b/tests/_support/fixtures.ts @@ -0,0 +1,58 @@ +import { + expect, + test as base, + type TestInfo, +} from "@playwright/test"; +import { PowerPointPage } from "../pages/powerpoint-page"; +import { installOfficeMock } from "./office-mock"; +import { TypstMock } from "./typst-mock"; + +export type PptypstFixtures = { + _forEachTest: null; + powerPointPage: PowerPointPage; + typstMock: TypstMock; +}; + +export type PptypstTestFixtures = Pick; +type PptypstTestCallback = (_fixtures: PptypstTestFixtures, _testInfo: TestInfo) => unknown; + +export * from "@playwright/test"; +export { expect }; + +const extendedTest = base.extend({ + // https://playwright.dev/docs/test-fixtures#adding-global-beforeeachaftereach-hooks + _forEachTest: [ + async ({ page }, use) => { + await page.addInitScript(() => { + window.localStorage.clear(); + }); + + await use(null); + }, + { auto: true }, + ], + + /** Installs the Typst dependency mock and exposes recorded compiler calls. */ + typstMock: async ({ page }, use) => { + const typstMock = new TypstMock(page); + await typstMock.install(); + await use(typstMock); + }, + + /** Opens the PowerPoint task pane page after Office and Typst mocks are ready. */ + powerPointPage: async ({ page, typstMock }, use) => { + await installOfficeMock(page); + + const powerPointPage = new PowerPointPage(page); + await powerPointPage.goto(); + await typstMock.waitUntilReady(); + + await use(powerPointPage); + }, +}); + +type PptypstTest = { + (_title: string, _callback: PptypstTestCallback): void; +} & typeof extendedTest; + +export const test = extendedTest as PptypstTest; diff --git a/tests/_support/office-mock.ts b/tests/_support/office-mock.ts new file mode 100644 index 0000000..0397a04 --- /dev/null +++ b/tests/_support/office-mock.ts @@ -0,0 +1,32 @@ +import type { Page } from "@playwright/test"; +import fs from "node:fs/promises"; +import path from "node:path"; + +const officeMockDir = path.join(process.cwd(), "tests", "_support", "office"); +const officeMockFiles = [ + "types.ts", + "office-primitives.ts", + "document-model.ts", + "adapter.ts", + "install.ts", +]; + +/** Replaces the hosted Office script with the minimal APIs needed for task pane startup. */ +export async function installOfficeMock(page: Page) { + await page.route("https://appsforoffice.microsoft.com/lib/1/hosted/office.js", async (route) => { + await route.fulfill({ contentType: "application/javascript", body: await compileOfficeMock() }); + }); +} + +async function compileOfficeMock() { + const { compileBrowserMockSource } = await import("./transpile-browser-mock"); + const sourceParts = await Promise.all( + officeMockFiles.map(fileName => fs.readFile(path.join(officeMockDir, fileName), "utf8")), + ); + + return compileBrowserMockSource( + officeMockDir, + sourceParts.join("\n\n"), + path.join(officeMockDir, "index.ts"), + ); +} diff --git a/tests/_support/office/adapter.ts b/tests/_support/office/adapter.ts new file mode 100644 index 0000000..1d4057d --- /dev/null +++ b/tests/_support/office/adapter.ts @@ -0,0 +1,110 @@ +/* eslint-disable no-unused-vars, @typescript-eslint/no-unused-vars */ + +// Office.js-shaped adapter around the document model. +class MockPresentation { + readonly slides: MockItemCollection; + readonly pageSetup: MockPageSetup; + private readonly documentModel: MockPowerPointDocument; + + constructor(documentModel: MockPowerPointDocument) { + this.documentModel = documentModel; + this.slides = new MockItemCollection( + () => this.documentModel.slides, + slideId => new MockSlide(this.documentModel, slideId, true), + ); + this.pageSetup = new MockPageSetup( + this.documentModel.slideWidth, + this.documentModel.slideHeight, + ); + } + + getSelectedShapes(): MockCollection { + return new MockCollection(() => this.documentModel.getSelectedShapes()); + } + + getSelectedSlides(): MockCollection { + return new MockCollection(() => this.documentModel.getSelectedSlides()); + } +} + +class MockRequestContext { + readonly presentation: MockPresentation; + + constructor(documentModel: MockPowerPointDocument) { + this.presentation = new MockPresentation(documentModel); + } + + async sync() {} +} + +function serializeTypstSource(source: MockTypstSource): string { + const documentNode = document.implementation.createDocument( + SHAPE_XML_NAMESPACE, + "pptypst:content", + null, + ); + const root = documentNode.documentElement; + + const preambleNode = documentNode.createElementNS(SHAPE_XML_NAMESPACE, "pptypst:preamble"); + preambleNode.textContent = source.preamble; + + const bodyNode = documentNode.createElementNS(SHAPE_XML_NAMESPACE, "pptypst:body"); + bodyNode.textContent = source.body; + + root.appendChild(preambleNode); + root.appendChild(bodyNode); + return new XMLSerializer().serializeToString(documentNode); +} + +function createOfficeHost(documentModel: MockPowerPointDocument): MockOfficeHost { + return { + HostType: { PowerPoint: "PowerPoint" }, + EventType: { DocumentSelectionChanged: "DocumentSelectionChanged" }, + AsyncResultStatus: { Succeeded: "succeeded", Failed: "failed" }, + CoercionType: { XmlSvg: "xmlSvg" }, + actions: { + associate() {}, + }, + context: { + document: { + addHandlerAsync(_eventType: string, handler: SelectionChangedHandler) { + documentModel.addSelectionHandler(handler); + }, + setSelectedDataAsync(data, _options, callback) { + const result = documentModel.insertSvg(data); + callback(result.status === "succeeded" + ? { status: "succeeded" } + : { status: "failed", error: result.error }); + }, + }, + }, + async onReady(callback: OfficeReadyCallback) { + await callback({ host: "PowerPoint" }); + }, + }; +} + +function createPowerPointHost(documentModel: MockPowerPointDocument): MockPowerPointHost { + return { + async run(callback: (_context: MockRequestContext) => Promise | T) { + return callback(new MockRequestContext(documentModel)); + }, + }; +} + +function createTestHarness(documentModel: MockPowerPointDocument): MockOfficeTestHarness { + return { + reset(seed?: MockOfficeSeed) { + documentModel.reset(seed); + }, + async selectShapes(slideId: string, shapeIds: string[]) { + await documentModel.selectShapes(slideId, shapeIds); + }, + async clearSelection(slideId?: string) { + await documentModel.clearSelection(slideId); + }, + snapshot() { + return documentModel.snapshot(); + }, + }; +} diff --git a/tests/_support/office/document-model.ts b/tests/_support/office/document-model.ts new file mode 100644 index 0000000..dfc3658 --- /dev/null +++ b/tests/_support/office/document-model.ts @@ -0,0 +1,284 @@ +/* eslint-disable no-unused-vars, @typescript-eslint/no-unused-vars */ + +// In-memory PowerPoint document model. +class MockShape implements Loadable, Identifiable { + readonly id: string; + altTextTitle = ""; + altTextDescription = ""; + name = ""; + left = 0; + top = 0; + width = 160; + height = 40; + rotation = 0; + svgContent: string | null = null; + + readonly fill = new MockFill(null); + readonly tags: MockTagCollection; + readonly customXmlParts: MockCustomXmlPartCollection; + + private readonly tagMap = new Map(); + private readonly xmlParts: MockXmlPart[] = []; + private readonly documentModel: MockPowerPointDocument; + private readonly parentSlide: MockSlide; + + constructor( + documentModel: MockPowerPointDocument, + parentSlide: MockSlide, + id: string, + ) { + this.documentModel = documentModel; + this.parentSlide = parentSlide; + this.id = id; + this.tags = new MockTagCollection(this.tagMap, (key, value) => { + if (key === "TypstFillColor") { + this.fill.foregroundColor = value === "disabled" ? null : value; + } + }); + this.customXmlParts = new MockCustomXmlPartCollection( + () => this.xmlParts, + xml => this.addCustomXmlPart(xml), + ); + } + + load() {} + + delete() { + this.parentSlide.removeShape(this.id); + this.documentModel.removeSelectedShape(this.id); + } + + getParentSlide(): MockSlide { + return this.parentSlide; + } + + applySeed(seed: MockSeedShape) { + this.left = seed.left ?? this.left; + this.top = seed.top ?? this.top; + this.width = seed.width ?? this.width; + this.height = seed.height ?? this.height; + this.rotation = seed.rotation ?? this.rotation; + this.altTextTitle = seed.altTextTitle ?? this.altTextTitle; + this.altTextDescription = seed.altTextDescription ?? this.altTextDescription; + this.name = seed.name ?? this.name; + this.fill.foregroundColor = seed.fillColor ?? null; + this.svgContent = seed.svgContent ?? null; + + for (const [key, value] of Object.entries(seed.tags ?? {})) { + this.tags.add(key, value); + } + + if (seed.typstSource) { + this.customXmlParts.add(serializeTypstSource(seed.typstSource)); + } + } + + snapshot(): MockShapeSnapshot { + return { + id: this.id, + left: this.left, + top: this.top, + width: this.width, + height: this.height, + rotation: this.rotation, + altTextTitle: this.altTextTitle, + altTextDescription: this.altTextDescription, + name: this.name, + fillColor: this.fill.foregroundColor, + tags: Object.fromEntries(this.tagMap.entries()), + customXml: this.xmlParts.map(part => part.xml), + svgContent: this.svgContent, + }; + } + + private addCustomXmlPart(xml: string): MockXmlPart { + const documentNode = new DOMParser().parseFromString(xml, "application/xml"); + const part = new MockXmlPart( + this.documentModel.nextXmlPartId(), + xml, + documentNode.documentElement.namespaceURI, + ); + + this.xmlParts.push(part); + return part; + } +} + +class MockSlide implements Loadable, Identifiable { + readonly id: string; + readonly isNullObject: boolean; + readonly shapes: MockItemCollection; + + private readonly shapeList: MockShape[] = []; + private readonly documentModel: MockPowerPointDocument; + + constructor( + documentModel: MockPowerPointDocument, + id: string, + isNullObject = false, + ) { + this.documentModel = documentModel; + this.id = id; + this.isNullObject = isNullObject; + this.shapes = new MockItemCollection(() => this.shapeList); + } + + load() {} + + addShape(seed: MockSeedShape = {}): MockShape { + const shape = new MockShape( + this.documentModel, + this, + seed.id ?? this.documentModel.nextShapeId(), + ); + + shape.applySeed(seed); + this.shapeList.push(shape); + return shape; + } + + removeShape(shapeId: string) { + const index = this.shapeList.findIndex(shape => shape.id === shapeId); + if (index >= 0) { + this.shapeList.splice(index, 1); + } + } + + snapshot(): MockSlideSnapshot { + return { + id: this.id, + shapes: this.shapeList.map(shape => shape.snapshot()), + }; + } +} + +class MockPageSetup implements Loadable { + readonly slideWidth: number; + readonly slideHeight: number; + + constructor( + slideWidth: number, + slideHeight: number, + ) { + this.slideWidth = slideWidth; + this.slideHeight = slideHeight; + } + + load() {} +} + +class MockPowerPointDocument { + slideWidth = DEFAULT_SLIDE_WIDTH; + slideHeight = DEFAULT_SLIDE_HEIGHT; + slides: MockSlide[] = []; + selectedSlideIds: string[] = []; + selectedShapeIds: string[] = []; + readonly insertedSvgCalls: { slideId: string | null; svg: string }[] = []; + + private readonly selectionHandlers: SelectionChangedHandler[] = []; + private shapeCounter = 1; + private xmlCounter = 1; + + constructor(seed: MockOfficeSeed = {}) { + this.reset(seed); + } + + reset(seed: MockOfficeSeed = {}) { + this.slideWidth = seed.slideWidth ?? DEFAULT_SLIDE_WIDTH; + this.slideHeight = seed.slideHeight ?? DEFAULT_SLIDE_HEIGHT; + this.slides = []; + this.selectedSlideIds = []; + this.selectedShapeIds = []; + this.insertedSvgCalls.length = 0; + this.shapeCounter = 1; + this.xmlCounter = 1; + + const slideSeeds = seed.slides?.length ? seed.slides : [{ id: "slide-1", shapes: [] }]; + slideSeeds.forEach((slideSeed, index) => { + const slide = new MockSlide(this, slideSeed.id ?? `slide-${String(index + 1)}`); + this.slides.push(slide); + slideSeed.shapes?.forEach(shapeSeed => slide.addShape(shapeSeed)); + }); + + this.selectedSlideIds = seed.selectedSlideIds?.length + ? [...seed.selectedSlideIds] + : [this.slides.at(0)?.id].filter((value): value is string => typeof value === "string"); + this.selectedShapeIds = seed.selectedShapeIds ? [...seed.selectedShapeIds] : []; + } + + nextShapeId(): string { + const id = `shape-${String(this.shapeCounter)}`; + this.shapeCounter += 1; + return id; + } + + nextXmlPartId(): string { + const id = `xml-${String(this.xmlCounter)}`; + this.xmlCounter += 1; + return id; + } + + addSelectionHandler(handler: SelectionChangedHandler) { + this.selectionHandlers.push(handler); + } + + getSelectedSlides(): MockSlide[] { + return this.selectedSlideIds + .map(slideId => this.slides.find(slide => slide.id === slideId)) + .filter((slide): slide is MockSlide => Boolean(slide)); + } + + getSelectedShapes(): MockShape[] { + return this.slides + .flatMap(slide => slide.shapes.items) + .filter(shape => this.selectedShapeIds.includes(shape.id)); + } + + removeSelectedShape(shapeId: string) { + this.selectedShapeIds = this.selectedShapeIds.filter(id => id !== shapeId); + } + + async selectShapes(slideId: string, shapeIds: string[] = []) { + this.selectedSlideIds = [slideId]; + this.selectedShapeIds = [...shapeIds]; + await this.triggerSelectionChanged(); + } + + async clearSelection(slideId?: string) { + const fallbackSlideId = slideId ?? this.selectedSlideIds.at(0) ?? this.slides.at(0)?.id; + this.selectedSlideIds = fallbackSlideId ? [fallbackSlideId] : []; + this.selectedShapeIds = []; + await this.triggerSelectionChanged(); + } + + insertSvg(svg: string): OfficeAsyncResult { + const targetSlide = this.getSelectedSlides().at(0) ?? this.slides.at(0) ?? null; + this.insertedSvgCalls.push({ slideId: targetSlide?.id ?? null, svg }); + + if (!targetSlide) { + return { status: "failed", error: new Error("No target slide available.") }; + } + + const shape = targetSlide.addShape({ svgContent: svg }); + this.selectedSlideIds = [targetSlide.id]; + this.selectedShapeIds = [shape.id]; + return { status: "succeeded" }; + } + + snapshot(): MockOfficeSnapshot { + return { + slideWidth: this.slideWidth, + slideHeight: this.slideHeight, + selectedSlideIds: [...this.selectedSlideIds], + selectedShapeIds: [...this.selectedShapeIds], + insertedSvgCalls: this.insertedSvgCalls.map(call => ({ ...call })), + slides: this.slides.map(slide => slide.snapshot()), + }; + } + + private async triggerSelectionChanged() { + for (const handler of this.selectionHandlers) { + await handler(); + } + } +} diff --git a/tests/_support/office/install.ts b/tests/_support/office/install.ts new file mode 100644 index 0000000..137b759 --- /dev/null +++ b/tests/_support/office/install.ts @@ -0,0 +1,7 @@ +// Browser global installation. +const mockGlobals = globalThis as unknown as MockGlobals; +const documentModel = new MockPowerPointDocument(mockGlobals.__pptypstOfficeSeed); + +mockGlobals.Office = createOfficeHost(documentModel); +mockGlobals.PowerPoint = createPowerPointHost(documentModel); +mockGlobals.__pptypstOfficeMock = createTestHarness(documentModel); diff --git a/tests/_support/office/office-primitives.ts b/tests/_support/office/office-primitives.ts new file mode 100644 index 0000000..e6439f1 --- /dev/null +++ b/tests/_support/office/office-primitives.ts @@ -0,0 +1,152 @@ +/* eslint-disable no-unused-vars, @typescript-eslint/no-unused-vars */ + +// Small Office.js collection/value objects used by app code. +class MockCollection implements Loadable { + private readonly getItems: () => T[]; + + constructor(getItems: () => T[]) { + this.getItems = getItems; + } + + get items(): T[] { + return this.getItems(); + } + + load() {} +} + +class MockItemCollection extends MockCollection { + private readonly missingItem?: (_id: string) => T; + + constructor( + getItems: () => T[], + missingItem?: (_id: string) => T, + ) { + super(getItems); + this.missingItem = missingItem; + } + + getItem(id: string): T { + const item = this.items.find(candidate => candidate.id === id); + if (item) return item; + if (this.missingItem) return this.missingItem(id); + + throw new Error(`Item ${id} not found.`); + } +} + +class MockFill implements Loadable { + foregroundColor: string | null; + + constructor(foregroundColor: string | null) { + this.foregroundColor = foregroundColor; + } + + load() {} +} + +class MockTagItem implements Loadable { + readonly key: string; + readonly isNullObject: boolean; + private readonly getValue: () => string; + + constructor( + key: string, + getValue: () => string, + isNullObject = false, + ) { + this.key = key; + this.getValue = getValue; + this.isNullObject = isNullObject; + } + + get value(): string { + return this.getValue(); + } + + load() {} +} + +class MockTagCollection implements Loadable { + private readonly tagMap: Map; + private readonly onAdd: (_key: string, _value: string) => void; + + constructor( + tagMap: Map, + onAdd: (_key: string, _value: string) => void, + ) { + this.tagMap = tagMap; + this.onAdd = onAdd; + } + + get items(): MockTagItem[] { + return Array.from( + this.tagMap.entries(), + ([key, value]) => new MockTagItem(key, () => value), + ); + } + + add(key: string, value: string) { + this.tagMap.set(key, value); + this.onAdd(key, value); + } + + getItemOrNullObject(key: string): MockTagItem { + if (!this.tagMap.has(key)) { + return new MockTagItem(key, () => "", true); + } + + return new MockTagItem(key, () => this.tagMap.get(key) ?? ""); + } + + load() {} +} + +class MockXmlPart implements Loadable { + readonly id: string; + readonly xml: string; + readonly namespaceUri: string | null; + + constructor( + id: string, + xml: string, + namespaceUri: string | null, + ) { + this.id = id; + this.xml = xml; + this.namespaceUri = namespaceUri; + } + + getXml() { + return { value: this.xml }; + } + + load() {} +} + +class MockCustomXmlPartCollection implements Loadable { + private readonly getParts: () => MockXmlPart[]; + private readonly addPart: (_xml: string) => MockXmlPart; + + constructor( + getParts: () => MockXmlPart[], + addPart: (_xml: string) => MockXmlPart, + ) { + this.getParts = getParts; + this.addPart = addPart; + } + + get items(): MockXmlPart[] { + return this.getParts(); + } + + add(xml: string): MockXmlPart { + return this.addPart(xml); + } + + getByNamespace(namespaceUri: string): MockCollection { + return new MockCollection(() => this.getParts().filter(part => part.namespaceUri === namespaceUri)); + } + + load() {} +} diff --git a/tests/_support/office/types.ts b/tests/_support/office/types.ts new file mode 100644 index 0000000..3018cb6 --- /dev/null +++ b/tests/_support/office/types.ts @@ -0,0 +1,124 @@ +/* + * Browser replacement for Office.js used by the PowerPoint Playwright tests. + * + * These files are concatenated and transpiled into one classic script because + * the app loads hosted `office.js` without module support. + */ + +/* eslint-disable no-unused-vars, @typescript-eslint/no-unused-vars */ + +type OfficeReadyInfo = { host: "PowerPoint" }; +type OfficeReadyCallback = (_info: OfficeReadyInfo) => void | Promise; +type SelectionChangedHandler = () => void | Promise; + +// Test-facing seed and snapshot data. +type MockTypstSource = { + preamble: string; + body: string; +}; + +type MockSeedShape = { + id?: string; + left?: number; + top?: number; + width?: number; + height?: number; + rotation?: number; + fillColor?: string | null; + altTextTitle?: string; + altTextDescription?: string; + name?: string; + tags?: Record; + typstSource?: MockTypstSource; + svgContent?: string; +}; + +type MockSeedSlide = { + id?: string; + shapes?: MockSeedShape[]; +}; + +type MockOfficeSeed = { + slides?: MockSeedSlide[]; + selectedSlideIds?: string[]; + selectedShapeIds?: string[]; + slideWidth?: number; + slideHeight?: number; +}; + +type MockShapeSnapshot = { + id: string; + left: number; + top: number; + width: number; + height: number; + rotation: number; + altTextTitle: string; + altTextDescription: string; + name: string; + fillColor: string | null; + tags: Record; + customXml: string[]; + svgContent: string | null; +}; + +type MockSlideSnapshot = { + id: string; + shapes: MockShapeSnapshot[]; +}; + +type MockOfficeSnapshot = { + slideWidth: number; + slideHeight: number; + selectedSlideIds: string[]; + selectedShapeIds: string[]; + insertedSvgCalls: { slideId: string | null; svg: string }[]; + slides: MockSlideSnapshot[]; +}; + +// Global APIs installed for the browser app. +type OfficeAsyncResult = { status: string; error?: Error }; + +type MockOfficeDocument = { + addHandlerAsync: (_eventType: string, _handler: SelectionChangedHandler) => void; + setSelectedDataAsync: ( + _data: string, + _options: { coercionType: string }, + _callback: (_result: OfficeAsyncResult) => void, + ) => void; +}; + +type MockOfficeHost = { + HostType: { PowerPoint: "PowerPoint" }; + EventType: { DocumentSelectionChanged: "DocumentSelectionChanged" }; + AsyncResultStatus: { Succeeded: "succeeded"; Failed: "failed" }; + CoercionType: { XmlSvg: "xmlSvg" }; + actions: { associate: (_name: string, _handler: unknown) => void }; + context: { document: MockOfficeDocument }; + onReady: (_callback: OfficeReadyCallback) => Promise; +}; + +type MockPowerPointHost = { + run: (_callback: (_context: MockRequestContext) => Promise | T) => Promise; +}; + +type MockOfficeTestHarness = { + reset: (_seed?: MockOfficeSeed) => void; + selectShapes: (_slideId: string, _shapeIds: string[]) => Promise; + clearSelection: (_slideId?: string) => Promise; + snapshot: () => MockOfficeSnapshot; +}; + +type MockGlobals = { + Office: MockOfficeHost; + PowerPoint: MockPowerPointHost; + __pptypstOfficeMock: MockOfficeTestHarness; + __pptypstOfficeSeed?: MockOfficeSeed; +}; + +type Loadable = { load: (_properties?: unknown) => void }; +type Identifiable = { id: string }; + +const SHAPE_XML_NAMESPACE = "https://splines.github.io/pptypst/shape/v1"; +const DEFAULT_SLIDE_WIDTH = 960; +const DEFAULT_SLIDE_HEIGHT = 540; diff --git a/tests/_support/transpile-browser-mock.ts b/tests/_support/transpile-browser-mock.ts new file mode 100644 index 0000000..d59f48b --- /dev/null +++ b/tests/_support/transpile-browser-mock.ts @@ -0,0 +1,28 @@ +import fs from "fs/promises"; +import * as ts from "typescript"; + +const compiledMocks = new Map(); + +/** Transpiles an in-memory TypeScript browser mock source into JavaScript. */ +export function compileBrowserMockSource(cacheKey: string, source: string, fileName = cacheKey) { + const cached = compiledMocks.get(cacheKey); + if (cached) return cached; + + const output = ts.transpileModule(source, { + compilerOptions: { + module: ts.ModuleKind.ES2022, + target: ts.ScriptTarget.ES2022, + sourceMap: false, + }, + fileName, + }).outputText; + + compiledMocks.set(cacheKey, output); + return output; +} + +/** Transpiles a TypeScript browser mock into JavaScript for Playwright route fulfillment. */ +export async function compileBrowserMock(filePath: string) { + const source = await fs.readFile(filePath, "utf8"); + return compileBrowserMockSource(filePath, source, filePath); +} diff --git a/tests/_support/typst-mock.ts b/tests/_support/typst-mock.ts new file mode 100644 index 0000000..c51e26e --- /dev/null +++ b/tests/_support/typst-mock.ts @@ -0,0 +1,68 @@ +import type { Page } from "@playwright/test"; +import path from "node:path"; +import { compileBrowserMock } from "./transpile-browser-mock"; + +export type TypstMockCalls = { + addSourceCalls: { path: string; source: string }[]; + compileCalls: { mainFilePath: string }[]; + renderSvgCalls: { + format: string; + artifactContent: number[]; + data_selection: Record; + }[]; +}; + +const stateModuleUrl = "/pptypst/__test__/typst-state.js"; + +function browserMockPath(fileName: string) { + return path.join(process.cwd(), "tests", "_support", "browser-mocks", fileName); +} + +/** Installs route-level mocks for the Typst dependencies used by the preview path. */ +export class TypstMock { + private readonly page: Page; + + constructor(page: Page) { + this.page = page; + } + + /** 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"); + await this.routeModule("**/@myriaddreamin_typst__ts_dist_esm_fs_memory__mjs.js*", "typst-memory-access-model.ts"); + await this.routeModule("**/typst_ts_web_compiler_bg.wasm?*", "typst-wasm-url.ts"); + 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. */ + async waitUntilReady() { + await this.page.waitForFunction(async (moduleUrl) => { + const stateModule = await import(moduleUrl) as { + typstMockReady: () => boolean; + }; + return stateModule.typstMockReady(); + }, stateModuleUrl); + } + + /** Returns the Typst compiler and renderer calls recorded in the browser. */ + async calls(): Promise { + return this.page.evaluate(async (moduleUrl) => { + const stateModule = await import(moduleUrl) as { + typstMockCalls: () => TypstMockCalls; + }; + return stateModule.typstMockCalls(); + }, stateModuleUrl); + } + + private async routeModule(url: string, fileName: string) { + await this.page.route(url, async (route) => { + await route.fulfill({ + contentType: "application/javascript", + body: await compileBrowserMock(browserMockPath(fileName)), + }); + }); + } +} diff --git a/tests/pages/powerpoint-page.ts b/tests/pages/powerpoint-page.ts new file mode 100644 index 0000000..6d154d2 --- /dev/null +++ b/tests/pages/powerpoint-page.ts @@ -0,0 +1,203 @@ +import { expect, type Page } from "@playwright/test"; + +export type TypstShapeSeed = { + id?: string; + left?: number; + top?: number; + width?: number; + height?: number; + rotation?: number; + fillColor?: string | null; + altTextTitle?: string; + altTextDescription?: string; + name?: string; + tags?: Record; + typstSource?: { preamble: string; body: string }; + svgContent?: string; +}; + +export type OfficeMockSeed = { + slides?: { id?: string; shapes?: TypstShapeSeed[] }[]; + selectedSlideIds?: string[]; + selectedShapeIds?: string[]; + slideWidth?: number; + slideHeight?: number; +}; + +export type ShapeSnapshot = { + id: string; + left: number; + top: number; + width: number; + height: number; + rotation: number; + altTextTitle: string; + altTextDescription: string; + name: string; + fillColor: string | null; + tags: Record; + customXml: string[]; + svgContent: string | null; +}; + +export type OfficeSnapshot = { + selectedSlideIds: string[]; + selectedShapeIds: string[]; + insertedSvgCalls: { slideId: string | null; svg: string }[]; + slides: { id: string; shapes: ShapeSnapshot[] }[]; +}; + +type OfficeMockWindow = Window & typeof globalThis & { + __pptypstOfficeSeed?: OfficeMockSeed; + __pptypstOfficeMock: { + reset: (_seed?: OfficeMockSeed) => void; + selectShapes: (_slideId: string, _shapeIds: string[]) => Promise; + clearSelection: (_slideId?: string) => Promise; + snapshot: () => OfficeSnapshot; + }; +}; + +export const typstShapeTags = { + kind: "TypstKind", + fontSize: "TypstFontSize", + fillColor: "TypstFillColor", + mathMode: "TypstMathMode", +} as const; + +export const typstShapeMetadata = { + name: "Typst Shape", + altTextTitle: "PPTypst shape", + altTextDescription: "Generated by PPTypst. Edit this shape with the PPTypst add-in.", +} as const; + +/** Page object for the PPTypst PowerPoint task pane. */ +export class PowerPointPage { + private readonly page: Page; + + constructor(page: Page) { + this.page = page; + } + + /** Opens the task pane and waits until the add-in has finished its initial setup. */ + async goto() { + await this.page.goto("powerpoint.html"); + await expect(this.page.locator("#insertBtn")).toContainText( + /Insert|Update/, + ); + } + + /** Seeds the browser Office mock and reloads the task pane around that model. */ + async gotoWithOffice(seed: OfficeMockSeed) { + await this.page.addInitScript((officeSeed) => { + window.localStorage.clear(); + const officeWindow = window as OfficeMockWindow; + officeWindow.__pptypstOfficeSeed = officeSeed; + }, seed); + await this.goto(); + } + + /** Types a Typst expression into the editor, triggering the preview update. */ + async previewExpression(expression: string) { + await this.page.locator("#typstInput").fill(expression); + } + + async setPreamble(preamble: string) { + if (!(await this.page.locator("#preambleInput").isVisible())) { + await this.page.locator("#preambleSummary").click(); + } + await this.page.locator("#preambleInput").fill(preamble); + } + + async setFontSize(fontSize: string) { + await this.page.locator("#fontSize").fill(fontSize); + } + + async setFillColor(fillColor: string | null) { + const fillEnabled = this.page.locator("#fillColorEnabled"); + if (fillColor === null) { + await fillEnabled.setChecked(false); + return; + } + + await fillEnabled.setChecked(true); + await this.page.locator("#fillColor").fill(fillColor); + } + + async insertOrUpdate() { + await this.page.locator("#insertBtn").click(); + } + + async bulkUpdateFontSize() { + await this.page.locator("#bulkUpdateBtn").click(); + } + + async selectShapes(slideId: string, shapeIds: string[]) { + await this.page.evaluate( + async ({ selectedSlideId, selectedShapeIds }) => { + const officeWindow = window as OfficeMockWindow; + await officeWindow.__pptypstOfficeMock.selectShapes( + selectedSlideId, + selectedShapeIds, + ); + }, + { selectedSlideId: slideId, selectedShapeIds: shapeIds }, + ); + } + + async clearSelection(slideId?: string) { + await this.page.evaluate(async (selectedSlideId) => { + const officeWindow = window as OfficeMockWindow; + await officeWindow.__pptypstOfficeMock.clearSelection(selectedSlideId); + }, slideId); + } + + async snapshot(): Promise { + return this.page.evaluate(() => { + const officeWindow = window as OfficeMockWindow; + return officeWindow.__pptypstOfficeMock.snapshot(); + }); + } + + async expectStatus(message: string) { + await expect(this.page.locator("#status")).toHaveText(message); + } + + async expectInsertMode() { + await expect(this.page.locator("#insertBtn")).toContainText("Insert"); + } + + async expectUpdateMode() { + await expect(this.page.locator("#insertBtn")).toContainText("Update"); + } + + async expectBulkUpdateVisible() { + await expect(this.page.locator("#bulkUpdateBtn")).toBeVisible(); + } + + async expectTypstCode(code: string) { + await expect(this.page.locator("#typstInput")).toHaveValue(code); + } + + async expectPreamble(preamble: string) { + await expect(this.page.locator("#preambleInput")).toHaveValue(preamble); + } + + async expectFontSize(fontSize: string) { + await expect(this.page.locator("#fontSize")).toHaveValue(fontSize); + } + + async expectFillColor(fillColor: string | null) { + if (fillColor === null) { + await expect(this.page.locator("#fillColorEnabled")).not.toBeChecked(); + return; + } + + await expect(this.page.locator("#fillColorEnabled")).toBeChecked(); + await expect(this.page.locator("#fillColor")).toHaveValue(fillColor); + } + + /** Asserts that the preview pane contains a rendered SVG. */ + async expectPreviewVisible() { + await expect(this.page.locator("#previewContent svg")).toBeVisible(); + } +} diff --git a/tests/powerpoint.spec.ts b/tests/powerpoint.spec.ts new file mode 100644 index 0000000..86fe935 --- /dev/null +++ b/tests/powerpoint.spec.ts @@ -0,0 +1,216 @@ +import { expect } from "@playwright/test"; +import { test } from "./_support/fixtures"; +import { + type ShapeSnapshot, + type TypstShapeSeed, + typstShapeMetadata, + typstShapeTags, +} from "./pages/powerpoint-page"; + +const slideId = "slide-1"; + +function typstShape(seed: TypstShapeSeed): TypstShapeSeed { + const { tags, typstSource, ...rest } = seed; + return { + ...rest, + altTextTitle: typstShapeMetadata.altTextTitle, + altTextDescription: typstShapeMetadata.altTextDescription, + name: typstShapeMetadata.name, + tags: { + [typstShapeTags.kind]: "typst", + [typstShapeTags.fontSize]: "28", + [typstShapeTags.fillColor]: "#000000", + [typstShapeTags.mathMode]: "true", + ...tags, + }, + typstSource: { + preamble: "", + body: "x", + ...typstSource, + }, + }; +} + +function expectTypstShape(shape: ShapeSnapshot, expected: { + body: string; + preamble: string; + fontSize: string; + fillColor: string; +}) { + expect(shape.name).toBe(typstShapeMetadata.name); + expect(shape.altTextTitle).toBe(typstShapeMetadata.altTextTitle); + expect(shape.altTextDescription).toBe(typstShapeMetadata.altTextDescription); + expect(shape.tags[typstShapeTags.kind]).toBe("typst"); + expect(shape.tags[typstShapeTags.fontSize]).toBe(expected.fontSize); + expect(shape.tags[typstShapeTags.fillColor]).toBe(expected.fillColor); + expect(shape.customXml).toHaveLength(1); + if (expected.preamble) { + expect(shape.customXml[0]).toContain(`${expected.preamble}`); + } else { + expect(shape.customXml[0]).toMatch(/|<\/pptypst:preamble>/); + } + expect(shape.customXml[0]).toContain(`${expected.body}`); +} + +test("inserts a Typst shape into the selected PowerPoint slide", async ({ powerPointPage }) => { + await powerPointPage.gotoWithOffice({ slides: [{ id: slideId }] }); + + await powerPointPage.previewExpression("integral_a^b f(x) dif x"); + await powerPointPage.insertOrUpdate(); + + await powerPointPage.expectStatus("Inserted Typst SVG."); + const snapshot = await powerPointPage.snapshot(); + const insertedShape = snapshot.slides[0].shapes[0]; + + expect(snapshot.insertedSvgCalls).toHaveLength(1); + expect(snapshot.selectedShapeIds).toEqual([insertedShape.id]); + expectTypstShape(insertedShape, { + body: "integral_a^b f(x) dif x", + preamble: "", + fontSize: "28", + fillColor: "#000000", + }); +}); + +test("updates the selected Typst shape in place semantically", async ({ powerPointPage }) => { + await powerPointPage.gotoWithOffice({ + slides: [{ + id: slideId, + shapes: [typstShape({ + id: "old-shape", + left: 120, + top: 80, + width: 240, + height: 60, + rotation: 15, + tags: { + [typstShapeTags.fontSize]: "20", + [typstShapeTags.fillColor]: "#336699", + }, + typstSource: { preamble: "#let old = 1", body: "old_body" }, + })], + }], + selectedSlideIds: [slideId], + selectedShapeIds: ["old-shape"], + }); + + await powerPointPage.expectUpdateMode(); + await powerPointPage.expectTypstCode("old_body"); + await powerPointPage.expectPreamble("#let old = 1"); + await powerPointPage.expectFontSize("20"); + await powerPointPage.expectFillColor("#336699"); + + await powerPointPage.previewExpression("new_body"); + await powerPointPage.setPreamble("#let local = 2"); + await powerPointPage.setFontSize("36"); + await powerPointPage.setFillColor("#cc5500"); + await powerPointPage.insertOrUpdate(); + + await powerPointPage.expectStatus("Updated Typst SVG."); + const snapshot = await powerPointPage.snapshot(); + const updatedShape = snapshot.slides[0].shapes[0]; + + expect(snapshot.slides[0].shapes).toHaveLength(1); + expect(updatedShape.id).not.toBe("old-shape"); + expect(updatedShape.rotation).toBe(15); + expectTypstShape(updatedShape, { + body: "new_body", + preamble: "#let local = 2", + fontSize: "36", + fillColor: "#cc5500", + }); +}); + +test("bulk-updates the font size of multiple selected Typst shapes", async ({ powerPointPage }) => { + await powerPointPage.gotoWithOffice({ + slides: [{ + id: slideId, + shapes: [ + typstShape({ + id: "shape-a", + tags: { [typstShapeTags.fontSize]: "18", [typstShapeTags.fillColor]: "#112233" }, + typstSource: { preamble: "#let a = 1", body: "a" }, + }), + typstShape({ + id: "shape-b", + tags: { [typstShapeTags.fontSize]: "24", [typstShapeTags.fillColor]: "disabled" }, + typstSource: { preamble: "#let b = 2", body: "b" }, + }), + ], + }], + selectedSlideIds: [slideId], + selectedShapeIds: ["shape-a", "shape-b"], + }); + + await powerPointPage.expectBulkUpdateVisible(); + await powerPointPage.setFontSize("42"); + await powerPointPage.bulkUpdateFontSize(); + + await powerPointPage.expectStatus("Updated 2 of 2 Typst shapes with font size 42."); + const snapshot = await powerPointPage.snapshot(); + const shapes = snapshot.slides[0].shapes; + + expect(shapes).toHaveLength(2); + expect(shapes.map(shape => shape.id)).not.toContain("shape-a"); + expect(shapes.map(shape => shape.id)).not.toContain("shape-b"); + expectTypstShape(shapes[0], { + body: "a", + preamble: "#let a = 1", + fontSize: "42", + fillColor: "#112233", + }); + expectTypstShape(shapes[1], { + body: "b", + preamble: "#let b = 2", + fontSize: "42", + fillColor: "disabled", + }); +}); + +test("keeps global preamble separate from a selected shape preamble", async ({ powerPointPage }) => { + await powerPointPage.gotoWithOffice({ + slides: [{ + id: slideId, + shapes: [typstShape({ + id: "local-shape", + typstSource: { preamble: "#let local = 1", body: "local_body" }, + })], + }], + selectedSlideIds: [slideId], + }); + + await powerPointPage.setPreamble("#let global = 1"); + await powerPointPage.previewExpression("global_body"); + await powerPointPage.insertOrUpdate(); + await powerPointPage.expectStatus("Inserted Typst SVG."); + + await powerPointPage.selectShapes(slideId, ["local-shape"]); + await powerPointPage.expectPreamble("#let local = 1"); + await powerPointPage.previewExpression("updated_local_body"); + await powerPointPage.setPreamble("#let local = 2"); + await powerPointPage.insertOrUpdate(); + await powerPointPage.expectStatus("Updated Typst SVG."); + + await powerPointPage.clearSelection(slideId); + await powerPointPage.expectInsertMode(); + await powerPointPage.expectPreamble("#let global = 1"); + + const snapshot = await powerPointPage.snapshot(); + const globalShape = snapshot.slides[0].shapes.find(shape => shape.customXml[0]?.includes("global_body")); + const localShape = snapshot.slides[0].shapes.find(shape => shape.customXml[0]?.includes("updated_local_body")); + + expect(globalShape).toBeDefined(); + expect(localShape).toBeDefined(); + expectTypstShape(globalShape as ShapeSnapshot, { + body: "global_body", + preamble: "#let global = 1", + fontSize: "28", + fillColor: "#000000", + }); + expectTypstShape(localShape as ShapeSnapshot, { + body: "updated_local_body", + preamble: "#let local = 2", + fontSize: "28", + fillColor: "#000000", + }); +}); diff --git a/tests/preview.spec.ts b/tests/preview.spec.ts new file mode 100644 index 0000000..25f59af --- /dev/null +++ b/tests/preview.spec.ts @@ -0,0 +1,33 @@ +import { expect } from "@playwright/test"; +import { test } from "./_support/fixtures"; + +test("previews Typst math expressions", async ({ powerPointPage, typstMock }) => { + await powerPointPage.previewExpression("integral_a^b f(x) dif x"); + + await powerPointPage.expectPreviewVisible(); + + const calls = await typstMock.calls(); + expect(calls.addSourceCalls).toEqual([ + { + path: "/main.typ", + source: "#set page(margin: 3pt, background: none, width: auto, fill: none, height: auto)\n" + + "#set text(size: 28pt)\n" + + "$\n" + + "integral_a^b f(x) dif x\n" + + "$", + }, + ]); + expect(calls.compileCalls).toEqual([{ mainFilePath: "/main.typ" }]); + expect(calls.renderSvgCalls).toEqual([ + { + format: "vector", + artifactContent: [1, 2, 3], + data_selection: { + body: true, + defs: true, + css: true, + js: false, + }, + }, + ]); +}); diff --git a/tsconfig.json b/tsconfig.json index 193abd9..645efed 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -6,6 +6,10 @@ "ESNext", "DOM" ], + "types": [ + "node", + "office-js" + ], "moduleResolution": "bundler", "isolatedModules": true, "useDefineForClassFields": true, @@ -24,5 +28,7 @@ "web/src/**/*.d.ts", "web/src/**/*.ts", "web/src/**/*.js", + "./playwright.config.ts", + "./tests/**/*.ts", ], } \ No newline at end of file