Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions shared/font-system/src/bundled.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});
});
17 changes: 17 additions & 0 deletions tests/behavior/fixtures/superdoc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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;
}
Expand Down
85 changes: 81 additions & 4 deletions tests/behavior/harness/main.ts
Original file line number Diff line number Diff line change
@@ -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<typeof createSuperDocUI>;

Expand Down Expand Up @@ -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');
Expand Down Expand Up @@ -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();
Expand All @@ -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) {
Expand Down Expand Up @@ -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<string, unknown> } | 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 };
Expand Down
42 changes: 39 additions & 3 deletions tests/behavior/harness/vite.config.ts
Original file line number Diff line number Diff line change
@@ -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') },
Comment thread
caio-pizzol marked this conversation as resolved.
...getAliases(true),
],
conditions: ['source'],
},
server: {
Expand Down
7 changes: 6 additions & 1 deletion tests/behavior/playwright.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
},
Expand Down
Binary file not shown.
82 changes: 82 additions & 0 deletions tests/behavior/tests/fonts/npm-custom-fonts.spec.ts
Original file line number Diff line number Diff line change
@@ -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<string[]> {
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' });
});
});
47 changes: 47 additions & 0 deletions tests/behavior/tests/fonts/npm-document-fonts.spec.ts
Original file line number Diff line number Diff line change
@@ -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<string[]> {
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');
});
});
Loading
Loading