diff --git a/test/__fixtures__/no-component-schemas.json b/test/__fixtures__/no-component-schemas.json new file mode 100644 index 000000000..b3952dbaa --- /dev/null +++ b/test/__fixtures__/no-component-schemas.json @@ -0,0 +1,18 @@ +{ + "openapi": "3.0.3", + "info": { + "title": "No component schemas", + "version": "1.0.0" + }, + "paths": { + "/ping": { + "get": { + "responses": { + "200": { + "description": "ok" + } + } + } + } + } +} diff --git a/test/__fixtures__/resolve-schema-edges.json b/test/__fixtures__/resolve-schema-edges.json new file mode 100644 index 000000000..70e620e67 --- /dev/null +++ b/test/__fixtures__/resolve-schema-edges.json @@ -0,0 +1,71 @@ +{ + "openapi": "3.0.3", + "info": { + "title": "Resolve schema edges", + "version": "1.0.0" + }, + "paths": { + "/x": { + "get": { + "responses": { + "200": { + "description": "ok", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Pet" + } + } + } + } + } + } + } + }, + "components": { + "schemas": { + "Pet": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "name": { + "$ref": "#/components/schemas/Name" + } + } + }, + "Name": { + "type": "string" + }, + "Tags": { + "type": "array", + "items": { + "type": "string" + } + }, + "Alias": { + "$ref": "#/components/schemas/Pet" + }, + "Holder": { + "type": "object", + "properties": { + "items": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PetRef" + } + } + } + }, + "PetRef": { + "type": "object", + "properties": { + "id": { + "type": "string" + } + } + } + } + } +} diff --git a/test/commands/changelogs/index.test.ts b/test/commands/changelogs/index.test.ts index 5ce23d909..5211decff 100644 --- a/test/commands/changelogs/index.test.ts +++ b/test/commands/changelogs/index.test.ts @@ -1,11 +1,13 @@ import type { PageObject } from '../../helpers/page.types.js'; import fs from 'node:fs'; +import fsPromises from 'node:fs/promises'; +import os from 'node:os'; import path from 'node:path'; import chalk from 'chalk'; import grayMatter from 'gray-matter'; -import { beforeAll, beforeEach, describe, expect, it } from 'vitest'; +import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; import Command from '../../../src/commands/changelogs.js'; import { APIv1Error } from '../../../src/lib/apiError.js'; @@ -42,6 +44,150 @@ describe('rdme changelogs', () => { ); }); + it('should rethrow filesystem errors other than a missing path', async () => { + const denied = Object.assign(new Error('permission denied'), { code: 'EACCES' }); + vi.spyOn(fsPromises, 'stat').mockRejectedValue(denied); + + await expect(run(['./test/__fixtures__/changelogs/new-docs', '--key', key])).rejects.toBe(denied); + + vi.restoreAllMocks(); + }); + + it('should upload child changelogs after their parentDocSlug target', async () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'rdme-changelogs-parent-')); + try { + fs.writeFileSync( + path.join(tmpDir, 'parent.md'), + `--- +title: Parent changelog +--- + +Parent body +`, + ); + fs.writeFileSync( + path.join(tmpDir, 'child.md'), + `--- +title: Child changelog +parentDocSlug: parent +--- + +Child body +`, + ); + + const getMocks = getAPIv1Mock() + .get('/api/v1/changelogs/parent') + .basicAuth({ user: key }) + .reply(404, { error: 'CHANGELOG_NOTFOUND' }) + .get('/api/v1/changelogs/child') + .basicAuth({ user: key }) + .reply(404, { error: 'CHANGELOG_NOTFOUND' }); + + const postMocks = getAPIv1Mock() + .post('/api/v1/changelogs', body => body.slug === 'parent') + .basicAuth({ user: key }) + .reply(201, { slug: 'parent', _id: '1' }) + .post('/api/v1/changelogs', body => body.slug === 'child') + .basicAuth({ user: key }) + .reply(201, { slug: 'child', _id: '2' }); + + const result = await run([tmpDir, '--key', key]); + + expect(result).toContain("successfully created 'parent'"); + expect(result).toContain("successfully created 'child'"); + expect(result.indexOf("successfully created 'parent'")).toBeLessThan( + result.indexOf("successfully created 'child'"), + ); + + getMocks.done(); + postMocks.done(); + } finally { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + }); + + it('should sort changelogs that use the legacy parentDoc attribute', async () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'rdme-changelogs-parentdoc-')); + try { + fs.writeFileSync( + path.join(tmpDir, 'child.md'), + `--- +title: Child changelog +parentDoc: 5f92cbf10cf217478ba93561 +--- + +Child body +`, + ); + fs.writeFileSync( + path.join(tmpDir, 'parent.md'), + `--- +title: Parent changelog +--- + +Parent body +`, + ); + + const getMocks = getAPIv1Mock() + .get('/api/v1/changelogs/parent') + .basicAuth({ user: key }) + .reply(404, { error: 'CHANGELOG_NOTFOUND' }) + .get('/api/v1/changelogs/child') + .basicAuth({ user: key }) + .reply(404, { error: 'CHANGELOG_NOTFOUND' }); + + const postMocks = getAPIv1Mock() + .post('/api/v1/changelogs', body => body.slug === 'parent') + .basicAuth({ user: key }) + .reply(201, { slug: 'parent', _id: '1' }) + .post('/api/v1/changelogs', body => body.slug === 'child') + .basicAuth({ user: key }) + .reply(201, { slug: 'child', _id: '2' }); + + const result = await run([tmpDir, '--key', key]); + + expect(result).toContain("successfully created 'parent'"); + expect(result).toContain("successfully created 'child'"); + + getMocks.done(); + postMocks.done(); + } finally { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + }); + + it('should error when parentDocSlug values form a cycle', async () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'rdme-changelogs-cycle-')); + try { + fs.writeFileSync( + path.join(tmpDir, 'a.md'), + `--- +title: A +parentDocSlug: b +--- + +A +`, + ); + fs.writeFileSync( + path.join(tmpDir, 'b.md'), + `--- +title: B +parentDocSlug: a +--- + +B +`, + ); + + await expect(run([tmpDir, '--key', key])).rejects.toThrow(/Cyclic dependency/); + } finally { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + }); + describe('existing changelogs', () => { let simpleDoc: PageObject; let anotherDoc: PageObject; diff --git a/test/commands/docs/migrate.test.ts b/test/commands/docs/migrate.test.ts index 240b1ae53..c6dc7fa47 100644 --- a/test/commands/docs/migrate.test.ts +++ b/test/commands/docs/migrate.test.ts @@ -1,17 +1,85 @@ +import type { MigrationStats } from '../../../src/lib/hooks/exported.js'; +import type { PageMetadata } from '../../../src/lib/readPage.js'; import type { OclifOutput } from '../../helpers/oclif.js'; +import type { Hook } from '@oclif/core'; -import { beforeAll, describe, expect, it } from 'vitest'; +import { execFileSync } from 'node:child_process'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +import { Config } from '@oclif/core'; +import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest'; import Command from '../../../src/commands/docs/migrate.js'; import { runCommand } from '../../helpers/oclif.js'; +const fixtureDocs = 'test/__fixtures__/docs/new-docs'; + +type HookResult = Hook.Result; + +function emptyHookResult(): HookResult { + return { successes: [], failures: [] }; +} + +function installMigrateHooks(hooks: { + fileScan?: (opts: unknown) => HookResult; + validation?: (opts: { pages: PageMetadata[] }) => HookResult; +}) { + const originalRunHook = Config.prototype.runHook; + vi.spyOn(Config.prototype, 'runHook').mockImplementation(function runHookOverride(this: Config, event, opts) { + if (event === 'pre_markdown_file_scan') { + return hooks.fileScan ? hooks.fileScan(opts) : emptyHookResult(); + } + if (event === 'pre_markdown_validation') { + return hooks.validation ? hooks.validation(opts as { pages: PageMetadata[] }) : emptyHookResult(); + } + return originalRunHook.call(this, event, opts); + }); +} + +function successfulValidation(pages: PageMetadata[], pluginName = 'test-plugin'): HookResult { + return { + successes: [ + { + hook: 'pre_markdown_validation', + plugin: { name: pluginName } as Hook.Result['successes'][number]['plugin'], + result: { + pages, + stats: { + pages: pages.map(page => ({ + inputPath: page.filePath, + outputPath: page.filePath, + slug: page.slug, + title: String(page.data.title ?? page.slug), + type: 'migrated' as const, + })), + }, + }, + }, + ], + failures: [], + }; +} + describe('rdme docs migrate', () => { let run: (args?: string[]) => OclifOutput; + const tempDirs: string[] = []; beforeAll(() => { run = runCommand(Command); }); + afterEach(() => { + vi.restoreAllMocks(); + while (tempDirs.length) { + const dir = tempDirs.pop(); + if (dir && fs.existsSync(dir)) { + fs.rmSync(dir, { recursive: true, force: true }); + } + } + }); + it('should error out if no path is passed', async () => { const output = await run(); expect(output).toMatchSnapshot(); @@ -27,5 +95,180 @@ describe('rdme docs migrate', () => { expect(output).toMatchSnapshot(); }); - it.todo('should load plugin and transform docs'); + it('should throw when a file-scan plugin fails', async () => { + installMigrateHooks({ + fileScan: () => ({ + successes: [], + failures: [ + { + hook: 'pre_markdown_file_scan', + plugin: { name: 'broken-scan' } as Hook.Result['failures'][number]['plugin'], + error: new Error('scan exploded'), + }, + ], + }), + }); + + const output = await run([fixtureDocs, '--hide-experimental-warning']); + + expect(output.error).toBeInstanceOf(Error); + expect(output.error?.message).toBe('Error executing the `broken-scan` plugin: scan exploded'); + }); + + it('should throw when a validation plugin fails', async () => { + installMigrateHooks({ + validation: () => ({ + successes: [], + failures: [ + { + hook: 'pre_markdown_validation', + plugin: { name: 'broken-validate' } as Hook.Result['failures'][number]['plugin'], + error: new Error('validate exploded'), + }, + ], + }), + }); + + const output = await run([fixtureDocs, '--hide-experimental-warning']); + + expect(output.error).toBeInstanceOf(Error); + expect(output.error?.message).toBe('Error executing the `broken-validate` plugin: validate exploded'); + }); + + it('should ignore hook results that are empty or not Error instances', async () => { + const outputDir = fs.mkdtempSync(path.join(os.tmpdir(), 'rdme-migrate-out-')); + tempDirs.push(outputDir); + + installMigrateHooks({ + fileScan: () => ({ + successes: [ + { + hook: 'pre_markdown_file_scan', + plugin: { name: 'noop-scan' } as Hook.Result['successes'][number]['plugin'], + result: null, + }, + ], + failures: [ + { + hook: 'pre_markdown_file_scan', + plugin: { name: 'non-error-scan' } as Hook.Result['failures'][number]['plugin'], + error: 'not-an-error' as unknown as Error, + }, + ], + }), + validation: () => ({ + successes: [ + { + hook: 'pre_markdown_validation', + plugin: { name: 'empty-pages' } as Hook.Result['successes'][number]['plugin'], + result: { pages: [], stats: { pages: [] } }, + }, + ], + failures: [ + { + hook: 'pre_markdown_validation', + plugin: { name: 'non-error-validate' } as Hook.Result['failures'][number]['plugin'], + error: 'not-an-error' as unknown as Error, + }, + ], + }), + }); + + const output = await run([fixtureDocs, '--hide-experimental-warning', '--out', outputDir]); + + expect(output.error).toBeUndefined(); + expect(output.stdout).not.toContain('Markdown files updated'); + expect(fs.existsSync(path.join(outputDir, fixtureDocs, 'new-doc.md'))).toBe(false); + }); + + it('should rewrite the scan path when a file-scan plugin returns a directory', async () => { + const outputDir = fs.mkdtempSync(path.join(os.tmpdir(), 'rdme-migrate-out-')); + tempDirs.push(outputDir); + + installMigrateHooks({ + fileScan: () => ({ + successes: [ + { + hook: 'pre_markdown_file_scan', + plugin: { name: 'redirect' } as Hook.Result['successes'][number]['plugin'], + result: fixtureDocs, + }, + ], + failures: [], + }), + validation: ({ pages }) => successfulValidation(pages, 'redirect'), + }); + + const output = await run(['test/__fixtures__/ref-oas', '--hide-experimental-warning', '--out', outputDir]); + + expect(output.error).toBeUndefined(); + expect(output.stdout).toContain('Markdown files updated via the `redirect` plugin'); + expect(fs.existsSync(path.join(outputDir, fixtureDocs, 'new-doc.md'))).toBe(true); + expect((output.result as { stats: MigrationStats }).stats.results.redirect.pages).toHaveLength(1); + }); + + it('should write transformed pages and record plugin stats', async () => { + const outputDir = fs.mkdtempSync(path.join(os.tmpdir(), 'rdme-migrate-out-')); + tempDirs.push(outputDir); + + installMigrateHooks({ + validation: ({ pages }) => + successfulValidation( + pages.map(page => ({ + ...page, + data: { ...page.data, title: 'Migrated title' }, + })), + ), + }); + + const output = await run([fixtureDocs, '--hide-experimental-warning', '--out', outputDir]); + + expect(output.error).toBeUndefined(); + expect(output.stdout).toContain('1 Markdown files updated via the `test-plugin` plugin'); + const written = fs.readFileSync(path.join(outputDir, fixtureDocs, 'new-doc.md'), 'utf8'); + expect(written).toContain('title: Migrated title'); + expect((output.result as { stats: MigrationStats }).stats.results['test-plugin'].pages[0]).toMatchObject({ + slug: 'new-doc', + type: 'migrated', + }); + }); + + it('should skip frontmatter validation when `--skip-validation` is set', async () => { + const outputDir = fs.mkdtempSync(path.join(os.tmpdir(), 'rdme-migrate-out-')); + tempDirs.push(outputDir); + + installMigrateHooks({ + validation: ({ pages }) => successfulValidation(pages), + }); + + const output = await run([fixtureDocs, '--hide-experimental-warning', '--skip-validation', '--out', outputDir]); + + expect(output.error).toBeUndefined(); + expect(output.stderr).not.toContain('Validating frontmatter data'); + expect(fs.existsSync(path.join(outputDir, fixtureDocs, 'new-doc.md'))).toBe(true); + }); + + it('should record the unzipped directory when the input is a zip of guides', async () => { + const workspace = fs.mkdtempSync(path.join(os.tmpdir(), 'rdme-migrate-zip-')); + tempDirs.push(workspace); + const outputDir = path.join(workspace, 'out'); + fs.mkdirSync(outputDir); + + const sourceDir = path.join(workspace, 'guides'); + fs.mkdirSync(sourceDir); + fs.copyFileSync(path.join(fixtureDocs, 'new-doc.md'), path.join(sourceDir, 'new-doc.md')); + const zipPath = path.join(workspace, 'guides.zip'); + execFileSync('zip', ['-r', zipPath, 'guides'], { cwd: workspace }); + + installMigrateHooks({ + validation: ({ pages }) => successfulValidation(pages, 'zip-plugin'), + }); + + const output = await run([zipPath, '--hide-experimental-warning', '--skip-validation', '--out', outputDir]); + + expect(output.error).toBeUndefined(); + const stats = (output.result as { stats: MigrationStats }).stats; + expect(stats.unzippedAssetsDir).toBeDefined(); + expect(stats.unzippedAssetsDir).toMatch(/guides$/); + }); }); diff --git a/test/commands/openapi/__snapshots__/inspect.test.ts.snap b/test/commands/openapi/__snapshots__/inspect.test.ts.snap index bbe29460a..dd401c95d 100644 --- a/test/commands/openapi/__snapshots__/inspect.test.ts.snap +++ b/test/commands/openapi/__snapshots__/inspect.test.ts.snap @@ -5,6 +5,13 @@ exports[`rdme openapi inspect > feature reports > should generate a report for @ circularRefs: You do not use this.] `; +exports[`rdme openapi inspect > feature reports > should generate a report for @readme/oas-examples/3.0/json/readme-ex… (w/ [ 'circularRefs', 'webhooks' ]) 1`] = ` +[SoftError: +circularRefs: You do not use this. + +webhooks: You do not use this.] +`; + exports[`rdme openapi inspect > feature reports > should generate a report for @readme/oas-examples/3.0/json/readme-le… (w/ [ 'polymorphism' ]) 1`] = ` " polymorphism: diff --git a/test/commands/openapi/inspect.test.ts b/test/commands/openapi/inspect.test.ts index 47c4351fe..e7a5d232e 100644 --- a/test/commands/openapi/inspect.test.ts +++ b/test/commands/openapi/inspect.test.ts @@ -1,8 +1,9 @@ import type { OclifOutput } from '../../helpers/oclif.js'; -import { beforeAll, describe, expect, it } from 'vitest'; +import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest'; import Command from '../../../src/commands/openapi/inspect.js'; +import * as analyzeOas from '../../../src/lib/analyzeOas.js'; import { runCommand } from '../../helpers/oclif.js'; describe('rdme openapi inspect', () => { @@ -50,6 +51,11 @@ describe('rdme openapi inspect', () => { feature: ['circularRefs'], shouldSoftError: true, }, + { + spec: '@readme/oas-examples/3.0/json/readme-extensions.json', + feature: ['circularRefs', 'webhooks'], + shouldSoftError: true, + }, ]; // oxlint-disable vitest/no-conditional-expect @@ -66,4 +72,69 @@ describe('rdme openapi inspect', () => { }); // oxlint-enable vitest/no-conditional-expect }); + + describe('analyzer failures', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('should fail the spinner when analysis throws', async () => { + vi.spyOn(analyzeOas, 'default').mockRejectedValue(new Error('analyzer down')); + const spec = require.resolve('@readme/oas-examples/3.0/json/petstore.json'); + const output = await run([spec]); + + expect(output.error).toBeInstanceOf(Error); + expect(output.error?.message).toBe('analyzer down'); + }); + }); + + describe('report edge cases', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('should generate a report for a Swagger 2.0 definition', async () => { + const spec = require.resolve('@readme/oas-examples/2.0/json/petstore.json'); + const { result, error } = await run([spec]); + + expect(error).toBeUndefined(); + expect(result).toContain('OpenAPI Features'); + }); + + it('should highlight unusually large APIs and omit empty or hidden stats', async () => { + const original = analyzeOas.default; + vi.spyOn(analyzeOas, 'default').mockImplementation(async spec => { + const analysis = await original(spec); + analysis.general.operationTotal = { name: 'Operation', found: 201 }; + analysis.general.mediaTypes = { name: 'Media Type', found: [] }; + analysis.general.securityTypes = { name: 'Security Scheme', found: 0 }; + analysis.openapi.style.hidden = true; + analysis.openapi.links.url = 'https://example.com/links'; + return analysis; + }); + + const spec = require.resolve('@readme/oas-examples/3.0/json/petstore.json'); + const { result, error } = await run([spec]); + + expect(error).toBeUndefined(); + expect(result).toContain('Wow!'); + expect(result).toContain('https://example.com/links'); + expect(result).not.toContain('style'); + }); + + it('should fall back when a 3.1 feature has no 3.1 docs URL', async () => { + const original = analyzeOas.default; + vi.spyOn(analyzeOas, 'default').mockImplementation(async spec => { + const analysis = await original(spec); + analysis.openapi.webhooks.url = { '3.0': 'https://example.com/webhooks-3.0' }; + return analysis; + }); + + const spec = require.resolve('@readme/oas-examples/3.1/json/train-travel.json'); + const { result, error } = await run([spec]); + + expect(error).toBeUndefined(); + expect(result).toContain('This feature is not available on OpenAPI v3.1.'); + }); + }); }); diff --git a/test/commands/openapi/reduce.test.ts b/test/commands/openapi/reduce.test.ts index 9c8153ab4..1d2eaa6dc 100644 --- a/test/commands/openapi/reduce.test.ts +++ b/test/commands/openapi/reduce.test.ts @@ -96,6 +96,16 @@ describe('rdme openapi reduce', () => { expect(Object.keys(reducedSpec.paths['/pet/findByStatus'])).toStrictEqual(['get']); }); + it('should reduce every method on a path when no methods are selected', async () => { + const spec = require.resolve('@readme/oas-examples/3.0/json/petstore.json'); + prompts.inject(['paths', ['/pet'], [], 'output.json']); + + const { result, error } = await run([spec]); + expect(error).toBeUndefined(); + expect(result).toBe('Your reduced API definition has been saved to output.json! 🤏'); + expect(Object.keys(reducedSpec.paths)).toContain('/pet'); + }); + it('should reduce with no prompts via opts', async () => { await expect( run([ diff --git a/test/commands/openapi/resolve.test.ts b/test/commands/openapi/resolve.test.ts index bd8c7c9a2..b6201d449 100644 --- a/test/commands/openapi/resolve.test.ts +++ b/test/commands/openapi/resolve.test.ts @@ -7,6 +7,8 @@ import prompts from 'prompts'; import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; import Command from '../../../src/commands/openapi/resolve.js'; +import * as analyzeOas from '../../../src/lib/analyzeOas.js'; +import * as promptWrapper from '../../../src/lib/promptWrapper.js'; import { runCommand } from '../../helpers/oclif.js'; describe('openapi resolve', () => { @@ -70,5 +72,170 @@ describe('openapi resolve', () => { error: new Error('Sorry, this command only supports OpenAPI 3.0+ definitions.'), }); }); + + it('should fail if the definition has no component schemas', async () => { + const spec = './test/__fixtures__/no-component-schemas.json'; + + const { error } = await run([spec]); + expect(error).toStrictEqual(new Error('The file does not contain component schemas.')); + }); + + it('should fail when a reported circular reference path is too short', async () => { + vi.spyOn(analyzeOas, 'default').mockResolvedValue({ + general: {}, + openapi: { + circularRefs: { present: true, locations: ['#/foo'] }, + }, + } as never); + + const spec = require.resolve('@readme/oas-examples/3.0/json/petstore.json'); + const { error } = await run([spec]); + + expect(error).toStrictEqual(new Error('Invalid reference path: #/foo')); + }); + + it('should fail when a reported circular reference points at a missing property', async () => { + vi.spyOn(analyzeOas, 'default').mockResolvedValue({ + general: {}, + openapi: { + circularRefs: { present: true, locations: ['#/components/schemas/Pet/properties/not-a-real-prop'] }, + }, + } as never); + + const spec = require.resolve('@readme/oas-examples/3.0/json/petstore.json'); + const { error } = await run([spec]); + + expect(error).toStrictEqual(new Error('Property "not-a-real-prop" is not found or schema is invalid.')); + }); + + it('should fail when remaining circular references cannot be replaced', async () => { + vi.spyOn(analyzeOas, 'default').mockResolvedValue({ + general: {}, + openapi: { + circularRefs: { present: true, locations: ['#/components/schemas/Pet/properties/id'] }, + }, + } as never); + + const spec = require.resolve('@readme/oas-examples/3.0/json/petstore.json'); + const { error } = await run([spec]); + + expect(error).toStrictEqual( + new Error('Unable to resolve all circular references, even with fallback replacements.'), + ); + }); + + it('should fail when creating a proxy schema from an unsupported schema type', async () => { + vi.spyOn(analyzeOas, 'default').mockResolvedValue({ + general: {}, + openapi: { + circularRefs: { present: true, locations: ['#/components/schemas/Pet/properties/name'] }, + }, + } as never); + + const { error } = await run(['./test/__fixtures__/resolve-schema-edges.json']); + + expect(error).toStrictEqual(new Error('Unsupported schema type detected in: Name')); + }); + + it('should skip creating a second proxy schema for array items that already include Ref', async () => { + let calls = 0; + vi.spyOn(analyzeOas, 'default').mockImplementation(() => { + calls += 1; + return Promise.resolve({ + general: {}, + openapi: { + circularRefs: { + present: calls === 1, + locations: calls === 1 ? ['#/components/schemas/Holder/properties/items/items'] : [], + }, + }, + } as never); + }); + + prompts.inject(['output.json']); + const { error } = await run(['./test/__fixtures__/resolve-schema-edges.json']); + + expect(error).toBeUndefined(); + expect(fsWriteFileSyncSpy).toHaveBeenCalledWith('output.json', expect.any(String)); + }); + + it('should replace leftover array and $ref schemas, then fail on an invalid leftover schema', async () => { + let calls = 0; + vi.spyOn(analyzeOas, 'default').mockImplementation(() => { + calls += 1; + let locations = ['#/components/schemas/Pet/properties/id']; + if (calls === 12) locations = ['#/components/schemas/Tags/properties/ignored']; + if (calls === 13) locations = ['#/components/schemas/Alias/properties/ignored']; + if (calls >= 14) locations = ['#/components/schemas/Name/properties/ignored']; + return Promise.resolve({ + general: {}, + openapi: { + circularRefs: { present: true, locations }, + }, + } as never); + }); + + const { error } = await run(['./test/__fixtures__/resolve-schema-edges.json']); + + expect(error).toStrictEqual(new Error('Invalid schema format: {"type":"string"}')); + }); + + it('should fail object replacement when a leftover circular path is too short', async () => { + let calls = 0; + vi.spyOn(analyzeOas, 'default').mockImplementation(() => { + calls += 1; + return Promise.resolve({ + general: {}, + openapi: { + circularRefs: { + present: true, + locations: calls <= 11 ? ['#/components/schemas/Pet/properties/id'] : ['#/foo'], + }, + }, + } as never); + }); + + const spec = require.resolve('@readme/oas-examples/3.0/json/petstore.json'); + const { error } = await run([spec]); + + expect(error).toStrictEqual(new Error('Invalid reference path: #/foo')); + }); + + it('should use the spec filename when prompting for an output path', async () => { + vi.spyOn(promptWrapper, 'default').mockImplementation(questions => { + const question = Array.isArray(questions) ? questions[0] : questions; + const initial = typeof question.initial === 'function' ? question.initial() : question.initial; + return Promise.resolve({ outputPath: initial }); + }); + + const spec = './test/__fixtures__/circular-ref-oas/circular-references.json'; + const { error } = await run([spec]); + + expect(error).toBeUndefined(); + expect(fsWriteFileSyncSpy).toHaveBeenCalledWith('circular-references.openapi.json', expect.any(String)); + }); + + it('should warn and then fail when leftover circular refs point at a missing schema', async () => { + let calls = 0; + vi.spyOn(analyzeOas, 'default').mockImplementation(() => { + calls += 1; + const locations = + calls <= 11 ? ['#/components/schemas/Pet/properties/id'] : ['#/components/schemas/Ghost/properties/missing']; + return Promise.resolve({ + general: {}, + openapi: { + circularRefs: { present: true, locations }, + }, + } as never); + }); + + const spec = require.resolve('@readme/oas-examples/3.0/json/petstore.json'); + const { error, stderr } = await run([spec]); + + expect(stderr).toContain('Schema not found for: Ghost'); + expect(error).toStrictEqual( + new Error('Unable to resolve all circular references, even with fallback replacements.'), + ); + }); }); }); diff --git a/test/commands/openapi/upload.test.ts b/test/commands/openapi/upload.test.ts index af2b4612e..cc9e14171 100644 --- a/test/commands/openapi/upload.test.ts +++ b/test/commands/openapi/upload.test.ts @@ -321,6 +321,65 @@ describe('rdme openapi upload', () => { }); }); + it('should abort if the user declines overwriting an existing API definition', async () => { + prompts.inject([false]); + const mock = getAPIv2Mock({ authorization: `Bearer ${key}` }) + .get(`/branches/${branch}/apis`) + .reply(200, { data: [{ filename: slugifiedFilename }] }); + + const result = await run(['--branch', branch, filename, '--key', key, '--slug', slugifiedFilename]); + + expect(result.error).toBeInstanceOf(Error); + expect(result.error?.message).toBe('Aborting, no changes were made.'); + + mock.done(); + }); + + it('should throw when the upload response is missing a status and URI', async () => { + prompts.inject([true]); + const mock = getAPIv2Mock({ authorization: `Bearer ${key}` }) + .get(`/branches/${branch}/apis`) + .reply(200, { data: [] }) + .post(`/branches/${branch}/apis`) + .reply(200, { data: {} }); + + const result = await run(['--branch', branch, filename, '--key', key]); + + expect(result.error?.message).toContain('unexpected error'); + + mock.done(); + }); + + it('should fail the spinner when the upload request is rejected', async () => { + prompts.inject([true]); + const mock = getAPIv2Mock({ authorization: `Bearer ${key}` }) + .get(`/branches/${branch}/apis`) + .reply(200, { data: [] }) + .post(`/branches/${branch}/apis`) + .reply(400, { title: 'Bad request', detail: 'upload exploded' }); + + const result = await run(['--branch', branch, filename, '--key', key]); + + expect(result.error).toBeDefined(); + expect(result.error?.message).toContain('Bad request'); + + mock.done(); + }); + + it('should label nested Swagger files in the slug warning', async () => { + const swaggerPath = require.resolve('@readme/oas-examples/2.0/json/petstore.json'); + const mock = getAPIv2Mock({ authorization: `Bearer ${key}` }) + .get(`/branches/${branch}/apis`) + .reply(200, { data: [] }); + + const result = await run(['--branch', branch, swaggerPath, '--key', key, '--dry-run']); + + expect(result.error).toBeUndefined(); + expect(result.stderr).toContain('This Swagger file is located in a subfolder'); + + mock.done(); + }); + describe('and the `--slug` flag is passed', () => { it('should use the provided slug (no file extension) as the filename', async () => { const customSlug = 'custom-slug'; @@ -1516,6 +1575,21 @@ describe('rdme openapi upload', () => { mock.done(); }); + it('should treat a project response without a subdomain as unknown', async () => { + const mock = getAPIv2Mock({ authorization: `Bearer ${key}` }) + .get('/projects/me') + .reply(200, { data: { name: 'Owl Factory' } }) + .get(`/branches/${branch}/apis`) + .reply(200, { data: [] }); + + const result = await run(['--branch', branch, filename, '--key', key, '--dry-run']); + + expect(result.error).toBeUndefined(); + expect(result.stdout).toContain('Project: unknown'); + + mock.done(); + }); + it('should include the project in dry-run output', async () => { const mock = getAPIv2Mock({ authorization: `Bearer ${key}` }) .get('/projects/me') diff --git a/test/commands/openapi/validate.test.ts b/test/commands/openapi/validate.test.ts index ce599448e..6ffa77c8f 100644 --- a/test/commands/openapi/validate.test.ts +++ b/test/commands/openapi/validate.test.ts @@ -1,7 +1,10 @@ import type { OclifOutput } from '../../helpers/oclif.js'; import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import OASNormalize from 'oas-normalize'; import prompts from 'prompts'; import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; @@ -65,6 +68,14 @@ describe('rdme openapi validate', () => { }); describe('error handling', () => { + it('should throw an error if no API definition can be discovered', async () => { + const result = await run(['--working-directory', './test/__fixtures__/docs/new-docs']); + + expect(result.error).toBeInstanceOf(Error); + expect(result.error?.message).toContain("We couldn't find an OpenAPI or Swagger definition."); + expect(result.error?.message).toContain('rdme openapi validate ./path/to/api/definition'); + }); + it('should throw an error if invalid JSON is supplied', () => { return expect(run(['./test/__fixtures__/invalid-json/yikes.json'])).resolves.toMatchSnapshot(); }); @@ -81,6 +92,19 @@ describe('rdme openapi validate', () => { return expect(run(['./test/__fixtures__/invalid-swagger.json'])).resolves.toMatchSnapshot(); }); + it('should throw an error if converting the definition fails', async () => { + vi.spyOn(OASNormalize.prototype, 'convert').mockRejectedValue(new Error('cannot convert this definition')); + const spec = require.resolve('@readme/oas-examples/3.0/json/petstore.json'); + + try { + const result = await run([spec]); + expect(result.error).toBeInstanceOf(Error); + expect(result.error?.message).toBe('cannot convert this definition'); + } finally { + vi.restoreAllMocks(); + } + }); + it('should throw an error if an invalid API definition has many errors', () => { return expect(run(['./test/__fixtures__/very-invalid-oas.json'])).resolves.toMatchSnapshot(); }); @@ -107,6 +131,22 @@ describe('rdme openapi validate', () => { await expect(run([spec, '--github'])).resolves.toMatchSnapshot(); }); + + it('should refuse to pick among multiple discovered API definitions', async () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'rdme-multi-oas-')); + const spec = require.resolve('@readme/oas-examples/3.0/json/petstore.json'); + fs.copyFileSync(spec, path.join(tmpDir, 'one.json')); + fs.copyFileSync(spec, path.join(tmpDir, 'two.json')); + + try { + const result = await run(['--working-directory', tmpDir]); + + expect(result.error).toBeInstanceOf(Error); + expect(result.error?.message).toBe('Multiple API definitions found in current directory. Please specify file.'); + } finally { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + }); }); describe('GHA onboarding E2E tests', () => { diff --git a/test/commands/pages/export.test.ts b/test/commands/pages/export.test.ts index e477c8341..64f050d02 100644 --- a/test/commands/pages/export.test.ts +++ b/test/commands/pages/export.test.ts @@ -10,6 +10,7 @@ import { afterEach, beforeEach, beforeAll, describe, expect, it, vi } from 'vite import DocsExportCommand from '../../../src/commands/docs/export.js'; import ReferenceExportCommand from '../../../src/commands/reference/export.js'; +import * as safePath from '../../../src/lib/safePath.js'; import { getAPIv2Mock } from '../../helpers/get-api-mock.js'; import { runCommand } from '../../helpers/oclif.js'; @@ -254,6 +255,54 @@ Child body`), } }); + it('should still export empty link pages when --docs-only is set', async () => { + const tmpDir = tempExportDir(); + try { + const mock = getAPIv2Mock({ authorization }) + .get(`/branches/stable/categories/${route}`) + .reply(200, { data: [{ title: 'Links' }] }) + .get(`/branches/stable/categories/${route}/Links/pages`) + .reply(200, { data: [{ slug: 'external' }] }) + .get(`/branches/stable/${route}/external`) + .reply(200, { + data: { + slug: 'external', + title: 'External docs', + type: 'link', + content: { + body: '', + link: { url: 'https://example.com/docs', new_tab: true }, + }, + category: { uri: `https://api.readme.com/v2/branches/stable/categories/${route}/links` }, + }, + }); + + const output = await run([tmpDir, '--key', key, '--docs-only']); + + expect(output.error).toBeUndefined(); + expect(output.result).toMatchObject({ failed: [], skipped: 0 }); + expect(fs.writeFileSync).toHaveBeenCalledTimes(1); + expect(fs.writeFileSync).toHaveBeenCalledWith( + path.join(tmpDir, '.temp_download', 'external.md'), + expect.stringContaining('url: https://example.com/docs'), + { encoding: 'utf-8' }, + ); + expect(fs.writeFileSync).toHaveBeenCalledWith( + path.join(tmpDir, '.temp_download', 'external.md'), + expect.stringContaining('type: link'), + { encoding: 'utf-8' }, + ); + expect(fs.copyFileSync).toHaveBeenCalledWith( + path.join(tmpDir, '.temp_download', 'external.md'), + path.join(tmpDir, 'links', 'external.md'), + ); + + mock.done(); + } finally { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + }); + it('should skip empty non-link pages when --docs-only is set', async () => { const tmpDir = tempExportDir(); try { @@ -464,6 +513,283 @@ Child body`), } }); + it('should skip a page when its parent URI decodes to an unsafe path segment', async () => { + const tmpDir = tempExportDir(); + try { + const mock = getAPIv2Mock({ authorization }) + .get(`/branches/stable/categories/${route}`) + .reply(200, { data: [{ title: 'Main' }] }) + .get(`/branches/stable/categories/${route}/Main/pages`) + .reply(200, { data: [{ slug: 'child' }] }) + .get(`/branches/stable/${route}/child`) + .reply(200, { + data: { + slug: 'child', + title: 'Child', + type: 'basic', + content: { body: 'Child body' }, + category: { uri: `https://api.readme.com/v2/branches/stable/categories/${route}/main` }, + parent: { uri: `/branches/stable/${route}/%2e%2e%2fescape` }, + }, + }); + + const output = await run([tmpDir, '--key', key]); + + expect(fs.writeFileSync).not.toHaveBeenCalled(); + expect(fs.copyFileSync).not.toHaveBeenCalled(); + expect(output.stderr).toContain('Skipping page "child"'); + expect(output.stderr).toContain('invalid'); + expect(output.result).toMatchObject({ failed: ['child'] }); + + mock.done(); + } finally { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + }); + + it('should remove the temporary download folder when the categories request fails', async () => { + const tmpDir = tempExportDir(); + try { + const mock = getAPIv2Mock({ authorization }) + .get(`/branches/stable/categories/${route}`) + .reply(500, { title: 'Internal error' }); + + const output = await run([tmpDir, '--key', key]); + + expect(output.error).toBeDefined(); + expect(fs.existsSync(path.join(tmpDir, '.temp_download'))).toBe(false); + + mock.done(); + } finally { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + }); + + it('should warn when downloaded files have no frontmatter or slug', async () => { + const tmpDir = tempExportDir(); + try { + vi.mocked(fs.writeFileSync).mockImplementation((file, data) => { + const filePath = String(file); + const fd = fs.openSync(filePath, 'w'); + fs.writeSync(fd, typeof data === 'string' ? data : String(data)); + fs.closeSync(fd); + if (filePath.endsWith(`${path.sep}intro.md`)) { + const tempFolder = path.dirname(filePath); + for (const [name, contents] of [ + ['no-frontmatter.md', '# just a heading\n'], + ['no-slug.md', '---\ntitle: Missing slug\n---\n'], + ] as const) { + const extra = fs.openSync(path.join(tempFolder, name), 'w'); + fs.writeSync(extra, contents); + fs.closeSync(extra); + } + } + }); + + const mock = getAPIv2Mock({ authorization }) + .get(`/branches/stable/categories/${route}`) + .reply(200, { data: [{ title: 'Main' }] }) + .get(`/branches/stable/categories/${route}/Main/pages`) + .reply(200, { data: [{ slug: 'intro' }] }) + .get(`/branches/stable/${route}/intro`) + .reply(200, { + data: { + slug: 'intro', + title: 'Introduction', + type: 'basic', + content: { body: 'Hello world' }, + category: { uri: `https://api.readme.com/v2/branches/stable/categories/${route}/main` }, + }, + }); + + const output = await run([tmpDir, '--key', key]); + + expect(output.error).toBeUndefined(); + expect(output.stderr).toContain('no frontmatter found'); + expect(output.stderr).toContain('No slug found'); + + mock.done(); + } finally { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + }); + + it('should include markdown files found in subdirectories of the temp download folder', async () => { + const tmpDir = tempExportDir(); + try { + vi.mocked(fs.writeFileSync).mockImplementation((file, data) => { + const filePath = String(file); + const fd = fs.openSync(filePath, 'w'); + fs.writeSync(fd, typeof data === 'string' ? data : String(data)); + fs.closeSync(fd); + if (filePath.endsWith(`${path.sep}intro.md`)) { + const nestedDir = path.join(path.dirname(filePath), 'nested'); + fs.mkdirSync(nestedDir, { recursive: true }); + const extra = fs.openSync(path.join(nestedDir, 'nested-page.md'), 'w'); + fs.writeSync(extra, '---\nslug: nested-page\ntitle: Nested\n---\n'); + fs.closeSync(extra); + } + }); + + const mock = getAPIv2Mock({ authorization }) + .get(`/branches/stable/categories/${route}`) + .reply(200, { data: [{ title: 'Main' }] }) + .get(`/branches/stable/categories/${route}/Main/pages`) + .reply(200, { data: [{ slug: 'intro' }] }) + .get(`/branches/stable/${route}/intro`) + .reply(200, { + data: { + slug: 'intro', + title: 'Introduction', + type: 'basic', + content: { body: 'Hello world' }, + category: { uri: `https://api.readme.com/v2/branches/stable/categories/${route}/main` }, + }, + }); + + const output = await run([tmpDir, '--key', key]); + + expect(output.error).toBeUndefined(); + expect(fs.copyFileSync).toHaveBeenCalledWith( + expect.stringContaining('nested-page.md'), + expect.stringContaining('nested-page.md'), + ); + + mock.done(); + } finally { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + }); + + it('should refuse to restructure a file whose category path escapes the export directory', async () => { + const tmpDir = tempExportDir(); + try { + vi.mocked(fs.writeFileSync).mockImplementation((file, data) => { + const filePath = String(file); + const fd = fs.openSync(filePath, 'w'); + fs.writeSync(fd, typeof data === 'string' ? data : String(data)); + fs.closeSync(fd); + if (filePath.endsWith(`${path.sep}intro.md`)) { + const extra = fs.openSync(path.join(path.dirname(filePath), 'escape.md'), 'w'); + fs.writeSync( + extra, + `--- +slug: escape +category: + uri: .. +--- +`, + ); + fs.closeSync(extra); + } + }); + + const mock = getAPIv2Mock({ authorization }) + .get(`/branches/stable/categories/${route}`) + .reply(200, { data: [{ title: 'Main' }] }) + .get(`/branches/stable/categories/${route}/Main/pages`) + .reply(200, { data: [{ slug: 'intro' }] }) + .get(`/branches/stable/${route}/intro`) + .reply(200, { + data: { + slug: 'intro', + title: 'Introduction', + type: 'basic', + content: { body: 'Hello world' }, + category: { uri: `https://api.readme.com/v2/branches/stable/categories/${route}/main` }, + }, + }); + + const output = await run([tmpDir, '--key', key]); + + expect(output.error?.message).toBe('Refusing to write outside export directory: ../escape.md'); + + mock.done(); + } finally { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + }); + + it('should throw when a downloaded file has unparseable frontmatter', async () => { + const tmpDir = tempExportDir(); + try { + vi.mocked(fs.writeFileSync).mockImplementation((file, data) => { + const filePath = String(file); + const fd = fs.openSync(filePath, 'w'); + fs.writeSync(fd, typeof data === 'string' ? data : String(data)); + fs.closeSync(fd); + if (filePath.endsWith(`${path.sep}intro.md`)) { + const extra = fs.openSync(path.join(path.dirname(filePath), 'bad-yaml.md'), 'w'); + fs.writeSync(extra, '---\nslug: [\n---\n'); + fs.closeSync(extra); + } + }); + + const mock = getAPIv2Mock({ authorization }) + .get(`/branches/stable/categories/${route}`) + .reply(200, { data: [{ title: 'Main' }] }) + .get(`/branches/stable/categories/${route}/Main/pages`) + .reply(200, { data: [{ slug: 'intro' }] }) + .get(`/branches/stable/${route}/intro`) + .reply(200, { + data: { + slug: 'intro', + title: 'Introduction', + type: 'basic', + content: { body: 'Hello world' }, + category: { uri: `https://api.readme.com/v2/branches/stable/categories/${route}/main` }, + }, + }); + + const output = await run([tmpDir, '--key', key]); + + expect(output.error?.message).toMatch(/Error parsing frontmatter in .*bad-yaml\.md/); + + mock.done(); + } finally { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + }); + + it('should skip a page when the temporary export path cannot be resolved', async () => { + const tmpDir = tempExportDir(); + const originalResolve = safePath.resolvePathWithinRoot; + vi.spyOn(safePath, 'resolvePathWithinRoot').mockImplementation((root, ...segments) => { + if (String(root).includes('.temp_download') && String(segments[0]).endsWith('.md')) { + return null; + } + return originalResolve(root, ...segments); + }); + + try { + const mock = getAPIv2Mock({ authorization }) + .get(`/branches/stable/categories/${route}`) + .reply(200, { data: [{ title: 'Main' }] }) + .get(`/branches/stable/categories/${route}/Main/pages`) + .reply(200, { data: [{ slug: 'intro' }] }) + .get(`/branches/stable/${route}/intro`) + .reply(200, { + data: { + slug: 'intro', + title: 'Introduction', + type: 'basic', + content: { body: 'Hello world' }, + category: { uri: `https://api.readme.com/v2/branches/stable/categories/${route}/main` }, + }, + }); + + const output = await run([tmpDir, '--key', key]); + + expect(output.error).toBeUndefined(); + expect(output.stderr).toContain('refused to write outside'); + expect(output.result).toMatchObject({ failed: ['intro'] }); + + mock.done(); + } finally { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + }); + it('should warn and continue when a category contains no pages', async () => { const tmpDir = tempExportDir(); try { diff --git a/test/commands/pages/upload.test.ts b/test/commands/pages/upload.test.ts index 318b68f85..eeb86eedb 100644 --- a/test/commands/pages/upload.test.ts +++ b/test/commands/pages/upload.test.ts @@ -2,6 +2,8 @@ import type { FullUploadResults } from '../../../src/lib/syncPagePath.js'; import type { OclifOutput } from '../../helpers/oclif.js'; import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; import nock from 'nock'; import prompts from 'prompts'; @@ -286,6 +288,30 @@ describe.each([ expect(fs.writeFileSync).not.toHaveBeenCalled(); }); + it('should record a failed page when skip-validation leaves a non-string category URI', async () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'rdme-upload-bad-category-')); + try { + await fs.promises.writeFile( + path.join(tmpDir, 'bad-category.md'), + `--- +title: Bad category +category: + uri: 123 +--- + +Body +`, + ); + + const result = await run([tmpDir, '--key', key, '--skip-validation']); + + expect(result.error).toBeInstanceOf(TypeError); + expect(result.stderr).toContain('file(s) failed'); + } finally { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + }); + it('should skip client-side validation if the --skip-validation flag is passed', async () => { const mock = getAPIv2Mock({ authorization }) .get(`/branches/stable/${route}/legacy-category`) @@ -828,6 +854,71 @@ describe.each([ mock.done(); }); + + it('should treat string `position` values as numbers when ordering uploads', async () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'rdme-position-string-')); + // `writeFileSync` is mocked in this suite; use the promise API to persist fixtures. + await fs.promises.writeFile( + path.join(tmpDir, 'later.md'), + `--- +title: Later +category: + uri: category-slug +position: "10" +--- + +Body +`, + ); + await fs.promises.writeFile( + path.join(tmpDir, 'earlier.md'), + `--- +title: Earlier +category: + uri: category-slug +position: "2" +--- + +Body +`, + ); + + try { + const mock = getAPIv2Mock({ authorization }) + .get(`/branches/stable/${route}/later`) + .reply(404) + .get(`/branches/stable/${route}/earlier`) + .reply(404) + .post(`/branches/stable/${route}`, { + slug: 'earlier', + title: 'Earlier', + category: { uri: `/branches/stable/categories/${route}/category-slug` }, + position: '2', + content: { body: '\nBody\n' }, + }) + .reply(201, {}) + .post(`/branches/stable/${route}`, { + slug: 'later', + title: 'Later', + category: { uri: `/branches/stable/categories/${route}/category-slug` }, + position: '10', + content: { body: '\nBody\n' }, + }) + .reply(201, {}); + + const result = await run([tmpDir, '--key', key]); + + expect(result.error).toBeUndefined(); + expect((result.result as unknown as FullUploadResults).created.map(page => page.slug)).toStrictEqual([ + 'earlier', + 'later', + ]); + + mock.done(); + } finally { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + }); }); it('should error when pages form a cyclic parent chain', async () => { diff --git a/test/helpers/github-workflow-schema.json b/test/helpers/github-workflow-schema.json index 39559443d..ffcb27b67 100644 --- a/test/helpers/github-workflow-schema.json +++ b/test/helpers/github-workflow-schema.json @@ -1339,100 +1339,105 @@ }, "workflow_call": { "$comment": "https://docs.github.com/en/actions/learn-github-actions/events-that-trigger-workflows#workflow_call", - "type": "object", "description": "Allows workflows to be reused by other workflows.", - "properties": { - "inputs": { - "$comment": "https://docs.github.com/en/actions/reference/workflow-syntax-for-github-actions#onworkflow_callinputs", - "description": "When using the workflow_call keyword, you can optionally specify inputs that are passed to the called workflow from the caller workflow.", + "oneOf": [ + { "type": "null" }, + { "type": "object", - "patternProperties": { - "^[_a-zA-Z][a-zA-Z0-9_-]*$": { - "$comment": "https://docs.github.com/en/actions/creating-actions/metadata-syntax-for-github-actions#inputsinput_id", - "description": "A string identifier to associate with the input. The value of is a map of the input's metadata. The must be a unique identifier within the inputs object. The must start with a letter or _ and contain only alphanumeric characters, -, or _.", + "properties": { + "inputs": { + "$comment": "https://docs.github.com/en/actions/reference/workflow-syntax-for-github-actions#onworkflow_callinputs", + "description": "When using the workflow_call keyword, you can optionally specify inputs that are passed to the called workflow from the caller workflow.", "type": "object", - "properties": { - "description": { - "$comment": "https://help.github.com/en/github/automating-your-workflow-with-github-actions/metadata-syntax-for-github-actions#inputsinput_iddescription", - "description": "A string description of the input parameter.", - "type": "string" - }, - "required": { - "$comment": "https://help.github.com/en/github/automating-your-workflow-with-github-actions/metadata-syntax-for-github-actions#inputsinput_idrequired", - "description": "A boolean to indicate whether the action requires the input parameter. Set to true when the parameter is required.", - "type": "boolean" - }, - "type": { - "$comment": "https://docs.github.com/en/actions/learn-github-actions/workflow-syntax-for-github-actions#onworkflow_callinput_idtype", - "description": "Required if input is defined for the on.workflow_call keyword. The value of this parameter is a string specifying the data type of the input. This must be one of: boolean, number, or string.", - "type": "string", - "enum": ["boolean", "number", "string"] - }, - "default": { - "$comment": "https://help.github.com/en/github/automating-your-workflow-with-github-actions/metadata-syntax-for-github-actions#inputsinput_iddefault", - "description": "The default value is used when an input parameter isn't specified in a workflow file.", - "anyOf": [{ "type": "boolean" }, { "type": "number" }, { "type": "string" }] + "patternProperties": { + "^[_a-zA-Z][a-zA-Z0-9_-]*$": { + "$comment": "https://docs.github.com/en/actions/creating-actions/metadata-syntax-for-github-actions#inputsinput_id", + "description": "A string identifier to associate with the input. The value of is a map of the input's metadata. The must be a unique identifier within the inputs object. The must start with a letter or _ and contain only alphanumeric characters, -, or _.", + "type": "object", + "properties": { + "description": { + "$comment": "https://help.github.com/en/github/automating-your-workflow-with-github-actions/metadata-syntax-for-github-actions#inputsinput_iddescription", + "description": "A string description of the input parameter.", + "type": "string" + }, + "required": { + "$comment": "https://help.github.com/en/github/automating-your-workflow-with-github-actions/metadata-syntax-for-github-actions#inputsinput_idrequired", + "description": "A boolean to indicate whether the action requires the input parameter. Set to true when the parameter is required.", + "type": "boolean" + }, + "type": { + "$comment": "https://docs.github.com/en/actions/learn-github-actions/workflow-syntax-for-github-actions#onworkflow_callinput_idtype", + "description": "Required if input is defined for the on.workflow_call keyword. The value of this parameter is a string specifying the data type of the input. This must be one of: boolean, number, or string.", + "type": "string", + "enum": ["boolean", "number", "string"] + }, + "default": { + "$comment": "https://help.github.com/en/github/automating-your-workflow-with-github-actions/metadata-syntax-for-github-actions#inputsinput_iddefault", + "description": "The default value is used when an input parameter isn't specified in a workflow file.", + "anyOf": [{ "type": "boolean" }, { "type": "number" }, { "type": "string" }] + } + }, + "required": ["type"], + "additionalProperties": false } }, - "required": ["type"], "additionalProperties": false - } - }, - "additionalProperties": false - }, - "outputs": { - "$comment": "https://docs.github.com/en/actions/reference/workflow-syntax-for-github-actions#onworkflow_calloutputs", - "description": "When using the workflow_call keyword, you can optionally specify inputs that are passed to the called workflow from the caller workflow.", - "type": "object", - "patternProperties": { - "^[_a-zA-Z][a-zA-Z0-9_-]*$": { - "$comment": "https://docs.github.com/en/actions/sharing-automations/creating-actions/metadata-syntax-for-github-actions#outputsoutput_id", - "description": "A string identifier to associate with the output. The value of is a map of the output's metadata. The must be a unique identifier within the outputs object. The must start with a letter or _ and contain only alphanumeric characters, -, or _.", + }, + "outputs": { + "$comment": "https://docs.github.com/en/actions/reference/workflow-syntax-for-github-actions#onworkflow_calloutputs", + "description": "When using the workflow_call keyword, you can optionally specify inputs that are passed to the called workflow from the caller workflow.", "type": "object", - "properties": { - "description": { - "$comment": "https://docs.github.com/en/actions/sharing-automations/creating-actions/metadata-syntax-for-github-actions#outputsoutput_iddescription", - "description": "A string description of the output parameter.", - "type": "string" - }, - "value": { - "$comment": "https://docs.github.com/en/actions/sharing-automations/creating-actions/metadata-syntax-for-github-actions#outputsoutput_idvalue", - "description": "The value that the output parameter will be mapped to. You can set this to a string or an expression with context. For example, you can use the steps context to set the value of an output to the output value of a step.", - "type": "string" + "patternProperties": { + "^[_a-zA-Z][a-zA-Z0-9_-]*$": { + "$comment": "https://docs.github.com/en/actions/sharing-automations/creating-actions/metadata-syntax-for-github-actions#outputsoutput_id", + "description": "A string identifier to associate with the output. The value of is a map of the output's metadata. The must be a unique identifier within the outputs object. The must start with a letter or _ and contain only alphanumeric characters, -, or _.", + "type": "object", + "properties": { + "description": { + "$comment": "https://docs.github.com/en/actions/sharing-automations/creating-actions/metadata-syntax-for-github-actions#outputsoutput_iddescription", + "description": "A string description of the output parameter.", + "type": "string" + }, + "value": { + "$comment": "https://docs.github.com/en/actions/sharing-automations/creating-actions/metadata-syntax-for-github-actions#outputsoutput_idvalue", + "description": "The value that the output parameter will be mapped to. You can set this to a string or an expression with context. For example, you can use the steps context to set the value of an output to the output value of a step.", + "type": "string" + } + }, + "required": ["value"], + "additionalProperties": false } }, - "required": ["value"], "additionalProperties": false - } - }, - "additionalProperties": false - }, - "secrets": { - "$comment": "https://docs.github.com/en/actions/learn-github-actions/workflow-syntax-for-github-actions#onworkflow_callsecrets", - "type": "object", - "description": "A map of the secrets that can be used in the called workflow. Within the called workflow, you can use the secrets context to refer to a secret.", - "patternProperties": { - "^[_a-zA-Z][a-zA-Z0-9_-]*$": { - "$comment": "https://docs.github.com/en/actions/learn-github-actions/workflow-syntax-for-github-actions#onworkflow_callsecretssecret_id", + }, + "secrets": { + "$comment": "https://docs.github.com/en/actions/learn-github-actions/workflow-syntax-for-github-actions#onworkflow_callsecrets", "type": "object", - "description": "A string identifier to associate with the secret.", - "properties": { - "description": { - "description": "A string description of the secret parameter.", - "type": "string" - }, - "required": { - "$comment": "https://docs.github.com/en/actions/learn-github-actions/workflow-syntax-for-github-actions#onworkflow_callsecretssecret_idrequired", - "description": "A boolean specifying whether the secret must be supplied.", - "type": "boolean" + "description": "A map of the secrets that can be used in the called workflow. Within the called workflow, you can use the secrets context to refer to a secret.", + "patternProperties": { + "^[_a-zA-Z][a-zA-Z0-9_-]*$": { + "$comment": "https://docs.github.com/en/actions/learn-github-actions/workflow-syntax-for-github-actions#onworkflow_callsecretssecret_id", + "type": "object", + "description": "A string identifier to associate with the secret.", + "properties": { + "description": { + "description": "A string description of the secret parameter.", + "type": "string" + }, + "required": { + "$comment": "https://docs.github.com/en/actions/learn-github-actions/workflow-syntax-for-github-actions#onworkflow_callsecretssecret_idrequired", + "description": "A boolean specifying whether the secret must be supplied.", + "type": "boolean" + } + }, + "additionalProperties": false } }, "additionalProperties": false } - }, - "additionalProperties": false + } } - } + ] }, "workflow_dispatch": { "$comment": "https://github.blog/changelog/2020-07-06-github-actions-manual-triggers-with-workflow_dispatch/", diff --git a/test/lib/apiError.test.ts b/test/lib/apiError.test.ts index 9513629a5..17292b5cc 100644 --- a/test/lib/apiError.test.ts +++ b/test/lib/apiError.test.ts @@ -32,6 +32,16 @@ describe('#APIv1Error', () => { expect(error.code).toBe(response.error); }); + it('should use the API message when help text is omitted', () => { + const error = new APIv1Error({ + error: 'VERSION_EMPTY', + message: 'You need to specify a version.', + }); + + expect(error.code).toBe('VERSION_EMPTY'); + expect(error.message).toBe('You need to specify a version.'); + }); + it('should be able to handle generic non-API errors', () => { const msg = 'i am an generic javascript error'; const error = new APIv1Error(msg); diff --git a/test/lib/createGHA.test.ts b/test/lib/createGHA.test.ts index 4856621e1..06bb9b5f4 100644 --- a/test/lib/createGHA.test.ts +++ b/test/lib/createGHA.test.ts @@ -1,16 +1,19 @@ -import type { Command, Config } from '@oclif/core'; +import type { Command, Config, Hook } from '@oclif/core'; import type { Response } from 'simple-git'; import type { MockInstance } from 'vitest'; import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; import prompts from 'prompts'; import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; import configstore from '../../src/lib/configstore.js'; -import { getConfigStoreKey, getGHAFileName } from '../../src/lib/createGHA/index.js'; +import createGHA, { getConfigStoreKey, getGHAFileName } from '../../src/lib/createGHA/index.js'; import { getMajorPkgVersion } from '../../src/lib/getPkg.js'; import { git } from '../../src/lib/git.js'; +import createGHAHook from '../../src/lib/hooks/createGHA.js'; import { getGitRemoteMock, gitMock } from '../helpers/git-mock.js'; import ghaWorkflowSchema from '../helpers/github-workflow-schema.json' with { type: 'json' }; import { setupOclifConfig } from '../helpers/oclif.js'; @@ -286,4 +289,155 @@ describe('#createGHA', () => { }); }); }); + + /** + * These cases stay skipped at the oclif-hook layer because git mocks do not propagate through + * `Config.runHook`. Calling `createGHA` from source covers the same contracts (and the generated + * workflow command string) without depending on oclif's hook loader. + */ + describe('source-level git gates and workflow contents', () => { + const ctx = { debug: vi.fn() } as unknown as Hook.Context; + const commandWithKey = { + id: 'docs upload', + args: { path: {} }, + flags: { + key: { type: 'option' }, + github: { type: 'boolean' }, + 'dry-run': { type: 'boolean' }, + branch: { type: 'option' }, + }, + } as unknown as Command.Class; + const commandWithoutKey = { + id: 'openapi validate', + args: { spec: {} }, + flags: { + github: { type: 'boolean' }, + }, + } as unknown as Command.Class; + + it('throws when the command id cannot be determined', async () => { + await expect(createGHA.call(ctx, 'success!', { args: {}, flags: {} } as Command.Class, {})).rejects.toThrow( + 'unable to determine command ID yikes', + ); + }); + + it('returns the original command result when the working directory is not a git repo', async () => { + git.checkIsRepo = vi.fn(() => { + return Promise.reject(new Error('not a repo')) as unknown as Response; + }); + git.remote = getGitRemoteMock('', '', ''); + + await expect(createGHA.call(ctx, 'success!', commandWithKey, { key })).resolves.toBe('success!'); + expect(fs.writeFileSync).not.toHaveBeenCalled(); + }); + + it('returns the original command result when the repo has no remotes', async () => { + git.remote = getGitRemoteMock('', '', ''); + + await expect(createGHA.call(ctx, 'success!', commandWithKey, { key })).resolves.toBe('success!'); + expect(fs.writeFileSync).not.toHaveBeenCalled(); + }); + + it('returns the original command result when the remote cannot be reached', async () => { + git.remote = getGitRemoteMock('bad-remote', 'http://somebadurl.git'); + + await expect(createGHA.call(ctx, 'success!', commandWithKey, { key })).resolves.toBe('success!'); + expect(fs.writeFileSync).not.toHaveBeenCalled(); + }); + + it('returns the original command result when remotes are not GitHub', async () => { + git.remote = getGitRemoteMock('origin', 'https://gitlab.com/org/repo.git', 'main'); + + await expect(createGHA.call(ctx, 'success!', commandWithKey, { key })).resolves.toBe('success!'); + expect(fs.writeFileSync).not.toHaveBeenCalled(); + }); + + it('still runs onboarding when `--github` is set even if the directory is not a repo', async () => { + git.checkIsRepo = vi.fn(() => { + return Promise.reject(new Error('not a repo')) as unknown as Response; + }); + prompts.inject(['main', 'rdme-forced-github']); + + const result = await createGHA.call(ctx, 'success!', commandWithKey, { key, github: true, path: './docs' }); + + expect(result).toContain('Your GitHub Actions workflow file has been created!'); + expect(fs.writeFileSync).toHaveBeenCalledWith(getGHAFileName('rdme-forced-github'), expect.any(String)); + }); + + it('creates the GitHub workflow directory when it does not exist', async () => { + const existsSpy = vi.spyOn(fs, 'existsSync').mockReturnValue(false); + const mkdirSpy = vi.spyOn(fs, 'mkdirSync').mockReturnValue(undefined as never); + prompts.inject([true, 'main', 'rdme-mkdir']); + + await createGHA.call(ctx, '', commandWithKey, { key, path: './docs' }); + + expect(mkdirSpy).toHaveBeenCalledWith('.github/workflows', { recursive: true }); + existsSpy.mockRestore(); + mkdirSpy.mockRestore(); + }); + + it('switches into the repo root before writing the workflow file', async () => { + const repoRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'rdme-gha-root-')); + git.revparse = vi.fn(() => { + return Promise.resolve(repoRoot) as unknown as Response; + }); + prompts.inject([false]); + + await expect(createGHA.call(ctx, '', commandWithKey, { key })).rejects.toThrow( + 'GitHub Actions workflow creation cancelled', + ); + expect(process.cwd()).toBe(repoRoot); + + process.chdir(testWorkingDir); + fs.rmSync(repoRoot, { recursive: true, force: true }); + }); + + it('builds a workflow command string that redacts the key and omits `--github`', async () => { + prompts.inject([true, 'release', 'rdme-docs-upload']); + + const result = await createGHA.call(ctx, 'done', commandWithKey, { + path: './docs', + key: 'supersecretKEY', + github: true, + 'dry-run': true, + branch: '1.0.0', + }); + + const secretRef = `\${{ secrets.README_API_KEY }}`; + expect(yamlOutput).toContain(`rdme: docs upload ./docs --key=${secretRef} --dry-run --branch=1.0.0`); + expect(yamlOutput).not.toContain('supersecretKEY'); + expect(yamlOutput).not.toContain('--github'); + expect(result).toContain('••••••••••••etKEY'); + expect(result).toContain('README_API_KEY'); + }); + + it('does not ask the user to create an API key secret for commands without `--key`', async () => { + prompts.inject([true, 'main', 'rdme-openapi-validate']); + + const result = await createGHA.call(ctx, '', commandWithoutKey, { spec: 'petstore.json' }); + + expect(result).toContain("you're all set"); + expect(result).not.toContain('README_API_KEY'); + expect(yamlOutput).toContain('rdme: openapi validate petstore.json'); + }); + + it('returns the original command result in the default test environment', async () => { + vi.stubEnv('TEST_RDME_CREATEGHA', ''); + + await expect(createGHA.call(ctx, 'success!', commandWithKey, { key })).resolves.toBe('success!'); + expect(fs.writeFileSync).not.toHaveBeenCalled(); + }); + + it('delegates the oclif hook to createGHA', async () => { + vi.stubEnv('TEST_RDME_CREATEGHA', ''); + + await expect( + createGHAHook.call(ctx, { + result: 'hook-ok', + command: commandWithKey, + parsedOpts: { key }, + }), + ).resolves.toBe('hook-ok'); + }); + }); }); diff --git a/test/lib/exportCommandProperties.test.ts b/test/lib/exportCommandProperties.test.ts new file mode 100644 index 000000000..6f00d08d2 --- /dev/null +++ b/test/lib/exportCommandProperties.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, it } from 'vitest'; + +import { args, description, examples, flags, summary } from '../../src/lib/exportCommandProperties.js'; + +describe('exportCommandProperties', () => { + it.each(['Guides', 'Reference', 'Changelog', 'Custom Pages'] as const)( + 'should describe the %s export command', + section => { + expect(summary(section)).toMatch(/Export /); + expect(description(section, 'docs')).toContain('Downloads'); + expect(args(section).folder.required).toBe(true); + expect(examples(section).length).toBeGreaterThan(0); + expect(flags(section).key).toBeDefined(); + }, + ); + + it('should throw for an unknown section', () => { + expect(() => summary('Unknown' as never)).toThrow(TypeError); + }); +}); diff --git a/test/lib/fetch.test.ts b/test/lib/fetch.test.ts index 47195c983..5a71cb2e6 100644 --- a/test/lib/fetch.test.ts +++ b/test/lib/fetch.test.ts @@ -5,6 +5,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import pkg from '../../package.json' with { type: 'json' }; import DocsUploadCommand from '../../src/commands/docs/upload.js'; import { APIv1Error, APIv2Error } from '../../src/lib/apiError.js'; +import { git } from '../../src/lib/git.js'; import { cleanAPIv1Headers, emptyMappings, @@ -15,7 +16,7 @@ import { readmeAPIv1Fetch, readmeAPIv2Fetch, } from '../../src/lib/readmeAPIFetch.js'; -import { getAPIv1Mock, getAPIv2Mock } from '../helpers/get-api-mock.js'; +import { getAPIv1Mock, getAPIv2Mock, getAPIv2MockForGHA } from '../helpers/get-api-mock.js'; import { githubActionsEnv } from '../helpers/git-mock.js'; import { setupOclifConfig } from '../helpers/oclif.js'; @@ -113,6 +114,67 @@ describe('#readmeAPIv1Fetch()', () => { mock.done(); }); + it('should still send a source URL when git cannot resolve the repo root', async () => { + const key = 'API_KEY'; + const originalRevparse = git.revparse; + git.revparse = vi.fn(() => Promise.reject(new Error('not a repo'))) as typeof git.revparse; + + const mock = getAPIv1Mock() + .get('/api/v1') + .basicAuth({ user: key }) + .reply(200, function mock() { + return this.req.headers; + }); + + try { + const headers = await readmeAPIv1Fetch( + '/api/v1', + { + method: 'get', + headers: cleanAPIv1Headers(key), + }, + { + file: { path: '/abs/path/openapi.json', type: 'path' }, + }, + ).then(handleAPIv1Res); + + // `normalizeFilePath` falls back to `path.relative('', file.path)` when git has no + // repo root, so the constructed URL still includes the filename. + expect(headers['x-readme-source-url']).toContain('openapi.json'); + } finally { + git.revparse = originalRevparse; + } + + mock.done(); + }); + + it('should omit source URL header if GITHUB_SERVER_URL cannot be parsed', async () => { + const key = 'API_KEY'; + vi.stubEnv('GITHUB_SERVER_URL', 'not-a-valid-url'); + + const mock = getAPIv1Mock() + .get('/api/v1') + .basicAuth({ user: key }) + .reply(200, function mock() { + return this.req.headers; + }); + + const headers = await readmeAPIv1Fetch( + '/api/v1', + { + method: 'get', + headers: cleanAPIv1Headers(key), + }, + { + file: { path: 'openapi.json', type: 'path' }, + }, + ).then(handleAPIv1Res); + + expect(headers['x-readme-source-url']).toBeUndefined(); + + mock.done(); + }); + it('should omit source URL header if URL is invalid', async () => { const key = 'API_KEY'; // oxlint-disable-next-line unicorn/no-useless-undefined @@ -382,6 +444,13 @@ describe('#cleanAPIv1Headers()', () => { ]); }); + it('should set x-readme-version from the version argument', () => { + expect(Array.from(cleanAPIv1Headers('test', '1.2.3'))).toStrictEqual([ + ['authorization', 'Basic dGVzdDo='], + ['x-readme-version', '1.2.3'], + ]); + }); + it('should pass in properly defined headers', () => { const headers = new Headers({ 'x-readme-version': '1234', @@ -592,6 +661,83 @@ describe('#readmeAPIv2Fetch()', () => { mock.done(); }); }); + + describe('GitHub Actions source URL', () => { + beforeEach(() => { + githubActionsEnv.before(); + }); + + afterEach(() => { + githubActionsEnv.after(); + }); + + it('should omit source URL header if GITHUB_SERVER_URL cannot be parsed', async () => { + vi.stubEnv('GITHUB_SERVER_URL', 'not-a-valid-url'); + const oclifConfig = await setupOclifConfig(); + const command = new DocsUploadCommand([], oclifConfig); + vi.spyOn(command, 'debug').mockImplementation(() => {}); + + const mock = getAPIv2MockForGHA() + .get('/test-source-url') + .reply(200, function reply() { + return this.req.headers; + }); + + const headers = await readmeAPIv2Fetch + .call(command, '/test-source-url', { method: 'get' }, { file: { path: 'openapi.json', type: 'path' } }) + .then(res => res.json()); + + expect(headers['x-readme-source-url']).toBeUndefined(); + expect(command.debug).toHaveBeenCalledWith(expect.stringMatching(/error constructing github source url/)); + + mock.done(); + }); + }); + + describe('warning response header', () => { + it('should surface Warning headers from v2 responses', async () => { + const oclifConfig = await setupOclifConfig(); + const command = new DocsUploadCommand([], oclifConfig); + vi.spyOn(command, 'debug').mockImplementation(() => {}); + const warnSpy = vi.spyOn(command, 'warn').mockImplementation((input: Error | string) => input); + + const mock = getAPIv2Mock().get('/test-warning').reply( + 200, + { ok: true }, + { + Warning: '199 - "deprecated field"', + }, + ); + + const res = await readmeAPIv2Fetch.call(command, '/test-warning', { method: 'get' }); + + expect(res.status).toBe(200); + expect(warnSpy).toHaveBeenCalledWith('⚠️ ReadMe API Warning: deprecated field'); + + mock.done(); + }); + + it('should set x-readme-source-url from a remote spec URL', async () => { + const oclifConfig = await setupOclifConfig(); + const command = new DocsUploadCommand([], oclifConfig); + vi.spyOn(command, 'debug').mockImplementation(() => {}); + + const specUrl = 'https://example.com/openapi.json'; + const mock = getAPIv2Mock() + .get('/test-source-url') + .reply(200, function reply() { + return this.req.headers; + }); + + const headers = await readmeAPIv2Fetch + .call(command, '/test-source-url', { method: 'get' }, { file: { path: specUrl, type: 'url' } }) + .then(res => res.json()); + + expect(headers['x-readme-source-url']).toBe(specUrl); + + mock.done(); + }); + }); }); describe('#handleAPIv1Res', () => { diff --git a/test/lib/frontmatter.test.ts b/test/lib/frontmatter.test.ts index d32207c02..0f6a98852 100644 --- a/test/lib/frontmatter.test.ts +++ b/test/lib/frontmatter.test.ts @@ -350,6 +350,32 @@ describe.each([ }); }); +describe('#fix (guides link merge)', () => { + it('should merge a legacy link onto an existing content object', async () => { + const oclifConfig = await setupOclifConfig(); + const command = new DocsUploadCommand([], oclifConfig); + const schema = fetchSchema.call(command); + const data = { + title: 'Hello, world!', + type: 'link', + excerpt: 'Existing excerpt', + link: { + url: 'https://example.com', + new_tab: true, + }, + }; + + const result = fix.call(command, data, schema, emptyMappings); + + expect(result.hasIssues).toBe(true); + expect(result.updatedData).not.toHaveProperty('link'); + expect(result.updatedData.content).toStrictEqual({ + excerpt: 'Existing excerpt', + link: { url: 'https://example.com', new_tab: true }, + }); + }); +}); + describe('#writeFixes', () => { let command: DocsUploadCommand; diff --git a/test/lib/getPkgVersion.test.ts b/test/lib/getPkgVersion.test.ts index c3ed7d44c..c1c9af468 100644 --- a/test/lib/getPkgVersion.test.ts +++ b/test/lib/getPkgVersion.test.ts @@ -4,7 +4,7 @@ import nock from 'nock'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import pkg from '../../package.json' with { type: 'json' }; -import { getNodeVersion, getPkgVersion, getPkgVersionFromNPM } from '../../src/lib/getPkg.js'; +import { getMajorPkgVersion, getNodeVersion, getPkgVersion, getPkgVersionFromNPM } from '../../src/lib/getPkg.js'; describe('#getNodeVersion()', () => { it('should return a major version', () => { @@ -46,4 +46,12 @@ describe('#getPkgVersion()', () => { mock.done(); }); + + it('should return the package.json version when no dist tag is requested', async () => { + await expect(getPkgVersionFromNPM()).resolves.toBe(pkg.version); + }); + + it('should return the major version from package.json', async () => { + await expect(getMajorPkgVersion()).resolves.toBe(Number(pkg.version.split('.')[0])); + }); }); diff --git a/test/lib/hooks.test.ts b/test/lib/hooks.test.ts index 55bd13b16..9f957b910 100644 --- a/test/lib/hooks.test.ts +++ b/test/lib/hooks.test.ts @@ -103,6 +103,10 @@ describe('hooks', () => { it('should properly pass key flag', async () => { const keyFlag = await getKeyFlagAfterPrerun(mockContext, options); + await expect(keyFlag.parse?.call(mockContext)).rejects.toThrow('No project API key was specified.'); + await expect(keyFlag.parse?.call(mockContext, null as unknown as string)).rejects.toThrow( + 'No project API key was specified.', + ); await expect(keyFlag.parse?.call(mockContext, '')).rejects.toThrow('No project API key was specified.'); await expect(keyFlag.parse?.call(mockContext, ' ')).rejects.toThrow('No project API key was specified.'); @@ -137,6 +141,23 @@ describe('hooks', () => { expect(mockContext.debug).toHaveBeenCalledWith('api key found in config, returning'); }); + + it('should error if login succeeds but no API key was persisted', async () => { + vi.mocked(loginFlow).mockResolvedValue('Logged in successfully.'); + + const keyFlag = await getKeyFlagAfterPrerun(mockContext, options); + + await expect(keyFlag.default?.call(mockContext)).rejects.toThrow("We couldn't find your API key."); + }); + }); + + it('should skip key flag rewriting when the command has no `--key` flag', async () => { + const mockContext = { debug: vi.fn() } as unknown as Hook.Context; + const options = { Command: { flags: {} } }; + + await prerun.call(mockContext, options as never); + + expect(mockContext.debug).toHaveBeenCalledWith('current command does not have --key flag'); }); }); }); diff --git a/test/lib/normalizeStringArgvForGha.test.ts b/test/lib/normalizeStringArgvForGha.test.ts index 2d00f0e51..a0f709030 100644 --- a/test/lib/normalizeStringArgvForGha.test.ts +++ b/test/lib/normalizeStringArgvForGha.test.ts @@ -41,6 +41,10 @@ describe('normalizeStringArgvForGha()', () => { expect(normalizeStringArgvForGha(argv)).toStrictEqual(['whoami', '--title=']); }); + it('leaves a one-character unquoted value unchanged', () => { + expect(normalizeStringArgvForGha(['--tag=a'])).toStrictEqual(['--tag=a']); + }); + it('does not strip mismatched or partial quotes', () => { expect(normalizeStringArgvForGha(['--key="unfinished'])).toStrictEqual(['--key="unfinished']); expect(normalizeStringArgvForGha(['--url=https://example.com?q="x"'])).toStrictEqual([ diff --git a/test/lib/pageCommandProperties.test.ts b/test/lib/pageCommandProperties.test.ts new file mode 100644 index 000000000..5d6453c7c --- /dev/null +++ b/test/lib/pageCommandProperties.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, it } from 'vitest'; + +import { args, baseFlags, description, examples, summary } from '../../src/lib/pageCommandProperties.js'; + +describe('pageCommandProperties', () => { + it.each(['Guides', 'Reference', 'Changelog', 'Custom Pages'] as const)( + 'should describe the %s upload command', + section => { + expect(summary(section)).toMatch(/Upload /); + expect(description(section)).toContain('path'); + expect(args(section).path.required).toBe(true); + expect(examples(section).length).toBeGreaterThan(0); + expect(baseFlags(section)['dry-run']).toBeDefined(); + }, + ); + + it('should throw for an unknown section', () => { + expect(() => baseFlags('Unknown' as never)).toThrow('Unknown section: Unknown'); + }); +}); diff --git a/test/lib/safePath.test.ts b/test/lib/safePath.test.ts index 48f066c47..c10615faa 100644 --- a/test/lib/safePath.test.ts +++ b/test/lib/safePath.test.ts @@ -18,6 +18,7 @@ describe('#isSafePathSegment', () => { ['foo/bar', false], ['foo\\bar', false], ['has\0null', false], + ['foo..bar', false], ])('isSafePathSegment(%j) -> %s', (segment, expected) => { expect(isSafePathSegment(segment)).toBe(expected); }); @@ -39,6 +40,10 @@ describe('#decodeURILastSegment', () => { it('returns null for malformed percent-encoding that decodeURIComponent rejects', () => { expect(decodeURILastSegment('/branches/1.0/categories/guides/%E0%A4%A')).toBeNull(); }); + + it('returns null when the URI has an empty last segment', () => { + expect(decodeURILastSegment('/branches/1.0/categories/guides/')).toBeNull(); + }); }); describe('#resolvePathWithinRoot', () => {