diff --git a/README.md b/README.md index f04fca6e0..063fd949b 100644 --- a/README.md +++ b/README.md @@ -349,8 +349,8 @@ DESCRIPTION Preview your API Documentation Portal with live reload. Serves the portal described by 'src/apimatic.json' from your machine, reloading the browser as you edit the Markdown - pages in 'src/content', reorder them in a 'nav.json', or change the 'portal', 'languages' or 'plugin' block of - 'apimatic.json'. + pages in 'src/content', reorder them in a 'nav.json', edit the documents in 'src/spec', or change the 'portal', + 'languages' or 'plugin' block of 'apimatic.json'. Adding a language or a 'plugin' block, adding or removing a page in 'src/content', creating 'src/static', changing which documents are in 'src/spec' or which folders its 'APIMATIC-META.json' merges, or changing the path in diff --git a/portal-template/src/lib/openapi.server.ts b/portal-template/src/lib/openapi.server.ts index 3e76ac18e..cc300c404 100644 --- a/portal-template/src/lib/openapi.server.ts +++ b/portal-template/src/lib/openapi.server.ts @@ -11,6 +11,14 @@ export type OpenApiSource = StaticSource<{ pageData: OpenAPIPageData; metaData: * Markdown page. */ export async function openApiSource(): Promise { - const sections = await Promise.all(Object.entries(specs).map(([slug, file]) => openApiSection(slug, file, codeSamplesFile))); + const loading = Object.entries(specs).map(([slug, file]) => openApiSection(slug, file, codeSamplesFile)); + // Under `vite dev` a document saved half written would fail every page; a build must still fail on it. + const sections = import.meta.env.DEV + ? (await Promise.allSettled(loading)).flatMap((result) => { + if (result.status === 'fulfilled') return [result.value]; + console.error('[OpenAPI] Leaving its reference pages out of the preview:', result.reason); + return []; + }) + : await Promise.all(loading); return { files: sections.flatMap((section) => section.files) }; } diff --git a/portal-template/vite.config.ts b/portal-template/vite.config.ts index 821955faf..57b4643cd 100644 --- a/portal-template/vite.config.ts +++ b/portal-template/vite.config.ts @@ -14,9 +14,10 @@ import { remarkImageReferences } from './src/lib/remark-image-references.ts'; import { staticFunctionsBase } from './static-functions-base.ts'; import { staticImages } from './static-images.ts'; -export default defineConfig(async () => { +export default defineConfig(async ({ command, isPreview }) => { const [paths, identity] = await Promise.all([readBuildPaths(), readPortalIdentity()]); - const pages = await prerenderPages(paths, identity.siteUrl); + // `vite dev` prerenders nothing, and listing the pages bundles every spec: one that fails would stop it starting. + const pages = command === 'serve' && !isPreview ? [] : await prerenderPages(paths, identity.siteUrl); const publicDir: string | false = paths.staticDir ?? false; const portalProjectDirectory = fileURLToPath(new URL('.', import.meta.url)); diff --git a/src/actions/action-result.ts b/src/actions/action-result.ts index 404bc5cb2..8f9df03cf 100644 --- a/src/actions/action-result.ts +++ b/src/actions/action-result.ts @@ -1,7 +1,7 @@ enum ResultType { Success = 0, Cancel = 130, - Failure = 1, + Failure = 1 } export class ActionResult { @@ -18,18 +18,18 @@ export class ActionResult { } static success(value?: T): ActionResult { - return new ActionResult(ResultType.Success, "Succeeded", value); + return new ActionResult(ResultType.Success, 'Succeeded', value); } - static failed(message = "Failed", error?: E): ActionResult { + static failed(message = 'Failed', error?: E): ActionResult { return new ActionResult(ResultType.Failure, message, undefined, error); } - static cancelled(message = "Cancelled"): ActionResult { + static cancelled(message = 'Cancelled'): ActionResult { return new ActionResult(ResultType.Cancel, message); } - static stopped(message = "Stopped"): ActionResult { + static stopped(message = 'Stopped'): ActionResult { return new ActionResult(ResultType.Cancel, message); } @@ -68,11 +68,7 @@ export class ActionResult { return new ActionResult(this.resultType, this.message); } - public mapAll( - onSuccess: (value?: T) => R, - onFailure: () => R, - onCancel: () => R - ): R { + public mapAll(onSuccess: (value?: T) => R, onFailure: () => R, onCancel: () => R): R { switch (this.resultType) { case ResultType.Success: return onSuccess(this.value); @@ -82,4 +78,4 @@ export class ActionResult { return onCancel(); } } -} \ No newline at end of file +} diff --git a/src/actions/portal/serve.ts b/src/actions/portal/serve.ts index 7a5e9de8d..6c48ffd7e 100644 --- a/src/actions/portal/serve.ts +++ b/src/actions/portal/serve.ts @@ -9,9 +9,10 @@ import { ProjectContext } from '../../types/project-context.js'; import { isSkippedByGlob } from '../../types/portal/content-tree.js'; import { GeneratedPages } from '../../types/portal/generated-pages.js'; import { PortalArtifacts } from '../../types/portal/portal-artifacts.js'; -import { PortalSettings, PortalSource } from '../../types/portal/portal-source.js'; +import { PortalSettings, PortalSource, WatchedSource } from '../../types/portal/portal-source.js'; import { PreviewConfig } from '../../types/portal/preview-config.js'; import { PreviewContent } from '../../types/portal/preview-content.js'; +import { PreviewSpecs } from '../../types/portal/preview-specs.js'; import { FileWatch, FileWatchService } from '../../infrastructure/file-watch-service.js'; import { NetworkService } from '../../infrastructure/network-service.js'; import { LauncherService } from '../../infrastructure/launcher-service.js'; @@ -78,7 +79,9 @@ export class PortalServeAction { contentWatch?.recheck(); } }); + const specWatch = this.watchSpecs(project, source); const closeWatches = async () => { + await specWatch?.close(); await configWatch?.close(); await contentWatch?.close(); }; @@ -147,25 +150,15 @@ export class PortalServeAction { const applied = await this.projectService.applyConfig(portalProjectDirectory, settings); if (applied.isErr()) { - this.prompts.configNotApplied(applied.error); + this.prompts.notApplied('config', applied.error, sourceDirectory); return; } - if (preview.show(config, applied.value)) { - this.prompts.configApplied(); - } + this.prompts.configShown(preview.show(config, applied.value)); onApplied(settings); }; - return this.startWatch( - (onChange) => - this.fileWatchService.watch(sourceDirectory, new FileName(APIMATIC_CONFIG_FILE_NAME), onChange, (reason) => - this.prompts.configWatchFailed(reason) - ), - applyEdit, - { - notWatched: (reason) => this.prompts.configNotWatched(reason), - thrown: (reason) => this.prompts.configNotApplied(reason) - } + return this.startWatch('config', sourceDirectory, applyEdit, (onChange, onFailed) => + this.fileWatchService.watch(sourceDirectory, new FileName(APIMATIC_CONFIG_FILE_NAME), onChange, onFailed) ); } @@ -197,30 +190,37 @@ export class PortalServeAction { checked.value.files ); if (applied.isErr()) { - this.prompts.contentNotApplied(applied.error, sourceDirectory); + this.prompts.notApplied('content', applied.error, sourceDirectory); return; } - const shown = preview.show(checked.value.notices); - if (shown.fixed) { - this.prompts.contentAccepted(sourceDirectory); - } - this.prompts.contentNotices(shown.notices, sourceDirectory); + this.prompts.contentShown(preview.show(checked.value.notices), sourceDirectory); }; - return this.startWatch( - (onChange) => - this.fileWatchService.watchTree( - contentDirectory, - onChange, - (reason) => this.prompts.contentWatchFailed(reason, sourceDirectory), - // What the build never reads, as an editor's swap file, would only have the check run again. - isSkippedByGlob - ), - check, - { - notWatched: (reason) => this.prompts.contentNotWatched(reason, sourceDirectory), - thrown: (reason) => this.prompts.contentNotChecked(reason, sourceDirectory) + // What the build never reads, as an editor's swap file, would only have the check run again. + return this.startWatch('content', sourceDirectory, check, (onChange, onFailed) => + this.fileWatchService.watchTree(contentDirectory, onChange, onFailed, isSkippedByGlob) + ); + } + + /** Held to a build's rules, as `content/` is, but the preview itself leaves out a reference it cannot read. */ + private watchSpecs(project: ProjectContext, source: PortalSource): FileWatch | undefined { + const sourceDirectory = project.sourceDirectory(); + const sourceContext = project.portalSource(); + const preview = new PreviewSpecs(source.specs); + + const check = async () => { + const checked = await sourceContext.resolveSpecs(); + if (checked.isErr()) { + preview.refuse(); + this.prompts.specRejected(checked.error, sourceDirectory); + return; } + this.prompts.specsShown(preview.show(checked.value), sourceDirectory); + }; + + // As for content: an editor's swap file, saved while a document is refused, would only repeat the refusal. + return this.startWatch('spec', sourceDirectory, check, (onChange, onFailed) => + this.fileWatchService.watchTree(source.specDirectory, onChange, onFailed, isSkippedByGlob) ); } @@ -229,21 +229,22 @@ export class PortalServeAction { * before the preview started, which can take a minute, and a save made meanwhile reached no watch. */ private startWatch( - watch: (onChange: () => Promise) => Result, + watched: WatchedSource, + sourceDirectory: DirectoryPath, handle: () => Promise, - report: { notWatched: (reason: string) => void; thrown: (reason: string) => void } + watch: (onChange: () => Promise, onFailed: (reason: string) => void) => Result ): FileWatch | undefined { // The watch drops whatever its handler throws, which would leave the preview stale without a word. const onChange = async () => { try { await handle(); } catch (error) { - report.thrown(errorMessage(error)); + this.prompts.notChecked(watched, errorMessage(error), sourceDirectory); } }; - const started = watch(onChange); + const started = watch(onChange, (reason) => this.prompts.watchFailed(watched, reason, sourceDirectory)); if (started.isErr()) { - report.notWatched(started.error); + this.prompts.notWatched(watched, started.error, sourceDirectory); return undefined; } started.value.recheck(); diff --git a/src/actions/quickstart.ts b/src/actions/quickstart.ts index b82511175..1c620ffe7 100644 --- a/src/actions/quickstart.ts +++ b/src/actions/quickstart.ts @@ -280,8 +280,10 @@ export class QuickstartAction { if (await this.fileService.isZipFile(specPath)) { return { supported: true }; } - const document = OpenApiDocument.parse(specPath.name(), await this.fileService.getContents(specPath)); - return document === undefined ? { supported: true } : document.format(); + return OpenApiDocument.parse(await this.fileService.getContents(specPath)).match( + (document) => document.format(), + (): SpecFormat => ({ supported: true }) + ); } catch { return { supported: true }; } diff --git a/src/commands/portal/serve.ts b/src/commands/portal/serve.ts index bd376f0d7..ba4d41c1c 100644 --- a/src/commands/portal/serve.ts +++ b/src/commands/portal/serve.ts @@ -11,7 +11,7 @@ const defaultPortText = String(DEFAULT_PORTAL_PORT); export default class PortalServe extends Command { static readonly summary = 'Preview your API Documentation Portal with live reload.'; - static readonly description = `Serves the portal described by 'src/apimatic.json' from your machine, reloading the browser as you edit the Markdown pages in 'src/content', reorder them in a 'nav.json', or change the 'portal', 'languages' or 'plugin' block of 'apimatic.json'. + static readonly description = `Serves the portal described by 'src/apimatic.json' from your machine, reloading the browser as you edit the Markdown pages in 'src/content', reorder them in a 'nav.json', edit the documents in 'src/spec', or change the 'portal', 'languages' or 'plugin' block of 'apimatic.json'. Adding a language or a 'plugin' block, adding or removing a page in 'src/content', creating 'src/static', changing which documents are in 'src/spec' or which folders its 'APIMATIC-META.json' merges, or changing the path in 'portal.site.url', needs the preview restarted. diff --git a/src/infrastructure/file-service.ts b/src/infrastructure/file-service.ts index 6505b3b0a..85692a796 100644 --- a/src/infrastructure/file-service.ts +++ b/src/infrastructure/file-service.ts @@ -17,6 +17,14 @@ function isDanglingLink(error: unknown): boolean { return code === 'ENOENT' || code === 'ENOTDIR'; } +interface DirectoryEntries { + fileNames: FileName[]; + subDirectories: DirectoryPath[]; +} + +/** Each folder's entries by its path, read once for the files of one call. */ +type Listings = Map>; + function spelling(name: string, entries: T[], nameOf: (entry: T) => string): T | undefined { return ( entries.find((entry) => nameOf(entry) === name) ?? @@ -155,21 +163,43 @@ export class FileService { // By code point first: Windows and macOS open `logo.png` for `Logo.PNG`, where a web server would not. public async spelledOnDisk(root: DirectoryPath, file: FilePath): Promise { - const directory = await this.directorySpelledOnDisk(root, file.directory()); - if (directory === null) { - return null; - } - const { fileNames } = await this.listEntries(directory); - const match = spelling(file.name().toString(), fileNames, (candidate) => candidate.toString()); - return match === undefined ? null : new FilePath(directory, match); + const [found] = await this.spellingsOnDisk(root, [file]); + return found; } - /** `directory`, at or below `root`, as each folder below `root` is spelt on disk; null when one is not there. */ + /** As `spelledOnDisk` for each of `files`, listing each folder once: a split specification runs to thousands. */ + public async spellingsOnDisk(root: DirectoryPath, files: FilePath[]): Promise<(FilePath | null)[]> { + const listings: Listings = new Map(); + return await Promise.all( + files.map(async (file) => { + const directory = await this.spelledDirectory(root, file.directory(), listings); + if (directory === null) { + return null; + } + const { fileNames } = await this.listOnce(directory, listings); + const match = spelling(file.name().toString(), fileNames, (candidate) => candidate.toString()); + return match === undefined ? null : new FilePath(directory, match); + }) + ); + } + + /** `directory` as each folder below `root` is spelt on disk; null when one is not there, or it is not below `root`. */ public async directorySpelledOnDisk(root: DirectoryPath, directory: DirectoryPath): Promise { + return await this.spelledDirectory(root, directory, new Map()); + } + + private async spelledDirectory( + root: DirectoryPath, + directory: DirectoryPath, + listings: Listings + ): Promise { + if (!root.contains(directory)) { + return null; + } const relative = path.relative(root.toString(), directory.toString()); let found = root; for (const name of relative === '' ? [] : relative.split(path.sep)) { - const { subDirectories } = await this.listEntries(found); + const { subDirectories } = await this.listOnce(found, listings); const match = spelling(name, subDirectories, (subDirectory) => path.basename(subDirectory.toString())); if (match === undefined) { return null; @@ -179,10 +209,16 @@ export class FileService { return found; } + private listOnce(dir: DirectoryPath, listings: Listings): Promise { + const listing = listings.get(dir.toString()) ?? this.listEntries(dir); + listings.set(dir.toString(), listing); + return listing; + } + // The direct children of `dir`, split into files and directories; both empty when it cannot // be read. A symlink counts as what it points at: skipping it silently drops an entry the // user did put there. - private async listEntries(dir: DirectoryPath): Promise<{ fileNames: FileName[]; subDirectories: DirectoryPath[] }> { + private async listEntries(dir: DirectoryPath): Promise { const fileNames: FileName[] = []; const subDirectories: DirectoryPath[] = []; try { diff --git a/src/infrastructure/launcher-service.ts b/src/infrastructure/launcher-service.ts index dd1f4695d..98d931dcf 100644 --- a/src/infrastructure/launcher-service.ts +++ b/src/infrastructure/launcher-service.ts @@ -1,18 +1,18 @@ -import { FilePath } from "../types/file/filePath.js"; -import { execa } from "execa"; -import os from "os"; -import { spawn } from "child_process"; -import open from "open"; -import { UrlPath } from "../types/file/urlPath.js"; -import isInCi from "is-in-ci"; -import { DirectoryPath } from "../types/file/directoryPath.js"; +import { FilePath } from '../types/file/filePath.js'; +import { execa } from 'execa'; +import os from 'os'; +import { spawn } from 'child_process'; +import open from 'open'; +import { UrlPath } from '../types/file/urlPath.js'; +import isInCi from 'is-in-ci'; +import { DirectoryPath } from '../types/file/directoryPath.js'; export class LauncherService { public async openFolderInIde(directoryPath: DirectoryPath, fileToOpen: FilePath): Promise { if (isInCi) return false; try { const args = [directoryPath.toString(), fileToOpen.toString()]; - await execa("code", args); + await execa('code', args); return true; } catch { return false; @@ -36,22 +36,22 @@ export class LauncherService { let args: string[]; switch (os.platform()) { - case "win32": - command = "cmd"; - args = ["/c", "start", "", targetPath]; + case 'win32': + command = 'cmd'; + args = ['/c', 'start', '', targetPath]; break; - case "darwin": - command = "open"; + case 'darwin': + command = 'open'; args = [targetPath]; break; default: - command = "xdg-open"; + command = 'xdg-open'; args = [targetPath]; break; } try { - const child = spawn(command, args, { stdio: "ignore", detached: true }); + const child = spawn(command, args, { stdio: 'ignore', detached: true }); child.unref(); // Let it run without blocking } catch { // Silently ignore errors diff --git a/src/infrastructure/portal-project-service.ts b/src/infrastructure/portal-project-service.ts index 2dfd9fc12..ab2622372 100644 --- a/src/infrastructure/portal-project-service.ts +++ b/src/infrastructure/portal-project-service.ts @@ -2,7 +2,7 @@ import { createRequire } from 'node:module'; import path from 'node:path'; import fsExtra from 'fs-extra'; import { err, ok, Result } from 'neverthrow'; -import { DirectoryPath } from '../types/file/directoryPath.js'; +import { DirectoryPath, posix } from '../types/file/directoryPath.js'; import { FileName } from '../types/file/fileName.js'; import { FilePath } from '../types/file/filePath.js'; import { CodeSampleCatalogs } from '../types/portal/code-samples.js'; @@ -289,7 +289,8 @@ export class PortalProjectService { codeSamples: codeSamples === null ? null : codeSamples.toPosix(), contentDir: contentDirectory.toPosix(), generatedDir: projectDirectory.join(GENERATED_DIRECTORY_NAME).toPosix(), - staticDir: source.staticDirectory === null ? null : source.staticDirectory.toPosix(), + // The dev server's allow list names it, and Vite resolves a symlink before comparing. + staticDir: source.staticDirectory === null ? null : posix(canonical(source.staticDirectory.toString())), downloadsDir: downloads === null ? null : downloads.toPosix() }; diff --git a/src/prompts/api/transform.ts b/src/prompts/api/transform.ts index 24eff00a3..20479e796 100644 --- a/src/prompts/api/transform.ts +++ b/src/prompts/api/transform.ts @@ -1,14 +1,16 @@ -import { log, isCancel, confirm } from "@clack/prompts"; -import { DirectoryPath } from "../../types/file/directoryPath.js"; -import { format as f } from "../format.js"; -import { Result } from "neverthrow"; -import { TransformationResultData } from "../../infrastructure/services/transformation-service.js"; -import { withSpinner } from "../prompt.js"; +import { log, isCancel, confirm } from '@clack/prompts'; +import { DirectoryPath } from '../../types/file/directoryPath.js'; +import { format as f } from '../format.js'; +import { Result } from 'neverthrow'; +import { TransformationResultData } from '../../infrastructure/services/transformation-service.js'; +import { withSpinner } from '../prompt.js'; export class ApiTransformPrompts { public async overwriteApi(directory: DirectoryPath): Promise { const overwrite = await confirm({ - message: `A specification file already exists at ${f.path(directory)}. Do you want to overwrite the existing file?`, + message: `A specification file already exists at ${f.path( + directory + )}. Do you want to overwrite the existing file?`, initialValue: false }); @@ -25,7 +27,7 @@ export class ApiTransformPrompts { } public async transformApi(fn: Promise>) { - return withSpinner("Transforming API", "API transformed successfully.", "API transformation failed.", fn); + return withSpinner('Transforming API', 'API transformed successfully.', 'API transformation failed.', fn); } logTransformationError(error: string): void { diff --git a/src/prompts/portal/serve.ts b/src/prompts/portal/serve.ts index 8f34c69df..ed074ddd0 100644 --- a/src/prompts/portal/serve.ts +++ b/src/prompts/portal/serve.ts @@ -2,13 +2,16 @@ import { log } from '@clack/prompts'; import { once } from 'node:events'; import { APIMATIC_CONFIG_FILE_NAME } from '../../types/apimatic-config/document.js'; import { DirectoryPath } from '../../types/file/directoryPath.js'; +import { FilePath } from '../../types/file/filePath.js'; import { UrlPath } from '../../types/file/urlPath.js'; -import { ContentNotices } from '../../types/portal/content-notices.js'; import { MissingArtifacts } from '../../types/portal/generated-pages.js'; import { NAVIGATION_FILE_NAME } from '../../types/portal/portal-navigation.js'; import { METADATA_FILE_NAME } from '../../types/project-layout.js'; -import { ContentProblem, PortalSourceProblem } from '../../types/portal/portal-source.js'; +import { ContentProblem, PortalSourceProblem, WatchedSource } from '../../types/portal/portal-source.js'; +import { ContentShown } from '../../types/portal/preview-content.js'; +import { SpecsShown } from '../../types/portal/preview-specs.js'; import { PortalDevServer, PortalDevServerFailure } from '../../infrastructure/portal-dev-server-service.js'; +import { listedInProse } from '../../utils/string-utils.js'; import { Result } from 'neverthrow'; import { format as f } from '../format.js'; import { logTail, noteWrapped, withSpinner } from '../prompt.js'; @@ -18,10 +21,14 @@ import { reportContentNotices, reportContentProblems, reportSourceProblem, + specDirectoryOf, specPath, staticPath } from './source.js'; +const reportedOnRestart = (mistakes: string) => + `a mistake in ${mistakes} is only reported when the preview is restarted`; + export class PortalServePrompts { public usingFallbackPort(requestedPort: number, availablePort: number) { const message = @@ -54,6 +61,8 @@ export class PortalServePrompts { `So does removing a language from ${f.var('languages')} (updates the SDK pages) or the ` + `${f.var('plugin')} block (removes the Context Plugin pages).`, 'Mistakes in these files are reported on save, and the preview keeps what it last accepted.', + `Saving a document in ${specPath(sourceDirectory)} 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.', '', `Restart the preview after adding a language or a ${f.var('plugin')} block (its SDK or plugin is ` + `fetched at startup), adding or removing a page, creating ${staticPath(sourceDirectory)}, ` + @@ -67,11 +76,12 @@ export class PortalServePrompts { ); } - public configApplied() { - log.success(`Applied the changes to ${f.var(APIMATIC_CONFIG_FILE_NAME)}.`); + public configShown(applied: boolean) { + if (applied) { + log.success(`Applied the changes to ${f.var(APIMATIC_CONFIG_FILE_NAME)}.`); + } } - /** Explained as `portal generate` would explain it, since the same rules refused it. */ public configRejected(problem: PortalSourceProblem, sourceDirectory: DirectoryPath) { reportSourceProblem(problem, sourceDirectory); log.message('The preview keeps showing what it last accepted until the file is fixed.'); @@ -86,10 +96,6 @@ export class PortalServePrompts { ); } - public configNotApplied(reason: string) { - log.warn(`The changes to ${f.var(APIMATIC_CONFIG_FILE_NAME)} could not be applied to the preview: ${reason}`); - } - /** Vite reads its public directory once, and a missing one is served as none. */ public staticDirectoryNotServed(sourceDirectory: DirectoryPath) { const message = @@ -98,20 +104,6 @@ export class PortalServePrompts { log.warn(message); } - public configNotWatched(reason: string) { - log.warn( - `${f.var(APIMATIC_CONFIG_FILE_NAME)} cannot be watched (${reason}), so edits to it need the preview restarted.` - ); - } - - public configWatchFailed(reason: string) { - log.warn( - `${f.var(APIMATIC_CONFIG_FILE_NAME)} is no longer watched (${reason}), so further edits to it need the ` + - `preview restarted.` - ); - } - - /** Explained as `portal generate` would explain it, since the same rules refused it. */ public contentRejected(problems: ContentProblem[], sourceDirectory: DirectoryPath) { reportContentProblems(problems, sourceDirectory); log.message( @@ -120,34 +112,66 @@ export class PortalServePrompts { ); } - public contentNotApplied(reason: string, sourceDirectory: DirectoryPath) { - log.warn(`The changes to ${contentPath(sourceDirectory)} could not be applied to the preview: ${reason}`); + public contentShown({ fixed, notices }: ContentShown, sourceDirectory: DirectoryPath) { + if (fixed) { + this.fixed('content', sourceDirectory); + } + reportContentNotices(notices, sourceDirectory); } - public contentNotChecked(reason: string, sourceDirectory: DirectoryPath) { - log.warn(`The changes to ${contentPath(sourceDirectory)} could not be checked: ${reason}`); + public specRejected(problem: PortalSourceProblem, sourceDirectory: DirectoryPath) { + reportSourceProblem(problem, sourceDirectory); + log.message( + `The preview shows what it can still read until ${specPath(sourceDirectory)} is fixed; a build would stop here.` + ); } - public contentAccepted(sourceDirectory: DirectoryPath) { - log.success(`${contentPath(sourceDirectory)} is fixed; a build would accept it again.`); + public specsShown({ fixed, change }: SpecsShown, sourceDirectory: DirectoryPath) { + if (fixed) { + this.fixed('spec', sourceDirectory); + } + if (change === null) { + return; + } + const specDirectory = specDirectoryOf(sourceDirectory); + const changes = [ + filesAre(change.added, 'now read', specDirectory), + filesAre(change.removed, 'no longer read', specDirectory) + ].filter((each) => each !== null); + if (changes.length === 0) { + log.success( + `The documents read from ${specPath(sourceDirectory)} are the ones the preview started with again; ` + + 'it no longer needs a restart.' + ); + return; + } + log.warn( + `The documents read from ${specPath(sourceDirectory)} are not the ones the preview started with: ` + + `${changes.join(', and ')}.` + ); + log.message( + 'Restart the preview to match them; until then it shows the ones it started with that are still there.' + ); } - public contentNotices(notices: ContentNotices, sourceDirectory: DirectoryPath) { - reportContentNotices(notices, sourceDirectory); + public notWatched(watched: WatchedSource, reason: string, sourceDirectory: DirectoryPath) { + const { name, untilRestart } = describeWatched(watched, sourceDirectory); + log.warn(`${name} cannot be watched (${reason}), so ${untilRestart}.`); } - public contentNotWatched(reason: string, sourceDirectory: DirectoryPath) { - log.warn( - `${contentPath(sourceDirectory)} cannot be watched (${reason}), so a mistake in a page or a ` + - `${f.var(NAVIGATION_FILE_NAME)} is only reported when the preview is restarted.` - ); + public watchFailed(watched: WatchedSource, reason: string, sourceDirectory: DirectoryPath) { + const { name, untilRestart } = describeWatched(watched, sourceDirectory); + log.warn(`${name} is no longer watched (${reason}), so ${untilRestart}.`); } - public contentWatchFailed(reason: string, sourceDirectory: DirectoryPath) { - log.warn( - `${contentPath(sourceDirectory)} is no longer watched (${reason}), so a mistake in a page or ` + - `a ${f.var(NAVIGATION_FILE_NAME)} is only reported when the preview is restarted.` - ); + public notChecked(watched: WatchedSource, reason: string, sourceDirectory: DirectoryPath) { + const { name } = describeWatched(watched, sourceDirectory); + log.warn(`The changes to ${name} could not be checked: ${reason}`); + } + + public notApplied(watched: WatchedSource, reason: string, sourceDirectory: DirectoryPath) { + const { name } = describeWatched(watched, sourceDirectory); + log.warn(`The changes to ${name} could not be applied to the preview: ${reason}`); } public stopping() { @@ -166,4 +190,34 @@ export class PortalServePrompts { public async blockExecution() { await Promise.race([once(process, 'SIGINT'), once(process, 'SIGTERM')]); } + + private fixed(watched: WatchedSource, sourceDirectory: DirectoryPath) { + log.success(`${describeWatched(watched, sourceDirectory).name} is fixed; a build would accept it again.`); + } +} + +/** How a message names what is watched, and what is lost until a restart while it is not. */ +function describeWatched( + watched: WatchedSource, + sourceDirectory: DirectoryPath +): { name: string; untilRestart: string } { + switch (watched) { + case 'config': + return { name: f.var(APIMATIC_CONFIG_FILE_NAME), untilRestart: 'edits to it need the preview restarted' }; + case 'content': + return { + name: contentPath(sourceDirectory), + untilRestart: reportedOnRestart(`a page or a ${f.var(NAVIGATION_FILE_NAME)}`) + }; + case 'spec': + return { name: specPath(sourceDirectory), untilRestart: reportedOnRestart('a specification') }; + } +} + +function filesAre(files: FilePath[], state: string, specDirectory: DirectoryPath): string | null { + if (files.length === 0) { + return null; + } + const names = listedInProse(files.map((file) => f.var(file.relativeTo(specDirectory)))); + return `${names} ${files.length === 1 ? 'is' : 'are'} ${state}`; } diff --git a/src/prompts/portal/source.ts b/src/prompts/portal/source.ts index fa72765cd..b0136e51a 100644 --- a/src/prompts/portal/source.ts +++ b/src/prompts/portal/source.ts @@ -12,7 +12,8 @@ import { ReservedAddressPage, SharedAddress, SharedEndpoint, - SpecConversion + SpecConversion, + UnreadableSpecReason } from '../../types/portal/portal-source.js'; import { Shared } from '../../types/portal/shared.js'; import { MERGE_SETTINGS } from '../../types/portal/spec-metadata.js'; @@ -40,7 +41,10 @@ const mergeTheFolders = (): string => const addingToMergeOrder = (folders: string): string => `adding ${folders} to ${f.var(MERGE_SETTINGS.order)} when the file lists folders`; -export const specPath = (sourceDirectory: DirectoryPath): string => f.path(sourceDirectory.join(SPEC_DIRECTORY_NAME)); +export const specDirectoryOf = (sourceDirectory: DirectoryPath): DirectoryPath => + sourceDirectory.join(SPEC_DIRECTORY_NAME); + +export const specPath = (sourceDirectory: DirectoryPath): string => f.path(specDirectoryOf(sourceDirectory)); export const contentPath = (sourceDirectory: DirectoryPath): string => f.path(sourceDirectory.join(CONTENT_DIRECTORY_NAME)); @@ -95,6 +99,24 @@ function reportSpecConversion({ file, format, converted, others }: SpecConversio ); } +function unreadableBecause(reason: UnreadableSpecReason): string { + switch (reason.kind) { + case 'empty': + return 'it is empty'; + case 'noValue': + return 'it holds no value'; + case 'noKey': + return 'it is not JSON, and the portal reads YAML only when it holds a key'; + case 'gone': + return 'it is no longer there'; + case 'folder': + return 'it is a folder'; + case 'invalid': + case 'unopened': + return reason.message; + } +} + const folderNames = (folders: DirectoryPath[], sourceDirectory: DirectoryPath): string => listedInProse(folders.map((folder) => f.var(folder.relativeTo(sourceDirectory)))); @@ -141,7 +163,39 @@ export function reportSourceProblem(problem: PortalSourceProblem, sourceDirector return; } case 'unreadableSpec': { - log.error(`${relative(problem.file, sourceDirectory)} could not be read as JSON or YAML.`); + const file = relative(problem.file, sourceDirectory); + const why = unreadableBecause(problem.reason); + log.error( + problem.referencedBy === null + ? `${file} could not be read: ${why}.` + : `${file}, which ${relative(problem.referencedBy, sourceDirectory)} refers to, could not be read: ${why}.` + ); + return; + } + case 'missingSpecReference': { + const { file, foundAs } = problem.missing; + const referencedBy = relative(problem.referencedBy, sourceDirectory); + const refersTo = `${referencedBy} refers to ${relative(file, sourceDirectory)}`; + if (foundAs !== null) { + log.error(`${refersTo}, but the file is ${spelt(foundAs, sourceDirectory)}.`); + return; + } + log.error(`${refersTo}, but there is no such file.`); + log.message(`A ${f.var('$ref')} names a file from the folder of the file it is written in.`); + return; + } + case 'unresolvableSpecReference': { + const { file, ref } = problem.reference; + const writtenIn = relative(file, sourceDirectory); + log.error( + ref === '' + ? `${writtenIn} has an empty ${f.var('$ref')}.` + : `${writtenIn} has the ${f.var('$ref')} ${f.var(ref)}, which the portal cannot resolve.` + ); + log.message( + `A ${f.var('$ref')} names a part of the file it is in as ${f.var('#/components/schemas/Pet')}, ` + + `and a part of another as ${f.var('./pets.yaml#/Pet')}.` + ); return; } case 'unreadableMetadata': { diff --git a/src/types/file/urlPath.ts b/src/types/file/urlPath.ts index 42e92c47f..25aee896a 100644 --- a/src/types/file/urlPath.ts +++ b/src/types/file/urlPath.ts @@ -1,4 +1,4 @@ -import { URL } from "url"; +import { URL } from 'url'; export class UrlPath { private readonly url: string; @@ -10,7 +10,7 @@ export class UrlPath { public static create(url: string): UrlPath | undefined { try { const parsed = new URL(url); - if (["http:", "https:"].includes(parsed.protocol)) { + if (['http:', 'https:'].includes(parsed.protocol)) { return new UrlPath(url); } } catch { diff --git a/src/types/portal-source-context.ts b/src/types/portal-source-context.ts index 1993cbfa8..8230a3cea 100644 --- a/src/types/portal-source-context.ts +++ b/src/types/portal-source-context.ts @@ -19,7 +19,6 @@ import { } from './project-layout.js'; import { PLACEHOLDER_SITE, SiteConfig, SuggestedSite } from './portal/config/site-config.js'; import { AcceptedContent, ContentFile, ContentTree, ReadPage } from './portal/content-tree.js'; -import { Endpoint } from './portal/endpoint.js'; import { GeneratedPages, PluginSource } from './portal/generated-pages.js'; import { OpenApiDocument } from './portal/openapi-document.js'; import { parsePage } from './portal/page.js'; @@ -36,9 +35,11 @@ import { PortalSource, PortalSourceProblem, PortalSpec, - SpecConversion + SpecConversion, + UnreadableSpecReason } from './portal/portal-source.js'; import { shared } from './portal/shared.js'; +import { ReferencedFile, SpecBundle } from './portal/spec-bundle.js'; import { SpecMetadata } from './portal/spec-metadata.js'; import { SpecContext } from './spec-context.js'; import { TRANSFORMATIONS_DIRECTORY_NAME, transformedFileName } from './transform-context.js'; @@ -50,6 +51,12 @@ const definitionNames = (fileNames: FileName[]): FileName[] => const isDocumentName = (name: FileName): boolean => SPEC_EXTENSIONS.some((extension) => name.hasExtension(extension)); +const NO_DOCUMENT = new Set(['empty', 'noValue', 'noKey']); + +/** A file at the top of a folder that holds no document, which the build reads past as it does any file that is no API. */ +const holdsNoDocument = (problem: PortalSourceProblem): boolean => + problem.kind === 'unreadableSpec' && NO_DOCUMENT.has(problem.reason.kind); + type DiscoveredSpecs = Pick; /** An OpenAPI 3.x document at the top of a directory, read as a spec yet to be given its slug, and the site it suggests. */ @@ -64,6 +71,12 @@ interface ReadDirectory { definitions: FilePath[]; } +/** A document at the top of a directory in a format the portal does not read, which `api transform` converts. */ +interface OtherFormat { + file: FilePath; + format: string; +} + /** The specifications read from `spec/`, and the site the first of them suggests. */ interface ReadSpecs { specs: PortalSpec[]; @@ -167,7 +180,7 @@ export class PortalSourceContext { }); } - /** The `spec/` half of `resolve`, for quickstart to refuse a project the build would refuse before writing into it. */ + /** The `spec/` half of `resolve`, for quickstart before it writes into a project, and `portal serve` on each save. */ public async resolveSpecs(): Promise> { return (await this.specs()).map(({ specs }) => specs); } @@ -455,8 +468,10 @@ export class PortalSourceContext { if (await this.fileService.isZipFile(specPath)) { return PLACEHOLDER_SITE; } - const document = await this.readDocument(specPath); - return document === undefined ? PLACEHOLDER_SITE : document.suggestedSite(); + return (await this.readDocument(specPath)).match( + (document) => document.suggestedSite(), + () => PLACEHOLDER_SITE + ); } /** @@ -596,8 +611,8 @@ export class PortalSourceContext { .map((fileName) => new FilePath(this.specDirectory, fileName)); const apis = await Promise.all( files.map(async (file) => { - const document = await this.readDocument(file); - return document !== undefined && document.namesApiFormat() && document.declaresOperations(); + const read = await this.readDocument(file); + return read.isOk() && read.value.namesApiFormat() && read.value.declaresOperations(); }) ); return files.filter((_, index) => apis[index]); @@ -613,41 +628,59 @@ export class PortalSourceContext { const definitions: FilePath[] = []; // What each one is, for the conversion a directory without an OpenAPI 3.x document is pointed to. - const otherFormats: { file: FilePath; format: string }[] = []; + const otherFormats: OtherFormat[] = []; + const noDocument: PortalSourceProblem[] = []; const documentNames = fileNames.filter(isDocumentName); for (const fileName of documentNames) { const file = new FilePath(directory, fileName); - const document = await this.readDocument(file); - if (document === undefined) { - return err({ kind: 'unreadableSpec', file }); + const read = await this.readDocument(file); + if (read.isErr() && holdsNoDocument(read.error)) { + noDocument.push(read.error); + continue; } + if (read.isErr()) { + return err(read.error); + } + const document = read.value; if (document.namesApiFormat()) { definitions.push(file); } const format = document.format(); - if (!format.supported) { - if (format.format !== null) { - otherFormats.push({ file, format: format.format }); + if (format.supported) { + const bundle = await this.readBundle(file, document); + if (bundle.isErr()) { + return err(bundle.error); } - continue; + documents.push({ spec: { file, endpoints: bundle.value.endpoints() }, site: document.suggestedSite() }); + } else if (format.format !== null) { + otherFormats.push({ file, format: format.format }); } - - documents.push({ - spec: { file, endpoints: await this.endpoints(document, file) }, - site: document.suggestedSite() - }); } - if (documents.length === 0) { - const [convertible] = otherFormats; - // No document declares its format: one the portal would read, by extension, is named before any other file. - const conversion = - convertible === undefined - ? this.conversion(new FilePath(directory, documentNames[0] ?? fileNames[0]), null, 0) - : this.conversion(convertible.file, convertible.format, otherFormats.length - 1); - return err({ kind: 'noOpenApiSpec', conversion }); - } - return ok({ documents, definitions }); + return documents.length > 0 + ? ok({ documents, definitions }) + : err(this.withoutOpenApi(directory, fileNames, documentNames, otherFormats, noDocument)); + } + + /** Why a directory has no OpenAPI 3.x document: one to convert, or the first that holds nothing when they all do. */ + private withoutOpenApi( + directory: DirectoryPath, + fileNames: FileName[], + documentNames: FileName[], + otherFormats: OtherFormat[], + noDocument: PortalSourceProblem[] + ): PortalSourceProblem { + // As a document caught empty mid-save is, rather than as one to convert. + if (noDocument.length > 0 && noDocument.length === documentNames.length) { + return noDocument[0]; + } + const [convertible] = otherFormats; + // No document declares its format: one the portal would read, by extension, is named before any other file. + const conversion = + convertible === undefined + ? this.conversion(new FilePath(directory, documentNames[0] ?? fileNames[0]), null, 0) + : this.conversion(convertible.file, convertible.format, otherFormats.length - 1); + return { kind: 'noOpenApiSpec', conversion }; } private conversion(file: FilePath, format: string | null, others: number): SpecConversion { @@ -674,25 +707,70 @@ export class PortalSourceContext { }; } - // A path item in another file is read from it; one that file refers on to again is not followed. - private async endpoints(document: OpenApiDocument, file: FilePath): Promise { - const referenced = await Promise.all( - document.pathItemReferences(file.directory()).map(async ({ path, file: target, pointer }) => { - const targetDocument = await this.readDocument(target); - return targetDocument?.endpointsAt(path, pointer) ?? []; - }) - ); - // A method beside a path item's `$ref` declares again what the file it refers to declares. - const all = [...document.endpoints(), ...referenced.flat()]; - return all.filter((endpoint, index) => index === all.findIndex((other) => other.isEqual(endpoint))); + /** Reads every file the bundler would embed into `document`, a level of `$ref`s at a time. */ + private async readBundle( + file: FilePath, + document: OpenApiDocument + ): Promise> { + const bundle = new SpecBundle(file, document); + for (let unread = bundle.unread(); unread.length > 0; unread = bundle.unread()) { + const read = Result.combine( + await Promise.all(unread.map((next) => this.readDocument(next.file, next.referencedBy))) + ); + if (read.isErr()) { + return err(read.error); + } + unread.forEach((next, index) => bundle.add(next.file, read.value[index])); + } + const [unresolvable] = bundle.unresolvable(); + if (unresolvable !== undefined) { + return err({ kind: 'unresolvableSpecReference', reference: unresolvable }); + } + const misspelt = await this.misspeltReference(bundle.referenced()); + return misspelt === undefined ? ok(bundle) : err(misspelt); } - private async readDocument(file: FilePath): Promise { - try { - return OpenApiDocument.parse(file.name(), await this.fileService.getContents(file)); - } catch { + /** The first file read only as this file system ignores case, which a build where case matters would miss. */ + private async misspeltReference(referenced: ReferencedFile[]): Promise { + const files = referenced.map(({ file }) => file); + const spelt = await this.fileService.spellingsOnDisk(this.sourceDirectory, files); + // Null outside `src/`, whose folders are not the user's, or in no listing, as a name in another Unicode form is. + const index = spelt.findIndex((foundAs, at) => foundAs !== null && !foundAs.isEqual(files[at])); + if (index === -1) { return undefined; } + const { file, referencedBy } = referenced[index]; + return { kind: 'missingSpecReference', missing: { file, foundAs: spelt[index] }, referencedBy }; + } + + private async readDocument( + file: FilePath, + referencedBy: FilePath | null = null + ): Promise> { + let contents: string; + try { + contents = await this.fileService.getContents(file); + } catch (error) { + const reason = await this.whyUnread(file, error); + if (reason.kind === 'gone' && referencedBy !== null) { + // Null only for a name listed but not there as a file, as a dangling link is. + const missing = (await this.missingFile(file)) ?? { file, foundAs: null }; + return err({ kind: 'missingSpecReference', missing, referencedBy }); + } + return err({ kind: 'unreadableSpec', file, referencedBy, reason }); + } + return OpenApiDocument.parse(contents).mapErr( + (reason): PortalSourceProblem => ({ kind: 'unreadableSpec', file, referencedBy, reason }) + ); + } + + /** A listed document gone is one caught mid-save, which the next save fixes; a `$ref` may name a folder. */ + private async whyUnread(file: FilePath, error: unknown): Promise { + if (await this.fileService.fileExists(file)) { + return { kind: 'unopened', message: errorMessage(error) }; + } + const folder = file.directory().join(file.name().toString()); + return (await this.fileService.directoryExists(folder)) ? { kind: 'folder' } : { kind: 'gone' }; } private async readMetadata(file: FilePath): Promise { diff --git a/src/types/portal/openapi-document.ts b/src/types/portal/openapi-document.ts index 41f2e4fd6..9d2a2cc18 100644 --- a/src/types/portal/openapi-document.ts +++ b/src/types/portal/openapi-document.ts @@ -1,8 +1,8 @@ -import { basename, dirname } from 'node:path'; -import { parse as parseYaml } from 'yaml'; +import { err, ok, Result } from 'neverthrow'; +import { parse as parseYaml, YAMLParseError } from 'yaml'; import { DirectoryPath } from '../file/directoryPath.js'; -import { FileName } from '../file/fileName.js'; import { FilePath } from '../file/filePath.js'; +import { errorMessage } from '../../utils/error-utils.js'; import { isJsonObject, JsonObject } from '../../utils/json-utils.js'; import { stripByteOrderMark } from '../../utils/string-utils.js'; import { PLACEHOLDER_SITE, SuggestedSite } from './config/site-config.js'; @@ -15,6 +15,20 @@ import { Endpoint } from './endpoint.js'; */ export type SpecFormat = { supported: true } | { supported: false; format: string | null }; +/** Why the portal's bundler would fail on a text, or read nothing from it; `invalid` carries what the parser said. */ +export type ParseFailure = + | { kind: 'empty' } + | { kind: 'noValue' } + | { kind: 'noKey' } + | { kind: 'invalid'; message: string }; + +/** What a document's `$ref`s name, as the portal's bundler reads them. */ +export interface DocumentReferences { + files: FilePath[]; + /** Each one, as written, that names no file and no part of a document by a JSON pointer: `''`, `'#'` or `'#pet'`. */ + unnamed: string[]; +} + /** A path item kept in another file, where `pointer` locates it; empty for the whole file. */ export interface PathItemReference { path: string; @@ -26,29 +40,86 @@ const DESCRIPTION_LIMIT = 300; const HTTP_METHODS = new Set(['get', 'put', 'post', 'delete', 'options', 'head', 'patch', 'trace']); -const URL_SCHEME = /^[a-z][a-z\d+.-]+:/i; +// The bundler fetches these, and reads any other `$ref`, `file:` and `urn:` included, as a path. +const FETCHED_PROTOCOLS = new Set(['http:', 'https:']); -// The options @scalar/json-magic bundles with, so a spec reads here as it does in the portal. +// How @scalar/json-magic reads each file it bundles: as JSON, else as YAML with these options when it holds a key. const YAML_OPTIONS = { merge: true, maxAliasCount: 10000 }; +const YAML_KEY = /^[^:]+:/; +// A text that opens as JSON does within this many characters is taken for JSON, and never read as YAML. +const JSON_OPENING = /^\s*[[{]/; +const JSON_OPENING_WITHIN = 50; -/** A specification as written to disk, read the one way the wizard and the build agree on. */ +// The keys @scalar/json-magic writes what it has embedded under, which it does not read again. +const BUNDLER_KEYS = new Set(['x-ext', 'x-ext-urls']); + +/** A specification's document, or a file its `$ref`s name, read the one way the wizard and the build agree on. */ export class OpenApiDocument { - private constructor(private readonly document: JsonObject) {} + private readonly document: JsonObject; - /** - * Undefined when neither parser accepts the text. A document that parses to something other - * than an object is kept, and reports itself as no specification at all. - */ - public static parse(fileName: FileName, contents: string): OpenApiDocument | undefined { + private constructor(private readonly value: unknown) { + this.document = isJsonObject(value) ? value : {}; + } + + /** Fails where the portal's bundler fails or reads nothing; a value other than an object is no specification. */ + public static parse(contents: string): Result { + const text = stripByteOrderMark(contents); + if (text.trim() === '') { + return err({ kind: 'empty' }); + } + let value: unknown; try { - // JSON is valid YAML, but the YAML parser is far slower and specs run to megabytes, - // so each extension gets the parser built for it. - const text = stripByteOrderMark(contents); - const document: unknown = fileName.hasExtension('.json') ? JSON.parse(text) : parseYaml(text, YAML_OPTIONS); - return new OpenApiDocument(isJsonObject(document) ? document : {}); - } catch { - return undefined; + value = JSON.parse(text); + } catch (error) { + if (JSON_OPENING.test(text.slice(0, JSON_OPENING_WITHIN))) { + return err(invalid(error)); + } + if (!YAML_KEY.test(text)) { + return err({ kind: 'noKey' }); + } + try { + value = parseYaml(text, YAML_OPTIONS); + } catch (yamlError) { + return err(invalid(yamlError)); + } } + return value === null ? err({ kind: 'noValue' }) : ok(new OpenApiDocument(value)); + } + + /** The `$id`s this document declares, which a `$ref` in any file bundled into it names a schema by. */ + public schemaIds(): Set { + const ids = new Set(); + walk(this.value, (node) => { + if (typeof node.$id === 'string') { + ids.add(node.$id); + } + return true; + }); + return ids; + } + + /** What this document's `$ref`s name, as the portal's bundler finds them, given the root's `$id`s. */ + public references(directory: DirectoryPath, rootIds: ReadonlySet): DocumentReferences { + const names = new Set(); + const unnamed: string[] = []; + walk(this.value, (node) => { + // The bundler resolves what is below an `$id` against the `$id`, not the file. + if (typeof node.$id === 'string') { + return false; + } + const target = reference(node); + if (target === undefined) { + return true; + } + if (target.name === '' && !target.pointer.startsWith('/')) { + unnamed.push(target.written); + } else { + names.add(target.name); + } + return false; + }); + const files = [...names].filter((name) => !rootIds.has(name)).flatMap((name) => localFile(directory, name) ?? []); + return { files, unnamed }; } /** Every operation declared inline or behind a `$ref` into this document; see `pathItemReferences` for the rest. */ @@ -59,12 +130,9 @@ export class OpenApiDocument { /** The path items kept in other local files, resolved against the `directory` this document sits in. */ public pathItemReferences(directory: DirectoryPath): PathItemReference[] { return Object.entries(this.paths()).flatMap(([path, pathItem]) => { - const reference = isJsonObject(pathItem) && typeof pathItem.$ref === 'string' ? pathItem.$ref : ''; - const [file, pointer = ''] = reference.split('#'); - if (file === '' || URL_SCHEME.test(file)) { - return []; - } - return [{ path, file: new FilePath(directory.resolve(dirname(file)), new FileName(basename(file))), pointer }]; + const target = reference(pathItem); + const file = target && localFile(directory, target.name); + return target && file ? [{ path, file, pointer: target.pointer }] : []; }); } @@ -138,6 +206,44 @@ export class OpenApiDocument { } } +/** A node's `$ref`, split as the bundler splits it: the file it names, empty for its own, and the pointer into it. */ +function reference(node: unknown): { written: string; name: string; pointer: string } | undefined { + if (!isJsonObject(node) || typeof node.$ref !== 'string') { + return undefined; + } + const [name, pointer = ''] = node.$ref.split('#'); + return { written: node.$ref, name, pointer }; +} + +/** The file a `$ref` names, resolved as the bundler resolves it; undefined for none, or a URL it fetches. */ +function localFile(directory: DirectoryPath, name: string): FilePath | undefined { + const fetched = FETCHED_PROTOCOLS.has(URL.parse(name)?.protocol ?? ''); + return name === '' || fetched ? undefined : FilePath.resolve(directory, name.replaceAll('\\', '/')); +} + +// A YAML error goes on to quote the lines around it, and V8 quotes the JSON it stopped at across line breaks. +function invalid(error: unknown): ParseFailure { + const message = errorMessage(error); + const firstLine = error instanceof YAMLParseError ? message.split('\n')[0].replace(/:$/, '') : message; + return { kind: 'invalid', message: firstLine.replace(/\s+/g, ' ').trim() }; +} + +/** Each object and list once; into an object only when `visit` says so, and never into the keys the bundler writes. */ +function walk(node: unknown, visit: (node: JsonObject) => boolean, seen = new WeakSet()): void { + if (typeof node !== 'object' || node === null || seen.has(node)) { + return; + } + seen.add(node); + if (isJsonObject(node) && !visit(node)) { + return; + } + for (const [key, value] of Object.entries(node)) { + if (!BUNDLER_KEYS.has(key)) { + walk(value, visit, seen); + } + } +} + function valueAt(document: JsonObject, pointer: string): unknown { try { const segments = pointer diff --git a/src/types/portal/portal-source.ts b/src/types/portal/portal-source.ts index f7b71d8a7..064b524f8 100644 --- a/src/types/portal/portal-source.ts +++ b/src/types/portal/portal-source.ts @@ -5,8 +5,10 @@ import { Endpoint } from './endpoint.js'; import { SuggestedSite } from './config/site-config.js'; import { ContentNotices } from './content-notices.js'; import { GeneratedPages } from './generated-pages.js'; +import { ParseFailure } from './openapi-document.js'; import { PortalConfig } from './portal-config.js'; import { Shared } from './shared.js'; +import { UnresolvableReference } from './spec-bundle.js'; /** An OpenAPI document found in `src/spec/` or in a folder merged there, with the slug its section is mounted at. */ export interface PortalSpec { @@ -19,6 +21,9 @@ export interface PortalSpec { /** An operation more than one specification declares, which one code sample per method and path cannot serve. */ export type SharedEndpoint = Shared; +/** A part of the source directory that `portal serve` watches, and checks on each save. */ +export type WatchedSource = 'config' | 'content' | 'spec'; + /** What `apimatic.json` decides about a portal, which `portal serve` reads again on every edit. */ export interface PortalSettings { config: PortalConfig; @@ -116,6 +121,13 @@ export interface SpecConversion { others: number; } +/** Why a file a specification is read from could not be read; `unopened` carries what the file system said. */ +export type UnreadableSpecReason = + | ParseFailure + | { kind: 'gone' } + | { kind: 'folder' } + | { kind: 'unopened'; message: string }; + /** Why a source directory cannot be built; each variant maps to its own message. */ export type PortalSourceProblem = | { kind: 'missingConfig' } @@ -123,7 +135,9 @@ export type PortalSourceProblem = | { kind: 'invalidConfig'; errors: string[]; missingPortal: boolean } // Every problem found in `content/`, so that one run lists all that a build would refuse. | { kind: 'invalidContent'; problems: ContentProblem[] } - | { kind: 'unreadableSpec'; file: FilePath } + | { kind: 'unreadableSpec'; file: FilePath; referencedBy: FilePath | null; reason: UnreadableSpecReason } + | { kind: 'missingSpecReference'; missing: MissingFile; referencedBy: FilePath } + | { kind: 'unresolvableSpecReference'; reference: UnresolvableReference } | { kind: 'unreadableMetadata'; file: FilePath } // Its `folders` are not read unless a metadata file merges them, and `api transform` writes into one of its own. | { kind: 'emptySpecDirectory'; folders: DirectoryPath[] } diff --git a/src/types/portal/preview-config.ts b/src/types/portal/preview-config.ts index 8d2436011..27fd6eb32 100644 --- a/src/types/portal/preview-config.ts +++ b/src/types/portal/preview-config.ts @@ -1,9 +1,10 @@ import { PortalConfig } from './portal-config.js'; +import { PreviewRefusal } from './preview-refusal.js'; /** The notice is given once, on the save that brings it about, rather than on every save after it. */ export class PreviewConfig { private shown: PortalConfig; - private refused = false; + private readonly refusal = new PreviewRefusal(); /** * `servesStatic`: the dev server serves `static/` only if it was there at startup, and a @@ -14,7 +15,7 @@ export class PreviewConfig { } public refuse(): void { - this.refused = true; + this.refusal.refuse(); } public staticDirectoryNotServed(config: PortalConfig): boolean { @@ -27,9 +28,8 @@ export class PreviewConfig { * it is accepted again. */ public show(config: PortalConfig, written: boolean): boolean { - const announce = written || this.refused; - this.refused = false; + const fixed = this.refusal.accept(); this.shown = config; - return announce; + return written || fixed; } } diff --git a/src/types/portal/preview-content.ts b/src/types/portal/preview-content.ts index 7ec18ffc2..a1a5ab280 100644 --- a/src/types/portal/preview-content.ts +++ b/src/types/portal/preview-content.ts @@ -2,11 +2,18 @@ import { FilePath } from '../file/filePath.js'; import { ContentNotices, NoTabsListed } from './content-notices.js'; import { isSameTabEntry } from './portal-navigation.js'; import { SharedTabName, TabOwner } from './portal-tabs.js'; +import { PreviewRefusal } from './preview-refusal.js'; + +/** What to say of a save the preview accepts: whether it fixes a refused one, and the notices it brings. */ +export interface ContentShown { + fixed: boolean; + notices: ContentNotices; +} /** A notice is given once, on the save that brings it about, rather than on every save after it. */ export class PreviewContent { private shown: ContentNotices; - private refused = false; + private readonly refusal = new PreviewRefusal(); /** `startup`: what the checks before the preview started reported. */ constructor(startup: ContentNotices) { @@ -14,16 +21,12 @@ export class PreviewContent { } public refuse(): void { - this.refused = true; + this.refusal.refuse(); } - /** - * Records the content as shown, and answers whether to say it is fixed, having been refused, - * and which notices to give: each kind in full when it holds something new, and none otherwise. - */ - public show(notices: ContentNotices): { fixed: boolean; notices: ContentNotices } { - const shown = { fixed: this.refused, notices: PreviewContent.since(notices, this.shown) }; - this.refused = false; + /** Records the content as shown, and answers what to say: each kind of notice in full when any of it is new. */ + public show(notices: ContentNotices): ContentShown { + const shown = { fixed: this.refusal.accept(), notices: PreviewContent.since(notices, this.shown) }; this.shown = notices; return shown; } diff --git a/src/types/portal/preview-refusal.ts b/src/types/portal/preview-refusal.ts new file mode 100644 index 000000000..fb1790cac --- /dev/null +++ b/src/types/portal/preview-refusal.ts @@ -0,0 +1,15 @@ +/** A save the preview refused, so that the first one it accepts after it, and only that one, is said to fix it. */ +export class PreviewRefusal { + private refused = false; + + public refuse(): void { + this.refused = true; + } + + /** Whether this accepted save fixes one refused before it. */ + public accept(): boolean { + const fixed = this.refused; + this.refused = false; + return fixed; + } +} diff --git a/src/types/portal/preview-specs.ts b/src/types/portal/preview-specs.ts new file mode 100644 index 000000000..bf3e19406 --- /dev/null +++ b/src/types/portal/preview-specs.ts @@ -0,0 +1,52 @@ +import { FilePath } from '../file/filePath.js'; +import { PortalSpec } from './portal-source.js'; +import { PreviewRefusal } from './preview-refusal.js'; + +/** Since the preview started: the documents now read from `spec/`, and the ones no longer read. */ +export interface SpecsChange { + added: FilePath[]; + removed: FilePath[]; +} + +/** + * What to say of a save the preview accepts: whether it fixes a refused one, and a change not yet said, which + * adds and removes nothing once the documents are the ones the preview started with again. + */ +export interface SpecsShown { + fixed: boolean; + change: SpecsChange | null; +} + +/** The preview serves only the documents it started with, so a change to which are read from `spec/` is said once. */ +export class PreviewSpecs { + private readonly served: FilePath[]; + private shown: FilePath[]; + private readonly refusal = new PreviewRefusal(); + + /** `startup`: the documents the preview started with. */ + constructor(startup: PortalSpec[]) { + this.served = startup.map(({ file }) => file); + this.shown = this.served; + } + + public refuse(): void { + this.refusal.refuse(); + } + + /** Records the documents as shown, and answers what to say of them. */ + public show(specs: PortalSpec[]): SpecsShown { + const files = specs.map(({ file }) => file); + const said = isSameSet(files, this.shown); + this.shown = files; + const change = { added: notIn(files, this.served), removed: notIn(this.served, files) }; + return { fixed: this.refusal.accept(), change: said ? null : change }; + } +} + +function notIn(files: FilePath[], others: FilePath[]): FilePath[] { + return files.filter((file) => !others.some((other) => other.isEqual(file))); +} + +function isSameSet(left: FilePath[], right: FilePath[]): boolean { + return notIn(left, right).length === 0 && notIn(right, left).length === 0; +} diff --git a/src/types/portal/spec-bundle.ts b/src/types/portal/spec-bundle.ts new file mode 100644 index 000000000..585e4e290 --- /dev/null +++ b/src/types/portal/spec-bundle.ts @@ -0,0 +1,79 @@ +import { FilePath } from '../file/filePath.js'; +import { Endpoint } from './endpoint.js'; +import { OpenApiDocument } from './openapi-document.js'; + +/** A file a `$ref` names, with the file the `$ref` is written in. */ +export interface ReferencedFile { + file: FilePath; + referencedBy: FilePath; +} + +/** A `$ref` the portal's bundler fails on, as written, with the file it is written in. */ +export interface UnresolvableReference { + file: FilePath; + ref: string; +} + +/** A specification and every file the portal's bundler embeds into it, added as each is read. */ +export class SpecBundle { + private readonly rootIds: ReadonlySet; + private readonly documents = new Map(); + private readonly named = new Map(); + // Those of `named` not yet read, so a chain of files a level apiece is not scanned whole at each level. + private pending: ReferencedFile[] = []; + private readonly unresolved: UnresolvableReference[] = []; + + constructor(private readonly file: FilePath, private readonly document: OpenApiDocument) { + this.rootIds = document.schemaIds(); + this.add(file, document); + } + + /** Records `document` as read from `file`, and names the files it refers to. */ + public add(file: FilePath, document: OpenApiDocument): void { + this.documents.set(key(file), document); + const { files, unnamed } = document.references(file.directory(), this.rootIds); + for (const referenced of files) { + if (!this.named.has(key(referenced))) { + const named = { file: referenced, referencedBy: file }; + this.named.set(key(referenced), named); + this.pending.push(named); + } + } + // An empty `$ref` fails anywhere; `#` and `#pet` only in the root, as the bundler makes an embedded file's pointers. + const isRoot = file.isEqual(this.file); + for (const ref of unnamed.filter((written) => isRoot || written === '')) { + this.unresolved.push({ file, ref }); + } + } + + /** The `$ref`s read so far that name no file the bundler can read, in the order read. */ + public unresolvable(): UnresolvableReference[] { + return [...this.unresolved]; + } + + /** The files named but not read yet, each with the first file to name it. */ + public unread(): ReferencedFile[] { + this.pending = this.pending.filter(({ file }) => !this.documents.has(key(file))); + return [...this.pending]; + } + + /** Every file named, each with the first file to name it. */ + public referenced(): ReferencedFile[] { + return [...this.named.values()]; + } + + /** Its operations; a path item in another file is read from it, but not one that file refers on to. */ + public endpoints(): Endpoint[] { + const mounted = this.document + .pathItemReferences(this.file.directory()) + .flatMap(({ path, file, pointer }) => this.documents.get(key(file))?.endpointsAt(path, pointer) ?? []); + // A method beside a path item's `$ref` declares again what the file it refers to declares. + const all = [...this.document.endpoints(), ...mounted]; + return all.filter((endpoint, index) => index === all.findIndex((other) => other.isEqual(endpoint))); + } +} + +// By path rather than by `isEqual`: a split specification runs to thousands of files. +function key(file: FilePath): string { + return file.toString(); +} diff --git a/test/actions/portal/serve.test.ts b/test/actions/portal/serve.test.ts index f29e5d5c8..60f2ba9fb 100644 --- a/test/actions/portal/serve.test.ts +++ b/test/actions/portal/serve.test.ts @@ -17,7 +17,7 @@ import { FileName } from '../../../src/types/file/fileName'; import { FilePath } from '../../../src/types/file/filePath'; import { UrlPath } from '../../../src/types/file/urlPath'; import { CommandMetadata } from '../../../src/types/common/command-metadata'; -import { PortalSource } from '../../../src/types/portal/portal-source'; +import { PortalSource, WatchedSource } from '../../../src/types/portal/portal-source'; import { Language } from '../../../src/types/sdk/generate'; import { PortalSourceContext } from '../../../src/types/portal-source-context'; import { ProjectContext } from '../../../src/types/project-context'; @@ -52,6 +52,30 @@ describe('PortalServeAction', () => { openInBrowser ); + /** Stands in for the watch on `directory`, answered by `watching`; any other tree's watch is left alone. */ + const watchTreeAt = (directory: DirectoryPath, watching: { close: sinon.SinonStub; recheck: sinon.SinonStub }) => + new Promise<{ onChange: () => Promise; onFailed: (reason: string) => void }>((resolve) => { + watchTree.callsFake( + (watched: DirectoryPath, onChange: () => Promise, onFailed: (reason: string) => void) => { + if (!watched.isEqual(directory)) { + return ok({ close: sinon.stub().resolves(), recheck: sinon.stub() }); + } + resolve({ onChange, onFailed }); + return ok(watching); + } + ); + }); + + /** The reason `report` gave each time it was called for `watched`. */ + const reasonsFor = (report: sinon.SinonStub, watched: WatchedSource) => + report.getCalls().flatMap(({ args: [about, reason] }) => (about === watched ? [reason] : [])); + + /** How many of the saves `shown` was called for it said fix one refused. */ + const fixesSaid = (shown: sinon.SinonStub) => shown.getCalls().filter(({ args: [{ fixed }] }) => fixed).length; + + /** How many saves of `apimatic.json` it said were applied. */ + const appliedSaid = () => prompts.configShown.getCalls().filter(({ args: [applied] }) => applied).length; + beforeEach(() => { root = fs.mkdtempSync(path.join(os.tmpdir(), 'portal-serve-')); @@ -227,15 +251,7 @@ describe('PortalServeAction', () => { closeWatch = sinon.stub().resolves(); recheck = sinon.stub(); applyContent = sinon.stub(PortalProjectService.prototype, 'applyContent').resolves(ok(undefined)); - watched = new Promise((resolve) => { - watchTree.callsFake( - (directory: DirectoryPath, onChange: () => Promise, onFailed: (reason: string) => void) => { - expect(directory.toString()).to.equal(source.join('content').toString()); - resolve({ onChange, onFailed }); - return ok({ close: closeWatch, recheck }); - } - ); - }); + watched = watchTreeAt(source.join('content'), { close: closeWatch, recheck }); }); // The preview itself turns every page into an HTTP 500 and says nothing. @@ -264,14 +280,14 @@ describe('PortalServeAction', () => { it('says once that the content is fixed, and nothing for a save a build accepts', async () => { await whileServing(async (save) => { await save(JSON.stringify({ pages: ['intro'] })); - expect(prompts.contentRejected.called || prompts.contentAccepted.called).to.be.false; + expect(prompts.contentRejected.called || fixesSaid(prompts.contentShown) > 0).to.be.false; await save('{'); await save(JSON.stringify({ pages: ['intro'] })); await save(JSON.stringify({ pages: ['intro', '...'] })); expect(prompts.contentRejected.calledOnce).to.be.true; - expect(prompts.contentAccepted.calledOnce).to.be.true; + expect(fixesSaid(prompts.contentShown)).to.equal(1); }); }); @@ -299,8 +315,8 @@ describe('PortalServeAction', () => { await whileServing(async (save) => { await save(JSON.stringify({ title: 'Overview', pages: ['intro'] })); - expect(prompts.contentNotApplied.calledOnceWith('EACCES: permission denied')).to.be.true; - expect(prompts.contentNotices.called).to.be.false; + expect(reasonsFor(prompts.notApplied, 'content')).to.deep.equal(['EACCES: permission denied']); + expect(prompts.contentShown.called).to.be.false; }); }); @@ -310,9 +326,9 @@ describe('PortalServeAction', () => { await save(JSON.stringify({ title: 'Overview', pages: ['intro'] })); await save(JSON.stringify({ title: 'Overview', pages: ['intro', '...'] })); - const names = prompts.contentNotices + const names = prompts.contentShown .getCalls() - .map(({ args: [notices] }) => notices.sharedTabNames.map(({ name }) => name)); + .map(({ args: [{ notices }] }) => notices.sharedTabNames.map(({ name }) => name)); expect(names).to.deep.equal([['Overview'], []]); }); }); @@ -342,7 +358,7 @@ describe('PortalServeAction', () => { // What the watch does on a recheck: check the content as though it had just been saved. await (await watched).onChange(); - const [notices] = prompts.contentNotices.lastCall.args; + const [{ notices }] = prompts.contentShown.lastCall.args; expect(notices.sharedTabNames.map(({ name }) => name)).to.deep.equal(['Context Plugin']); await saveConfig(config); @@ -376,7 +392,7 @@ describe('PortalServeAction', () => { const result = await execute(new DirectoryPath(root)); - expect(prompts.contentNotWatched.calledOnceWith('EMFILE: too many open files')).to.be.true; + expect(reasonsFor(prompts.notWatched, 'content')).to.deep.equal(['EMFILE: too many open files']); expect(result.isCancelled()).to.be.true; }); @@ -384,7 +400,7 @@ describe('PortalServeAction', () => { await whileServing(async () => { (await watched).onFailed('EPERM: operation not permitted'); - expect(prompts.contentWatchFailed.calledOnceWith('EPERM: operation not permitted')).to.be.true; + expect(reasonsFor(prompts.watchFailed, 'content')).to.deep.equal(['EPERM: operation not permitted']); }); }); @@ -394,7 +410,7 @@ describe('PortalServeAction', () => { await whileServing(async (save) => { await save(JSON.stringify({ pages: ['intro'] })); - expect(prompts.contentNotChecked.calledOnceWith('EIO: i/o error')).to.be.true; + expect(reasonsFor(prompts.notChecked, 'content')).to.deep.equal(['EIO: i/o error']); }); }); @@ -408,13 +424,167 @@ describe('PortalServeAction', () => { expect(closeWatch.calledBefore(prompts.previewStopped)).to.be.true; }); - it('watches nothing when there is no content directory', async () => { + it('watches no content when there is no content directory', async () => { fs.rmSync(path.join(source.toString(), 'content'), { recursive: true }); interrupt(); await execute(new DirectoryPath(root)); - expect(watchTree.called).to.be.false; + const watchedDirectories = watchTree.getCalls().map(({ args: [directory] }) => directory.toString()); + expect(watchedDirectories).to.deep.equal([source.join('spec').toString()]); + }); + }); + + // The preview reloads a saved specification itself; the CLI's part is saying what a build would refuse. + describe('checking a save in the spec directory', () => { + let source: DirectoryPath; + let watched: Promise<{ onChange: () => Promise; onFailed: (reason: string) => void }>; + let closeWatch: sinon.SinonStub; + let recheck: sinon.SinonStub; + let written: string; + + const specFile = (name: string) => path.join(source.toString(), 'spec', name); + const writeSpec = (name: string, contents: string) => fs.writeFileSync(specFile(name), contents); + const halfWritten = () => writeSpec('Apimatic-Calculator.json', written.slice(0, written.length / 2)); + const restored = () => writeSpec('Apimatic-Calculator.json', written); + + /** Runs the preview until `body` is done with it, then stops it as CTRL+C would. */ + const whileServing = async (body: (saved: () => Promise) => Promise) => { + const running = execute(new DirectoryPath(root)); + const { onChange } = await watched; + try { + await body(onChange); + } finally { + interrupt(); + await running; + } + }; + + beforeEach(() => { + source = new DirectoryPath(root).join('src'); + fs.cpSync(FIXTURE_SOURCE.toString(), source.toString(), { recursive: true }); + written = fs.readFileSync(specFile('Apimatic-Calculator.json'), 'utf8'); + + closeWatch = sinon.stub().resolves(); + recheck = sinon.stub(); + watched = watchTreeAt(source.join('spec'), { close: closeWatch, recheck }); + }); + + // The preview itself leaves the document's reference out, and says nothing. + it('reports a document saved half written, as a build would', async () => { + await whileServing(async (saved) => { + halfWritten(); + await saved(); + + const [problem, sourceDirectory] = prompts.specRejected.firstCall.args; + expect(problem.kind === 'unreadableSpec' && problem.file.relativeTo(source)).to.equal( + 'spec/Apimatic-Calculator.json' + ); + expect(sourceDirectory.isEqual(source)).to.be.true; + }); + }); + + it('reports a file a document refers to that is not there', async () => { + const document = JSON.parse(written); + document.components.schemas.Pet = { $ref: './schemas/Pet.yaml' }; + + await whileServing(async (saved) => { + writeSpec('Apimatic-Calculator.json', JSON.stringify(document)); + await saved(); + + const [problem] = prompts.specRejected.firstCall.args; + expect(problem.kind === 'missingSpecReference' && problem.missing.file.relativeTo(source)).to.equal( + 'spec/schemas/Pet.yaml' + ); + }); + }); + + it('says once that the specifications are fixed, and nothing for a save a build accepts', async () => { + await whileServing(async (saved) => { + await saved(); + expect(prompts.specRejected.called || fixesSaid(prompts.specsShown) > 0).to.be.false; + + halfWritten(); + await saved(); + restored(); + await saved(); + await saved(); + + expect(prompts.specRejected.calledOnce).to.be.true; + expect(fixesSaid(prompts.specsShown)).to.equal(1); + }); + }); + + it('warns once that a document added needs a restart, and says once that nothing differs when it is taken out', async () => { + await whileServing(async (saved) => { + writeSpec( + 'orders.json', + JSON.stringify({ openapi: '3.0.0', info: { title: 'Orders', version: '1' }, paths: {} }) + ); + await saved(); + await saved(); + + const changes = () => prompts.specsShown.getCalls().flatMap(({ args: [{ change }] }) => change ?? []); + expect(changes()).to.have.length(1); + const [change] = changes(); + expect(change.added.map((file) => file.relativeTo(source))).to.deep.equal(['spec/orders.json']); + expect(change.removed).to.be.empty; + + fs.rmSync(specFile('orders.json')); + await saved(); + await saved(); + + expect(changes()).to.have.length(2); + expect(changes()[1]).to.deep.equal({ added: [], removed: [] }); + }); + }); + + // An editor's swap file would only have the specifications checked again for nothing. + it("leaves dot files, as an editor's swap file is, and node_modules out of the watch", async () => { + await whileServing(async () => { + const call = watchTree.getCalls().find(({ args: [directory] }) => directory.isEqual(source.join('spec'))); + const isIgnored: (name: string) => boolean = call?.args[3]; + + expect(['.Apimatic-Calculator.json.swp', 'node_modules'].every(isIgnored)).to.be.true; + expect(isIgnored('Apimatic-Calculator.json')).to.be.false; + }); + }); + + // The specifications are read before the preview starts, which can take a minute. + it('checks the specifications again once they are watched, and stops watching when the preview stops', async () => { + await whileServing(async () => { + expect(recheck.calledOnce).to.be.true; + }); + + expect(closeWatch.calledBefore(prompts.stopping)).to.be.true; + }); + + it('says when the specifications cannot be watched, and serves regardless', async () => { + watchTree.returns(err('EMFILE: too many open files')); + interrupt(); + + const result = await execute(new DirectoryPath(root)); + + expect(reasonsFor(prompts.notWatched, 'spec')).to.deep.equal(['EMFILE: too many open files']); + expect(result.isCancelled()).to.be.true; + }); + + it('tells the user when the specifications stop being watched', async () => { + await whileServing(async () => { + (await watched).onFailed('EPERM: operation not permitted'); + + expect(reasonsFor(prompts.watchFailed, 'spec')).to.deep.equal(['EPERM: operation not permitted']); + }); + }); + + it('says when a save could not be checked, rather than dropping it', async () => { + sinon.stub(PortalSourceContext.prototype, 'resolveSpecs').rejects(new Error('EIO: i/o error')); + + await whileServing(async (saved) => { + await saved(); + + expect(reasonsFor(prompts.notChecked, 'spec')).to.deep.equal(['EIO: i/o error']); + }); }); }); @@ -516,7 +686,7 @@ describe('PortalServeAction', () => { await (await watched).onChange(); expect(readProject('src/styles/theme.css')).to.contain('--color-fd-primary: #1d4ed8;'); - expect(prompts.configApplied.calledOnce).to.be.true; + expect(appliedSaid()).to.equal(1); }); }); @@ -530,7 +700,7 @@ describe('PortalServeAction', () => { expect(readProject('src/styles/theme.css')).to.contain('--color-fd-primary: #1d4ed8;'); expect(JSON.parse(readProject('portal.identity.json')).name).to.equal('Renamed API'); - expect(prompts.configApplied.calledOnce).to.be.true; + expect(appliedSaid()).to.equal(1); }); }); @@ -545,7 +715,7 @@ describe('PortalServeAction', () => { expect([readProject('portal.identity.json'), readProject('generated/sdks/typescript.mdx')]).to.deep.equal( before ); - expect(prompts.configApplied.called).to.be.false; + expect(appliedSaid()).to.equal(0); expect(prompts.configRejected.called).to.be.false; }); }); @@ -568,7 +738,7 @@ describe('PortalServeAction', () => { 'packageUrl="https://www.npmjs.com/package/calc"' ); expect(readProject('generated/sdks/index.mdx')).to.contain('install="npm install calc"'); - expect(prompts.configApplied.calledOnce).to.be.true; + expect(appliedSaid()).to.equal(1); }); }); @@ -593,11 +763,11 @@ describe('PortalServeAction', () => { }) ).to.be.true; expect(projectHas('generated/sdks/python.mdx')).to.be.false; - expect(prompts.configApplied.called).to.be.false; + expect(appliedSaid()).to.equal(0); await save(originalConfig()); - expect(prompts.configApplied.calledOnce).to.be.true; + expect(appliedSaid()).to.equal(1); }); }); @@ -619,7 +789,7 @@ describe('PortalServeAction', () => { expect(readProject('generated/context-plugin/index.mdx')).to.contain('Context Plugin'); expect(prompts.editNeedsRestart.called).to.be.false; - expect(prompts.configApplied.calledOnce).to.be.true; + expect(appliedSaid()).to.equal(1); }); }); @@ -633,7 +803,7 @@ describe('PortalServeAction', () => { await save(originalConfig()); expect(projectHas('generated/sdks/python.mdx')).to.be.false; - expect(prompts.configApplied.calledOnce).to.be.true; + expect(appliedSaid()).to.equal(1); }); }); }); @@ -666,11 +836,11 @@ describe('PortalServeAction', () => { await save(broken); expect(prompts.configRejected.firstCall.args[0].kind).to.equal('invalidConfig'); - expect(prompts.configApplied.called).to.be.false; + expect(appliedSaid()).to.equal(0); expect([readProject('portal.identity.json'), readProject('src/styles/theme.css')]).to.deep.equal(before); await save(originalConfig()); - expect(prompts.configApplied.calledOnce).to.be.true; + expect(appliedSaid()).to.equal(1); }); }); @@ -716,7 +886,7 @@ describe('PortalServeAction', () => { await save(config); expect(prompts.staticDirectoryNotServed.called).to.be.false; - expect(prompts.configApplied.calledOnce).to.be.true; + expect(appliedSaid()).to.equal(1); }); }); @@ -728,8 +898,8 @@ describe('PortalServeAction', () => { await save(config); - expect(prompts.configNotApplied.calledOnceWith('EACCES: permission denied')).to.be.true; - expect(prompts.configApplied.called).to.be.false; + expect(reasonsFor(prompts.notApplied, 'config')).to.deep.equal(['EACCES: permission denied']); + expect(appliedSaid()).to.equal(0); }); }); @@ -743,10 +913,10 @@ describe('PortalServeAction', () => { await save(config); - expect(prompts.configNotApplied.calledOnceWith('EBUSY: resource busy or locked')).to.be.true; + expect(reasonsFor(prompts.notChecked, 'config')).to.deep.equal(['EBUSY: resource busy or locked']); resolveSettings.restore(); await save(config); - expect(prompts.configApplied.calledOnce).to.be.true; + expect(appliedSaid()).to.equal(1); }); }); @@ -754,7 +924,7 @@ describe('PortalServeAction', () => { await whileServing(async () => { (await watched).onFailed('EPERM: operation not permitted'); - expect(prompts.configWatchFailed.calledOnceWith('EPERM: operation not permitted')).to.be.true; + expect(reasonsFor(prompts.watchFailed, 'config')).to.deep.equal(['EPERM: operation not permitted']); }); }); @@ -764,7 +934,7 @@ describe('PortalServeAction', () => { const result = await execute(new DirectoryPath(root)); - expect(prompts.configNotWatched.calledOnceWith('EMFILE: too many open files')).to.be.true; + expect(reasonsFor(prompts.notWatched, 'config')).to.deep.equal(['EMFILE: too many open files']); expect(result.isCancelled()).to.be.true; }); }); diff --git a/test/e2e/portal-serve.test.ts b/test/e2e/portal-serve.test.ts index 093eca0ff..b11324e75 100644 --- a/test/e2e/portal-serve.test.ts +++ b/test/e2e/portal-serve.test.ts @@ -11,6 +11,7 @@ import { FilePath } from '../../src/types/file/filePath'; import { CodeSampleCatalogs } from '../../src/types/portal/code-samples'; import { PortalArtifacts } from '../../src/types/portal/portal-artifacts'; import { canonical } from '../../src/infrastructure/tmp-extensions'; +import { sleep } from '../../src/infrastructure/timer-extensions'; // Opt-in: a real dev server needs a minute and every runtime dependency. const enabled = process.env.APIMATIC_E2E === '1'; @@ -19,13 +20,17 @@ const enabled = process.env.APIMATIC_E2E === '1'; this.timeout(10 * 60 * 1000); const fixture = new DirectoryPath(process.cwd()).join('test/resources/portal-inputs/default/src'); - let root: string | undefined; - let server: PortalDevServer | undefined; + const specFile = (source: DirectoryPath) => path.join(source.toString(), 'spec', 'Apimatic-Calculator.json'); + const reference = '/api/apimatic-calculator/simple-calculator/Calculate.md'; + const roots: string[] = []; + const servers: PortalDevServer[] = []; - before(async () => { - root = canonical(fs.mkdtempSync(path.join(os.tmpdir(), 'portal-serve-e2e-'))); + /** A preview of `source`, its project in the temp directory, which on a CI runner is another drive than the checkout. */ + const startPreview = async (source: DirectoryPath) => { + const root = canonical(fs.mkdtempSync(path.join(os.tmpdir(), 'portal-serve-e2e-'))); + roots.push(root); - const source = (await new PortalSourceContext(fixture).resolve())._unsafeUnwrap(); + const resolved = (await new PortalSourceContext(source).resolve())._unsafeUnwrap(); const delivered = path.join(root, 'delivered'); fs.mkdirSync(delivered, { recursive: true }); fs.writeFileSync(path.join(delivered, 'typescript.zip'), 'PK typescript'); @@ -35,47 +40,142 @@ const enabled = process.env.APIMATIC_E2E === '1'; new Map([['typescript', '## Installation\n\nInstall it.\n']]), undefined ); - expect(source.generatedPages.missingFrom(artifacts), 'the artifacts back every page').to.be.null; + expect(resolved.generatedPages.missingFrom(artifacts), 'the artifacts back every page').to.be.null; const project = new DirectoryPath(root).join('build'); fs.mkdirSync(project.toString(), { recursive: true }); - const prepared = (await new PortalProjectService().prepare(project, source, artifacts))._unsafeUnwrap(); + const prepared = (await new PortalProjectService().prepare(project, resolved, artifacts))._unsafeUnwrap(); const started = await new PortalDevServerService().start(prepared, await getPort()); if (started.isErr()) { throw new Error(`${started.error.message}\n${started.error.log.split('\n').slice(-20).join('\n')}`); } - server = started.value; + servers.push(started.value); + return { project, server: started.value }; + }; + + /** + * A copy of the fixture as `edit` leaves it, for a test that edits what its preview reads, reached through a link + * to it, as a user's folder may be and macOS's temp directory is. + */ + const linkedCopy = (edit: (source: DirectoryPath) => void = () => undefined): DirectoryPath => { + const root = canonical(fs.mkdtempSync(path.join(os.tmpdir(), 'portal-serve-source-'))); + roots.push(root); + const copy = path.join(root, 'copy'); + fs.cpSync(fixture.toString(), copy, { recursive: true }); + edit(new DirectoryPath(copy)); + fs.symlinkSync(copy, path.join(root, 'src'), 'junction'); + return new DirectoryPath(root).join('src'); + }; + + let checkout: { project: DirectoryPath; server: PortalDevServer }; + let linked: { source: DirectoryPath; server: PortalDevServer }; + + before(async () => { + checkout = await startPreview(fixture); + const source = linkedCopy(); + linked = { source, server: (await startPreview(source)).server }; }); after(async () => { - await server?.stop(); - if (root !== undefined) fs.rmSync(root, { recursive: true, force: true }); + for (const server of servers) await server.stop(); + roots.forEach((root) => fs.rmSync(root, { recursive: true, force: true })); }); + // Under `vite dev` a page that is not there is answered 200 too, so a Markdown twin is told by its type. + const servesMarkdown = async (from: PortalDevServer, address: string): Promise => { + const response = await globalThis.fetch(`${from.url.toString()}${address}`); + await response.arrayBuffer(); + return response.ok && (response.headers.get('content-type') ?? '').startsWith('text/markdown'); + }; + + // The preview reloads a saved specification in its own time. + const servesMarkdownOnceIt = async (from: PortalDevServer, address: string, expected: boolean): Promise => { + 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'); }); // The image comes from the static directory by its address, which Vite serves from any drive. - it("serves a page's static image at its public address, its module asked for before the page", async () => { + 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 module = await globalThis.fetch(`${server?.url.toString()}/@fs/${image}?import`); - const response = await globalThis.fetch(`${server?.url.toString()}/images/logo.png`); + const response = await globalThis.fetch(`${linked.server.url.toString()}/@fs/${image}`); - 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'); }); + + 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/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/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']); + }); +});