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(); }); }); diff --git a/tests/behavior/fixtures/superdoc.ts b/tests/behavior/fixtures/superdoc.ts index 920776f93e..4ac99bffbe 100644 --- a/tests/behavior/fixtures/superdoc.ts +++ b/tests/behavior/fixtures/superdoc.ts @@ -29,6 +29,22 @@ 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' + | 'package' + | 'include-calibri' + | 'exclude-cooper' + | 'bad-raw' + | 'bad-url' + | 'custom' + | 'custom-toolbar'; } type DocumentMode = 'editing' | 'suggesting' | 'viewing'; @@ -67,6 +83,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..85a9b52d29 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; @@ -75,6 +76,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 +180,62 @@ 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 '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': + // 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 + // under, so it must stay served-nowhere (see harness/vite.config.ts). + return { assetBaseUrl: '/fonts/' }; + } +} + +const harnessFonts = resolveHarnessFontsConfig(fontsMode); + function init(file?: File, content?: ContentOverrideInput) { if (instance) { instance.destroy(); @@ -188,10 +248,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) { @@ -237,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/harness/vite.config.ts b/tests/behavior/harness/vite.config.ts index 0c6f60ac42..3220d7b06b 100644 --- a/tests/behavior/harness/vite.config.ts +++ b/tests/behavior/harness/vite.config.ts @@ -1,18 +1,54 @@ 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 `/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('/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()) { + 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), + // 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/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, }, diff --git a/tests/behavior/tests/fonts/fixtures/calibri.docx b/tests/behavior/tests/fonts/fixtures/calibri.docx new file mode 100644 index 0000000000..a1e758e972 Binary files /dev/null and b/tests/behavior/tests/fonts/fixtures/calibri.docx differ 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-document-fonts.spec.ts b/tests/behavior/tests/fonts/npm-document-fonts.spec.ts new file mode 100644 index 0000000000..a02822cbd5 --- /dev/null +++ b/tests/behavior/tests/fonts/npm-document-fonts.spec.ts @@ -0,0 +1,47 @@ +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 }) => { + // 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 (/\.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'); + }); +}); 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..c1cf3dc58c --- /dev/null +++ b/tests/behavior/tests/fonts/npm-font-availability.spec.ts @@ -0,0 +1,249 @@ +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): 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"]'; +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). 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 (/\/bundled-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); + }); +}); + +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: 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); + 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([]); + }); +}); 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); + }); +});