=> {
+ const deadline = Date.now() + 60 * 1000;
+ let served = await servesMarkdown(from, address);
+ while (served !== expected && Date.now() < deadline) {
+ await sleep(250);
+ served = await servesMarkdown(from, address);
+ }
+ return served;
+ };
+
// A missing file answers 200 too, with the fallback page, so the type says what came back.
it('serves a dependency file asked for before its importer (apimatic-io#2287)', async () => {
- const installed = fs.realpathSync(path.join(root!, 'build', 'node_modules', '@tanstack', 'react-start'));
+ const installed = fs.realpathSync(
+ path.join(checkout.project.toString(), 'node_modules', '@tanstack', 'react-start')
+ );
const entry = path.join(installed, 'dist', 'plugin', 'default-entry', 'client.tsx').split(path.sep).join('/');
- const response = await globalThis.fetch(`${server?.url.toString()}/@fs/${entry}`);
+ const response = await globalThis.fetch(`${checkout.server.url.toString()}/@fs/${entry}`);
expect(response.status, await response.clone().text()).to.equal(200);
expect(response.headers.get('content-type')).to.contain('javascript');
});
- it('serves an image from the static directory asked for before the page importing it', async () => {
+ // The image comes from the static directory by its address, which Vite serves from any drive.
+ const sources: [string, () => { source: DirectoryPath; server: PortalDevServer }][] = [
+ ['the checkout', () => ({ source: fixture, server: checkout.server })],
+ ['a folder reached through a link', () => linked]
+ ];
+
+ sources.forEach(([label, preview]) => {
+ it(`serves a page's static image at its public address, its module asked for before the page, from ${label}`, async () => {
+ const { source, server } = preview();
+ const image = fs
+ .realpathSync(path.join(source.toString(), 'static', 'images', 'logo.png'))
+ .split(path.sep)
+ .join('/');
+
+ const module = await globalThis.fetch(`${server.url.toString()}/@fs/${image}?import`);
+ const response = await globalThis.fetch(`${server.url.toString()}/images/logo.png`);
+
+ expect(await module.text()).to.contain('export default "/images/logo.png"');
+ expect(response.status).to.equal(200);
+ expect(response.headers.get('content-type')).to.equal('image/png');
+ });
+ });
+
+ // The dev server names a module by its real path, which its allow list has to name too (apimatic-io#2287).
+ it('serves a static file by its real path when its folder is reached through a link', async () => {
const image = fs
- .realpathSync(path.join(fixture.toString(), 'static', 'images', 'logo.png'))
+ .realpathSync(path.join(linked.source.toString(), 'static', 'images', 'logo.png'))
.split(path.sep)
.join('/');
- const response = await globalThis.fetch(`${server?.url.toString()}/@fs/${image}`);
+ const response = await globalThis.fetch(`${linked.server.url.toString()}/@fs/${image}`);
expect(response.status).to.equal(200);
expect(response.headers.get('content-type')).to.equal('image/png');
});
+
+ it('leaves out the reference of a specification saved half written, serving the rest, until it is fixed', async () => {
+ const { source, server } = linked;
+ const spec = specFile(source);
+ const written = fs.readFileSync(spec, 'utf8');
+ expect(await servesMarkdown(server, reference), 'the reference before the save').to.be.true;
+
+ fs.writeFileSync(spec, written.slice(0, written.length / 2));
+ try {
+ expect(await servesMarkdownOnceIt(server, reference, false), 'the reference while broken').to.be.false;
+ expect(await servesMarkdown(server, '/authentication.md'), 'a Markdown page while broken').to.be.true;
+ } finally {
+ fs.writeFileSync(spec, written);
+ }
+
+ expect(await servesMarkdownOnceIt(server, reference, true), 'the reference once fixed').to.be.true;
+ });
+
+ // A `$ref` below an `$id` is resolved against the `$id`, so the check leaves it to the bundler, which fails on it.
+ it('starts with a specification the bundler fails on, serving the rest without its reference', async () => {
+ const source = linkedCopy((copy) => {
+ const document = JSON.parse(fs.readFileSync(specFile(copy), 'utf8'));
+ document.components.schemas.Tagged = { $id: 'tagged.json', properties: { tag: { $ref: './tag.yaml' } } };
+ fs.writeFileSync(specFile(copy), JSON.stringify(document));
+ });
+ const { server } = await startPreview(source);
+
+ expect(await servesMarkdown(server, '/authentication.md'), 'a Markdown page').to.be.true;
+ expect(await servesMarkdown(server, reference), 'the reference').to.be.false;
+ });
});
diff --git a/test/infrastructure/file-service.test.ts b/test/infrastructure/file-service.test.ts
index 81aa79ec0..67944017b 100644
--- a/test/infrastructure/file-service.test.ts
+++ b/test/infrastructure/file-service.test.ts
@@ -116,6 +116,7 @@ describe('FileService', () => {
});
afterEach(() => {
+ sinon.restore();
fs.rmSync(root.toString(), { recursive: true, force: true });
});
@@ -151,6 +152,28 @@ describe('FileService', () => {
expect((await fileService.directorySpelledOnDisk(root, root))?.isEqual(root)).to.be.true;
expect(await fileService.directorySpelledOnDisk(root, root.join('static', 'logos'))).to.be.null;
});
+
+ it('answers null for a directory that is not below the root', async () => {
+ expect(await fileService.directorySpelledOnDisk(root.join('static'), root)).to.be.null;
+ });
+
+ it('answers for several files, reading each folder once', async () => {
+ fs.writeFileSync(path.join(root.toString(), 'static', 'images', 'icon.png'), '');
+ const readdir = sinon.spy(fsExtra, 'readdir');
+
+ const found = await fileService.spellingsOnDisk(root, [
+ fileAt('static', 'images', 'Logo.png'),
+ fileAt('static', 'images', 'icon.png'),
+ fileAt('static', 'images', 'banner.png')
+ ]);
+
+ expect(found.map((file) => file?.relativeTo(root) ?? null)).to.deep.equal([
+ 'static/images/logo.png',
+ 'static/images/icon.png',
+ null
+ ]);
+ expect(readdir.callCount).to.equal(3);
+ });
});
describe('findSymlinks', () => {
diff --git a/test/infrastructure/portal-project-service.test.ts b/test/infrastructure/portal-project-service.test.ts
index a01f9de45..f6ace380a 100644
--- a/test/infrastructure/portal-project-service.test.ts
+++ b/test/infrastructure/portal-project-service.test.ts
@@ -266,6 +266,20 @@ describe('PortalProjectService', () => {
expect(module).to.contain("from '../../portal.identity.json'");
});
+ // The dev server compares a file's real path against its allow list, which names this directory.
+ it('names the static directory by its real path, when the source is reached through a link', async () => {
+ const real = path.join(root, 'real');
+ fs.mkdirSync(path.join(real, 'static'), { recursive: true });
+ fs.symlinkSync(real, path.join(root, 'linked'), 'junction');
+ const staticDirectory = new DirectoryPath(root).join('linked', 'static');
+
+ (await service.prepare(project, sourceFor({ staticDirectory }), NO_ARTIFACTS))._unsafeUnwrap();
+
+ expect(readConfig().staticDir).to.equal(
+ path.join(fs.realpathSync.native(real), 'static').split(path.sep).join('/')
+ );
+ });
+
it('reports no static directory when the project has none', async () => {
(await service.prepare(project, sourceFor(), NO_ARTIFACTS))._unsafeUnwrap();
diff --git a/test/infrastructure/tmp-extensions.test.ts b/test/infrastructure/tmp-extensions.test.ts
index 788c306b5..b584cb95a 100644
--- a/test/infrastructure/tmp-extensions.test.ts
+++ b/test/infrastructure/tmp-extensions.test.ts
@@ -1,40 +1,7 @@
import fs from 'fs';
-import os from 'os';
import path from 'path';
import { expect } from 'chai';
-import {
- canonical,
- PORTAL_PROJECT_DIRECTORY_NAME,
- portalProjectDirectoryBase,
- withDirPath,
- withPortalProjectDirectory
-} from '../../src/infrastructure/tmp-extensions';
-import { DirectoryPath } from '../../src/types/file/directoryPath';
-
-describe('portalProjectDirectoryBase', () => {
- // A plain string, not a DirectoryPath: that class resolves against the host's own path
- // rules, and this Windows path is checked on every platform.
- const source = 'D:\\work\\my-api\\src';
-
- it('uses the system temp directory when it shares the drive with the source', () => {
- expect(portalProjectDirectoryBase(source, 'D:\\Temp', 'win32')).to.equal('D:\\Temp');
- });
-
- it('compares drive letters without regard to case', () => {
- expect(portalProjectDirectoryBase(source, 'd:\\Temp', 'win32')).to.equal('d:\\Temp');
- });
-
- it('falls back to a folder beside the source when the temp directory is on another drive', () => {
- expect(portalProjectDirectoryBase(source, 'C:\\Users\\me\\AppData\\Local\\Temp', 'win32')).to.equal(
- path.win32.join('D:\\work\\my-api', PORTAL_PROJECT_DIRECTORY_NAME)
- );
- });
-
- it('never leaves the system temp directory on other platforms', () => {
- expect(portalProjectDirectoryBase('/work/my-api/src', '/tmp', 'linux')).to.equal('/tmp');
- expect(portalProjectDirectoryBase('/work/my-api/src', '/tmp', 'darwin')).to.equal('/tmp');
- });
-});
+import { withDirPath } from '../../src/infrastructure/tmp-extensions';
// The portal's dev server resolves its allow-list to real paths and then compares strings, so a
// temp directory named any other way than its real one has every page under it refused.
@@ -45,21 +12,10 @@ describe('the temp directory a run is given', () => {
});
});
- it('is spelled that way for a portal project too', async () => {
- const source = new DirectoryPath(fs.mkdtempSync(path.join(os.tmpdir(), 'build-real-'))).join('src');
-
- await withPortalProjectDirectory(source, async (directory) => {
- expect(directory.toString()).to.equal(fs.realpathSync.native(directory.toString()));
- });
- });
-});
-
-describe('withPortalProjectDirectory', () => {
- it('hands out a directory that exists while the callback runs and is gone afterwards', async () => {
- const source = new DirectoryPath(fs.mkdtempSync(path.join(os.tmpdir(), 'build-source-'))).join('src');
+ it('exists while the callback runs and is gone afterwards', async () => {
let seen: string | undefined;
- await withPortalProjectDirectory(source, async (directory) => {
+ await withDirPath(async (directory) => {
seen = directory.toString();
expect(fs.existsSync(seen)).to.be.true;
fs.writeFileSync(path.join(seen, 'marker.txt'), 'x');
@@ -68,49 +24,4 @@ describe('withPortalProjectDirectory', () => {
expect(seen).to.be.a('string');
expect(fs.existsSync(seen as string)).to.be.false;
});
-
- // Drive letters only exist on Windows, so the fallback can only be exercised for real there.
- (process.platform === 'win32' ? it : it.skip)(
- 'builds beside the source when the temp directory is on another drive, and cleans up',
- async () => {
- const project = canonical(fs.mkdtempSync(path.join(os.tmpdir(), 'build-project-')));
- const source = new DirectoryPath(project).join('src');
- const foreignTemp = 'Z:\\Temp';
- const fallback = path.join(project, PORTAL_PROJECT_DIRECTORY_NAME);
-
- await withPortalProjectDirectory(
- source,
- async (directory) => {
- expect(directory.toString().toLowerCase().startsWith(fallback.toLowerCase())).to.be.true;
- expect(fs.readFileSync(path.join(fallback, '.gitignore'), 'utf8')).to.equal('*\n');
- },
- foreignTemp
- );
-
- expect(fs.existsSync(fallback)).to.be.false;
- }
- );
-
- (process.platform === 'win32' ? it : it.skip)(
- 'leaves the shared folder alone while another run is in it',
- async () => {
- const project = canonical(fs.mkdtempSync(path.join(os.tmpdir(), 'build-shared-')));
- const source = new DirectoryPath(project).join('src');
- const fallback = path.join(project, PORTAL_PROJECT_DIRECTORY_NAME);
-
- await withPortalProjectDirectory(
- source,
- async () => {
- // Stands in for a second run still building in the same folder.
- fs.mkdirSync(path.join(fallback, 'another-run'), { recursive: true });
- },
- 'Z:\\Temp'
- );
-
- expect(fs.existsSync(path.join(fallback, '.gitignore')), 'the marker survives').to.be.true;
- expect(fs.existsSync(path.join(fallback, 'another-run')), "the other run's directory survives").to.be.true;
-
- fs.rmSync(project, { recursive: true, force: true });
- }
- );
});
diff --git a/test/portal-template.test.ts b/test/portal-template.test.ts
index a7b5b286f..83d5799e2 100644
--- a/test/portal-template.test.ts
+++ b/test/portal-template.test.ts
@@ -11,6 +11,7 @@ import {
TEMPLATE_DEPENDENCIES
} from '../src/infrastructure/portal-project-service';
import { GENERATED_DIRECTORY_NAME, PAGE_TEMPLATES } from '../src/types/portal/generated-pages';
+import { GENERATED_INCLUDES_DIRECTORY_NAME } from '../src/types/portal/page-fragments';
import { PortalIdentity } from '../src/types/portal/portal-config';
import type { BuildPaths } from '../portal-template/portal-config';
import type { Portal } from '../portal-template/src/lib/portal-types';
@@ -145,11 +146,17 @@ describe('portal template packaging', () => {
}
});
- // Named to Tailwind from `src/styles/`; a rename that missed it would cost every utility class in a page, silently.
- it('has Tailwind scan the copy of the pages, by its relative name', () => {
+ // Tailwind reads only the folders `app.css` names, so each must be one the CLI writes pages into.
+ it('has Tailwind scan the directories the CLI writes pages into', () => {
const stylesheet = fs.readFileSync(path.join(templateRoot, 'src/styles/app.css'), 'utf8');
- expect(stylesheet).to.contain(`@source '../../${CONTENT_COPY_DIRECTORY_NAME}';`);
+ for (const directory of [
+ CONTENT_COPY_DIRECTORY_NAME,
+ GENERATED_DIRECTORY_NAME,
+ GENERATED_INCLUDES_DIRECTORY_NAME
+ ]) {
+ expect(stylesheet).to.contain(`@source '../../${directory}';`);
+ }
});
// Left to itself the popover reads `window.location.href`, so a reader's query string and hash
diff --git a/test/portal-template/static-images.test.ts b/test/portal-template/static-images.test.ts
new file mode 100644
index 000000000..557cf64d5
--- /dev/null
+++ b/test/portal-template/static-images.test.ts
@@ -0,0 +1,110 @@
+import fs from 'fs';
+import os from 'os';
+import path from 'path';
+import { expect } from 'chai';
+import { normalizePath, type Plugin } from 'vite';
+import { staticImages } from '../../portal-template/static-images';
+
+type Resolve = (source: string, importer: string | undefined, options: object) => unknown;
+
+interface ResolveHook {
+ filter: { id: RegExp };
+ handler: (this: { resolve: Resolve }, source: string, importer: string | undefined, options: object) => unknown;
+}
+
+const resolveHook = (plugins: Plugin[]) => plugins[0].resolveId as unknown as ResolveHook;
+
+/** The preview's plugin for a portal served under `base`, where Vite counts a `.png` as an asset. */
+function previewLoad(staticDirectory: string, base = '/'): (id: string) => string | null {
+ const preview = staticImages(staticDirectory)[1];
+ const configResolved = preview.configResolved as (config: object) => void;
+ configResolved({ base, assetsInclude: (file: string) => file.endsWith('.png') });
+ return preview.load as (id: string) => string | null;
+}
+
+describe('staticImages', () => {
+ let root: string;
+ let real: string;
+ let linked: string;
+
+ beforeEach(() => {
+ root = fs.mkdtempSync(path.join(os.tmpdir(), 'static-images-'));
+ fs.mkdirSync(path.join(root, 'static'));
+ real = normalizePath(fs.realpathSync.native(path.join(root, 'static')));
+ linked = path.join(root, 'linked');
+ fs.symlinkSync(path.join(root, 'static'), linked, process.platform === 'win32' ? 'junction' : 'dir');
+ });
+
+ afterEach(() => {
+ fs.rmSync(root, { recursive: true, force: true });
+ });
+
+ it('resolves the absolute path Fumadocs puts behind a `./` across Windows drives', () => {
+ const hook = resolveHook(staticImages(null));
+ const asked: unknown[][] = [];
+ const resolve: Resolve = (...args) => {
+ asked.push(args);
+ return 'resolved';
+ };
+
+ for (const source of ['./D:/site/x.png', './d:/site/x.png']) {
+ expect(hook.filter.id.test(source), source).to.be.true;
+ }
+ const resolved = hook.handler.call({ resolve }, './D:/site/src/static/x.png', 'C:/project/content/page.md', {
+ isEntry: false
+ });
+
+ expect(resolved).to.equal('resolved');
+ expect(asked).to.deep.equal([
+ ['D:/site/src/static/x.png', 'C:/project/content/page.md', { isEntry: false, skipSelf: true }]
+ ]);
+ });
+
+ // The CLI builds no portal from a network share, so Fumadocs' `.///server/share/…` is not taken either.
+ it('leaves every other import to Vite', () => {
+ const { filter } = resolveHook(staticImages(null));
+
+ for (const source of [
+ './local.png',
+ './/local.png',
+ '../static/x.png',
+ 'D:/site/x.png',
+ '/images/x.png',
+ 'fumadocs-ui',
+ './//server/share/x.png'
+ ]) {
+ expect(filter.id.test(source), source).to.be.false;
+ }
+ });
+
+ it('changes only the preview, and only for a portal with a static directory', () => {
+ expect(staticImages(null).map((plugin) => plugin.name)).to.deep.equal(['apimatic:static-images:resolve']);
+ expect(staticImages(linked)[1].apply).to.equal('serve');
+ });
+
+ it('loads a static image as the address the public directory serves it at, under the base', () => {
+ expect(previewLoad(real)(`${real}/images/x.png`)).to.equal('export default "/images/x.png";');
+ expect(previewLoad(real, '/docs/')(`${real}/images/x.png`)).to.equal('export default "/docs/images/x.png";');
+ });
+
+ it('knows the static directory by its own spelling and by its real one', () => {
+ const load = previewLoad(linked);
+
+ expect(load(`${normalizePath(linked)}/images/x.png`)).to.equal('export default "/images/x.png";');
+ expect(load(`${real}/images/x.png`)).to.equal('export default "/images/x.png";');
+ });
+
+ it('knows the static directory on Windows whatever the case it is spelled in', function () {
+ if (process.platform !== 'win32') this.skip();
+
+ expect(previewLoad(real)(`${real.toUpperCase()}/images/x.png`)).to.equal('export default "/images/x.png";');
+ });
+
+ it('leaves a query, a file that is no asset and a file elsewhere to Vite', () => {
+ const load = previewLoad(real);
+
+ expect(load(`${real}/images/x.png?raw`)).to.be.null;
+ expect(load(`${real}/data.json`)).to.be.null;
+ expect(load(`${normalizePath(root)}/elsewhere/x.png`)).to.be.null;
+ });
+});
diff --git a/test/prompts/portal/serve.test.ts b/test/prompts/portal/serve.test.ts
index 7140d40b9..80fc1f294 100644
--- a/test/prompts/portal/serve.test.ts
+++ b/test/prompts/portal/serve.test.ts
@@ -4,7 +4,10 @@ import sinon from 'sinon';
import { log } from '@clack/prompts';
import { PortalServePrompts } from '../../../src/prompts/portal/serve.js';
import { DirectoryPath } from '../../../src/types/file/directoryPath.js';
+import { FilePath } from '../../../src/types/file/filePath.js';
import { UrlPath } from '../../../src/types/file/urlPath.js';
+import { ContentNotices } from '../../../src/types/portal/content-notices.js';
+import { PortalSourceProblem } from '../../../src/types/portal/portal-source.js';
import { Language } from '../../../src/types/sdk/generate.js';
describe('PortalServePrompts', () => {
@@ -49,6 +52,199 @@ describe('PortalServePrompts', () => {
'Mistakes in these files are reported on save, and the preview keeps what it last accepted.'
);
});
+
+ it('says a saved specification updates its reference, and what it cannot read is reported and left out', () => {
+ expect(printed()).to.match(
+ /Saving a document in '.*spec' updates its API reference; a mistake is reported on save, and the preview leaves what it cannot read out of the reference until it is fixed\./
+ );
+ });
+ });
+
+ /** The last line `log[level]` printed for `say`, without its colours. */
+ const lastLine = (level: 'warn' | 'message', say: (prompts: PortalServePrompts) => void) => {
+ const stubs = {
+ warn: sinon.stub(log, 'warn'),
+ message: sinon.stub(log, 'message'),
+ error: sinon.stub(log, 'error')
+ };
+ say(new PortalServePrompts());
+ return stripVTControlCharacters(String(stubs[level].lastCall.args[0]));
+ };
+
+ // Content keeps what it last accepted; a specification has no earlier version to keep.
+ it('says the preview shows what it can still read of a refused specification, and that a build would stop', () => {
+ const problem: PortalSourceProblem = { kind: 'emptySpecDirectory', folders: [] };
+
+ expect(lastLine('message', (prompts) => prompts.specRejected(problem, new DirectoryPath('src')))).to.match(
+ /^The preview shows what it can still read until '.*spec' is fixed; a build would stop here\.$/
+ );
+ });
+
+ describe('a watch that cannot start, or stops', () => {
+ const source = new DirectoryPath('src');
+ const cases: [string, (prompts: PortalServePrompts) => void, RegExp][] = [
+ [
+ 'apimatic.json, at the start',
+ (prompts) => prompts.notWatched('config', 'EMFILE', source),
+ /^'apimatic\.json' cannot be watched \(EMFILE\), so edits to it need the preview restarted\.$/
+ ],
+ [
+ 'apimatic.json, while serving',
+ (prompts) => prompts.watchFailed('config', 'EPERM', source),
+ /^'apimatic\.json' is no longer watched \(EPERM\), so edits to it need the preview restarted\.$/
+ ],
+ [
+ 'the content, at the start',
+ (prompts) => prompts.notWatched('content', 'EMFILE', source),
+ /^'.*content' cannot be watched \(EMFILE\), so a mistake in a page or a 'nav\.json' is only reported when the preview is restarted\.$/
+ ],
+ [
+ 'the content, while serving',
+ (prompts) => prompts.watchFailed('content', 'EPERM', source),
+ /^'.*content' is no longer watched \(EPERM\), so a mistake in a page or a 'nav\.json' is only reported when the preview is restarted\.$/
+ ],
+ [
+ 'the specifications, at the start',
+ (prompts) => prompts.notWatched('spec', 'EMFILE', source),
+ /^'.*spec' cannot be watched \(EMFILE\), so a mistake in a specification is only reported when the preview is restarted\.$/
+ ],
+ [
+ 'the specifications, while serving',
+ (prompts) => prompts.watchFailed('spec', 'EPERM', source),
+ /^'.*spec' is no longer watched \(EPERM\), so a mistake in a specification is only reported when the preview is restarted\.$/
+ ]
+ ];
+
+ cases.forEach(([label, say, expected]) => {
+ it(`says what is no longer reported on save for ${label}`, () => {
+ expect(lastLine('warn', say)).to.match(expected);
+ });
+ });
+ });
+
+ describe('a change it accepted that could not be applied to the preview', () => {
+ const source = new DirectoryPath('src');
+
+ it('names apimatic.json, and why', () => {
+ expect(lastLine('warn', (prompts) => prompts.notApplied('config', 'EACCES', source))).to.equal(
+ "The changes to 'apimatic.json' could not be applied to the preview: EACCES"
+ );
+ });
+
+ it('names the content directory, and why', () => {
+ expect(lastLine('warn', (prompts) => prompts.notApplied('content', 'EBUSY', source))).to.match(
+ /^The changes to '.*content' could not be applied to the preview: EBUSY$/
+ );
+ });
+ });
+
+ describe('a change to which documents are read from the spec directory', () => {
+ const document = (name: string) => FilePath.resolve(new DirectoryPath('src', 'spec'), name);
+
+ /** The warning, then the advice that follows it. */
+ const said = (added: string[], removed: string[]) => {
+ const warn = sinon.stub(log, 'warn');
+ const message = sinon.stub(log, 'message');
+ try {
+ new PortalServePrompts().specsShown(
+ { fixed: false, change: { added: added.map(document), removed: removed.map(document) } },
+ new DirectoryPath('src')
+ );
+ return [warn, message].map((stub) => stripVTControlCharacters(String(stub.firstCall.args[0])));
+ } finally {
+ sinon.restore();
+ }
+ };
+
+ // Whether a file was added or removed, or `APIMATIC-META.json` changed which folders merge.
+ it('names each document now read and no longer read, and that a restart matches them', () => {
+ const [warning, advice] = said(['orders.json'], ['pets.json']);
+
+ expect(warning).to.match(
+ /^The documents read from '.*spec' are not the ones the preview started with: 'orders\.json' is now read, and 'pets\.json' is no longer read\.$/
+ );
+ expect(advice).to.equal(
+ 'Restart the preview to match them; until then it shows the ones it started with that are still there.'
+ );
+ });
+
+ it('lists several documents, and says only what changed', () => {
+ expect(said(['a.json', 'b.json'], [])[0]).to.match(/: 'a\.json' and 'b\.json' are now read\.$/);
+ expect(said([], ['a.json', 'b.json', 'c.json'])[0]).to.match(
+ /: 'a\.json', 'b\.json' and 'c\.json' are no longer read\.$/
+ );
+ });
+
+ it('says when the documents are the ones it started with again, which needs no restart', () => {
+ const success = sinon.stub(log, 'success');
+ const warn = sinon.stub(log, 'warn');
+
+ new PortalServePrompts().specsShown(
+ { fixed: false, change: { added: [], removed: [] } },
+ new DirectoryPath('src')
+ );
+
+ expect(warn.called).to.be.false;
+ expect(stripVTControlCharacters(String(success.firstCall.args[0]))).to.match(
+ /^The documents read from '.*spec' are the ones the preview started with again; it no longer needs a restart\.$/
+ );
+ });
+
+ // Each folder `APIMATIC-META.json` merges may hold an `openapi.yaml`.
+ it('names a document in a folder by its path from the spec directory', () => {
+ expect(said(['payments/openapi.yaml'], ['orders/openapi.yaml'])[0]).to.match(
+ /: 'payments\/openapi\.yaml' is now read, and 'orders\/openapi\.yaml' is no longer read\.$/
+ );
+ });
+ });
+
+ describe('a save the preview accepts', () => {
+ const NO_NOTICES: ContentNotices = {
+ hiddenPages: [],
+ ignoredNavigationFiles: [],
+ sharedTabNames: [],
+ noTabsListed: undefined,
+ unseenHomeTitle: undefined
+ };
+
+ /** What `show` says succeeded, without its colours. */
+ const succeeded = (show: (prompts: PortalServePrompts, source: DirectoryPath) => void) => {
+ const success = sinon.stub(log, 'success');
+ try {
+ show(new PortalServePrompts(), new DirectoryPath('src'));
+ return success.getCalls().map((call) => stripVTControlCharacters(String(call.args[0])));
+ } finally {
+ success.restore();
+ }
+ };
+
+ it('says the content or the specifications are fixed, when the save fixes one refused', () => {
+ const content = succeeded((prompts, source) =>
+ prompts.contentShown({ fixed: true, notices: NO_NOTICES }, source)
+ );
+ const specs = succeeded((prompts, source) => prompts.specsShown({ fixed: true, change: null }, source));
+
+ expect(content).to.have.length(1);
+ expect(content[0]).to.match(/^'.*content' is fixed; a build would accept it again\.$/);
+ expect(specs).to.have.length(1);
+ expect(specs[0]).to.match(/^'.*spec' is fixed; a build would accept it again\.$/);
+ });
+
+ it('says nothing of a save that fixes nothing', () => {
+ const content = succeeded((prompts, source) =>
+ prompts.contentShown({ fixed: false, notices: NO_NOTICES }, source)
+ );
+ const specs = succeeded((prompts, source) => prompts.specsShown({ fixed: false, change: null }, source));
+
+ expect([...content, ...specs]).to.be.empty;
+ });
+
+ it('says the changes to apimatic.json were applied only when the preview says to', () => {
+ expect(succeeded((prompts) => prompts.configShown(true))).to.deep.equal([
+ "Applied the changes to 'apimatic.json'."
+ ]);
+ expect(succeeded((prompts) => prompts.configShown(false))).to.be.empty;
+ });
});
describe('an edit that needs artifacts the preview was started without', () => {
diff --git a/test/prompts/portal/source.test.ts b/test/prompts/portal/source.test.ts
index 2af4be54c..62fe5069f 100644
--- a/test/prompts/portal/source.test.ts
+++ b/test/prompts/portal/source.test.ts
@@ -14,7 +14,7 @@ import {
reportUnseenHomeTitle
} from '../../../src/prompts/portal/source.js';
import { Endpoint } from '../../../src/types/portal/endpoint.js';
-import { ContentProblem } from '../../../src/types/portal/portal-source.js';
+import { ContentProblem, UnreadableSpecReason } from '../../../src/types/portal/portal-source.js';
import { DirectoryPath } from '../../../src/types/file/directoryPath.js';
import { FileName } from '../../../src/types/file/fileName.js';
import { FilePath } from '../../../src/types/file/filePath.js';
@@ -219,9 +219,9 @@ describe('reportSourceProblem', () => {
it('names a document it cannot read by its folder', () => {
const file = new FilePath(spec.join('orders'), new FileName('openapi.yaml'));
- reportSourceProblem({ kind: 'unreadableSpec', file }, source);
+ reportSourceProblem({ kind: 'unreadableSpec', file, referencedBy: null, reason: { kind: 'empty' } }, source);
- expect(printed()).to.equal("'spec/orders/openapi.yaml' could not be read as JSON or YAML.");
+ expect(printed()).to.equal("'spec/orders/openapi.yaml' could not be read: it is empty.");
});
it('names a metadata file it cannot read, which is JSON only', () => {
@@ -314,6 +314,97 @@ describe('reportSourceProblem', () => {
});
});
+ describe('a file a specification is read from', () => {
+ const spec = source.join('spec');
+ const api = new FilePath(spec, new FileName('api.yaml'));
+ const pet = new FilePath(spec.join('schemas'), new FileName('Pet.json'));
+
+ it('names a document it cannot read, and why, in the words of the parser that refused it', () => {
+ const message =
+ 'Flow sequence in block collection must be sufficiently indented and end with a ] at line 3, column 1';
+
+ reportSourceProblem(
+ { kind: 'unreadableSpec', file: api, referencedBy: null, reason: { kind: 'invalid', message } },
+ source
+ );
+
+ expect(printed()).to.equal(`'spec/api.yaml' could not be read: ${message}.`);
+ });
+
+ it('names the file that refers to one it cannot read', () => {
+ const message = "Expected ',' or '}' after property value in JSON at position 13 (line 3 column 3)";
+
+ reportSourceProblem(
+ { kind: 'unreadableSpec', file: pet, referencedBy: api, reason: { kind: 'invalid', message } },
+ source
+ );
+
+ expect(printed()).to.equal(
+ `'spec/schemas/Pet.json', which 'spec/api.yaml' refers to, could not be read: ${message}.`
+ );
+ });
+
+ const reasons: [UnreadableSpecReason, string][] = [
+ [{ kind: 'empty' }, 'it is empty'],
+ [{ kind: 'noValue' }, 'it holds no value'],
+ [{ kind: 'noKey' }, 'it is not JSON, and the portal reads YAML only when it holds a key'],
+ [{ kind: 'gone' }, 'it is no longer there'],
+ [{ kind: 'folder' }, 'it is a folder'],
+ [{ kind: 'unopened', message: 'EACCES: permission denied' }, 'EACCES: permission denied']
+ ];
+
+ reasons.forEach(([reason, said]) => {
+ it(`says why a file could not be read, when it is ${reason.kind}`, () => {
+ reportSourceProblem({ kind: 'unreadableSpec', file: api, referencedBy: null, reason }, source);
+
+ expect(printed()).to.equal(`'spec/api.yaml' could not be read: ${said}.`);
+ });
+ });
+
+ it('names a file referred to that is not there, and where a reference is resolved from', () => {
+ reportSourceProblem(
+ { kind: 'missingSpecReference', missing: { file: pet, foundAs: null }, referencedBy: api },
+ source
+ );
+
+ expect(printed()).to.equal(
+ "'spec/api.yaml' refers to 'spec/schemas/Pet.json', but there is no such file.\n" +
+ "A '$ref' names a file from the folder of the file it is written in."
+ );
+ });
+
+ it('names a file referred to that is there in another case, as it is spelt', () => {
+ const lowerCase = new FilePath(spec.join('schemas'), new FileName('pet.json'));
+
+ reportSourceProblem(
+ { kind: 'missingSpecReference', missing: { file: lowerCase, foundAs: pet }, referencedBy: api },
+ source
+ );
+
+ expect(printed()).to.equal(
+ "'spec/api.yaml' refers to 'spec/schemas/pet.json', but the file is spelt 'spec/schemas/Pet.json' on disk."
+ );
+ });
+
+ const PART_NAMED =
+ "A '$ref' names a part of the file it is in as '#/components/schemas/Pet', and a part of another as " +
+ "'./pets.yaml#/Pet'.";
+
+ it('names the file a $ref the build cannot resolve is in, and how to name a part of a file', () => {
+ reportSourceProblem({ kind: 'unresolvableSpecReference', reference: { file: api, ref: '#pet' } }, source);
+
+ expect(printed()).to.equal(
+ `'spec/api.yaml' has the '$ref' '#pet', which the portal cannot resolve.\n${PART_NAMED}`
+ );
+ });
+
+ it('says a $ref is empty rather than quoting it', () => {
+ reportSourceProblem({ kind: 'unresolvableSpecReference', reference: { file: pet, ref: '' } }, source);
+
+ expect(printed()).to.equal(`'spec/schemas/Pet.json' has an empty '$ref'.\n${PART_NAMED}`);
+ });
+ });
+
describe("a page at an address kept for the portal's own pages", () => {
const content = source.join('content');
diff --git a/test/resources/portal-inputs/default/src/content/authentication.md b/test/resources/portal-inputs/default/src/content/authentication.mdx
similarity index 55%
rename from test/resources/portal-inputs/default/src/content/authentication.md
rename to test/resources/portal-inputs/default/src/content/authentication.mdx
index baa5213a0..14290046b 100644
--- a/test/resources/portal-inputs/default/src/content/authentication.md
+++ b/test/resources/portal-inputs/default/src/content/authentication.mdx
@@ -4,3 +4,5 @@ description: How requests are authorised.
---
Authentication for the fixture.
+
+Spaced out by a class only this page uses.
diff --git a/test/resources/portal-inputs/default/src/content/guides/components.mdx b/test/resources/portal-inputs/default/src/content/guides/components.mdx
new file mode 100644
index 000000000..e069b5136
--- /dev/null
+++ b/test/resources/portal-inputs/default/src/content/guides/components.mdx
@@ -0,0 +1,132 @@
+---
+title: Components
+description: Every component an author can write without importing it.
+---
+
+Inline contents
+
+## Callouts
+
+Default callout body.
+
+Warn callout body.
+
+Error callout body.
+
+Success callout body.
+
+Idea callout body.
+
+
+ Composed title
+ Composed callout body.
+
+
+## Cards
+
+
+
+
+ Plain card body.
+
+
+## Code
+
+```ts title="client.ts" lineNumbers
+const client = new Client(); // [!code highlight]
+```
+
+```python tab="Python"
+print("python tab body")
+```
+
+```ts tab="TypeScript"
+console.log('typescript tab body');
+```
+
+```npm
+npm install calc
+```
+
+
+
+ Composed first
+ Composed second
+
+
+
+```bash
+echo composed-first
+```
+
+
+
+
+```bash
+echo composed-second
+```
+
+
+
+
+## Tabs
+
+
+ First tab body.
+ Second tab body.
+
+
+
+ Left tab body.
+ Right tab body.
+
+
+## Steps
+
+
+ First step body.
+ Second step body.
+
+
+## Accordions
+
+
+ Accordion body.
+ Second accordion body.
+
+
+
+ Multiple one body.
+ Multiple two body.
+
+
+## Files
+
+
+
+
+
+
+
+
+
+
+
+## Type table
+
+
+
+## Include
+
+./intro.md
diff --git a/test/types/file/directory-path.test.ts b/test/types/file/directory-path.test.ts
index 8002e24fa..8419de254 100644
--- a/test/types/file/directory-path.test.ts
+++ b/test/types/file/directory-path.test.ts
@@ -30,6 +30,17 @@ describe('DirectoryPath.relativeTo', () => {
});
});
+describe('DirectoryPath.isOnNetworkShare', () => {
+ it('holds a Windows share, however its slashes are typed, and no local path', function () {
+ if (process.platform !== 'win32') this.skip();
+
+ expect(new DirectoryPath('\\\\server\\share\\api').isOnNetworkShare()).to.equal(true);
+ expect(new DirectoryPath('//server/share/api').isOnNetworkShare()).to.equal(true);
+ expect(new DirectoryPath('C:\\api').isOnNetworkShare()).to.equal(false);
+ expect(new DirectoryPath('\\\\?\\C:\\api').isOnNetworkShare()).to.equal(false);
+ });
+});
+
// What a file the build reads, and a log a reader compares to one, spell a path as on every platform.
describe('DirectoryPath.toPosix', () => {
it('keeps the whole path with forward slashes between its segments', () => {
diff --git a/test/types/portal-source-context.test.ts b/test/types/portal-source-context.test.ts
index b05b2843b..7b55d3e59 100644
--- a/test/types/portal-source-context.test.ts
+++ b/test/types/portal-source-context.test.ts
@@ -2,6 +2,7 @@ import fs from 'fs';
import os from 'os';
import path from 'path';
import { Buffer } from 'node:buffer';
+import { pathToFileURL } from 'node:url';
import Ajv from 'ajv';
import { expect } from 'chai';
import { Result } from 'neverthrow';
@@ -24,6 +25,7 @@ import { FilePath } from '../../src/types/file/filePath';
import { PLUGIN_SECTION, SDK_SECTION } from '../../src/types/portal/generated-pages';
import { TabEntry } from '../../src/types/portal/portal-navigation';
import { ZipService } from '../../src/infrastructure/zip-service';
+import { bundleSpec } from '../../portal-template/src/lib/openapi-bundle.server';
const OPENAPI = JSON.stringify({ openapi: '3.0.0', info: { title: 'Calc', version: '1' }, paths: {} });
@@ -379,6 +381,16 @@ describe('PortalSourceContext', () => {
});
});
+ /** A file the specifications could not be read from: the kind, the file, and the file that refers to it. */
+ const specProblem = (problem: PortalSourceProblem): [string, string, string | null] => {
+ if (problem.kind !== 'unreadableSpec' && problem.kind !== 'missingSpecReference') {
+ throw new Error(`expected a file the specifications are read from, got ${JSON.stringify(problem)}`);
+ }
+ const relative = (file: FilePath) => file.relativeTo(new DirectoryPath(root));
+ const file = problem.kind === 'missingSpecReference' ? problem.missing.file : problem.file;
+ return [problem.kind, relative(file), problem.referencedBy === null ? null : relative(problem.referencedBy)];
+ };
+
describe('resolveSpecs', () => {
const resolveSpecs = () => new PortalSourceContext(new DirectoryPath(root)).resolveSpecs();
@@ -414,6 +426,7 @@ describe('PortalSourceContext', () => {
const source = (await resolve())._unsafeUnwrap();
expect(source.specs.map((spec) => spec.slug)).to.deep.equal(['a', 'b', 'c']);
+ expect(source.specDirectory.toString()).to.equal(path.join(root, 'spec'));
});
it('ignores documents that carry no version key', async () => {
@@ -489,19 +502,75 @@ describe('PortalSourceContext', () => {
]);
});
- /** The document the problem names as unreadable, relative to the source directory. */
- const unreadable = async () => {
+ it('reports a document it cannot parse, with what the parser said', async () => {
+ write('spec/broken.json', '{ not json');
+
+ const problem = (await resolve())._unsafeUnwrapErr();
+
+ expect(specProblem(problem)).to.deep.equal(['unreadableSpec', 'spec/broken.json', null]);
+ expect(
+ problem.kind === 'unreadableSpec' && problem.reason.kind === 'invalid' && problem.reason.message
+ ).to.contain('JSON');
+ });
+
+ // As the portal's bundler reads it: by what it holds.
+ it('reads a document whatever its extension says', async () => {
+ write('spec/api.json', 'openapi: 3.0.0\ninfo:\n title: Calc\n version: "1"\npaths: {}\n');
+
+ expect((await resolve())._unsafeUnwrap().specs.map((spec) => spec.slug)).to.deep.equal(['api']);
+ });
+
+ // The build reads past a file that is no API, and never bundles it.
+ it('skips a file holding nothing, only comments, or no key beside a document', async () => {
+ write('spec/api.json', OPENAPI);
+ write('spec/orders.yaml', '');
+ write('spec/notes.yaml', '# orders: to come\n');
+ write('spec/states.yaml', '- active\n- inactive\n');
+
+ expect((await resolve())._unsafeUnwrap().specs.map((spec) => spec.slug)).to.deep.equal(['api']);
+ });
+
+ // As a document caught empty mid-save is; with nothing else to build from, it is not one to convert.
+ it('reports a document left empty when every document there holds nothing', async () => {
+ write('spec/api.yaml', '');
+ write('spec/notes.yaml', '# to come\n');
+
const problem = (await resolve())._unsafeUnwrapErr();
- if (problem.kind !== 'unreadableSpec') {
- throw new Error(`expected an unreadable document, got '${problem.kind}'`);
+
+ expect(specProblem(problem)).to.deep.equal(['unreadableSpec', 'spec/api.yaml', null]);
+ expect(problem.kind === 'unreadableSpec' && problem.reason).to.deep.equal({ kind: 'empty' });
+ });
+
+ // As an editor that saves by renaming over the file leaves it for a moment.
+ it('reports a document gone since it was listed as one it cannot read, rather than leaving it out', async () => {
+ write('spec/api.json', OPENAPI);
+ sinon.stub(FileService.prototype, 'getContents').rejects(new Error('ENOENT: no such file or directory'));
+ sinon.stub(FileService.prototype, 'fileExists').resolves(false);
+
+ try {
+ const problem = (await new PortalSourceContext(new DirectoryPath(root)).resolveSpecs())._unsafeUnwrapErr();
+
+ expect(specProblem(problem)).to.deep.equal(['unreadableSpec', 'spec/api.json', null]);
+ expect(problem.kind === 'unreadableSpec' && problem.reason).to.deep.equal({ kind: 'gone' });
+ } finally {
+ sinon.restore();
}
- return relative(problem.file);
- };
+ });
- it('reports a document it cannot parse', async () => {
- write('spec/broken.json', '{ not json');
+ it('reports a document it cannot open with what the file system said', async () => {
+ write('spec/api.json', OPENAPI);
+ sinon.stub(FileService.prototype, 'getContents').rejects(new Error('EACCES: permission denied'));
+
+ try {
+ const problem = (await new PortalSourceContext(new DirectoryPath(root)).resolveSpecs())._unsafeUnwrapErr();
- expect(await unreadable()).to.equal('spec/broken.json');
+ expect(problem.kind === 'unreadableSpec' && problem.reason).to.deep.equal({
+ kind: 'unopened',
+ message: 'EACCES: permission denied'
+ });
+ } finally {
+ sinon.restore();
+ }
});
it('reports an empty or absent spec directory', async () => {
@@ -558,8 +627,7 @@ describe('PortalSourceContext', () => {
paths: {
'/health': { get: {} },
'/pets': { $ref: './paths/pets.yaml' },
- '/owners': { $ref: 'shared.json#/items/owners' },
- '/missing': { $ref: './nowhere.yaml' }
+ '/owners': { $ref: 'shared.json#/items/owners' }
}
})
);
@@ -832,7 +900,27 @@ describe('PortalSourceContext', () => {
merging();
write('spec/alpha/openapi.json', '{ not json');
- expect(await unreadable()).to.equal('spec/alpha/openapi.json');
+ expect(specProblem((await resolve())._unsafeUnwrapErr())).to.deep.equal([
+ 'unreadableSpec',
+ 'spec/alpha/openapi.json',
+ null
+ ]);
+ });
+
+ it('skips a file holding nothing beside the definition of a folder, and reports one alone', async () => {
+ merging();
+ write('spec/alpha/openapi.json', OPENAPI);
+ write('spec/alpha/notes.yaml', '');
+
+ expect(await read()).to.deep.equal([['alpha', 'spec/alpha/openapi.json']]);
+
+ fs.rmSync(path.join(root, 'spec', 'alpha', 'openapi.json'));
+
+ expect(specProblem((await resolve())._unsafeUnwrapErr())).to.deep.equal([
+ 'unreadableSpec',
+ 'spec/alpha/notes.yaml',
+ null
+ ]);
});
it('reports a metadata file it cannot parse', async () => {
@@ -903,6 +991,341 @@ describe('PortalSourceContext', () => {
});
});
+ describe('the files a specification refers to', () => {
+ beforeEach(() => writeConfig({ site: { name: 'Calc' } }));
+ afterEach(() => sinon.restore());
+
+ /** `spec/api.json`, whose schemas are the given ones. */
+ const writeSpec = (schemas: Record, extra: Record = {}) =>
+ write(
+ 'spec/api.json',
+ JSON.stringify({
+ openapi: '3.0.0',
+ info: { title: 'Calc', version: '1' },
+ paths: {},
+ components: { schemas },
+ ...extra
+ })
+ );
+
+ it('refuses a file a specification refers to that is not there, naming the file that refers to it', async () => {
+ writeSpec({ Pet: { $ref: './schemas/Pet.yaml' } });
+
+ expect(specProblem((await resolve())._unsafeUnwrapErr())).to.deep.equal([
+ 'missingSpecReference',
+ 'spec/schemas/Pet.yaml',
+ 'spec/api.json'
+ ]);
+ });
+
+ // As a file system that matches case, which the build may run on, would miss it.
+ it('names the file a missing reference is spelt as on disk, in another case', async () => {
+ writeSpec({ Pet: { $ref: './schemas/pet.yaml' } });
+ write('spec/schemas/Pet.yaml', 'type: object\n');
+ const named = sinon.match((file: FilePath) => file.toString() === path.join(root, 'spec', 'schemas', 'pet.yaml'));
+ sinon
+ .stub(FileService.prototype, 'getContents')
+ .callThrough()
+ .withArgs(named)
+ .rejects(new Error('ENOENT: no such file or directory'));
+ sinon.stub(FileService.prototype, 'fileExists').callThrough().withArgs(named).resolves(false);
+
+ const problem = (await resolve())._unsafeUnwrapErr();
+
+ expect(specProblem(problem)).to.deep.equal(['missingSpecReference', 'spec/schemas/pet.yaml', 'spec/api.json']);
+ expect(
+ problem.kind === 'missingSpecReference' && problem.missing.foundAs?.relativeTo(new DirectoryPath(root))
+ ).to.equal('spec/schemas/Pet.yaml');
+ });
+
+ // Read here on a file system that ignores case, as Windows and macOS do; a build on Linux would miss it.
+ it('refuses a file or a folder named in another case than on disk, wherever it reads', async () => {
+ write('spec/schemas/Pet.yaml', 'type: object\n');
+ const foundAs = async () => {
+ const problem = (await resolve())._unsafeUnwrapErr();
+ expect(specProblem(problem)[0]).to.equal('missingSpecReference');
+ return problem.kind === 'missingSpecReference'
+ ? problem.missing.foundAs?.relativeTo(new DirectoryPath(root))
+ : null;
+ };
+
+ writeSpec({ Pet: { $ref: './schemas/pet.yaml' } });
+ expect(await foundAs()).to.equal('spec/schemas/Pet.yaml');
+
+ writeSpec({ Pet: { $ref: './Schemas/Pet.yaml' } });
+ expect(await foundAs()).to.equal('spec/schemas/Pet.yaml');
+ });
+
+ // As a name stored in another Unicode form, or a folder that cannot be listed, is.
+ it('accepts a file it reads but finds in no listing, rather than calling it misspelt', async () => {
+ writeSpec({ Pet: { $ref: './schemas/Pet.yaml' } });
+ write('spec/schemas/Pet.yaml', 'type: object\n');
+ sinon
+ .stub(FileService.prototype, 'spellingsOnDisk')
+ .callsFake(async (_root: DirectoryPath, files: FilePath[]) => files.map(() => null));
+
+ expect((await resolve()).isOk()).to.be.true;
+ });
+
+ // Its folders above the source directory are where the build runs, not the user's to spell.
+ it('accepts a file outside the source directory', async () => {
+ const outside = fs.mkdtempSync(path.join(os.tmpdir(), 'portal-shared-'));
+ try {
+ fs.writeFileSync(path.join(outside, 'Pet.yaml'), 'type: object\n');
+ writeSpec({ Pet: { $ref: path.relative(path.join(root, 'spec'), path.join(outside, 'Pet.yaml')) } });
+
+ expect((await resolve()).isOk()).to.be.true;
+ } finally {
+ fs.rmSync(outside, { recursive: true, force: true });
+ }
+ });
+
+ it('refuses a file referred to in turn that does not parse, naming the file that refers to it', async () => {
+ writeSpec({ Pet: { $ref: './schemas/Pet.yaml' } });
+ write('spec/schemas/Pet.yaml', 'type: object\nproperties:\n owner:\n $ref: ./Owner.yaml\n');
+ write('spec/schemas/Owner.yaml', 'type: object\nrequired: [name\n');
+
+ const problem = (await resolve())._unsafeUnwrapErr();
+
+ expect(specProblem(problem)).to.deep.equal([
+ 'unreadableSpec',
+ 'spec/schemas/Owner.yaml',
+ 'spec/schemas/Pet.yaml'
+ ]);
+ expect(problem.kind === 'unreadableSpec' && problem.reason.kind === 'invalid' && problem.reason.message).to.match(
+ /line \d+, column \d+$/
+ );
+ });
+
+ it('says a reference that names a folder names one', async () => {
+ writeSpec({ Pet: { $ref: './schemas/' } });
+ write('spec/schemas/Pet.yaml', 'type: object\n');
+
+ const problem = (await resolve())._unsafeUnwrapErr();
+
+ expect(specProblem(problem)).to.deep.equal(['unreadableSpec', 'spec/schemas', 'spec/api.json']);
+ expect(problem.kind === 'unreadableSpec' && problem.reason).to.deep.equal({ kind: 'folder' });
+ });
+
+ it('refuses an empty reference, naming the file it is written in', async () => {
+ writeSpec({ Pet: { $ref: './schemas/Pet.yaml' } });
+ write('spec/schemas/Pet.yaml', 'properties:\n owner:\n $ref: ""\n');
+
+ const problem = (await resolve())._unsafeUnwrapErr();
+ if (problem.kind !== 'unresolvableSpecReference') {
+ expect.fail(`expected a $ref the build cannot resolve, got '${problem.kind}'`);
+ }
+
+ const { file, ref } = problem.reference;
+ expect([file.relativeTo(new DirectoryPath(root)), ref]).to.deep.equal(['spec/schemas/Pet.yaml', '']);
+ });
+
+ it('follows a reference out of the spec directory, as the build does', async () => {
+ writeSpec({ Pet: { $ref: '../shared/Pet.yaml' } });
+
+ expect(specProblem((await resolve())._unsafeUnwrapErr())).to.deep.equal([
+ 'missingSpecReference',
+ 'shared/Pet.yaml',
+ 'spec/api.json'
+ ]);
+
+ write('shared/Pet.yaml', 'type: object\n');
+ expect((await resolve()).isOk()).to.be.true;
+ });
+
+ it('reads each file once, however often it is referred to, and ends at a cycle', async () => {
+ writeSpec({ Pet: { $ref: './schemas/Pet.yaml' }, Pets: { items: { $ref: './schemas/Pet.yaml#/Pet' } } });
+ write('spec/schemas/Pet.yaml', 'Pet:\n properties:\n owner:\n $ref: ./Owner.yaml\n');
+ write('spec/schemas/Owner.yaml', 'properties:\n pets:\n $ref: ./Pet.yaml\n');
+ const reads = sinon.spy(FileService.prototype, 'getContents');
+
+ expect((await resolve()).isOk()).to.be.true;
+ const specFiles = reads.args
+ .map(([file]) => file.relativeTo(new DirectoryPath(root)))
+ .filter((file) => file.startsWith('spec/'));
+ expect(specFiles.sort()).to.deep.equal(['spec/api.json', 'spec/schemas/Owner.yaml', 'spec/schemas/Pet.yaml']);
+ });
+
+ // Held to the bundler itself, since a file refused here would otherwise have built.
+ describe('as the portal bundles it', () => {
+ beforeEach(() => sinon.stub(globalThis.console, 'warn'));
+
+ const bundles = () =>
+ bundleSpec(path.join(root, 'spec', 'api.json')).then(
+ () => true,
+ () => false
+ );
+
+ /** `spec/api.json`, whose schema `Pet` is the file `schemas/` holding `contents`. */
+ const writeReferenced = (name: string, contents: string) => {
+ writeSpec({ Pet: { $ref: `./schemas/${name}` } });
+ write(`spec/schemas/${name}`, contents);
+ };
+
+ // `empty`: the bundler builds, with the reference pointing at nothing, so the page renders without it.
+ const BUNDLED = {
+ builds: 'as the bundler builds it',
+ fails: 'as the bundler fails on it',
+ empty: 'which the bundler reads as nothing'
+ };
+
+ const cases: [string, () => void, keyof typeof BUNDLED][] = [
+ ['a file that is there', () => writeReferenced('Pet.yaml', 'type: object\n'), 'builds'],
+ ['a file that is not there', () => writeSpec({ Pet: { $ref: './schemas/Pet.yaml' } }), 'fails'],
+ ['a file of half-written YAML', () => writeReferenced('Pet.yaml', 'type: object\nrequired: [name\n'), 'fails'],
+ ['a file of half-written JSON', () => writeReferenced('Pet.json', '{ "type": "obj'), 'empty'],
+ ['a JSON file holding YAML', () => writeReferenced('Pet.json', 'type: object\n'), 'builds'],
+ ['a YAML file that opens as JSON does', () => writeReferenced('Pet.yaml', '{ type: object }\n'), 'empty'],
+ [
+ 'a YAML file that opens as JSON does only past its first 50 characters',
+ () => writeReferenced('Pet.yaml', `${' '.repeat(50)}{ type: object }\n`),
+ 'builds'
+ ],
+ ['a file left empty', () => writeReferenced('Pet.yaml', ''), 'empty'],
+ ['a file holding only a comment', () => writeReferenced('Pet.yaml', '# type: object\n'), 'empty'],
+ [
+ 'a file named in a file that is a list',
+ () => {
+ writeSpec({ Pet: { allOf: { $ref: './schemas/parts.yaml' } } });
+ write('spec/schemas/parts.yaml', '- $ref: ./Base.yaml\n');
+ },
+ 'fails'
+ ],
+ [
+ 'a file named in example data',
+ () => writeSpec({ Pet: { type: 'object', example: { $ref: './examples/pet.json' } } }),
+ 'fails'
+ ],
+ [
+ 'a file named beside a reference, which is not read',
+ () => {
+ writeSpec({ Pet: { $ref: './Pet.yaml', properties: { owner: { $ref: './Owner.yaml' } } } });
+ write('spec/Pet.yaml', 'type: object\n');
+ },
+ 'builds'
+ ],
+ [
+ 'two files that refer to each other',
+ () => {
+ writeSpec({ Pet: { $ref: './Pet.yaml' } });
+ write('spec/Pet.yaml', 'properties:\n owner:\n $ref: ./Owner.yaml\n');
+ write('spec/Owner.yaml', 'properties:\n pet:\n $ref: ./Pet.yaml\n');
+ },
+ 'builds'
+ ],
+ [
+ 'a file named from the folder of the file that refers to it',
+ () => {
+ writeReferenced('Pet.yaml', 'properties:\n owner:\n $ref: ./Owner.yaml\n');
+ write('spec/schemas/Owner.yaml', 'type: object\n');
+ },
+ 'builds'
+ ],
+ [
+ 'a file named in a part of a file that no reference points into',
+ () => {
+ writeSpec({ Pet: { $ref: './schemas/models.yaml#/Pet' } });
+ write('spec/schemas/models.yaml', 'Pet:\n type: object\nOwner:\n $ref: ./Owner.yaml\n');
+ },
+ 'fails'
+ ],
+ [
+ 'a file outside the spec directory',
+ () => {
+ writeSpec({ Pet: { $ref: '../shared/Pet.yaml' } });
+ write('shared/Pet.yaml', 'type: object\n');
+ },
+ 'builds'
+ ],
+ [
+ 'a name written with a percent escape, which is not decoded',
+ () => {
+ writeSpec({ Pet: { $ref: './my%20pet.yaml' } });
+ write('spec/my pet.yaml', 'type: object\n');
+ },
+ 'fails'
+ ],
+ [
+ 'a schema named by its $id',
+ () => writeSpec({ Pet: { $id: 'pet.json', type: 'object' }, Pets: { items: { $ref: 'pet.json' } } }),
+ 'builds'
+ ],
+ [
+ 'a schema named by its $id from another file',
+ () => {
+ writeSpec({ Pet: { $id: 'pet.json', type: 'object' }, Owner: { $ref: './schemas/Owner.yaml' } });
+ write('spec/schemas/Owner.yaml', 'properties:\n pet:\n $ref: pet.json\n');
+ },
+ 'builds'
+ ],
+ [
+ 'a file named only under the keys the bundler writes',
+ () => writeSpec({}, { 'x-ext': { abc123: { $ref: './embedded.yaml' } } }),
+ 'builds'
+ ],
+ ['an empty reference', () => writeSpec({ Pet: { $ref: '' } }), 'fails'],
+ [
+ 'an empty reference in a file named',
+ () => writeReferenced('Pet.yaml', 'properties:\n owner:\n $ref: ""\n'),
+ 'fails'
+ ],
+ ['a bare # in the document bundled', () => writeSpec({ Pet: { $ref: '#' } }), 'fails'],
+ [
+ 'a schema named by its $anchor in the document bundled',
+ () => writeSpec({ Pet: { $anchor: 'pet', type: 'object' }, Pets: { items: { $ref: '#pet' } } }),
+ 'fails'
+ ],
+ [
+ 'a bare # or an anchor in a file named, which the bundler turns into pointers',
+ () => writeReferenced('Pet.yaml', 'properties:\n self:\n $ref: "#"\n tag:\n $ref: "#tag"\n'),
+ 'builds'
+ ],
+ [
+ 'a file named by a file: URL, which the bundler reads as a path',
+ () => {
+ writeSpec({ Pet: { $ref: pathToFileURL(path.join(root, 'spec', 'Pet.yaml')).href } });
+ write('spec/Pet.yaml', 'type: object\n');
+ },
+ 'fails'
+ ],
+ ['a name with another scheme', () => writeSpec({ Pet: { $ref: 'urn:pet' } }), 'fails'],
+ [
+ 'a folder',
+ () => {
+ writeSpec({ Pet: { $ref: './schemas' } });
+ write('spec/schemas/Pet.yaml', 'type: object\n');
+ },
+ 'fails'
+ ],
+ [
+ 'a part of a file that is not there, which the page renders without',
+ () => {
+ writeSpec({ Pet: { $ref: './Pet.yaml#/nope' } });
+ write('spec/Pet.yaml', 'type: object\n');
+ },
+ 'builds'
+ ]
+ ];
+
+ cases.forEach(([label, arrange, bundled]) => {
+ it(`${bundled === 'builds' ? 'accepts' : 'refuses'} ${label}, ${BUNDLED[bundled]}`, async () => {
+ arrange();
+
+ expect(await bundles(), 'the bundler').to.equal(bundled !== 'fails');
+ expect((await resolve()).isOk(), 'the check').to.equal(bundled === 'builds');
+ });
+ });
+
+ // Resolved against the $id, which names no folder of the specification, so the check leaves it to the build.
+ it('accepts a file named below an $id, which the bundler fails on', async () => {
+ writeSpec({ Pet: { $id: 'pet.json', properties: { tag: { $ref: './tag.yaml' } } } });
+
+ expect(await bundles(), 'the bundler').to.be.false;
+ expect((await resolve()).isOk(), 'the check').to.be.true;
+ });
+ });
+ });
+
describe('optional directories', () => {
beforeEach(() => {
writeConfig({ site: { name: 'Calc' } });
diff --git a/test/types/portal/openapi-document.test.ts b/test/types/portal/openapi-document.test.ts
index 1ad05d94a..f259fb78e 100644
--- a/test/types/portal/openapi-document.test.ts
+++ b/test/types/portal/openapi-document.test.ts
@@ -1,32 +1,48 @@
import { expect } from 'chai';
import path from 'path';
import { OpenApiDocument, SpecFormat } from '../../../src/types/portal/openapi-document';
-import { FileName } from '../../../src/types/file/fileName';
import { DirectoryPath } from '../../../src/types/file/directoryPath';
-const JSON_FILE = new FileName('spec.json');
-const YAML_FILE = new FileName('spec.yaml');
const BYTE_ORDER_MARK = '';
describe('OpenApiDocument', () => {
- const read = (contents: string, fileName = JSON_FILE): OpenApiDocument => {
- const document = OpenApiDocument.parse(fileName, contents);
- expect(document, `unparsable: ${contents}`).to.not.be.undefined;
- return document as OpenApiDocument;
+ const read = (contents: string): OpenApiDocument => {
+ const document = OpenApiDocument.parse(contents);
+ expect(document.isOk(), `unparsable: ${contents}`).to.be.true;
+ return document._unsafeUnwrap();
};
const readJson = (document: unknown, prefix = '') => read(prefix + JSON.stringify(document));
const withInfo = (info: Record) => readJson({ openapi: '3.0.0', info, paths: {} });
+ const refused = (contents: string) => OpenApiDocument.parse(contents)._unsafeUnwrapErr();
describe('parse', () => {
- it('reads JSON by the .json extension and YAML by any other', () => {
+ it('reads JSON, and YAML, by what the text holds', () => {
expect(readJson({ openapi: '3.1.0' }).format()).to.deep.equal({ supported: true });
- expect(read('openapi: 3.1.0\n', YAML_FILE).format()).to.deep.equal({ supported: true });
+ expect(read('openapi: 3.1.0\n').format()).to.deep.equal({ supported: true });
});
- it('is undefined for text neither parser accepts', () => {
- expect(OpenApiDocument.parse(JSON_FILE, '{ not json')).to.be.undefined;
- expect(OpenApiDocument.parse(YAML_FILE, 'key: [unclosed')).to.be.undefined;
+ // Matched loosely: the words are the parser's own, and change with its version.
+ it('fails with what the parser said, on one line, saying where it stopped', () => {
+ const said = (contents: string) => {
+ const failure = refused(contents);
+ return failure.kind === 'invalid' ? failure.message : `not invalid but ${failure.kind}`;
+ };
+ const yaml = said('openapi: 3.0.0\ninfo:\n title: [unclosed\n');
+ const json = said('{\n "a": 1\n "b": 2\n}');
+ const quoting = said('[1,\n2,\n,3]');
+
+ expect(yaml).to.match(/at line 4, column 1$/);
+ expect(json).to.contain('line 3 column 3');
+ [yaml, json, quoting].forEach((message) => expect(message).to.not.contain('\n'));
+ });
+
+ // The portal's bundler reads these as nothing, so a reference to one would be empty.
+ it('fails on text the bundler reads as nothing, saying which kind it is', () => {
+ expect(refused(' \n')).to.deep.equal({ kind: 'empty' });
+ expect(refused('# only a comment: here\n')).to.deep.equal({ kind: 'noValue' });
+ expect(refused('{ type: object }').kind).to.equal('invalid');
+ expect(refused('- active\n- inactive\n')).to.deep.equal({ kind: 'noKey' });
});
it('treats a document that is not an object as no specification', () => {
@@ -36,7 +52,7 @@ describe('OpenApiDocument', () => {
it('reads a document written with a byte-order mark', () => {
expect(readJson({ openapi: '3.0.0' }, BYTE_ORDER_MARK).format()).to.deep.equal({ supported: true });
- expect(read(`${BYTE_ORDER_MARK}openapi: 3.0.0\n`, YAML_FILE).format()).to.deep.equal({ supported: true });
+ expect(read(`${BYTE_ORDER_MARK}openapi: 3.0.0\n`).format()).to.deep.equal({ supported: true });
});
});
@@ -157,7 +173,7 @@ describe('OpenApiDocument', () => {
...aliases
].join('\n');
- expect(read(yaml, YAML_FILE).endpoints().map(String)).to.deep.equal(['GET /merged']);
+ expect(read(yaml).endpoints().map(String)).to.deep.equal(['GET /merged']);
});
it('lists the inline operations of every path, leaving a path item in another file to its reference', () => {
@@ -217,6 +233,114 @@ describe('OpenApiDocument', () => {
});
});
+ describe('schemaIds', () => {
+ it('names each $id the document declares, however deep', () => {
+ const document = readJson({ Pet: { $id: 'pet.json', properties: { tag: { $id: 'tag.json' } } } });
+
+ expect([...document.schemaIds()]).to.deep.equal(['pet.json', 'tag.json']);
+ });
+ });
+
+ describe('references', () => {
+ const specDirectory = new DirectoryPath('/project/src/spec');
+ const referenced = (document: unknown, rootIds = new Set()) =>
+ readJson(document).references(specDirectory, rootIds).files.map(String);
+ const inSpec = (file: string) => path.resolve('/project/src/spec', file);
+
+ it('finds a $ref on any object, in data, extensions and lists too, against the folder of the document', () => {
+ const files = referenced({
+ openapi: '3.0.0',
+ paths: { '/pets': { $ref: './paths/pets.yaml' } },
+ components: {
+ schemas: { Pet: { allOf: [{ $ref: 'schemas/Base.yaml' }] } },
+ examples: { Pet: { value: { $ref: 'examples/pet.json' } } }
+ },
+ 'x-shared': { $ref: '../shared/owners.yaml#/Owner' }
+ });
+
+ expect(files).to.deep.equal([
+ inSpec('paths/pets.yaml'),
+ inSpec('schemas/Base.yaml'),
+ inSpec('examples/pet.json'),
+ path.resolve('/project/src/shared/owners.yaml')
+ ]);
+ });
+
+ it('reads no further into an object with a $ref, as the bundler does not', () => {
+ const files = referenced({ Pet: { $ref: './pet.yaml', properties: { owner: { $ref: './owner.yaml' } } } });
+
+ expect(files).to.deep.equal([inSpec('pet.yaml')]);
+ });
+
+ it('leaves out references into the document, URLs, and what the bundler has embedded', () => {
+ const files = referenced({
+ local: { $ref: '#/components/schemas/Pet' },
+ remote: { $ref: 'https://example.com/pet.yaml' },
+ 'x-ext': { abc123: { $ref: './embedded.yaml' } },
+ 'x-ext-urls': { abc123: { $ref: './mapped.yaml' } }
+ });
+
+ expect(files).to.be.empty;
+ });
+
+ // The bundler fetches only these, and reads `C:/shared/pet.yaml` as a path, as it does any other scheme.
+ it('takes only http and https for URLs, and any other scheme for the name of a file', () => {
+ const files = referenced({
+ a: { $ref: 'file:///shared/pet.yaml' },
+ b: { $ref: 'urn:pet' },
+ c: { $ref: 'ab:c.yaml' },
+ d: { $ref: 'http://example.com/pet.yaml' }
+ });
+
+ expect(files).to.deep.equal([inSpec('file:///shared/pet.yaml'), inSpec('urn:pet'), inSpec('ab:c.yaml')]);
+ });
+
+ it('sets apart, as written, each $ref that names no file and no part of a document by a pointer', () => {
+ const { files, unnamed } = readJson({
+ empty: { $ref: '' },
+ whole: { $ref: '#' },
+ anchor: { $ref: '#pet' },
+ pointer: { $ref: '#/components/schemas/Pet' },
+ root: { $ref: '#/' }
+ }).references(specDirectory, new Set());
+
+ expect(files).to.be.empty;
+ expect(unnamed).to.deep.equal(['', '#', '#pet']);
+ });
+
+ it('reads no further below an $id, against which the bundler resolves what is under it', () => {
+ expect(referenced({ Pet: { $id: 'pet.json', properties: { tag: { $ref: './tag.yaml' } } } })).to.be.empty;
+ });
+
+ // The bundler takes the $ids of the document it starts from, and only those, as names of schemas.
+ it('leaves out a reference naming an $id of the root document, as a schema rather than a file', () => {
+ const files = referenced(
+ { owner: { $ref: 'pet.json#/properties/tag' }, tag: { $ref: './tag.yaml' } },
+ new Set(['pet.json'])
+ );
+
+ expect(files).to.deep.equal([inSpec('tag.yaml')]);
+ });
+
+ it('follows a reference in a document that is a list', () => {
+ expect(referenced([{ name: 'limit' }, { $ref: './parameters/offset.yaml' }])).to.deep.equal([
+ inSpec('parameters/offset.yaml')
+ ]);
+ });
+
+ it('reads a backslash as a slash, and leaves a percent escape as written', () => {
+ const files = referenced({ a: { $ref: '.\\schemas\\Pet.yaml' }, b: { $ref: './my%20pet.yaml' } });
+
+ expect(files).to.deep.equal([inSpec('schemas/Pet.yaml'), inSpec('my%20pet.yaml')]);
+ });
+
+ it('survives an alias that holds itself', () => {
+ const document = read('openapi: 3.0.0\nloop: &loop\n again: *loop\n next: { $ref: ./next.yaml }\n');
+
+ expect(document.references(specDirectory, new Set()).files.map(String)).to.deep.equal([inSpec('next.yaml')]);
+ });
+ });
+
describe('endpointsAt', () => {
it('names the operations of the path item a pointer locates after the path it is mounted at', () => {
const file = readJson({ get: {}, 'x-library': { owners: { post: {} } } });
diff --git a/test/types/portal/preview-config.test.ts b/test/types/portal/preview-config.test.ts
index a0b2eb771..e87a87ff9 100644
--- a/test/types/portal/preview-config.test.ts
+++ b/test/types/portal/preview-config.test.ts
@@ -41,5 +41,13 @@ describe('PreviewConfig', () => {
expect(preview.show(named('Pets'), false)).to.equal(true);
expect(preview.show(named('Pets'), false)).to.equal(false);
});
+
+ it('takes a refusal as fixed by a save that also wrote something, and not again after it', () => {
+ const preview = new PreviewConfig(named('Pets'), false);
+ preview.refuse();
+
+ expect(preview.show(named('Cats'), true)).to.equal(true);
+ expect(preview.show(named('Cats'), false)).to.equal(false);
+ });
});
});
diff --git a/test/types/portal/preview-refusal.test.ts b/test/types/portal/preview-refusal.test.ts
new file mode 100644
index 000000000..7ad277b10
--- /dev/null
+++ b/test/types/portal/preview-refusal.test.ts
@@ -0,0 +1,15 @@
+import { expect } from 'chai';
+import { PreviewRefusal } from '../../../src/types/portal/preview-refusal';
+
+describe('PreviewRefusal', () => {
+ it('is fixed by the first save accepted after a refusal, and by no other', () => {
+ const refusal = new PreviewRefusal();
+ expect(refusal.accept()).to.be.false;
+
+ refusal.refuse();
+ refusal.refuse();
+
+ expect(refusal.accept()).to.be.true;
+ expect(refusal.accept()).to.be.false;
+ });
+});
diff --git a/test/types/portal/preview-specs.test.ts b/test/types/portal/preview-specs.test.ts
new file mode 100644
index 000000000..63cf8a02b
--- /dev/null
+++ b/test/types/portal/preview-specs.test.ts
@@ -0,0 +1,62 @@
+import { expect } from 'chai';
+import { DirectoryPath } from '../../../src/types/file/directoryPath';
+import { FileName } from '../../../src/types/file/fileName';
+import { FilePath } from '../../../src/types/file/filePath';
+import { PortalSpec } from '../../../src/types/portal/portal-source';
+import { PreviewSpecs } from '../../../src/types/portal/preview-specs';
+
+describe('PreviewSpecs', () => {
+ const spec = new DirectoryPath('src', 'spec');
+ const document = (name: string): PortalSpec => ({
+ slug: name,
+ file: new FilePath(spec, new FileName(name)),
+ endpoints: []
+ });
+ const named = (files: FilePath[]) => files.map((file) => file.name().toString());
+
+ /** The change each save warns of, by the names added and removed, for a preview started with `startup`. */
+ const warnings = (startup: string[], saves: string[][]) => {
+ const preview = new PreviewSpecs(startup.map(document));
+ return saves.map((names) => {
+ const { change } = preview.show(names.map(document));
+ return change === null ? null : { added: named(change.added), removed: named(change.removed) };
+ });
+ };
+
+ it('warns of nothing while the documents are the ones it started with', () => {
+ expect(warnings(['pets.json'], [['pets.json'], ['pets.json']])).to.deep.equal([null, null]);
+ });
+
+ it('warns of a document added or removed since it started, on the save that does it and not after', () => {
+ expect(
+ warnings(['pets.json'], [['orders.json', 'pets.json'], ['orders.json', 'pets.json'], ['orders.json']])
+ ).to.deep.equal([
+ { added: ['orders.json'], removed: [] },
+ null,
+ { added: ['orders.json'], removed: ['pets.json'] }
+ ]);
+ });
+
+ // The restart it was told it needs is no longer needed.
+ it('says once that nothing differs on a return to the documents it started with, and warns of the change made anew', () => {
+ expect(
+ warnings(
+ ['pets.json'],
+ [['orders.json', 'pets.json'], ['pets.json'], ['pets.json'], ['orders.json', 'pets.json']]
+ )
+ ).to.deep.equal([
+ { added: ['orders.json'], removed: [] },
+ { added: [], removed: [] },
+ null,
+ { added: ['orders.json'], removed: [] }
+ ]);
+ });
+
+ it('says the specifications are fixed once, on the first save it accepts after refusing one', () => {
+ const preview = new PreviewSpecs([document('pets.json')]);
+ preview.refuse();
+
+ expect(preview.show([document('pets.json')]).fixed).to.be.true;
+ expect(preview.show([document('pets.json')]).fixed).to.be.false;
+ });
+});
diff --git a/test/types/portal/spec-bundle.test.ts b/test/types/portal/spec-bundle.test.ts
new file mode 100644
index 000000000..fdb724994
--- /dev/null
+++ b/test/types/portal/spec-bundle.test.ts
@@ -0,0 +1,77 @@
+import { expect } from 'chai';
+import path from 'path';
+import { DirectoryPath } from '../../../src/types/file/directoryPath';
+import { FilePath } from '../../../src/types/file/filePath';
+import { OpenApiDocument } from '../../../src/types/portal/openapi-document';
+import { SpecBundle } from '../../../src/types/portal/spec-bundle';
+
+describe('SpecBundle', () => {
+ const spec = new DirectoryPath(path.resolve('/project/src/spec'));
+ const at = (name: string) => FilePath.resolve(spec, name);
+ const parsed = (value: unknown) => OpenApiDocument.parse(JSON.stringify(value))._unsafeUnwrap();
+
+ /** Each file still to read, and the file that names it. */
+ const unread = (bundle: SpecBundle) =>
+ bundle
+ .unread()
+ .map(({ file, referencedBy }) => `${file.relativeTo(spec)}, named in ${referencedBy.relativeTo(spec)}`);
+
+ it('lists each file named and not yet read once, with the first file to name it, and ends at a cycle', () => {
+ const bundle = new SpecBundle(
+ at('api.json'),
+ parsed({ Pet: { $ref: './Pet.json' }, Pets: { items: { $ref: './Pet.json#/Pet' } } })
+ );
+ expect(unread(bundle)).to.deep.equal(['Pet.json, named in api.json']);
+
+ bundle.add(at('Pet.json'), parsed({ owner: { $ref: './Owner.json' }, root: { $ref: './api.json' } }));
+ expect(unread(bundle)).to.deep.equal(['Owner.json, named in Pet.json']);
+
+ bundle.add(at('Owner.json'), parsed({ pet: { $ref: './Pet.json' } }));
+ expect(unread(bundle)).to.be.empty;
+ });
+
+ // The bundler turns `#` and `#pet` in a file it embeds into pointers to where it put the file.
+ it('keeps each $ref the bundler fails on: an empty one anywhere, and a bare # or an anchor in the root only', () => {
+ const bundle = new SpecBundle(
+ at('api.json'),
+ parsed({ whole: { $ref: '#' }, anchor: { $ref: '#pet' }, Pet: { $ref: './Pet.json' } })
+ );
+ bundle.add(at('Pet.json'), parsed({ whole: { $ref: '#' }, anchor: { $ref: '#pet' }, empty: { $ref: '' } }));
+
+ expect(bundle.unresolvable().map(({ file, ref }) => [file.relativeTo(spec), ref])).to.deep.equal([
+ ['api.json', '#'],
+ ['api.json', '#pet'],
+ ['Pet.json', '']
+ ]);
+ });
+
+ it('names what a file refers to from its own folder', () => {
+ const bundle = new SpecBundle(at('api.json'), parsed({ Pet: { $ref: './schemas/Pet.json' } }));
+
+ bundle.add(at('schemas/Pet.json'), parsed({ owner: { $ref: './Owner.json' } }));
+
+ expect(unread(bundle)).to.deep.equal(['schemas/Owner.json, named in schemas/Pet.json']);
+ });
+
+ it('lists the operations of a path item kept in another file after its own', () => {
+ const bundle = new SpecBundle(
+ at('api.json'),
+ parsed({ openapi: '3.0.0', paths: { '/pets': { $ref: './paths.json#/pets' }, '/health': { get: {} } } })
+ );
+
+ bundle.add(at('paths.json'), parsed({ pets: { get: {}, post: {} } }));
+
+ expect(bundle.endpoints().map(String)).to.deep.equal(['GET /health', 'GET /pets', 'POST /pets']);
+ });
+
+ it('lists once an operation declared both beside the $ref of a path item and in the file it refers to', () => {
+ const bundle = new SpecBundle(
+ at('api.json'),
+ parsed({ openapi: '3.0.0', paths: { '/pets': { $ref: './paths.json#/pets', get: {} } } })
+ );
+
+ bundle.add(at('paths.json'), parsed({ pets: { get: {}, post: {} } }));
+
+ expect(bundle.endpoints().map(String)).to.deep.equal(['GET /pets', 'POST /pets']);
+ });
+});