From 95ec065885b7ab073608cd079d4a5ae8049bd345 Mon Sep 17 00:00:00 2001 From: Caio Pizzol Date: Sat, 13 Jun 2026 20:28:48 -0300 Subject: [PATCH 1/9] test(fonts): npm font availability + curation behavior specs (SD-3441) Drive the bundled-font modes through the existing behavior harness via a `fonts` config option (no-pack / curation / malformed raw / bad base), and serve the built dist/fonts at /fonts/ so face loads are assertable. Covers: no-pack baseline (exact + rich-absent), include drops the built-in baseline, exclude removes only the named family, applying Calibri loads its substitute (200) and stores the logical name not Carlito, and malformed raw fonts.bundled warns once without crashing. Default mode keeps the rich pack, so existing toolbar specs are unaffected. --- tests/behavior/fixtures/superdoc.ts | 8 + tests/behavior/harness/main.ts | 41 ++++- tests/behavior/harness/vite.config.ts | 31 +++- .../tests/fonts/npm-font-availability.spec.ts | 169 ++++++++++++++++++ 4 files changed, 243 insertions(+), 6 deletions(-) create mode 100644 tests/behavior/tests/fonts/npm-font-availability.spec.ts diff --git a/tests/behavior/fixtures/superdoc.ts b/tests/behavior/fixtures/superdoc.ts index 920776f93e..ca44450e80 100644 --- a/tests/behavior/fixtures/superdoc.ts +++ b/tests/behavior/fixtures/superdoc.ts @@ -29,6 +29,13 @@ interface HarnessConfig { documentMode?: 'editing' | 'viewing' | 'suggesting'; previewScroll?: boolean; blockPreviewScrollEvents?: boolean; + /** + * Bundled-font mode forwarded to the harness as `?fonts=`. Unset keeps the rich pack (the default + * the other specs rely on). Drives the font-availability specs: `no-pack` baseline, curation + * (`include-calibri` / `exclude-cooper`), malformed raw config (`bad-raw`), and a 404 base + * (`bad-url`). + */ + fonts?: 'no-pack' | 'pack' | 'include-calibri' | 'exclude-cooper' | 'bad-raw' | 'bad-url'; } type DocumentMode = 'editing' | 'suggesting' | 'viewing'; @@ -67,6 +74,7 @@ function buildHarnessUrl(config: HarnessConfig = {}): string { if (config.documentMode) params.set('documentMode', config.documentMode); if (config.previewScroll) params.set('previewScroll', '1'); if (config.blockPreviewScrollEvents) params.set('blockPreviewScrollEvents', '1'); + if (config.fonts) params.set('fonts', config.fonts); const qs = params.toString(); return qs ? `${HARNESS_URL}?${qs}` : HARNESS_URL; } diff --git a/tests/behavior/harness/main.ts b/tests/behavior/harness/main.ts index 3e6aa1cc12..2b1116b4e7 100644 --- a/tests/behavior/harness/main.ts +++ b/tests/behavior/harness/main.ts @@ -75,6 +75,9 @@ const contentOverride = params.get('contentOverride') ?? undefined; const overrideType = (params.get('overrideType') as OverrideType | null) ?? undefined; const previewScroll = params.get('previewScroll') === '1'; const blockPreviewScrollEvents = params.get('blockPreviewScrollEvents') === '1'; +// Bundled-font mode for the font-availability specs. Unset = the rich pack (back-compat: existing +// specs assert the rich toolbar), see resolveHarnessFontsConfig. +const fontsMode = params.get('fonts'); if (!showCaret) { document.documentElement.style.setProperty('caret-color', 'transparent', 'important'); @@ -176,6 +179,37 @@ function applyContentOverride(config: SuperDocConfig, input?: ContentOverrideInp } } +/** + * Bundled-font config for the harness, chosen by the `fonts` query param so the font-availability + * specs can drive each mode from one harness. `null` (no param) keeps the rich pack so existing + * behavior specs are unaffected. Curation goes through the RAW `fonts.bundled` path - what a CDN or + * plain-JS consumer hand-writes, and what `createSuperDocFonts` feeds underneath. + */ +function resolveHarnessFontsConfig(mode: string | null): SuperDocConfig['fonts'] | undefined { + switch (mode) { + case 'no-pack': + // No pack configured: conservative baseline toolbar, logical names render with system fonts, + // and nothing fetches a bundled substitute. + return undefined; + case 'include-calibri': + return { assetBaseUrl: '/fonts/', bundled: { include: ['Calibri'] } }; + case 'exclude-cooper': + return { assetBaseUrl: '/fonts/', bundled: { exclude: ['Cooper Black'] } }; + case 'bad-raw': + // Malformed raw config: a bare string where an array is required. Must warn once and fall back + // to the full pack, never crash init (guards the fonts.bundled coercion). + return { assetBaseUrl: '/fonts/', bundled: { include: 'Calibri' as unknown as string[] } }; + case 'bad-url': + // Pack configured but the assets are not served there: faces 404 on use, with a clear warning. + return { assetBaseUrl: '/__missing-fonts__/' }; + case 'pack': + default: + return { assetBaseUrl: '/fonts/' }; + } +} + +const harnessFonts = resolveHarnessFontsConfig(fontsMode); + function init(file?: File, content?: ContentOverrideInput) { if (instance) { instance.destroy(); @@ -188,10 +222,9 @@ function init(file?: File, content?: ContentOverrideInput) { selector: '#editor', useLayoutEngine: layout, telemetry: { enabled: false }, - // Configure the bundled pack so the harness exercises the FULL toolbar + substitution (the - // product's rich experience). Without a configured pack the toolbar is the conservative - // baseline; font specs assert the rich list, so the harness opts in here. - fonts: { assetBaseUrl: '/fonts/' }, + // Bundled-font config, selected by the `fonts` query param (default keeps the rich pack so + // existing specs are unaffected). See resolveHarnessFontsConfig. + ...(harnessFonts !== undefined ? { fonts: harnessFonts } : {}), onReady: ({ superdoc }: SuperDocReadyPayload) => { harnessWindow.superdoc = superdoc; if (comments === 'panel' && commentsPanel) { diff --git a/tests/behavior/harness/vite.config.ts b/tests/behavior/harness/vite.config.ts index 0c6f60ac42..8e9b0ddd0c 100644 --- a/tests/behavior/harness/vite.config.ts +++ b/tests/behavior/harness/vite.config.ts @@ -1,16 +1,43 @@ import { createRequire } from 'node:module'; -import { defineConfig } from 'vite'; +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { defineConfig, type Plugin } from 'vite'; import { getAliases } from '../../../packages/superdoc/vite.config.js'; const superdocRequire = createRequire(new URL('../../../packages/superdoc/package.json', import.meta.url)); const vue = superdocRequire('@vitejs/plugin-vue').default; +// Serve the built bundled `.woff2` at `/fonts/` (the harness default assetBaseUrl) so the +// font-availability specs can assert real face loads (200) and the no-pack/curation absence of them. +// Production-faithful: it serves packages/superdoc/dist/fonts, the same set a CDN/npm consumer gets. +// Requires `superdoc` to be built (CI builds it before the behavior job; locally run +// `pnpm --filter superdoc build`). The `bad-url` mode points elsewhere on purpose, so it 404s here. +const here = path.dirname(fileURLToPath(import.meta.url)); +const bundledFontsDir = path.resolve(here, '../../../packages/superdoc/dist/fonts'); +const serveBundledFonts: Plugin = { + name: 'serve-bundled-fonts', + configureServer(server) { + server.middlewares.use('/fonts', (req, res, next) => { + const name = decodeURIComponent((req.url ?? '').split('?')[0]).replace(/^\/+/, ''); + const file = path.join(bundledFontsDir, name); + if (name && file.startsWith(bundledFontsDir) && fs.existsSync(file) && fs.statSync(file).isFile()) { + res.setHeader('Content-Type', 'font/woff2'); + res.setHeader('Access-Control-Allow-Origin', '*'); + fs.createReadStream(file).pipe(res); + return; + } + next(); + }); + }, +}; + export default defineConfig({ define: { __APP_VERSION__: JSON.stringify('behavior-harness'), __IS_DEBUG__: true, }, - plugins: [vue()], + plugins: [vue(), serveBundledFonts], resolve: { alias: getAliases(true), conditions: ['source'], diff --git a/tests/behavior/tests/fonts/npm-font-availability.spec.ts b/tests/behavior/tests/fonts/npm-font-availability.spec.ts new file mode 100644 index 0000000000..5003f666e5 --- /dev/null +++ b/tests/behavior/tests/fonts/npm-font-availability.spec.ts @@ -0,0 +1,169 @@ +import { test, expect, type SuperDocFixture } from '../../fixtures/superdoc.js'; + +// Font availability + curation on the npm path, driven through the existing Vite harness via the +// `fonts` config mode (see resolveHarnessFontsConfig in harness/main.ts). These rows assert what the +// toolbar ADVERTISES and that malformed raw config degrades safely - none of them need the bundled +// `.woff2` to be served, so they run without the font middleware. Apply/load/export and the CDN path +// live in sibling specs. + +const FONT_TOGGLE = '[data-item="btn-fontFamily-toggle"]'; +const FONT_OPTION = '[data-item="btn-fontFamily-option"]'; +const OPTION_LABEL = `${FONT_OPTION} .toolbar-dropdown-option__label`; + +// The full advertised set when the pack is configured (mirrors font-dropdown-document-options.spec). +const RICH_LABELS = [ + 'Arial', + 'Arial Black', + 'Arial Narrow', + 'Baskerville Old Face', + 'Bookman Old Style', + 'Brush Script MT', + 'Calibri', + 'Century', + 'Century Gothic', + 'Comic Sans MS', + 'Cooper Black', + 'Courier New', + 'Garamond', + 'Georgia', + 'Gill Sans MT Condensed', + 'Helvetica', + 'Lucida Console', + 'Segoe UI', + 'Tahoma', + 'Times New Roman', + 'Trebuchet MS', + 'Verdana', +]; + +const BASELINE_LABELS = ['Arial', 'Courier New', 'Times New Roman']; + +async function openFontDropdown(superdoc: SuperDocFixture): Promise { + await superdoc.page.locator(FONT_TOGGLE).click(); + await superdoc.page.locator(FONT_OPTION).first().waitFor({ state: 'visible', timeout: 5000 }); + await superdoc.waitForStable(); +} + +async function fontOptionLabels(superdoc: SuperDocFixture): Promise { + return (await superdoc.page.locator(OPTION_LABEL).allInnerTexts()).map((label) => label.trim()); +} + +async function selectFontOption(superdoc: SuperDocFixture, label: string): Promise { + await superdoc.page + .locator(FONT_OPTION) + .filter({ has: superdoc.page.getByText(label, { exact: true }) }) + .click(); + await superdoc.waitForStable(); + await superdoc.page + .locator('.presentation-editor__viewport') + .first() + .click({ position: { x: 50, y: 50 } }); + await superdoc.waitForStable(); +} + +test.describe('npm, no pack configured', () => { + test.use({ config: { toolbar: 'full', fonts: 'no-pack' } }); + + test('blank doc advertises only the one-per-generic baseline', async ({ superdoc }) => { + await openFontDropdown(superdoc); + // Exact list: with no pack and no document fonts, the toolbar is the conservative baseline only. + expect(await fontOptionLabels(superdoc)).toEqual(BASELINE_LABELS); + }); + + test('the rich pack is not advertised without a configured pack', async ({ superdoc }) => { + await openFontDropdown(superdoc); + const labels = await fontOptionLabels(superdoc); + for (const richOnly of ['Calibri', 'Georgia', 'Verdana', 'Cooper Black', 'Comic Sans MS']) { + expect(labels).not.toContain(richOnly); + } + }); +}); + +test.describe('npm + include curation', () => { + test.use({ config: { toolbar: 'full', fonts: 'include-calibri' } }); + + test('include is a strict allow-list: the built-in baseline is dropped', async ({ superdoc }) => { + await openFontDropdown(superdoc); + const labels = await fontOptionLabels(superdoc); + // The contract: include gates the built-in set down to the named families, so the rest of the + // baseline (Courier New, Times New Roman) and every other rich family are gone - the + // surprising-but-current behavior. Arial still shows because the blank document itself uses it: + // document-used fonts always appear in the toolbar, independent of bundled curation. + expect(labels).toContain('Calibri'); + for (const dropped of ['Courier New', 'Times New Roman', 'Georgia', 'Verdana', 'Cooper Black', 'Comic Sans MS']) { + expect(labels).not.toContain(dropped); + } + }); +}); + +test.describe('npm + exclude curation', () => { + test.use({ config: { toolbar: 'full', fonts: 'exclude-cooper' } }); + + test('exclude removes only the named family from the rich set', async ({ superdoc }) => { + await openFontDropdown(superdoc); + const labels = await fontOptionLabels(superdoc); + expect(labels).toEqual(RICH_LABELS.filter((label) => label !== 'Cooper Black')); + expect(labels).not.toContain('Cooper Black'); + }); +}); + +test.describe('npm + pack: applying a bundled font', () => { + test.use({ config: { toolbar: 'full', fonts: 'pack' } }); + + test('applying Calibri loads its bundled face (200) and stores the logical name, not Carlito', async ({ + superdoc, + }) => { + // Capture bundled-font responses from the moment we apply (faces load lazily, only on use). + const fontResponses: Array<{ url: string; status: number }> = []; + superdoc.page.on('response', (res) => { + if (/\/fonts\/.*\.woff2(\?|$)/.test(res.url())) fontResponses.push({ url: res.url(), status: res.status() }); + }); + + await superdoc.type('Calibri sample'); + await superdoc.waitForStable(); + const pos = await superdoc.findTextPos('Calibri sample'); + await superdoc.setTextSelection(pos, pos + 'Calibri sample'.length); + await superdoc.waitForStable(); + + await openFontDropdown(superdoc); + await selectFontOption(superdoc, 'Calibri'); + + // Stored/exported value is the logical Word family - never the physical substitute (Carlito). + await superdoc.assertTextMarkAttrs('Calibri sample', 'textStyle', { fontFamily: 'Calibri' }); + + // The substitute face actually loaded over the wire (200), proving the configured pack serves. + await expect + .poll(() => fontResponses.filter((r) => r.status === 200).length, { timeout: 10_000 }) + .toBeGreaterThan(0); + }); +}); + +test.describe('npm + malformed raw fonts.bundled', () => { + test.use({ config: { toolbar: 'full', fonts: 'bad-raw' } }); + + test('a non-array include warns once and falls back to the full pack, never crashing init', async ({ superdoc }) => { + // The init warning fires during SuperDoc construction, which the fixture already awaited. Reload + // with a console listener attached so we capture that first init. + const warnings: string[] = []; + superdoc.page.on('console', (msg) => { + if (msg.type() === 'warning') warnings.push(msg.text()); + }); + await superdoc.page.reload({ waitUntil: 'networkidle' }); + await superdoc.page.waitForFunction( + () => (window as Window & { superdocReady?: boolean }).superdocReady === true, + null, + { + timeout: 30_000, + }, + ); + await superdoc.waitForStable(); + + // Exactly one shape warning, and crucially NO per-character "X is not a bundled font" spam. + expect(warnings.filter((w) => /fonts\.bundled\.include must be an array/.test(w))).toHaveLength(1); + expect(warnings.filter((w) => /is not a bundled font/.test(w))).toHaveLength(0); + + // No crash: the editor came up, and the malformed curation fell back to the full pack. + await openFontDropdown(superdoc); + expect(await fontOptionLabels(superdoc)).toEqual(RICH_LABELS); + }); +}); From 4d6281b87805598cdd3de745fda0da11e2f1991a Mon Sep 17 00:00:00 2001 From: Caio Pizzol Date: Sat, 13 Jun 2026 20:32:40 -0300 Subject: [PATCH 2/9] test(fonts): no-pack document preserves font names without substitution (SD-3441) Load a Calibri document with no pack configured and assert it fetches no bundled .woff2, keeps the logical Calibri name on the run, and still lists Calibri in the toolbar as a document font. Guards the 1.40 regression: preserving a document's font name must not activate a substitute the app never configured. --- .../tests/fonts/fixtures/calibri.docx | Bin 0 -> 13506 bytes .../tests/fonts/npm-document-fonts.spec.ts | 46 ++++++++++++++++++ 2 files changed, 46 insertions(+) create mode 100644 tests/behavior/tests/fonts/fixtures/calibri.docx create mode 100644 tests/behavior/tests/fonts/npm-document-fonts.spec.ts diff --git a/tests/behavior/tests/fonts/fixtures/calibri.docx b/tests/behavior/tests/fonts/fixtures/calibri.docx new file mode 100644 index 0000000000000000000000000000000000000000..a1e758e972e08c706eb5b9ca97410c8d152846c0 GIT binary patch literal 13506 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 JO#9o}{{d7-Oilm* literal 0 HcmV?d00001 diff --git a/tests/behavior/tests/fonts/npm-document-fonts.spec.ts b/tests/behavior/tests/fonts/npm-document-fonts.spec.ts new file mode 100644 index 0000000000..45df827590 --- /dev/null +++ b/tests/behavior/tests/fonts/npm-document-fonts.spec.ts @@ -0,0 +1,46 @@ +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { test, expect, type SuperDocFixture } from '../../fixtures/superdoc.js'; + +// A document that USES bundled fonts, with no pack configured. The point: SuperDoc preserves the +// document's font names (they show in the toolbar as document fonts) but must NOT activate a bundled +// substitute for them - no `.woff2` is fetched. This is the regression the 1.40 rollback was about: +// advertising/serving a substitute the app never configured. + +const HERE = path.dirname(fileURLToPath(import.meta.url)); +const CALIBRI_DOC = path.resolve(HERE, 'fixtures/calibri.docx'); // runs in Arial + Calibri + +const FONT_TOGGLE = '[data-item="btn-fontFamily-toggle"]'; +const FONT_OPTION = '[data-item="btn-fontFamily-option"]'; +const OPTION_LABEL = `${FONT_OPTION} .toolbar-dropdown-option__label`; + +async function fontOptionLabels(superdoc: SuperDocFixture): Promise { + await superdoc.page.locator(FONT_TOGGLE).click(); + await superdoc.page.locator(FONT_OPTION).first().waitFor({ state: 'visible', timeout: 5000 }); + await superdoc.waitForStable(); + return (await superdoc.page.locator(OPTION_LABEL).allInnerTexts()).map((label) => label.trim()); +} + +test.describe('npm, no pack: a document that uses bundled fonts', () => { + test.use({ config: { toolbar: 'full', fonts: 'no-pack' } }); + + test('preserves the document font name without fetching a bundled substitute', async ({ superdoc }) => { + const fontRequests: string[] = []; + superdoc.page.on('request', (req) => { + if (/\/fonts\/.*\.woff2(\?|$)/.test(req.url())) fontRequests.push(req.url()); + }); + + await superdoc.loadDocument(CALIBRI_DOC); + await superdoc.waitForStable(); + + // Rendering a Calibri document with no pack must not fetch a substitute face. + expect(fontRequests).toEqual([]); + + // The Calibri run keeps its logical Word name (no substitution baked in). + await superdoc.assertTextMarkAttrs('Hamburgefonts', 'textStyle', { fontFamily: 'Calibri' }); + + // The document's font still appears in the toolbar - document fonts are advertised even with no + // pack - so the user can re-apply it; it simply renders with the system font, not a substitute. + expect(await fontOptionLabels(superdoc)).toContain('Calibri'); + }); +}); From fa296aae1e87cd60f3d9cf9bb9d8412d66c1fbfe Mon Sep 17 00:00:00 2001 From: Caio Pizzol Date: Sat, 13 Jun 2026 20:49:15 -0300 Subject: [PATCH 3/9] test(fonts): @superdoc-dev/fonts package import smoke test (SD-3441) Add a `package` harness mode that imports superdocFonts from @superdoc-dev/fonts (aliased to source in the harness, so no dep churn) and asserts applying Calibri loads Carlito 200 from the package's bundler-emitted asset (new URL(import.meta.url)), with no assetBaseUrl. Proves the import-and-go DX the assetBaseUrl harness does not cover. --- tests/behavior/fixtures/superdoc.ts | 2 +- tests/behavior/harness/main.ts | 5 ++ tests/behavior/harness/vite.config.ts | 9 ++- .../tests/fonts/npm-package-fonts.spec.ts | 64 +++++++++++++++++++ 4 files changed, 78 insertions(+), 2 deletions(-) create mode 100644 tests/behavior/tests/fonts/npm-package-fonts.spec.ts diff --git a/tests/behavior/fixtures/superdoc.ts b/tests/behavior/fixtures/superdoc.ts index ca44450e80..265a61359c 100644 --- a/tests/behavior/fixtures/superdoc.ts +++ b/tests/behavior/fixtures/superdoc.ts @@ -35,7 +35,7 @@ interface HarnessConfig { * (`include-calibri` / `exclude-cooper`), malformed raw config (`bad-raw`), and a 404 base * (`bad-url`). */ - fonts?: 'no-pack' | 'pack' | 'include-calibri' | 'exclude-cooper' | 'bad-raw' | 'bad-url'; + fonts?: 'no-pack' | 'pack' | 'package' | 'include-calibri' | 'exclude-cooper' | 'bad-raw' | 'bad-url'; } type DocumentMode = 'editing' | 'suggesting' | 'viewing'; diff --git a/tests/behavior/harness/main.ts b/tests/behavior/harness/main.ts index 2b1116b4e7..ce6471605d 100644 --- a/tests/behavior/harness/main.ts +++ b/tests/behavior/harness/main.ts @@ -1,6 +1,7 @@ import 'superdoc/style.css'; import { SuperDoc } from 'superdoc'; import { createSuperDocUI } from 'superdoc/ui'; +import { superdocFonts } from '@superdoc-dev/fonts'; type SuperDocUIInstance = ReturnType; @@ -202,6 +203,10 @@ function resolveHarnessFontsConfig(mode: string | null): SuperDocConfig['fonts'] case 'bad-url': // Pack configured but the assets are not served there: faces 404 on use, with a clear warning. return { assetBaseUrl: '/__missing-fonts__/' }; + case 'package': + // The real `@superdoc-dev/fonts` DX: no assetBaseUrl, the package resolves each face to a + // bundler-emitted asset URL. Proves the import-and-go path users copy from the docs. + return superdocFonts as SuperDocConfig['fonts']; case 'pack': default: return { assetBaseUrl: '/fonts/' }; diff --git a/tests/behavior/harness/vite.config.ts b/tests/behavior/harness/vite.config.ts index 8e9b0ddd0c..f8ce260fe6 100644 --- a/tests/behavior/harness/vite.config.ts +++ b/tests/behavior/harness/vite.config.ts @@ -39,7 +39,14 @@ export default defineConfig({ }, plugins: [vue(), serveBundledFonts], resolve: { - alias: getAliases(true), + // Alias the optional published pack to its source so the harness can import it without declaring + // a dep (pnpm's isolated linker would otherwise not link it here). This still exercises the real + // DX: Vite resolves the package's `new URL('../assets/x.woff2', import.meta.url)` and emits the + // asset, which is what the `fonts: 'package'` mode verifies end to end. + alias: [ + { find: '@superdoc-dev/fonts', replacement: path.resolve(here, '../../../packages/fonts/src/index.ts') }, + ...getAliases(true), + ], conditions: ['source'], }, server: { diff --git a/tests/behavior/tests/fonts/npm-package-fonts.spec.ts b/tests/behavior/tests/fonts/npm-package-fonts.spec.ts new file mode 100644 index 0000000000..22fff20818 --- /dev/null +++ b/tests/behavior/tests/fonts/npm-package-fonts.spec.ts @@ -0,0 +1,64 @@ +import { test, expect, type SuperDocFixture } from '../../fixtures/superdoc.js'; + +// The real `@superdoc-dev/fonts` DX, distinct from the `assetBaseUrl: '/fonts/'` harness: import the +// package, pass `superdocFonts`, and the bundled faces resolve to bundler-emitted asset URLs (the +// package writes them as `new URL('../assets/', import.meta.url)`). This proves the +// import-and-go path users copy from the docs actually loads a face over the wire. + +const FONT_TOGGLE = '[data-item="btn-fontFamily-toggle"]'; +const FONT_OPTION = '[data-item="btn-fontFamily-option"]'; + +async function openFontDropdown(superdoc: SuperDocFixture): Promise { + await superdoc.page.locator(FONT_TOGGLE).click(); + await superdoc.page.locator(FONT_OPTION).first().waitFor({ state: 'visible', timeout: 5000 }); + await superdoc.waitForStable(); +} + +async function selectFontOption(superdoc: SuperDocFixture, label: string): Promise { + await superdoc.page + .locator(FONT_OPTION) + .filter({ has: superdoc.page.getByText(label, { exact: true }) }) + .click(); + await superdoc.waitForStable(); + await superdoc.page + .locator('.presentation-editor__viewport') + .first() + .click({ position: { x: 50, y: 50 } }); + await superdoc.waitForStable(); +} + +test.describe('npm + @superdoc-dev/fonts package (real DX)', () => { + test.use({ config: { toolbar: 'full', fonts: 'package' } }); + + test('applying Calibri loads the package-emitted substitute (200) and stores the logical name', async ({ + superdoc, + }) => { + const fontResponses: Array<{ url: string; status: number }> = []; + superdoc.page.on('response', (res) => { + if (/\.woff2(\?|$)/.test(res.url())) fontResponses.push({ url: res.url(), status: res.status() }); + }); + + await superdoc.type('Calibri via package'); + await superdoc.waitForStable(); + const pos = await superdoc.findTextPos('Calibri via package'); + await superdoc.setTextSelection(pos, pos + 'Calibri via package'.length); + await superdoc.waitForStable(); + + await openFontDropdown(superdoc); + await selectFontOption(superdoc, 'Calibri'); + + // Stored/exported value is the logical Word family, never the physical substitute. + await superdoc.assertTextMarkAttrs('Calibri via package', 'textStyle', { fontFamily: 'Calibri' }); + + // Carlito (Calibri's substitute) loaded 200 from the PACKAGE's emitted asset path - the + // import-and-go DX: `new URL('../assets/Carlito-Regular.woff2', import.meta.url)` resolved and + // served by the bundler, with no assetBaseUrl. + await expect + .poll(() => fontResponses.find((r) => /packages\/fonts\/assets\/Carlito.*\.woff2/.test(r.url))?.status ?? 0, { + timeout: 10_000, + }) + .toBe(200); + // Nothing came from an assetBaseUrl `/fonts/` path - the package resolved every face itself. + expect(fontResponses.every((r) => !/localhost:9990\/fonts\//.test(r.url))).toBe(true); + }); +}); From 6a19d5e5d1adeddaf09cae64bd3ac7ba83e0cb18 Mon Sep 17 00:00:00 2001 From: Caio Pizzol Date: Sat, 13 Jun 2026 21:37:21 -0300 Subject: [PATCH 4/9] fix(test): serve the bundled-font harness at /bundled-fonts/, not /fonts/ (SD-3441) The /fonts/ middleware made bundled substitutes load for every spec (the harness default assetBaseUrl is /fonts/), so specs reading a rendered font - e.g. list-marker-font-inheritance - saw Liberation Serif instead of Times New Roman. Serve the assets at a distinct /bundled-fonts/ base used only by the pack font mode, leaving the default /fonts/ unserved (its prior state) so substitutes are advertised but not loaded. --- tests/behavior/harness/main.ts | 6 ++++++ tests/behavior/harness/vite.config.ts | 14 ++++++++------ .../tests/fonts/npm-document-fonts.spec.ts | 3 ++- .../tests/fonts/npm-font-availability.spec.ts | 7 +++++-- 4 files changed, 21 insertions(+), 9 deletions(-) diff --git a/tests/behavior/harness/main.ts b/tests/behavior/harness/main.ts index ce6471605d..ecc59985b5 100644 --- a/tests/behavior/harness/main.ts +++ b/tests/behavior/harness/main.ts @@ -208,7 +208,13 @@ function resolveHarnessFontsConfig(mode: string | null): SuperDocConfig['fonts'] // bundler-emitted asset URL. Proves the import-and-go path users copy from the docs. return superdocFonts as SuperDocConfig['fonts']; case 'pack': + // A configured pack whose assets are actually SERVED (see the /bundled-fonts middleware), so + // face-load specs can assert a real 200. Distinct from the default base on purpose. + return { assetBaseUrl: '/bundled-fonts/' }; default: + // The rich pack advertised but NOT served: substitutes appear in the toolbar but are never + // fetched, so rendered text keeps logical names. This is the default every non-font spec runs + // under, so it must stay served-nowhere (see harness/vite.config.ts). return { assetBaseUrl: '/fonts/' }; } } diff --git a/tests/behavior/harness/vite.config.ts b/tests/behavior/harness/vite.config.ts index f8ce260fe6..3220d7b06b 100644 --- a/tests/behavior/harness/vite.config.ts +++ b/tests/behavior/harness/vite.config.ts @@ -8,17 +8,19 @@ import { getAliases } from '../../../packages/superdoc/vite.config.js'; const superdocRequire = createRequire(new URL('../../../packages/superdoc/package.json', import.meta.url)); const vue = superdocRequire('@vitejs/plugin-vue').default; -// Serve the built bundled `.woff2` at `/fonts/` (the harness default assetBaseUrl) so the -// font-availability specs can assert real face loads (200) and the no-pack/curation absence of them. -// Production-faithful: it serves packages/superdoc/dist/fonts, the same set a CDN/npm consumer gets. -// Requires `superdoc` to be built (CI builds it before the behavior job; locally run -// `pnpm --filter superdoc build`). The `bad-url` mode points elsewhere on purpose, so it 404s here. +// Serve the built bundled `.woff2` at `/bundled-fonts/` so the face-load specs can assert real loads +// (200). Deliberately NOT `/fonts/`: the harness's default assetBaseUrl is `/fonts/`, and existing +// specs rely on it staying UNSERVED - substitutes are advertised but never fetched, so rendered text +// keeps the logical Word name (e.g. the list-marker specs read the computed family). Serving `/fonts/` +// globally makes those substitutes load and breaks them. Only the `pack` font mode points here. +// Production-faithful: serves packages/superdoc/dist/fonts (CI builds superdoc before the behavior +// job; locally run `pnpm --filter superdoc build`). const here = path.dirname(fileURLToPath(import.meta.url)); const bundledFontsDir = path.resolve(here, '../../../packages/superdoc/dist/fonts'); const serveBundledFonts: Plugin = { name: 'serve-bundled-fonts', configureServer(server) { - server.middlewares.use('/fonts', (req, res, next) => { + server.middlewares.use('/bundled-fonts', (req, res, next) => { const name = decodeURIComponent((req.url ?? '').split('?')[0]).replace(/^\/+/, ''); const file = path.join(bundledFontsDir, name); if (name && file.startsWith(bundledFontsDir) && fs.existsSync(file) && fs.statSync(file).isFile()) { diff --git a/tests/behavior/tests/fonts/npm-document-fonts.spec.ts b/tests/behavior/tests/fonts/npm-document-fonts.spec.ts index 45df827590..a02822cbd5 100644 --- a/tests/behavior/tests/fonts/npm-document-fonts.spec.ts +++ b/tests/behavior/tests/fonts/npm-document-fonts.spec.ts @@ -25,9 +25,10 @@ test.describe('npm, no pack: a document that uses bundled fonts', () => { test.use({ config: { toolbar: 'full', fonts: 'no-pack' } }); test('preserves the document font name without fetching a bundled substitute', async ({ superdoc }) => { + // No pack means no bundled substitution at all, so assert NO `.woff2` is fetched from any base. const fontRequests: string[] = []; superdoc.page.on('request', (req) => { - if (/\/fonts\/.*\.woff2(\?|$)/.test(req.url())) fontRequests.push(req.url()); + if (/\.woff2(\?|$)/.test(req.url())) fontRequests.push(req.url()); }); await superdoc.loadDocument(CALIBRI_DOC); diff --git a/tests/behavior/tests/fonts/npm-font-availability.spec.ts b/tests/behavior/tests/fonts/npm-font-availability.spec.ts index 5003f666e5..1ecfa928d1 100644 --- a/tests/behavior/tests/fonts/npm-font-availability.spec.ts +++ b/tests/behavior/tests/fonts/npm-font-availability.spec.ts @@ -113,10 +113,13 @@ test.describe('npm + pack: applying a bundled font', () => { test('applying Calibri loads its bundled face (200) and stores the logical name, not Carlito', async ({ superdoc, }) => { - // Capture bundled-font responses from the moment we apply (faces load lazily, only on use). + // Capture bundled-font responses from the moment we apply (faces load lazily, only on use). The + // 'pack' mode uses the SERVED `/bundled-fonts/` base; the default `/fonts/` is intentionally + // unserved so it doesn't perturb non-font specs (see harness/vite.config.ts). const fontResponses: Array<{ url: string; status: number }> = []; superdoc.page.on('response', (res) => { - if (/\/fonts\/.*\.woff2(\?|$)/.test(res.url())) fontResponses.push({ url: res.url(), status: res.status() }); + if (/\/bundled-fonts\/.*\.woff2(\?|$)/.test(res.url())) + fontResponses.push({ url: res.url(), status: res.status() }); }); await superdoc.type('Calibri sample'); From 5496c8eaaf6c4fd3c38c46fa4cf3b604e9085723 Mon Sep 17 00:00:00 2001 From: Caio Pizzol Date: Sat, 13 Jun 2026 22:07:17 -0300 Subject: [PATCH 5/9] test(fonts): sync pack assets in the harness webServer startup (SD-3441) The package-import spec resolves faces to packages/fonts/assets via the source alias. That dir is gitignored and generated by the fonts package's prepare/build, which behavior CI skips (--ignore-scripts; root build only builds superdoc). The spec passes in CI today via an ambient workspace mechanism, but relying on it is fragile - so sync the assets explicitly in the Playwright webServer command. Verified cold: cleared assets + Vite cache -> sync writes 65 faces -> Carlito loads 200. --- tests/behavior/playwright.config.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/tests/behavior/playwright.config.ts b/tests/behavior/playwright.config.ts index a309a52f6d..361206cee6 100644 --- a/tests/behavior/playwright.config.ts +++ b/tests/behavior/playwright.config.ts @@ -32,7 +32,12 @@ export default defineConfig({ // CI: shard across runners with --shard=1/3, --shard=2/3, --shard=3/3 webServer: { - command: 'pnpm exec vite --config harness/vite.config.ts harness/', + // Sync the gitignored @superdoc-dev/fonts package assets before serving. The package-import font + // spec resolves faces to `packages/fonts/assets/*.woff2` (via the harness source alias), and + // those are generated by the fonts package's prepare/build - which behavior CI skips + // (`--ignore-scripts`, and root build only builds superdoc). Syncing here keeps a clean local or + // CI checkout honest. `sync` is a standalone copy from shared/font-system/assets (idempotent). + command: 'pnpm --filter @superdoc-dev/fonts run sync && pnpm exec vite --config harness/vite.config.ts harness/', port: 9990, reuseExistingServer: !process.env.CI, }, From 60b779c9bcd977d4f36dfbf3a637268807e834b7 Mon Sep 17 00:00:00 2001 From: Caio Pizzol Date: Sun, 14 Jun 2026 05:51:56 -0300 Subject: [PATCH 6/9] test(font-system): assert the conflicting-config warning text, not just the count (SD-3441) The shared-registry first-config-wins contract (#14) is already covered here; tighten the assertion so it checks the warning explains WHY the later config was dropped, not merely that console.warn fired once. --- shared/font-system/src/bundled.test.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/shared/font-system/src/bundled.test.ts b/shared/font-system/src/bundled.test.ts index f4e6b4bef8..efaa6723fa 100644 --- a/shared/font-system/src/bundled.test.ts +++ b/shared/font-system/src/bundled.test.ts @@ -121,6 +121,8 @@ describe('installBundledSubstitutes URL resolution', () => { expect(reg.sourcesFor('Carlito')).toContain('url(/first/Carlito-Regular.woff2)'); expect(reg.sourcesFor('Carlito')).not.toContain('url(/second/Carlito-Regular.woff2)'); expect(warn).toHaveBeenCalledTimes(1); + // Assert the message, not just the count: a dev must see WHY the second config was dropped. + expect(warn.mock.calls[0][0]).toMatch(/a later fonts config .* is ignored/); warn.mockRestore(); }); }); From 225fb3a9a61266af5b8fe3327f8c13ce4208b6f5 Mon Sep 17 00:00:00 2001 From: Caio Pizzol Date: Sun, 14 Jun 2026 06:01:03 -0300 Subject: [PATCH 7/9] test(fonts): broken-base advertising + no-pack programmatic apply (SD-3441) bad-url: a configured-but-broken assetBaseUrl still advertises the rich set (presence-gated) and requests faces from exactly that base, staying graceful (run keeps the logical name). Note: SuperDoc does not emit its own load-failure warning - the browser reports the decode failure - and the Vite harness returns an SPA fallback rather than a 404, so this asserts base-honoring + graceful fallback, not a 404. programmatic apply (no pack): applying a bundled font via the editor command keeps the logical name and fetches no substitute - the resolver gate, UI-aside. --- .../tests/fonts/npm-font-availability.spec.ts | 78 +++++++++++++++++++ 1 file changed, 78 insertions(+) diff --git a/tests/behavior/tests/fonts/npm-font-availability.spec.ts b/tests/behavior/tests/fonts/npm-font-availability.spec.ts index 1ecfa928d1..efda9a30f5 100644 --- a/tests/behavior/tests/fonts/npm-font-availability.spec.ts +++ b/tests/behavior/tests/fonts/npm-font-availability.spec.ts @@ -170,3 +170,81 @@ test.describe('npm + malformed raw fonts.bundled', () => { expect(await fontOptionLabels(superdoc)).toEqual(RICH_LABELS); }); }); + +test.describe('npm + pack with a broken asset base', () => { + test.use({ config: { toolbar: 'full', fonts: 'bad-url' } }); + + test('advertises rich on config presence and requests faces from the configured base, staying graceful', async ({ + superdoc, + }) => { + const woff2: string[] = []; + superdoc.page.on('response', (res) => { + if (/\.woff2(\?|$)/.test(res.url())) woff2.push(res.url()); + }); + + // Advertising is gated on config PRESENCE, not on the assets actually being served - so a + // configured-but-broken base still shows the full rich set. + await openFontDropdown(superdoc); + expect(await fontOptionLabels(superdoc)).toEqual(RICH_LABELS); + await superdoc.page.keyboard.press('Escape'); + await superdoc.waitForStable(); + + await superdoc.type('Broken base sample'); + await superdoc.waitForStable(); + const pos = await superdoc.findTextPos('Broken base sample'); + await superdoc.setTextSelection(pos, pos + 'Broken base sample'.length); + await superdoc.waitForStable(); + await openFontDropdown(superdoc); + await selectFontOption(superdoc, 'Calibri'); + + // SuperDoc honors the configured base: it requests Calibri's substitute from exactly where the + // app pointed it, not anywhere else. (A real server would 404 here; this harness returns Vite's + // SPA fallback, which the browser then can't decode - SuperDoc itself does not warn on a load + // failure, it falls back to the logical name, which the run keeps.) + await expect + .poll(() => woff2.some((u) => /\/__missing-fonts__\/Carlito.*\.woff2/.test(u)), { timeout: 10_000 }) + .toBe(true); + expect(woff2.every((u) => /\/__missing-fonts__\//.test(u))).toBe(true); + await superdoc.assertTextMarkAttrs('Broken base sample', 'textStyle', { fontFamily: 'Calibri' }); + }); +}); + +test.describe('npm, no pack: programmatic apply', () => { + test.use({ config: { fonts: 'no-pack' } }); + + test('applying a bundled font via the editor command keeps the name and fetches no substitute', async ({ + superdoc, + }) => { + // Exercises the resolver gate with the UI out of the picture: a font absent from the no-pack + // toolbar can still be applied programmatically (or arrive in a document), and must keep its + // logical name without pulling a substitute. + const fontRequests: string[] = []; + superdoc.page.on('request', (req) => { + if (/\.woff2(\?|$)/.test(req.url())) fontRequests.push(req.url()); + }); + const warnings: string[] = []; + superdoc.page.on('console', (msg) => { + if (msg.type() === 'warning') warnings.push(msg.text()); + }); + + await superdoc.type('Programmatic sample'); + await superdoc.waitForStable(); + const pos = await superdoc.findTextPos('Programmatic sample'); + await superdoc.setTextSelection(pos, pos + 'Programmatic sample'.length); + await superdoc.waitForStable(); + + await superdoc.page.evaluate(() => { + ( + window as unknown as { editor: { commands: { setFontFamily: (f: string) => void } } } + ).editor.commands.setFontFamily('Calibri'); + }); + await superdoc.waitForStable(); + + // Stored value is the logical name; no pack means no substitute fetch and no font-config warning. + await superdoc.assertTextMarkAttrs('Programmatic sample', 'textStyle', { fontFamily: 'Calibri' }); + expect(fontRequests).toEqual([]); + expect( + warnings.filter((w) => /bundled|substitute|@superdoc-dev\/fonts|assetBaseUrl|not a bundled font/i.test(w)), + ).toEqual([]); + }); +}); From d612a0ecc10bb4ddf08ab9bf228d16acc6d7e2de Mon Sep 17 00:00:00 2001 From: Caio Pizzol Date: Sun, 14 Jun 2026 06:08:21 -0300 Subject: [PATCH 8/9] test(fonts): custom-font render-vs-selectable split (SD-3441) Add 'custom' / 'custom-toolbar' harness modes that register a licensed font ('Brand Sans', sourced from a served woff2) via fonts.families, optionally listing it in modules.toolbar.fonts. Two specs lock the contract: registration alone renders the font when applied but does NOT add a toolbar option; listing it in modules.toolbar.fonts makes it selectable. Also fix the now-stale npm-font-availability header. --- tests/behavior/fixtures/superdoc.ts | 11 ++- tests/behavior/harness/main.ts | 33 ++++++++ .../tests/fonts/npm-custom-fonts.spec.ts | 82 +++++++++++++++++++ .../tests/fonts/npm-font-availability.spec.ts | 8 +- 4 files changed, 129 insertions(+), 5 deletions(-) create mode 100644 tests/behavior/tests/fonts/npm-custom-fonts.spec.ts diff --git a/tests/behavior/fixtures/superdoc.ts b/tests/behavior/fixtures/superdoc.ts index 265a61359c..4ac99bffbe 100644 --- a/tests/behavior/fixtures/superdoc.ts +++ b/tests/behavior/fixtures/superdoc.ts @@ -35,7 +35,16 @@ interface HarnessConfig { * (`include-calibri` / `exclude-cooper`), malformed raw config (`bad-raw`), and a 404 base * (`bad-url`). */ - fonts?: 'no-pack' | 'pack' | 'package' | 'include-calibri' | 'exclude-cooper' | 'bad-raw' | 'bad-url'; + fonts?: + | 'no-pack' + | 'pack' + | 'package' + | 'include-calibri' + | 'exclude-cooper' + | 'bad-raw' + | 'bad-url' + | 'custom' + | 'custom-toolbar'; } type DocumentMode = 'editing' | 'suggesting' | 'viewing'; diff --git a/tests/behavior/harness/main.ts b/tests/behavior/harness/main.ts index ecc59985b5..85a9b52d29 100644 --- a/tests/behavior/harness/main.ts +++ b/tests/behavior/harness/main.ts @@ -211,6 +211,21 @@ function resolveHarnessFontsConfig(mode: string | null): SuperDocConfig['fonts'] // A configured pack whose assets are actually SERVED (see the /bundled-fonts middleware), so // face-load specs can assert a real 200. Distinct from the default base on purpose. return { assetBaseUrl: '/bundled-fonts/' }; + case 'custom': + case 'custom-toolbar': + // A consumer-licensed font registered via fonts.families - NO bundled pack. The face source is a + // real served woff2 (a bundled face) under a DISTINCT family name, so it decodes and renders. + // With no pack the toolbar stays baseline, which lets a spec prove fonts.families alone does NOT + // add a toolbar option (selectability comes from modules.toolbar.fonts, set when mode is + // 'custom-toolbar', or from the document using the font). + return { + families: [ + { + family: 'Brand Sans', + faces: [{ source: '/bundled-fonts/Carlito-Regular.woff2', weight: 400, style: 'normal' }], + }, + ], + } as SuperDocConfig['fonts']; default: // The rich pack advertised but NOT served: substitutes appear in the toolbar but are never // fetched, so rendered text keeps logical names. This is the default every non-font spec runs @@ -281,6 +296,24 @@ function init(file?: File, content?: ContentOverrideInput) { }; } + // Custom toolbar font list (modules.toolbar.fonts): a custom family is SELECTABLE only when the + // consumer lists it here (fonts.families registration alone does not add a toolbar option). This + // replaces the built-in list entirely. + if (fontsMode === 'custom-toolbar') { + config.modules = { + ...(config.modules ?? {}), + toolbar: { + ...((config.modules as { toolbar?: Record } | undefined)?.toolbar ?? {}), + fonts: [ + { label: 'Arial', key: 'Arial, sans-serif' }, + { label: 'Times New Roman', key: 'Times New Roman, serif' }, + { label: 'Courier New', key: 'Courier New, monospace' }, + { label: 'Brand Sans', key: 'Brand Sans', props: { style: { fontFamily: 'Brand Sans' } } }, + ], + }, + }; + } + // Comments if (comments === 'on' || comments === 'panel') { config.comments = { visible: true }; diff --git a/tests/behavior/tests/fonts/npm-custom-fonts.spec.ts b/tests/behavior/tests/fonts/npm-custom-fonts.spec.ts new file mode 100644 index 0000000000..9c93b72edb --- /dev/null +++ b/tests/behavior/tests/fonts/npm-custom-fonts.spec.ts @@ -0,0 +1,82 @@ +import { test, expect, type SuperDocFixture } from '../../fixtures/superdoc.js'; + +// Custom (consumer-licensed) fonts, split along the contract that surprises people: +// - fonts.families REGISTERS a face so it RENDERS when the document uses it - but registration alone +// does NOT add a toolbar option. +// - A custom family becomes SELECTABLE only when the consumer lists it in modules.toolbar.fonts (or +// the document already uses it, which surfaces it as a document font). +// The harness registers "Brand Sans" from a real served woff2 under a distinct name (see the `custom` +// modes in harness/main.ts). + +const FONT_TOGGLE = '[data-item="btn-fontFamily-toggle"]'; +const FONT_OPTION = '[data-item="btn-fontFamily-option"]'; +const OPTION_LABEL = `${FONT_OPTION} .toolbar-dropdown-option__label`; + +async function fontOptionLabels(superdoc: SuperDocFixture): Promise { + await superdoc.page.locator(FONT_TOGGLE).click(); + await superdoc.page.locator(FONT_OPTION).first().waitFor({ state: 'visible', timeout: 5000 }); + await superdoc.waitForStable(); + return (await superdoc.page.locator(OPTION_LABEL).allInnerTexts()).map((label) => label.trim()); +} + +test.describe('npm + custom font registered via fonts.families', () => { + test.use({ config: { toolbar: 'full', fonts: 'custom' } }); + + test('renders when applied but is NOT a toolbar option from registration alone', async ({ superdoc }) => { + const woff2: Array<{ url: string; status: number }> = []; + superdoc.page.on('response', (res) => { + if (/\.woff2(\?|$)/.test(res.url())) woff2.push({ url: res.url(), status: res.status() }); + }); + + // Registration alone does not add a toolbar row: the dropdown is the no-pack baseline only. + expect(await fontOptionLabels(superdoc)).not.toContain('Brand Sans'); + await superdoc.page.keyboard.press('Escape'); + await superdoc.waitForStable(); + + // It still RENDERS when the document uses it: apply it programmatically, and its registered face + // loads over the wire. + await superdoc.type('Brand Sans sample'); + await superdoc.waitForStable(); + const pos = await superdoc.findTextPos('Brand Sans sample'); + await superdoc.setTextSelection(pos, pos + 'Brand Sans sample'.length); + await superdoc.waitForStable(); + await superdoc.page.evaluate(() => { + ( + window as unknown as { editor: { commands: { setFontFamily: (f: string) => void } } } + ).editor.commands.setFontFamily('Brand Sans'); + }); + await superdoc.waitForStable(); + + await superdoc.assertTextMarkAttrs('Brand Sans sample', 'textStyle', { fontFamily: 'Brand Sans' }); + await expect.poll(() => woff2.filter((r) => r.status === 200).length, { timeout: 10_000 }).toBeGreaterThan(0); + }); +}); + +test.describe('npm + custom font listed in modules.toolbar.fonts', () => { + test.use({ config: { toolbar: 'full', fonts: 'custom-toolbar' } }); + + test('is selectable because the consumer provided the toolbar list', async ({ superdoc }) => { + await superdoc.type('Toolbar custom sample'); + await superdoc.waitForStable(); + const pos = await superdoc.findTextPos('Toolbar custom sample'); + await superdoc.setTextSelection(pos, pos + 'Toolbar custom sample'.length); + await superdoc.waitForStable(); + + // A consumer-provided fonts list replaces the built-in one entirely, so Brand Sans appears and is + // selectable. Open the dropdown once, assert it's there, then pick it. + const labels = await fontOptionLabels(superdoc); + expect(labels).toContain('Brand Sans'); + await superdoc.page + .locator(FONT_OPTION) + .filter({ has: superdoc.page.getByText('Brand Sans', { exact: true }) }) + .click(); + await superdoc.waitForStable(); + await superdoc.page + .locator('.presentation-editor__viewport') + .first() + .click({ position: { x: 50, y: 50 } }); + await superdoc.waitForStable(); + + await superdoc.assertTextMarkAttrs('Toolbar custom sample', 'textStyle', { fontFamily: 'Brand Sans' }); + }); +}); diff --git a/tests/behavior/tests/fonts/npm-font-availability.spec.ts b/tests/behavior/tests/fonts/npm-font-availability.spec.ts index efda9a30f5..e127d4c069 100644 --- a/tests/behavior/tests/fonts/npm-font-availability.spec.ts +++ b/tests/behavior/tests/fonts/npm-font-availability.spec.ts @@ -1,10 +1,10 @@ import { test, expect, type SuperDocFixture } from '../../fixtures/superdoc.js'; // Font availability + curation on the npm path, driven through the existing Vite harness via the -// `fonts` config mode (see resolveHarnessFontsConfig in harness/main.ts). These rows assert what the -// toolbar ADVERTISES and that malformed raw config degrades safely - none of them need the bundled -// `.woff2` to be served, so they run without the font middleware. Apply/load/export and the CDN path -// live in sibling specs. +// `fonts` config mode (see resolveHarnessFontsConfig in harness/main.ts): no-pack baseline, +// include/exclude curation, applying a bundled font (loads the substitute, keeps the logical name), +// malformed raw config, a broken asset base, and programmatic apply. The package-import DX, +// document-font preservation, and custom fonts live in sibling specs. const FONT_TOGGLE = '[data-item="btn-fontFamily-toggle"]'; const FONT_OPTION = '[data-item="btn-fontFamily-option"]'; From d3b5efc4efb75fdda26a0309a647c9874a670762 Mon Sep 17 00:00:00 2001 From: Caio Pizzol Date: Sun, 14 Jun 2026 06:25:12 -0300 Subject: [PATCH 9/9] test(fonts): tighten the broken-base spec comment (SD-3441) Trim the wordiest comment to the why (base-honored; no 404/SuperDoc warning - browser reports the decode failure, Vite serves an SPA fallback). Other comments encode real contracts and stay. --- tests/behavior/tests/fonts/npm-font-availability.spec.ts | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/tests/behavior/tests/fonts/npm-font-availability.spec.ts b/tests/behavior/tests/fonts/npm-font-availability.spec.ts index e127d4c069..c1cf3dc58c 100644 --- a/tests/behavior/tests/fonts/npm-font-availability.spec.ts +++ b/tests/behavior/tests/fonts/npm-font-availability.spec.ts @@ -197,10 +197,9 @@ test.describe('npm + pack with a broken asset base', () => { await openFontDropdown(superdoc); await selectFontOption(superdoc, 'Calibri'); - // SuperDoc honors the configured base: it requests Calibri's substitute from exactly where the - // app pointed it, not anywhere else. (A real server would 404 here; this harness returns Vite's - // SPA fallback, which the browser then can't decode - SuperDoc itself does not warn on a load - // failure, it falls back to the logical name, which the run keeps.) + // SuperDoc honors the configured base: the substitute is requested from exactly where the app + // pointed it. No 404 or SuperDoc warning to assert here - SuperDoc falls back to the logical name + // on a load failure (the browser reports it), and this Vite harness serves an SPA fallback. await expect .poll(() => woff2.some((u) => /\/__missing-fonts__\/Carlito.*\.woff2/.test(u)), { timeout: 10_000 }) .toBe(true);