From 588ba4c5bedcd21a51637980947edda09fdd80d9 Mon Sep 17 00:00:00 2001 From: Splines Date: Sat, 21 Mar 2026 13:01:42 +0100 Subject: [PATCH 01/19] Init Playwright config --- .github/workflows/playwright.yml | 27 +++++++++ .gitignore | 9 ++- .vscode/extensions.json | 1 + DEV.md | 11 ++++ package-lock.json | 94 ++++++++++++++++++++++++++++++-- package.json | 3 + playwright.config.ts | 38 +++++++++++++ tests/example.spec.ts | 18 ++++++ tsconfig.json | 2 + 9 files changed, 197 insertions(+), 6 deletions(-) create mode 100644 .github/workflows/playwright.yml create mode 100644 playwright.config.ts create mode 100644 tests/example.spec.ts diff --git a/.github/workflows/playwright.yml b/.github/workflows/playwright.yml new file mode 100644 index 0000000..3eb1314 --- /dev/null +++ b/.github/workflows/playwright.yml @@ -0,0 +1,27 @@ +name: Playwright Tests +on: + push: + branches: [ main, master ] + pull_request: + branches: [ main, master ] +jobs: + test: + timeout-minutes: 60 + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: lts/* + - name: Install dependencies + run: npm ci + - name: Install Playwright Browsers + run: npx playwright install --with-deps + - name: Run Playwright tests + run: npx playwright test + - uses: actions/upload-artifact@v4 + 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..8763d3e 100644 --- a/DEV.md +++ b/DEV.md @@ -47,6 +47,17 @@ 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 +npm run test # or even easier, just use the Playwright VSCode extension +``` + ## 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. diff --git a/package-lock.json b/package-lock.json index 9516ff0..19afd82 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,7 +15,9 @@ "devDependencies": { "@eslint/css": "^0.14.1", "@html-eslint/eslint-plugin": "^0.52.1", + "@playwright/test": "^1.58.2", "@stylistic/eslint-plugin": "^5.7.0", + "@types/node": "^25.5.0", "@types/office-js": "^1.0.568", "eslint": "^9.39.2", "globals": "^17.0.0", @@ -1580,6 +1582,17 @@ "node": ">=14.18.0" } }, + "node_modules/@inquirer/core/node_modules/@types/node": { + "version": "20.19.37", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.37.tgz", + "integrity": "sha512-8kzdPJ3FsNsVIurqBs7oodNnCEVbni9yUEkaHbgptDACOPW04jimGagZ51E6+lXUwJjgnBw+hyko/lkFWCldqw==", + "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", @@ -2778,6 +2791,22 @@ } } }, + "node_modules/@playwright/test": { + "version": "1.58.2", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.58.2.tgz", + "integrity": "sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.58.2" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/@rollup/rollup-android-arm-eabi": { "version": "4.59.0", "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.59.0.tgz", @@ -3196,13 +3225,13 @@ } }, "node_modules/@types/node": { - "version": "20.19.37", - "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.37.tgz", - "integrity": "sha512-8kzdPJ3FsNsVIurqBs7oodNnCEVbni9yUEkaHbgptDACOPW04jimGagZ51E6+lXUwJjgnBw+hyko/lkFWCldqw==", + "version": "25.5.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.5.0.tgz", + "integrity": "sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw==", "dev": true, "license": "MIT", "dependencies": { - "undici-types": "~6.21.0" + "undici-types": "~7.18.0" } }, "node_modules/@types/node-fetch": { @@ -3216,6 +3245,13 @@ "form-data": "^4.0.4" } }, + "node_modules/@types/node/node_modules/undici-types": { + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", + "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/office-js": { "version": "1.0.584", "resolved": "https://registry.npmjs.org/@types/office-js/-/office-js-1.0.584.tgz", @@ -8255,6 +8291,53 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/playwright": { + "version": "1.58.2", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.58.2.tgz", + "integrity": "sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.58.2" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.58.2", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.2.tgz", + "integrity": "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/playwright/node_modules/fsevents": { + "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", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/portfinder": { "version": "1.0.38", "resolved": "https://registry.npmjs.org/portfinder/-/portfinder-1.0.38.tgz", @@ -9640,7 +9723,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", diff --git a/package.json b/package.json index 9f3e90a..5e99f3f 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" @@ -14,7 +15,9 @@ "devDependencies": { "@eslint/css": "^0.14.1", "@html-eslint/eslint-plugin": "^0.52.1", + "@playwright/test": "^1.58.2", "@stylistic/eslint-plugin": "^5.7.0", + "@types/node": "^25.5.0", "@types/office-js": "^1.0.568", "eslint": "^9.39.2", "globals": "^17.0.0", diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 0000000..407b075 --- /dev/null +++ b/playwright.config.ts @@ -0,0 +1,38 @@ +import { defineConfig, devices } from "@playwright/test"; + +/** + * 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: !!process.env.CI, + /* Retry on CI only */ + retries: process.env.CI ? 2 : 0, + /* Opt out of parallel tests on CI. */ + workers: process.env.CI ? 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: "https://localhost:3155/pptypst/", + }, + + /* 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 start', + // url: 'http://localhost:3000', + // reuseExistingServer: !process.env.CI, + // }, +}); diff --git a/tests/example.spec.ts b/tests/example.spec.ts new file mode 100644 index 0000000..839cef5 --- /dev/null +++ b/tests/example.spec.ts @@ -0,0 +1,18 @@ +import { test, expect } from "@playwright/test"; + +test("has title", async ({ page }) => { + await page.goto("https://playwright.dev/"); + + // Expect a title "to contain" a substring. + await expect(page).toHaveTitle(/Playwright/); +}); + +test("get started link", async ({ page }) => { + await page.goto("https://playwright.dev/"); + + // Click the get started link. + await page.getByRole("link", { name: "Get started" }).click(); + + // Expects page to have a heading with the name of Installation. + await expect(page.getByRole("heading", { name: "Installation" })).toBeVisible(); +}); diff --git a/tsconfig.json b/tsconfig.json index e8bff31..370b488 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -24,5 +24,7 @@ "web/src/**/*.d.ts", "web/src/**/*.ts", "web/src/**/*.js", + "./playwright.config.ts", + "./tests/**/*.ts", ], } \ No newline at end of file From ff5c940db70595cccbae838121c4bcba46fb3f92 Mon Sep 17 00:00:00 2001 From: Splines Date: Sat, 21 Mar 2026 13:02:42 +0100 Subject: [PATCH 02/19] Indicate to run the web server --- DEV.md | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/DEV.md b/DEV.md index 8763d3e..01fa5f6 100644 --- a/DEV.md +++ b/DEV.md @@ -54,8 +54,11 @@ npm run validate-manifest npx playwright install-deps chromium npx playwright install chromium -# Run tests -npm run test # or even easier, just use the Playwright VSCode extension +# Start the webserver +npm run dev + +# Run tests (or even easier, just use the Playwright VSCode extension) +npm run test ``` ## Test production-like environment From 376270010f2ddca413ff1f7b66459ce77d5bc1e8 Mon Sep 17 00:00:00 2001 From: Splines Date: Sun, 7 Jun 2026 00:27:31 +0200 Subject: [PATCH 03/19] Add useful dev links section --- DEV.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/DEV.md b/DEV.md index 01fa5f6..0c128c6 100644 --- a/DEV.md +++ b/DEV.md @@ -72,3 +72,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) From 0d96276ee49af1a063711a7f4db8a21424ef90d1 Mon Sep 17 00:00:00 2001 From: Splines Date: Sun, 7 Jun 2026 00:31:03 +0200 Subject: [PATCH 04/19] Setup test server for Playwright tests --- playwright.config.ts | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/playwright.config.ts b/playwright.config.ts index 407b075..c678bce 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -30,9 +30,11 @@ export default defineConfig({ ], /* Run your local dev server before starting the tests */ - // webServer: { - // command: 'npm run start', - // url: 'http://localhost:3000', - // reuseExistingServer: !process.env.CI, - // }, + webServer: { + command: "npm run dev -- --host 127.0.0.1 --port 3157 --strictPort", + url: "https://127.0.0.1:3157/pptypst/powerpoint.html", + reuseExistingServer: !process.env.CI, + timeout: 120_000, + ignoreHTTPSErrors: true, + }, }); From cd3721f63090ff9b196a4c32d60eb8a180002134 Mon Sep 17 00:00:00 2001 From: Splines Date: Sun, 7 Jun 2026 00:58:07 +0200 Subject: [PATCH 05/19] Delete example scene --- tests/example.spec.ts | 18 ------------------ 1 file changed, 18 deletions(-) delete mode 100644 tests/example.spec.ts diff --git a/tests/example.spec.ts b/tests/example.spec.ts deleted file mode 100644 index 839cef5..0000000 --- a/tests/example.spec.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { test, expect } from "@playwright/test"; - -test("has title", async ({ page }) => { - await page.goto("https://playwright.dev/"); - - // Expect a title "to contain" a substring. - await expect(page).toHaveTitle(/Playwright/); -}); - -test("get started link", async ({ page }) => { - await page.goto("https://playwright.dev/"); - - // Click the get started link. - await page.getByRole("link", { name: "Get started" }).click(); - - // Expects page to have a heading with the name of Installation. - await expect(page.getByRole("heading", { name: "Installation" })).toBeVisible(); -}); From 9ac383c260a685f6051a5d4937ea1897ca98a44f Mon Sep 17 00:00:00 2001 From: Splines Date: Sun, 7 Jun 2026 01:37:12 +0200 Subject: [PATCH 06/19] Explicitly include node and office-js types --- tsconfig.json | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/tsconfig.json b/tsconfig.json index fb097af..645efed 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -6,6 +6,10 @@ "ESNext", "DOM" ], + "types": [ + "node", + "office-js" + ], "moduleResolution": "bundler", "isolatedModules": true, "useDefineForClassFields": true, From 3cc945c0cafc151863d40ed750fc210010c25ef4 Mon Sep 17 00:00:00 2001 From: Splines Date: Sun, 7 Jun 2026 01:37:22 +0200 Subject: [PATCH 07/19] Use different port for tests --- playwright.config.ts | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/playwright.config.ts b/playwright.config.ts index c678bce..7d5c756 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -1,4 +1,7 @@ import { defineConfig, devices } from "@playwright/test"; +import process from "node:process"; + +const isCI = Boolean(process.env.CI); /** * See https://playwright.dev/docs/test-configuration. @@ -8,17 +11,18 @@ export default defineConfig({ /* Run tests in files in parallel */ fullyParallel: true, /* Fail the build on CI if you accidentally left test.only in the source code. */ - forbidOnly: !!process.env.CI, + forbidOnly: isCI, /* Retry on CI only */ - retries: process.env.CI ? 2 : 0, + retries: isCI ? 2 : 0, /* Opt out of parallel tests on CI. */ - workers: process.env.CI ? 1 : undefined, + 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: "https://localhost:3155/pptypst/", + baseURL: "https://127.0.0.1:3157/pptypst/", + ignoreHTTPSErrors: true, }, /* Configure projects for major browsers */ @@ -33,7 +37,7 @@ export default defineConfig({ webServer: { command: "npm run dev -- --host 127.0.0.1 --port 3157 --strictPort", url: "https://127.0.0.1:3157/pptypst/powerpoint.html", - reuseExistingServer: !process.env.CI, + reuseExistingServer: !isCI, timeout: 120_000, ignoreHTTPSErrors: true, }, From 883cd2dd684826ac2dde6f331691d08a4995ace5 Mon Sep 17 00:00:00 2001 From: Splines Date: Sun, 7 Jun 2026 01:38:28 +0200 Subject: [PATCH 08/19] Init mock implementations & test preview --- tests/_support/browser-mocks/office.ts | 58 ++++++++++++++++ .../typst-memory-access-model.ts | 1 + tests/_support/browser-mocks/typst-options.ts | 9 +++ .../browser-mocks/typst-package-registry.ts | 8 +++ .../browser-mocks/typst-state-module.d.ts | 24 +++++++ tests/_support/browser-mocks/typst-state.ts | 37 ++++++++++ .../_support/browser-mocks/typst-wasm-url.ts | 1 + tests/_support/browser-mocks/typst.ts | 50 ++++++++++++++ tests/_support/fixtures.ts | 58 ++++++++++++++++ tests/_support/office-mock.ts | 16 +++++ tests/_support/transpile-browser-mock.ts | 23 +++++++ tests/_support/typst-mock.ts | 68 +++++++++++++++++++ tests/pages/powerpoint-page.ts | 26 +++++++ tests/preview.spec.ts | 33 +++++++++ 14 files changed, 412 insertions(+) create mode 100644 tests/_support/browser-mocks/office.ts create mode 100644 tests/_support/browser-mocks/typst-memory-access-model.ts create mode 100644 tests/_support/browser-mocks/typst-options.ts create mode 100644 tests/_support/browser-mocks/typst-package-registry.ts create mode 100644 tests/_support/browser-mocks/typst-state-module.d.ts create mode 100644 tests/_support/browser-mocks/typst-state.ts create mode 100644 tests/_support/browser-mocks/typst-wasm-url.ts create mode 100644 tests/_support/browser-mocks/typst.ts create mode 100644 tests/_support/fixtures.ts create mode 100644 tests/_support/office-mock.ts create mode 100644 tests/_support/transpile-browser-mock.ts create mode 100644 tests/_support/typst-mock.ts create mode 100644 tests/pages/powerpoint-page.ts create mode 100644 tests/preview.spec.ts diff --git a/tests/_support/browser-mocks/office.ts b/tests/_support/browser-mocks/office.ts new file mode 100644 index 0000000..d9df8bb --- /dev/null +++ b/tests/_support/browser-mocks/office.ts @@ -0,0 +1,58 @@ +type OfficeReadyInfo = { host: "PowerPoint" }; +type OfficeReadyCallback = (_info: OfficeReadyInfo) => void | Promise; +type LoadableCollection = { items: unknown[]; load: (_properties?: unknown) => void }; + +type PowerPointContext = { + presentation: { + getSelectedShapes: () => LoadableCollection; + getSelectedSlides: () => LoadableCollection; + }; + sync: () => Promise; +}; + +type MockGlobals = { + Office: { + HostType: { PowerPoint: "PowerPoint" }; + EventType: { DocumentSelectionChanged: "DocumentSelectionChanged" }; + actions: { associate: (_name: string, _handler: unknown) => void }; + context: { document: { addHandlerAsync: (_eventType: string, _handler: unknown) => void } }; + onReady: (_callback: OfficeReadyCallback) => Promise; + }; + PowerPoint: { + run: (_callback: (_context: PowerPointContext) => Promise | void) => Promise; + }; +}; + +function emptyCollection(): LoadableCollection { + return { items: [], load() {} }; +} + +const mockGlobals = globalThis as unknown as MockGlobals; + +mockGlobals.Office = { + HostType: { PowerPoint: "PowerPoint" }, + EventType: { DocumentSelectionChanged: "DocumentSelectionChanged" }, + actions: { + associate() {}, + }, + context: { + document: { + addHandlerAsync() {}, + }, + }, + async onReady(callback: OfficeReadyCallback) { + await callback({ host: "PowerPoint" }); + }, +}; + +mockGlobals.PowerPoint = { + async run(callback: (_context: PowerPointContext) => Promise | void) { + await callback({ + presentation: { + getSelectedShapes: emptyCollection, + getSelectedSlides: emptyCollection, + }, + async sync() {}, + }); + }, +}; 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..9a74a91 --- /dev/null +++ b/tests/_support/browser-mocks/typst-state-module.d.ts @@ -0,0 +1,24 @@ +declare module "https://127.0.0.1:3157/pptypst/__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..9dd16fc --- /dev/null +++ b/tests/_support/browser-mocks/typst.ts @@ -0,0 +1,50 @@ +import { typstMockState } from "https://127.0.0.1:3157/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") { + throw 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..c35cb29 --- /dev/null +++ b/tests/_support/office-mock.ts @@ -0,0 +1,16 @@ +import type { Page } from "@playwright/test"; +import path from "node:path"; + +const officeMockPath = path.join(process.cwd(), "tests", "_support", "browser-mocks", "office.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 { compileBrowserMock } = await import("./transpile-browser-mock"); + return compileBrowserMock(officeMockPath); +} diff --git a/tests/_support/transpile-browser-mock.ts b/tests/_support/transpile-browser-mock.ts new file mode 100644 index 0000000..1dbaa8d --- /dev/null +++ b/tests/_support/transpile-browser-mock.ts @@ -0,0 +1,23 @@ +import fs from "fs/promises"; +import * as ts from "typescript"; + +const compiledMocks = new Map(); + +/** Transpiles a TypeScript browser mock into JavaScript for Playwright route fulfillment. */ +export async function compileBrowserMock(filePath: string) { + const cached = compiledMocks.get(filePath); + if (cached) return cached; + + const source = await fs.readFile(filePath, "utf8"); + const output = ts.transpileModule(source, { + compilerOptions: { + module: ts.ModuleKind.ES2022, + target: ts.ScriptTarget.ES2022, + sourceMap: false, + }, + fileName: filePath, + }).outputText; + + compiledMocks.set(filePath, output); + return output; +} 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..bd59f82 --- /dev/null +++ b/tests/pages/powerpoint-page.ts @@ -0,0 +1,26 @@ +import { expect, type Page } from "@playwright/test"; + +/** 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"); + } + + /** Types a Typst expression into the editor, triggering the preview update. */ + async previewExpression(expression: string) { + await this.page.locator("#typstInput").fill(expression); + } + + /** Asserts that the preview pane contains a rendered SVG. */ + async expectPreviewVisible() { + await expect(this.page.locator("#previewContent svg")).toBeVisible(); + } +} 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, + }, + }, + ]); +}); From fd2103f713b5692566e4db67345c4ede19ab5108 Mon Sep 17 00:00:00 2001 From: Splines Date: Sun, 7 Jun 2026 02:14:03 +0200 Subject: [PATCH 09/19] Add more PowerPoint tests --- tests/_support/browser-mocks/office.ts | 635 ++++++++++++++++++++++++- tests/pages/powerpoint-page.ts | 171 ++++++- tests/powerpoint.spec.ts | 216 +++++++++ 3 files changed, 999 insertions(+), 23 deletions(-) create mode 100644 tests/powerpoint.spec.ts diff --git a/tests/_support/browser-mocks/office.ts b/tests/_support/browser-mocks/office.ts index d9df8bb..465e19a 100644 --- a/tests/_support/browser-mocks/office.ts +++ b/tests/_support/browser-mocks/office.ts @@ -1,43 +1,625 @@ type OfficeReadyInfo = { host: "PowerPoint" }; type OfficeReadyCallback = (_info: OfficeReadyInfo) => void | Promise; -type LoadableCollection = { items: unknown[]; load: (_properties?: unknown) => void }; +type SelectionChangedHandler = () => void | Promise; -type PowerPointContext = { - presentation: { - getSelectedShapes: () => LoadableCollection; - getSelectedSlides: () => LoadableCollection; - }; - sync: () => Promise; +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[]; +}; + +type Loadable = { load: (_properties?: unknown) => void }; + +const SHAPE_XML_NAMESPACE = "https://splines.github.io/pptypst/shape/v1"; +const DEFAULT_SLIDE_WIDTH = 960; +const DEFAULT_SLIDE_HEIGHT = 540; + +class MockCollection implements Loadable { + private readonly getItems: () => T[]; + + constructor(getItems: () => T[]) { + this.getItems = getItems; + } + + get items(): T[] { + return this.getItems(); + } + + load() {} +} + +class MockFill implements Loadable { + foregroundColor: string | null; + + constructor(color: string | null) { + this.foregroundColor = color; + } + + 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 onTagAdd: (_key: string, _value: string) => void; + + constructor( + tagMap: Map, + onTagAdd: (_key: string, _value: string) => void, + ) { + this.tagMap = tagMap; + this.onTagAdd = onTagAdd; + } + + 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.onTagAdd(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() {} +} + +class MockShape implements Loadable { + readonly mock: MockPowerPointRuntime; + readonly parentSlide: MockSlide; + readonly id: string; + altTextTitle = ""; + altTextDescription = ""; + name = ""; + left = 0; + top = 0; + width = 160; + height = 40; + rotation = 0; + readonly fill: MockFill; + readonly tags: MockTagCollection; + readonly customXmlParts: MockCustomXmlPartCollection; + svgContent: string | null = null; + + private readonly tagMap = new Map(); + private readonly xmlParts: MockXmlPart[] = []; + + constructor( + mock: MockPowerPointRuntime, + parentSlide: MockSlide, + id: string, + ) { + this.mock = mock; + this.parentSlide = parentSlide; + this.id = id; + this.fill = new MockFill(null); + 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.mock.removeSelectedShape(this.id); + } + + getParentSlide(): MockSlide { + return this.parentSlide; + } + + 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, + }; + } + + 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; + + if (seed.tags) { + for (const [key, value] of Object.entries(seed.tags)) { + this.tags.add(key, value); + } + } + + if (seed.typstSource) { + this.customXmlParts.add(serializeTypstSource(seed.typstSource)); + } + } + + private addCustomXmlPart(xml: string): MockXmlPart { + const documentNode = new DOMParser().parseFromString(xml, "application/xml"); + const namespaceUri = documentNode.documentElement.namespaceURI; + const part = new MockXmlPart(this.mock.nextXmlPartId(), xml, namespaceUri); + this.xmlParts.push(part); + return part; + } +} + +class MockShapeCollection extends MockCollection { + private readonly getById: (_id: string) => MockShape | undefined; + + constructor( + getItems: () => MockShape[], + getById: (_id: string) => MockShape | undefined, + ) { + super(getItems); + this.getById = getById; + } + + getItem(id: string): MockShape { + const shape = this.getById(id); + if (!shape) { + throw new Error(`Shape ${id} not found.`); + } + + return shape; + } +} + +class MockSlide implements Loadable { + readonly mock: MockPowerPointRuntime; + readonly id: string; + readonly shapes: MockShapeCollection; + readonly isNullObject: boolean; + + private readonly shapeList: MockShape[] = []; + + constructor( + mock: MockPowerPointRuntime, + id: string, + isNullObject = false, + ) { + this.mock = mock; + this.id = id; + this.isNullObject = isNullObject; + this.shapes = new MockShapeCollection( + () => this.shapeList, + shapeId => this.shapeList.find(shape => shape.id === shapeId), + ); + } + + load() {} + + addShape(seed: MockSeedShape = {}): MockShape { + const shape = new MockShape(this.mock, this, seed.id ?? this.mock.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 MockSlideCollection extends MockCollection { + private readonly getById: (_id: string) => MockSlide | undefined; + private readonly mock: MockPowerPointRuntime; + + constructor( + getItems: () => MockSlide[], + getById: (_id: string) => MockSlide | undefined, + mock: MockPowerPointRuntime, + ) { + super(getItems); + this.getById = getById; + this.mock = mock; + } + + getItem(id: string): MockSlide { + return this.getById(id) ?? new MockSlide(this.mock, id, true); + } +} + +class MockPageSetup implements Loadable { + readonly slideWidth: number; + readonly slideHeight: number; + + constructor( + slideWidth: number, + slideHeight: number, + ) { + this.slideWidth = slideWidth; + this.slideHeight = slideHeight; + } + + load() {} +} + +class MockPresentation { + readonly slides: MockSlideCollection; + readonly pageSetup: MockPageSetup; + private readonly mock: MockPowerPointRuntime; + + constructor(mock: MockPowerPointRuntime) { + this.mock = mock; + this.slides = new MockSlideCollection( + () => this.mock.slideList, + slideId => this.mock.slideList.find(slide => slide.id === slideId), + this.mock, + ); + this.pageSetup = new MockPageSetup(this.mock.slideWidth, this.mock.slideHeight); + } + + getSelectedShapes(): MockCollection { + return new MockCollection(() => this.mock.getSelectedShapes()); + } + + getSelectedSlides(): MockCollection { + return new MockCollection(() => this.mock.getSelectedSlides()); + } +} + +class MockRequestContext { + readonly presentation: MockPresentation; + + constructor(mock: MockPowerPointRuntime) { + this.presentation = new MockPresentation(mock); + } + + async sync() {} +} + +class MockPowerPointRuntime { + slideWidth = DEFAULT_SLIDE_WIDTH; + slideHeight = DEFAULT_SLIDE_HEIGHT; + slideList: MockSlide[] = []; + selectedSlideIds: string[] = []; + selectedShapeIds: string[] = []; + readonly insertedSvgCalls: { slideId: string | null; svg: string }[] = []; + private readonly selectionHandlers: SelectionChangedHandler[] = []; + private shapeCounter = 1; + private xmlCounter = 1; + + constructor() { + this.reset(); + } + + reset(seed: MockOfficeSeed = {}) { + this.slideWidth = seed.slideWidth ?? DEFAULT_SLIDE_WIDTH; + this.slideHeight = seed.slideHeight ?? DEFAULT_SLIDE_HEIGHT; + this.slideList = []; + this.selectedSlideIds = []; + this.selectedShapeIds = []; + this.insertedSvgCalls.length = 0; + this.shapeCounter = 1; + this.xmlCounter = 1; + + const slides = seed.slides && seed.slides.length > 0 ? seed.slides : [{ id: "slide-1", shapes: [] }]; + slides.forEach((slideSeed, index) => { + const slide = new MockSlide(this, slideSeed.id ?? `slide-${String(index + 1)}`); + this.slideList.push(slide); + slideSeed.shapes?.forEach((shapeSeed) => { + slide.addShape(shapeSeed); + }); + }); + + this.selectedSlideIds = seed.selectedSlideIds?.length + ? [...seed.selectedSlideIds] + : [this.slideList.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.slideList.find(slide => slide.id === slideId)) + .filter((slide): slide is MockSlide => Boolean(slide)); + } + + getSelectedShapes(): MockShape[] { + return this.slideList + .flatMap(slide => slide.shapes.items) + .filter(shape => this.selectedShapeIds.includes(shape.id)); + } + + removeSelectedShape(shapeId: string) { + this.selectedShapeIds = this.selectedShapeIds.filter(id => id !== shapeId); + } + + async setSelection(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.slideList.at(0)?.id; + this.selectedSlideIds = fallbackSlideId ? [fallbackSlideId] : []; + this.selectedShapeIds = []; + await this.triggerSelectionChanged(); + } + + insertSvg(svg: string) { + const targetSlide = this.getSelectedSlides().at(0) ?? this.slideList.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", shapeId: shape.id }; + } + + snapshot(): MockOfficeSnapshot { + return { + slideWidth: this.slideWidth, + slideHeight: this.slideHeight, + selectedSlideIds: [...this.selectedSlideIds], + selectedShapeIds: [...this.selectedShapeIds], + insertedSvgCalls: this.insertedSvgCalls.map(call => ({ ...call })), + slides: this.slideList.map(slide => slide.snapshot()), + }; + } + + private async triggerSelectionChanged() { + for (const handler of this.selectionHandlers) { + await handler(); + } + } +} + +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); +} + type MockGlobals = { Office: { HostType: { PowerPoint: "PowerPoint" }; EventType: { DocumentSelectionChanged: "DocumentSelectionChanged" }; + AsyncResultStatus: { Succeeded: "succeeded"; Failed: "failed" }; + CoercionType: { XmlSvg: "xmlSvg" }; actions: { associate: (_name: string, _handler: unknown) => void }; - context: { document: { addHandlerAsync: (_eventType: string, _handler: unknown) => void } }; + context: { + document: { + addHandlerAsync: (_eventType: string, _handler: SelectionChangedHandler) => void; + setSelectedDataAsync: ( + _data: string, + _options: { coercionType: string }, + _callback: (_result: { status: string; error?: Error }) => void, + ) => void; + }; + }; onReady: (_callback: OfficeReadyCallback) => Promise; }; PowerPoint: { - run: (_callback: (_context: PowerPointContext) => Promise | void) => Promise; + run: (_callback: (_context: MockRequestContext) => Promise | T) => Promise; + }; + __pptypstOfficeMock: { + reset: (_seed?: MockOfficeSeed) => void; + selectShapes: (_slideId: string, _shapeIds: string[]) => Promise; + clearSelection: (_slideId?: string) => Promise; + snapshot: () => MockOfficeSnapshot; }; + __pptypstOfficeSeed?: MockOfficeSeed; }; -function emptyCollection(): LoadableCollection { - return { items: [], load() {} }; -} - const mockGlobals = globalThis as unknown as MockGlobals; +const runtime = new MockPowerPointRuntime(); +runtime.reset(mockGlobals.__pptypstOfficeSeed); mockGlobals.Office = { HostType: { PowerPoint: "PowerPoint" }, EventType: { DocumentSelectionChanged: "DocumentSelectionChanged" }, + AsyncResultStatus: { Succeeded: "succeeded", Failed: "failed" }, + CoercionType: { XmlSvg: "xmlSvg" }, actions: { associate() {}, }, context: { document: { - addHandlerAsync() {}, + addHandlerAsync(_eventType: string, handler: SelectionChangedHandler) { + runtime.addSelectionHandler(handler); + }, + setSelectedDataAsync(data, _options, callback) { + const result = runtime.insertSvg(data); + callback(result.status === "succeeded" + ? { status: mockGlobals.Office.AsyncResultStatus.Succeeded } + : { status: mockGlobals.Office.AsyncResultStatus.Failed, error: result.error }); + }, }, }, async onReady(callback: OfficeReadyCallback) { @@ -46,13 +628,22 @@ mockGlobals.Office = { }; mockGlobals.PowerPoint = { - async run(callback: (_context: PowerPointContext) => Promise | void) { - await callback({ - presentation: { - getSelectedShapes: emptyCollection, - getSelectedSlides: emptyCollection, - }, - async sync() {}, - }); + async run(callback: (_context: MockRequestContext) => Promise | T) { + return callback(new MockRequestContext(runtime)); + }, +}; + +mockGlobals.__pptypstOfficeMock = { + reset(seed?: MockOfficeSeed) { + runtime.reset(seed); + }, + async selectShapes(slideId: string, shapeIds: string[]) { + await runtime.setSelection(slideId, shapeIds); + }, + async clearSelection(slideId?: string) { + await runtime.clearSelection(slideId); + }, + snapshot() { + return runtime.snapshot(); }, }; diff --git a/tests/pages/powerpoint-page.ts b/tests/pages/powerpoint-page.ts index bd59f82..b8ca881 100644 --- a/tests/pages/powerpoint-page.ts +++ b/tests/pages/powerpoint-page.ts @@ -1,5 +1,75 @@ 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; @@ -11,7 +81,17 @@ export class PowerPointPage { /** 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"); + 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. */ @@ -19,6 +99,95 @@ export class PowerPointPage { 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 = "slide-1") { + 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", + }); +}); From bcfb59b532d5c5b97620a17804791f3a48ad9dbd Mon Sep 17 00:00:00 2001 From: Splines Date: Sun, 7 Jun 2026 02:33:31 +0200 Subject: [PATCH 10/19] Refactor office.ts --- tests/_support/browser-mocks/office.ts | 465 +++++++++++++------------ 1 file changed, 247 insertions(+), 218 deletions(-) diff --git a/tests/_support/browser-mocks/office.ts b/tests/_support/browser-mocks/office.ts index 465e19a..2ea0e28 100644 --- a/tests/_support/browser-mocks/office.ts +++ b/tests/_support/browser-mocks/office.ts @@ -1,7 +1,20 @@ +/* + * Browser replacement for Office.js used by the PowerPoint Playwright tests. + * + * Keep this file import-free. It is served as the classic `office.js` script, + * so the browser will not evaluate ES module imports here. + * + * The mock has three layers: + * 1. Seed and snapshot DTOs used by tests. + * 2. A small in-memory PowerPoint document model. + * 3. Office.js-shaped facades exposed on globalThis. + */ + 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; @@ -66,12 +79,54 @@ type MockOfficeSnapshot = { 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; +// Small Office.js collection/value objects used by app code. class MockCollection implements Loadable { private readonly getItems: () => T[]; @@ -86,11 +141,31 @@ class MockCollection implements Loadable { 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(color: string | null) { - this.foregroundColor = color; + constructor(foregroundColor: string | null) { + this.foregroundColor = foregroundColor; } load() {} @@ -101,7 +176,11 @@ class MockTagItem implements Loadable { readonly isNullObject: boolean; private readonly getValue: () => string; - constructor(key: string, getValue: () => string, isNullObject = false) { + constructor( + key: string, + getValue: () => string, + isNullObject = false, + ) { this.key = key; this.getValue = getValue; this.isNullObject = isNullObject; @@ -116,23 +195,26 @@ class MockTagItem implements Loadable { class MockTagCollection implements Loadable { private readonly tagMap: Map; - private readonly onTagAdd: (_key: string, _value: string) => void; + private readonly onAdd: (_key: string, _value: string) => void; constructor( tagMap: Map, - onTagAdd: (_key: string, _value: string) => void, + onAdd: (_key: string, _value: string) => void, ) { this.tagMap = tagMap; - this.onTagAdd = onTagAdd; + this.onAdd = onAdd; } get items(): MockTagItem[] { - return Array.from(this.tagMap.entries(), ([key, value]) => new MockTagItem(key, () => value)); + return Array.from( + this.tagMap.entries(), + ([key, value]) => new MockTagItem(key, () => value), + ); } add(key: string, value: string) { this.tagMap.set(key, value); - this.onTagAdd(key, value); + this.onAdd(key, value); } getItemOrNullObject(key: string): MockTagItem { @@ -195,9 +277,8 @@ class MockCustomXmlPartCollection implements Loadable { load() {} } -class MockShape implements Loadable { - readonly mock: MockPowerPointRuntime; - readonly parentSlide: MockSlide; +// In-memory PowerPoint document model. +class MockShape implements Loadable, Identifiable { readonly id: string; altTextTitle = ""; altTextDescription = ""; @@ -207,23 +288,25 @@ class MockShape implements Loadable { width = 160; height = 40; rotation = 0; - readonly fill: MockFill; + svgContent: string | null = null; + + readonly fill = new MockFill(null); readonly tags: MockTagCollection; readonly customXmlParts: MockCustomXmlPartCollection; - svgContent: string | null = null; private readonly tagMap = new Map(); private readonly xmlParts: MockXmlPart[] = []; + private readonly documentModel: MockPowerPointDocument; + private readonly parentSlide: MockSlide; constructor( - mock: MockPowerPointRuntime, + documentModel: MockPowerPointDocument, parentSlide: MockSlide, id: string, ) { - this.mock = mock; + this.documentModel = documentModel; this.parentSlide = parentSlide; this.id = id; - this.fill = new MockFill(null); this.tags = new MockTagCollection(this.tagMap, (key, value) => { if (key === "TypstFillColor") { this.fill.foregroundColor = value === "disabled" ? null : value; @@ -239,31 +322,13 @@ class MockShape implements Loadable { delete() { this.parentSlide.removeShape(this.id); - this.mock.removeSelectedShape(this.id); + this.documentModel.removeSelectedShape(this.id); } getParentSlide(): MockSlide { return this.parentSlide; } - 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, - }; - } - applySeed(seed: MockSeedShape) { this.left = seed.left ?? this.left; this.top = seed.top ?? this.top; @@ -276,10 +341,8 @@ class MockShape implements Loadable { this.fill.foregroundColor = seed.fillColor ?? null; this.svgContent = seed.svgContent ?? null; - if (seed.tags) { - for (const [key, value] of Object.entries(seed.tags)) { - this.tags.add(key, value); - } + for (const [key, value] of Object.entries(seed.tags ?? {})) { + this.tags.add(key, value); } if (seed.typstSource) { @@ -287,62 +350,65 @@ class MockShape implements Loadable { } } + 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 namespaceUri = documentNode.documentElement.namespaceURI; - const part = new MockXmlPart(this.mock.nextXmlPartId(), xml, namespaceUri); + const part = new MockXmlPart( + this.documentModel.nextXmlPartId(), + xml, + documentNode.documentElement.namespaceURI, + ); + this.xmlParts.push(part); return part; } } -class MockShapeCollection extends MockCollection { - private readonly getById: (_id: string) => MockShape | undefined; - - constructor( - getItems: () => MockShape[], - getById: (_id: string) => MockShape | undefined, - ) { - super(getItems); - this.getById = getById; - } - - getItem(id: string): MockShape { - const shape = this.getById(id); - if (!shape) { - throw new Error(`Shape ${id} not found.`); - } - - return shape; - } -} - -class MockSlide implements Loadable { - readonly mock: MockPowerPointRuntime; +class MockSlide implements Loadable, Identifiable { readonly id: string; - readonly shapes: MockShapeCollection; readonly isNullObject: boolean; + readonly shapes: MockItemCollection; private readonly shapeList: MockShape[] = []; + private readonly documentModel: MockPowerPointDocument; constructor( - mock: MockPowerPointRuntime, + documentModel: MockPowerPointDocument, id: string, isNullObject = false, ) { - this.mock = mock; + this.documentModel = documentModel; this.id = id; this.isNullObject = isNullObject; - this.shapes = new MockShapeCollection( - () => this.shapeList, - shapeId => this.shapeList.find(shape => shape.id === shapeId), - ); + this.shapes = new MockItemCollection(() => this.shapeList); } load() {} addShape(seed: MockSeedShape = {}): MockShape { - const shape = new MockShape(this.mock, this, seed.id ?? this.mock.nextShapeId()); + const shape = new MockShape( + this.documentModel, + this, + seed.id ?? this.documentModel.nextShapeId(), + ); + shape.applySeed(seed); this.shapeList.push(shape); return shape; @@ -363,25 +429,6 @@ class MockSlide implements Loadable { } } -class MockSlideCollection extends MockCollection { - private readonly getById: (_id: string) => MockSlide | undefined; - private readonly mock: MockPowerPointRuntime; - - constructor( - getItems: () => MockSlide[], - getById: (_id: string) => MockSlide | undefined, - mock: MockPowerPointRuntime, - ) { - super(getItems); - this.getById = getById; - this.mock = mock; - } - - getItem(id: string): MockSlide { - return this.getById(id) ?? new MockSlide(this.mock, id, true); - } -} - class MockPageSetup implements Loadable { readonly slideWidth: number; readonly slideHeight: number; @@ -397,77 +444,42 @@ class MockPageSetup implements Loadable { load() {} } -class MockPresentation { - readonly slides: MockSlideCollection; - readonly pageSetup: MockPageSetup; - private readonly mock: MockPowerPointRuntime; - - constructor(mock: MockPowerPointRuntime) { - this.mock = mock; - this.slides = new MockSlideCollection( - () => this.mock.slideList, - slideId => this.mock.slideList.find(slide => slide.id === slideId), - this.mock, - ); - this.pageSetup = new MockPageSetup(this.mock.slideWidth, this.mock.slideHeight); - } - - getSelectedShapes(): MockCollection { - return new MockCollection(() => this.mock.getSelectedShapes()); - } - - getSelectedSlides(): MockCollection { - return new MockCollection(() => this.mock.getSelectedSlides()); - } -} - -class MockRequestContext { - readonly presentation: MockPresentation; - - constructor(mock: MockPowerPointRuntime) { - this.presentation = new MockPresentation(mock); - } - - async sync() {} -} - -class MockPowerPointRuntime { +class MockPowerPointDocument { slideWidth = DEFAULT_SLIDE_WIDTH; slideHeight = DEFAULT_SLIDE_HEIGHT; - slideList: MockSlide[] = []; + slides: MockSlide[] = []; selectedSlideIds: string[] = []; selectedShapeIds: string[] = []; readonly insertedSvgCalls: { slideId: string | null; svg: string }[] = []; + private readonly selectionHandlers: SelectionChangedHandler[] = []; private shapeCounter = 1; private xmlCounter = 1; - constructor() { - this.reset(); + constructor(seed: MockOfficeSeed = {}) { + this.reset(seed); } reset(seed: MockOfficeSeed = {}) { this.slideWidth = seed.slideWidth ?? DEFAULT_SLIDE_WIDTH; this.slideHeight = seed.slideHeight ?? DEFAULT_SLIDE_HEIGHT; - this.slideList = []; + this.slides = []; this.selectedSlideIds = []; this.selectedShapeIds = []; this.insertedSvgCalls.length = 0; this.shapeCounter = 1; this.xmlCounter = 1; - const slides = seed.slides && seed.slides.length > 0 ? seed.slides : [{ id: "slide-1", shapes: [] }]; - slides.forEach((slideSeed, index) => { + 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.slideList.push(slide); - slideSeed.shapes?.forEach((shapeSeed) => { - slide.addShape(shapeSeed); - }); + this.slides.push(slide); + slideSeed.shapes?.forEach(shapeSeed => slide.addShape(shapeSeed)); }); this.selectedSlideIds = seed.selectedSlideIds?.length ? [...seed.selectedSlideIds] - : [this.slideList.at(0)?.id].filter((value): value is string => typeof value === "string"); + : [this.slides.at(0)?.id].filter((value): value is string => typeof value === "string"); this.selectedShapeIds = seed.selectedShapeIds ? [...seed.selectedShapeIds] : []; } @@ -489,12 +501,12 @@ class MockPowerPointRuntime { getSelectedSlides(): MockSlide[] { return this.selectedSlideIds - .map(slideId => this.slideList.find(slide => slide.id === slideId)) + .map(slideId => this.slides.find(slide => slide.id === slideId)) .filter((slide): slide is MockSlide => Boolean(slide)); } getSelectedShapes(): MockShape[] { - return this.slideList + return this.slides .flatMap(slide => slide.shapes.items) .filter(shape => this.selectedShapeIds.includes(shape.id)); } @@ -503,22 +515,23 @@ class MockPowerPointRuntime { this.selectedShapeIds = this.selectedShapeIds.filter(id => id !== shapeId); } - async setSelection(slideId: string, shapeIds: string[] = []) { + 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.slideList.at(0)?.id; + const fallbackSlideId = slideId ?? this.selectedSlideIds.at(0) ?? this.slides.at(0)?.id; this.selectedSlideIds = fallbackSlideId ? [fallbackSlideId] : []; this.selectedShapeIds = []; await this.triggerSelectionChanged(); } - insertSvg(svg: string) { - const targetSlide = this.getSelectedSlides().at(0) ?? this.slideList.at(0) ?? null; + 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.") }; } @@ -526,7 +539,7 @@ class MockPowerPointRuntime { const shape = targetSlide.addShape({ svgContent: svg }); this.selectedSlideIds = [targetSlide.id]; this.selectedShapeIds = [shape.id]; - return { status: "succeeded", shapeId: shape.id }; + return { status: "succeeded" }; } snapshot(): MockOfficeSnapshot { @@ -536,7 +549,7 @@ class MockPowerPointRuntime { selectedSlideIds: [...this.selectedSlideIds], selectedShapeIds: [...this.selectedShapeIds], insertedSvgCalls: this.insertedSvgCalls.map(call => ({ ...call })), - slides: this.slideList.map(slide => slide.snapshot()), + slides: this.slides.map(slide => slide.snapshot()), }; } @@ -547,6 +560,43 @@ class MockPowerPointRuntime { } } +// 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, @@ -566,84 +616,63 @@ function serializeTypstSource(source: MockTypstSource): string { return new XMLSerializer().serializeToString(documentNode); } -type MockGlobals = { - Office: { - HostType: { PowerPoint: "PowerPoint" }; - EventType: { DocumentSelectionChanged: "DocumentSelectionChanged" }; - AsyncResultStatus: { Succeeded: "succeeded"; Failed: "failed" }; - CoercionType: { XmlSvg: "xmlSvg" }; - actions: { associate: (_name: string, _handler: unknown) => void }; +// Browser global installation. +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) => void; - setSelectedDataAsync: ( - _data: string, - _options: { coercionType: string }, - _callback: (_result: { status: string; error?: Error }) => void, - ) => void; - }; - }; - onReady: (_callback: OfficeReadyCallback) => Promise; - }; - PowerPoint: { - run: (_callback: (_context: MockRequestContext) => Promise | T) => Promise; + 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" }); + }, }; - __pptypstOfficeMock: { - reset: (_seed?: MockOfficeSeed) => void; - selectShapes: (_slideId: string, _shapeIds: string[]) => Promise; - clearSelection: (_slideId?: string) => Promise; - snapshot: () => MockOfficeSnapshot; +} + +function createPowerPointHost(documentModel: MockPowerPointDocument): MockPowerPointHost { + return { + async run(callback: (_context: MockRequestContext) => Promise | T) { + return callback(new MockRequestContext(documentModel)); + }, }; - __pptypstOfficeSeed?: MockOfficeSeed; -}; +} -const mockGlobals = globalThis as unknown as MockGlobals; -const runtime = new MockPowerPointRuntime(); -runtime.reset(mockGlobals.__pptypstOfficeSeed); - -mockGlobals.Office = { - HostType: { PowerPoint: "PowerPoint" }, - EventType: { DocumentSelectionChanged: "DocumentSelectionChanged" }, - AsyncResultStatus: { Succeeded: "succeeded", Failed: "failed" }, - CoercionType: { XmlSvg: "xmlSvg" }, - actions: { - associate() {}, - }, - context: { - document: { - addHandlerAsync(_eventType: string, handler: SelectionChangedHandler) { - runtime.addSelectionHandler(handler); - }, - setSelectedDataAsync(data, _options, callback) { - const result = runtime.insertSvg(data); - callback(result.status === "succeeded" - ? { status: mockGlobals.Office.AsyncResultStatus.Succeeded } - : { status: mockGlobals.Office.AsyncResultStatus.Failed, error: result.error }); - }, +function createTestHarness(documentModel: MockPowerPointDocument): MockOfficeTestHarness { + return { + reset(seed?: MockOfficeSeed) { + documentModel.reset(seed); }, - }, - async onReady(callback: OfficeReadyCallback) { - await callback({ host: "PowerPoint" }); - }, -}; + async selectShapes(slideId: string, shapeIds: string[]) { + await documentModel.selectShapes(slideId, shapeIds); + }, + async clearSelection(slideId?: string) { + await documentModel.clearSelection(slideId); + }, + snapshot() { + return documentModel.snapshot(); + }, + }; +} -mockGlobals.PowerPoint = { - async run(callback: (_context: MockRequestContext) => Promise | T) { - return callback(new MockRequestContext(runtime)); - }, -}; +const mockGlobals = globalThis as unknown as MockGlobals; +const documentModel = new MockPowerPointDocument(mockGlobals.__pptypstOfficeSeed); -mockGlobals.__pptypstOfficeMock = { - reset(seed?: MockOfficeSeed) { - runtime.reset(seed); - }, - async selectShapes(slideId: string, shapeIds: string[]) { - await runtime.setSelection(slideId, shapeIds); - }, - async clearSelection(slideId?: string) { - await runtime.clearSelection(slideId); - }, - snapshot() { - return runtime.snapshot(); - }, -}; +mockGlobals.Office = createOfficeHost(documentModel); +mockGlobals.PowerPoint = createPowerPointHost(documentModel); +mockGlobals.__pptypstOfficeMock = createTestHarness(documentModel); From 6f74ef047c8b1462dad23e909d6600c66888d5d4 Mon Sep 17 00:00:00 2001 From: Splines Date: Sun, 7 Jun 2026 02:56:44 +0200 Subject: [PATCH 11/19] Refactor Office.js mock into modular files --- tests/_support/browser-mocks/office.ts | 678 --------------------- tests/_support/office-mock.ts | 22 +- tests/_support/office/adapter.ts | 110 ++++ tests/_support/office/document-model.ts | 284 +++++++++ tests/_support/office/install.ts | 7 + tests/_support/office/office-primitives.ts | 152 +++++ tests/_support/office/types.ts | 124 ++++ tests/_support/transpile-browser-mock.ts | 17 +- 8 files changed, 707 insertions(+), 687 deletions(-) delete mode 100644 tests/_support/browser-mocks/office.ts create mode 100644 tests/_support/office/adapter.ts create mode 100644 tests/_support/office/document-model.ts create mode 100644 tests/_support/office/install.ts create mode 100644 tests/_support/office/office-primitives.ts create mode 100644 tests/_support/office/types.ts diff --git a/tests/_support/browser-mocks/office.ts b/tests/_support/browser-mocks/office.ts deleted file mode 100644 index 2ea0e28..0000000 --- a/tests/_support/browser-mocks/office.ts +++ /dev/null @@ -1,678 +0,0 @@ -/* - * Browser replacement for Office.js used by the PowerPoint Playwright tests. - * - * Keep this file import-free. It is served as the classic `office.js` script, - * so the browser will not evaluate ES module imports here. - * - * The mock has three layers: - * 1. Seed and snapshot DTOs used by tests. - * 2. A small in-memory PowerPoint document model. - * 3. Office.js-shaped facades exposed on globalThis. - */ - -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; - -// 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() {} -} - -// 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(); - } - } -} - -// 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); -} - -// Browser global installation. -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(); - }, - }; -} - -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-mock.ts b/tests/_support/office-mock.ts index c35cb29..0397a04 100644 --- a/tests/_support/office-mock.ts +++ b/tests/_support/office-mock.ts @@ -1,7 +1,15 @@ import type { Page } from "@playwright/test"; +import fs from "node:fs/promises"; import path from "node:path"; -const officeMockPath = path.join(process.cwd(), "tests", "_support", "browser-mocks", "office.ts"); +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) { @@ -11,6 +19,14 @@ export async function installOfficeMock(page: Page) { } async function compileOfficeMock() { - const { compileBrowserMock } = await import("./transpile-browser-mock"); - return compileBrowserMock(officeMockPath); + 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 index 1dbaa8d..d59f48b 100644 --- a/tests/_support/transpile-browser-mock.ts +++ b/tests/_support/transpile-browser-mock.ts @@ -3,21 +3,26 @@ import * as ts from "typescript"; const compiledMocks = new Map(); -/** Transpiles a TypeScript browser mock into JavaScript for Playwright route fulfillment. */ -export async function compileBrowserMock(filePath: string) { - const cached = compiledMocks.get(filePath); +/** 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 source = await fs.readFile(filePath, "utf8"); const output = ts.transpileModule(source, { compilerOptions: { module: ts.ModuleKind.ES2022, target: ts.ScriptTarget.ES2022, sourceMap: false, }, - fileName: filePath, + fileName, }).outputText; - compiledMocks.set(filePath, output); + 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); +} From f0e2f5d5b41ecc3f0508d6a90022cd38b76314a8 Mon Sep 17 00:00:00 2001 From: Splines Date: Sun, 7 Jun 2026 03:01:43 +0200 Subject: [PATCH 12/19] Adjust template GitHub workflow --- .github/workflows/playwright.yml | 27 ------------------------ .github/workflows/test.yml | 35 ++++++++++++++++++++++++++++++++ 2 files changed, 35 insertions(+), 27 deletions(-) delete mode 100644 .github/workflows/playwright.yml create mode 100644 .github/workflows/test.yml diff --git a/.github/workflows/playwright.yml b/.github/workflows/playwright.yml deleted file mode 100644 index 3eb1314..0000000 --- a/.github/workflows/playwright.yml +++ /dev/null @@ -1,27 +0,0 @@ -name: Playwright Tests -on: - push: - branches: [ main, master ] - pull_request: - branches: [ main, master ] -jobs: - test: - timeout-minutes: 60 - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 - with: - node-version: lts/* - - name: Install dependencies - run: npm ci - - name: Install Playwright Browsers - run: npx playwright install --with-deps - - name: Run Playwright tests - run: npx playwright test - - uses: actions/upload-artifact@v4 - if: ${{ !cancelled() }} - with: - name: playwright-report - path: playwright-report/ - retention-days: 30 diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..f38f8fa --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,35 @@ +name: Playwright Tests + +on: + push: + branches: [ main, next ] + pull_request: + branches: [ main, next ] + +jobs: + test: + timeout-minutes: 60 + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v6 + + - 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 --no-shell + + - 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 From c678273b87d5c768162b540a07e6ae31c8c9ffbd Mon Sep 17 00:00:00 2001 From: Splines Date: Sun, 7 Jun 2026 03:02:43 +0200 Subject: [PATCH 13/19] Give correct instructions for Playwright installation --- DEV.md | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/DEV.md b/DEV.md index 0c128c6..80682c2 100644 --- a/DEV.md +++ b/DEV.md @@ -54,10 +54,9 @@ npm run validate-manifest npx playwright install-deps chromium npx playwright install chromium -# Start the webserver -npm run dev - # 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 ``` From ecbb5bb1b69d31a0536e7ccebf5a3ff6a1317be9 Mon Sep 17 00:00:00 2001 From: Splines Date: Sun, 7 Jun 2026 03:19:50 +0200 Subject: [PATCH 14/19] Use http instead of https for Playwright tests --- .config/vite.config.js | 45 +++++++++++-------- DEV.md | 2 + playwright.config.ts | 16 ++++--- .../browser-mocks/typst-state-module.d.ts | 2 +- tests/_support/browser-mocks/typst.ts | 2 +- 5 files changed, 41 insertions(+), 26 deletions(-) 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/DEV.md b/DEV.md index 80682c2..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. diff --git a/playwright.config.ts b/playwright.config.ts index 7d5c756..b0365e8 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -2,6 +2,10 @@ 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. @@ -21,8 +25,7 @@ export default defineConfig({ /* 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: "https://127.0.0.1:3157/pptypst/", - ignoreHTTPSErrors: true, + baseURL: testBaseUrl, }, /* Configure projects for major browsers */ @@ -35,10 +38,13 @@ export default defineConfig({ /* Run your local dev server before starting the tests */ webServer: { - command: "npm run dev -- --host 127.0.0.1 --port 3157 --strictPort", - url: "https://127.0.0.1:3157/pptypst/powerpoint.html", + 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, - ignoreHTTPSErrors: true, }, }); diff --git a/tests/_support/browser-mocks/typst-state-module.d.ts b/tests/_support/browser-mocks/typst-state-module.d.ts index 9a74a91..134a919 100644 --- a/tests/_support/browser-mocks/typst-state-module.d.ts +++ b/tests/_support/browser-mocks/typst-state-module.d.ts @@ -1,4 +1,4 @@ -declare module "https://127.0.0.1:3157/pptypst/__test__/typst-state.js" { +declare module "*__test__/typst-state.js" { export const typstMockState: { rendererInitOptions: { hasGetModule: boolean }[]; addSourceCalls: { path: string; source: string }[]; diff --git a/tests/_support/browser-mocks/typst.ts b/tests/_support/browser-mocks/typst.ts index 9dd16fc..618acb2 100644 --- a/tests/_support/browser-mocks/typst.ts +++ b/tests/_support/browser-mocks/typst.ts @@ -1,4 +1,4 @@ -import { typstMockState } from "https://127.0.0.1:3157/pptypst/__test__/typst-state.js"; +import { typstMockState } from "/pptypst/__test__/typst-state.js"; type CompilerInitOptions = { beforeBuild: unknown[]; getModule: unknown }; type CompileOptions = { mainFilePath: string }; From 6bbe70f8c3b7f4e0dd88505b481c0ec3162fd0cb Mon Sep 17 00:00:00 2001 From: Splines Date: Sun, 7 Jun 2026 03:23:38 +0200 Subject: [PATCH 15/19] Don't set default value for slideId --- tests/pages/powerpoint-page.ts | 26 +++++++++++++++++--------- 1 file changed, 17 insertions(+), 9 deletions(-) diff --git a/tests/pages/powerpoint-page.ts b/tests/pages/powerpoint-page.ts index b8ca881..6d154d2 100644 --- a/tests/pages/powerpoint-page.ts +++ b/tests/pages/powerpoint-page.ts @@ -81,7 +81,9 @@ export class PowerPointPage { /** 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/); + await expect(this.page.locator("#insertBtn")).toContainText( + /Insert|Update/, + ); } /** Seeds the browser Office mock and reloads the task pane around that model. */ @@ -100,7 +102,7 @@ export class PowerPointPage { } async setPreamble(preamble: string) { - if (!await this.page.locator("#preambleInput").isVisible()) { + if (!(await this.page.locator("#preambleInput").isVisible())) { await this.page.locator("#preambleSummary").click(); } await this.page.locator("#preambleInput").fill(preamble); @@ -130,13 +132,19 @@ export class PowerPointPage { } 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 = "slide-1") { + 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); From 758a735b1cae2a91ce33d89e189f729d704b0a06 Mon Sep 17 00:00:00 2001 From: Splines Date: Sun, 7 Jun 2026 03:24:41 +0200 Subject: [PATCH 16/19] Also install chromium shell --- .github/workflows/test.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index f38f8fa..727d2ba 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -22,7 +22,7 @@ jobs: run: npm ci # ci: "clean install" - name: Install Playwright Chromium deps & browser - run: npx playwright install-deps chromium && npx playwright install chromium --no-shell + run: npx playwright install-deps chromium && npx playwright install chromium - name: Run Playwright tests run: npx playwright test From 787fa2d2263560526e13fb66b09481a0e7956427 Mon Sep 17 00:00:00 2001 From: Splines Date: Sun, 7 Jun 2026 03:27:04 +0200 Subject: [PATCH 17/19] Reject Promise instead of throwing --- tests/_support/browser-mocks/typst.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/_support/browser-mocks/typst.ts b/tests/_support/browser-mocks/typst.ts index 618acb2..68578be 100644 --- a/tests/_support/browser-mocks/typst.ts +++ b/tests/_support/browser-mocks/typst.ts @@ -18,7 +18,9 @@ export function createTypstCompiler() { return { init(options: CompilerInitOptions) { if (typeof options.getModule !== "function") { - throw new Error("Expected Typst compiler getModule option."); + return Promise.reject( + new Error("Expected Typst compiler getModule option."), + ); } return Promise.resolve(); }, From 4675796702bceb90d99eabd36a5a43bdb0f07c3c Mon Sep 17 00:00:00 2001 From: Splines Date: Sun, 7 Jun 2026 03:28:13 +0200 Subject: [PATCH 18/19] Don't persist credentials in code checkout --- .github/workflows/test.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 727d2ba..1bc4fbd 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -13,6 +13,8 @@ jobs: steps: - name: Checkout code uses: actions/checkout@v6 + with: + persist-credentials: false - uses: actions/setup-node@v6 with: From 1f9005a9618e5aa50b2c787567c2bbadb1917f9d Mon Sep 17 00:00:00 2001 From: Splines Date: Sun, 7 Jun 2026 03:36:24 +0200 Subject: [PATCH 19/19] Require review for test runs --- .github/workflows/test.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 1bc4fbd..7740f96 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -8,7 +8,8 @@ on: jobs: test: - timeout-minutes: 60 + environment: testing-review + timeout-minutes: 10 runs-on: ubuntu-latest steps: - name: Checkout code