From d83cb775706cc45d142653030cfd5e43dcc5e259 Mon Sep 17 00:00:00 2001 From: JounQin Date: Thu, 9 Apr 2026 16:28:51 +0800 Subject: [PATCH 1/7] test: add rstest unit tests for doom and export packages - Add @rstest/core with root + per-package configs - Add 115 tests covering remark-lint rules, plugins, and utility functions - Add tsconfig.test.json and per-test-directory tsconfigs for ESLint project service - Integrate test step into CI workflow --- .github/workflows/ci.yml | 2 +- package.json | 6 +- packages/doom/rstest.config.ts | 6 + .../test/plugins/replace/parse-toc.test.ts | 120 ++++++++++ .../doom/test/plugins/replace/utils.test.ts | 210 +++++++++++++++++ packages/doom/test/remark-lint/_helper.ts | 30 +++ .../remark-lint/list-item-punctuation.test.ts | 31 +++ .../test/remark-lint/list-item-size.test.ts | 23 ++ .../list-table-introduction.test.ts | 23 ++ .../maximum-link-content-length.test.ts | 27 +++ .../test/remark-lint/no-deep-heading.test.ts | 20 ++ .../test/remark-lint/no-deep-list.test.ts | 23 ++ .../remark-lint/no-empty-table-cell.test.ts | 20 ++ .../no-heading-punctuation.test.ts | 21 ++ .../no-heading-special-characters.test.ts | 20 ++ .../remark-lint/no-heading-sup-sub.test.ts | 18 ++ .../no-multi-open-api-paths.test.ts | 20 ++ .../remark-lint/no-paragraph-indent.test.ts | 23 ++ .../doom/test/remark-lint/table-size.test.ts | 22 ++ .../doom/test/remark-lint/unit-case.test.ts | 22 ++ packages/doom/test/tsconfig.json | 4 + packages/doom/test/utils/helpers.test.ts | 98 ++++++++ packages/export/rstest.config.ts | 6 + .../utils/convertPathToPosix.test.ts | 46 ++++ .../export-pdf-core/utils/getUrlLink.test.ts | 47 ++++ .../html-export-pdf/utils/isValidUrl.test.ts | 44 ++++ .../html-export-pdf/utils/replaceExt.test.ts | 60 +++++ .../export/test/merge-pdfs/formatDate.test.ts | 75 +++++++ packages/export/test/tsconfig.json | 4 + rstest.config.ts | 5 + tsconfig.json | 1 + tsconfig.test.json | 15 ++ yarn.lock | 212 +++++++++++++++++- 33 files changed, 1301 insertions(+), 3 deletions(-) create mode 100644 packages/doom/rstest.config.ts create mode 100644 packages/doom/test/plugins/replace/parse-toc.test.ts create mode 100644 packages/doom/test/plugins/replace/utils.test.ts create mode 100644 packages/doom/test/remark-lint/_helper.ts create mode 100644 packages/doom/test/remark-lint/list-item-punctuation.test.ts create mode 100644 packages/doom/test/remark-lint/list-item-size.test.ts create mode 100644 packages/doom/test/remark-lint/list-table-introduction.test.ts create mode 100644 packages/doom/test/remark-lint/maximum-link-content-length.test.ts create mode 100644 packages/doom/test/remark-lint/no-deep-heading.test.ts create mode 100644 packages/doom/test/remark-lint/no-deep-list.test.ts create mode 100644 packages/doom/test/remark-lint/no-empty-table-cell.test.ts create mode 100644 packages/doom/test/remark-lint/no-heading-punctuation.test.ts create mode 100644 packages/doom/test/remark-lint/no-heading-special-characters.test.ts create mode 100644 packages/doom/test/remark-lint/no-heading-sup-sub.test.ts create mode 100644 packages/doom/test/remark-lint/no-multi-open-api-paths.test.ts create mode 100644 packages/doom/test/remark-lint/no-paragraph-indent.test.ts create mode 100644 packages/doom/test/remark-lint/table-size.test.ts create mode 100644 packages/doom/test/remark-lint/unit-case.test.ts create mode 100644 packages/doom/test/tsconfig.json create mode 100644 packages/doom/test/utils/helpers.test.ts create mode 100644 packages/export/rstest.config.ts create mode 100644 packages/export/test/export-pdf-core/utils/convertPathToPosix.test.ts create mode 100644 packages/export/test/export-pdf-core/utils/getUrlLink.test.ts create mode 100644 packages/export/test/html-export-pdf/utils/isValidUrl.test.ts create mode 100644 packages/export/test/html-export-pdf/utils/replaceExt.test.ts create mode 100644 packages/export/test/merge-pdfs/formatDate.test.ts create mode 100644 packages/export/test/tsconfig.json create mode 100644 rstest.config.ts create mode 100644 tsconfig.test.json diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index acc51616..78f60147 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -40,7 +40,7 @@ jobs: - name: Build and Lint run: | yarn build - yarn run-p docs lint typecov + yarn run-p docs lint test typecov env: PARSER_NO_WATCH: true RAW_TERMS_URL: ${{ vars.RAW_TERMS_URL }} diff --git a/package.json b/package.json index 98890fee..978edd17 100644 --- a/package.json +++ b/package.json @@ -25,6 +25,8 @@ "release": "yarn build && changeset publish", "serve": "yarn doom serve", "swc-node": "node --enable-source-maps --import @swc-node/register/esm-register", + "test": "rstest run", + "test:watch": "rstest watch", "translate": "yarn doom translate -g '*' -s zh -t en", "typecov": "type-coverage", "version": "changeset version && yarn --no-immutable" @@ -32,6 +34,7 @@ "devDependencies": { "@changesets/changelog-github": "^0.6.0", "@changesets/cli": "^2.30.0", + "@rstest/core": "^0.9.6", "@swc-node/register": "^1.11.1", "@swc/core": "^1.15.24", "@types/cli-progress": "^3.11.6", @@ -66,7 +69,8 @@ "ignoreAsAssertion": true, "ignoreFiles": [ "**/lib/**/*.d.ts", - "**/pyodide/**/*.d.ts" + "**/pyodide/**/*.d.ts", + "**/test/**" ], "ignoreNonNullAssertion": true, "showRelativePath": true, diff --git a/packages/doom/rstest.config.ts b/packages/doom/rstest.config.ts new file mode 100644 index 00000000..6b13df28 --- /dev/null +++ b/packages/doom/rstest.config.ts @@ -0,0 +1,6 @@ +import { defineConfig } from '@rstest/core' + +export default defineConfig({ + testEnvironment: 'node', + include: ['test/**/*.test.ts'], +}) diff --git a/packages/doom/test/plugins/replace/parse-toc.test.ts b/packages/doom/test/plugins/replace/parse-toc.test.ts new file mode 100644 index 00000000..d0e76866 --- /dev/null +++ b/packages/doom/test/plugins/replace/parse-toc.test.ts @@ -0,0 +1,120 @@ +import { describe, expect, test } from '@rstest/core' +import type { Root } from 'mdast' +import remarkGfm from 'remark-gfm' +import remarkParse from 'remark-parse' +import { unified } from 'unified' + +import { parseToc } from '../../../src/plugins/replace/parse-toc.ts' + +const parser = unified().use(remarkParse).use(remarkGfm) + +const parseMarkdown = (markdown: string): Root => { + return parser.parse(markdown) +} + +describe('parseToc', () => { + test('extracts h1 as title and skips it from toc', () => { + const markdown = '# Main Title\n\n## Section 1\n\n### Subsection' + const tree = parseMarkdown(markdown) + const { title, toc } = parseToc(tree) + + expect(title).toBe('Main Title') + expect(toc.length).toBe(2) + expect(toc[0]?.text).toBe('Section 1') + expect(toc[0]?.depth).toBe(2) + expect(toc[1]?.text).toBe('Subsection') + }) + + test('collects h2 to h4 by default', () => { + const markdown = '## H2\n### H3\n#### H4\n##### H5' + const tree = parseMarkdown(markdown) + const { toc } = parseToc(tree) + + expect(toc.length).toBe(3) + expect(toc[0]?.depth).toBe(2) + expect(toc[1]?.depth).toBe(3) + expect(toc[2]?.depth).toBe(4) + }) + + test('skips h5 and deeper by default', () => { + const markdown = '## H2\n##### H5\n###### H6' + const tree = parseMarkdown(markdown) + const { toc } = parseToc(tree) + + expect(toc.length).toBe(1) + expect(toc[0]?.text).toBe('H2') + }) + + test('includes all depths when allDepths is true', () => { + const markdown = '## H2\n##### H5\n###### H6' + const tree = parseMarkdown(markdown) + const { toc } = parseToc(tree, true) + + expect(toc.length).toBe(3) + expect(toc[0]?.depth).toBe(2) + expect(toc[1]?.depth).toBe(5) + expect(toc[2]?.depth).toBe(6) + }) + + test('generates slugs from heading text', () => { + const markdown = '## Hello World' + const tree = parseMarkdown(markdown) + const { toc } = parseToc(tree) + + expect(toc[0]?.id).toBe('hello-world') + }) + + test('handles inline code in headings', () => { + const markdown = '## `const` keyword' + const tree = parseMarkdown(markdown) + const { toc } = parseToc(tree) + + expect(toc[0]?.text).toContain('`const`') + }) + + test('handles strong text in headings', () => { + const markdown = '## **Bold** text' + const tree = parseMarkdown(markdown) + const { toc } = parseToc(tree) + + expect(toc[0]?.text).toContain('**Bold**') + }) + + test('assigns correct index to toc items', () => { + const markdown = '## First\n\n## Second\n\n## Third' + const tree = parseMarkdown(markdown) + const { toc } = parseToc(tree) + + expect(toc[0]?.index).toBe(0) + expect(toc[1]?.index).toBe(1) + expect(toc[2]?.index).toBe(2) + }) + + test('returns empty toc and empty title for document without headings', () => { + const markdown = 'Just some text without headings' + const tree = parseMarkdown(markdown) + const { title, toc } = parseToc(tree) + + expect(title).toBe('') + expect(toc.length).toBe(0) + }) + + test('sets title only once for multiple h1s', () => { + const markdown = '# First Title\n\n# Second Title\n\n## Section' + const tree = parseMarkdown(markdown) + const { title, toc } = parseToc(tree) + + expect(title).toBe('First Title') + expect(toc.length).toBe(1) + }) + + test('skips h1 from toc when allDepths is true', () => { + const markdown = '# Main\n## Sub' + const tree = parseMarkdown(markdown) + const { toc } = parseToc(tree, true) + + expect(toc.length).toBe(2) + expect(toc[0]?.depth).toBe(1) + expect(toc[1]?.depth).toBe(2) + }) +}) diff --git a/packages/doom/test/plugins/replace/utils.test.ts b/packages/doom/test/plugins/replace/utils.test.ts new file mode 100644 index 00000000..f19c0a63 --- /dev/null +++ b/packages/doom/test/plugins/replace/utils.test.ts @@ -0,0 +1,210 @@ +import { describe, expect, test } from '@rstest/core' +import type { Root } from 'mdast' +import remarkFrontmatter from 'remark-frontmatter' +import remarkParse from 'remark-parse' +import { unified } from 'unified' + +import type { ReferenceItem } from '../../../src/plugins/replace/types.ts' +import { + normalizeReferenceItems, + getFrontmatterNode, +} from '../../../src/plugins/replace/utils.ts' + +const parser = unified().use(remarkParse).use(remarkFrontmatter, ['yaml']) + +const parseMarkdownWithFrontmatter = (markdown: string): Root => { + return parser.parse(markdown) +} + +describe('normalizeReferenceItems', () => { + test('returns empty object for empty array', () => { + const result = normalizeReferenceItems([]) + + expect(result).toEqual({}) + }) + + test('normalizes single source with path only', () => { + const items: ReferenceItem[] = [ + { + sources: [ + { + name: 'api-docs', + path: '/path/to/file.md', + }, + ], + }, + ] + + const result = normalizeReferenceItems(items) + + expect(result['api-docs']).toBeDefined() + expect(result['api-docs'].path).toBe('/path/to/file.md') + expect(result['api-docs'].anchor).toBeUndefined() + }) + + test('splits path and anchor when present', () => { + const items: ReferenceItem[] = [ + { + sources: [ + { + name: 'api-section', + path: '/path/to/file.md#section-id', + }, + ], + }, + ] + + const result = normalizeReferenceItems(items) + + expect(result['api-section'].path).toBe('/path/to/file.md') + expect(result['api-section'].anchor).toBe('section-id') + }) + + test('includes remote metadata (repo, branch, publicBase)', () => { + const items: ReferenceItem[] = [ + { + repo: 'alauda/doom', + branch: 'main', + publicBase: 'https://example.com', + sources: [ + { + name: 'remote-api', + path: '/api.md', + }, + ], + }, + ] + + const result = normalizeReferenceItems(items) + + expect(result['remote-api'].repo).toBe('alauda/doom') + expect(result['remote-api'].branch).toBe('main') + expect(result['remote-api'].publicBase).toBe('https://example.com') + }) + + test('preserves source properties (ignoreHeading, frontmatterMode)', () => { + const items: ReferenceItem[] = [ + { + sources: [ + { + name: 'config-api', + path: '/config.md', + ignoreHeading: true, + frontmatterMode: 'merge', + }, + ], + }, + ] + + const result = normalizeReferenceItems(items) + + expect(result['config-api'].ignoreHeading).toBe(true) + expect(result['config-api'].frontmatterMode).toBe('merge') + }) + + test('handles multiple sources from single item', () => { + const items: ReferenceItem[] = [ + { + repo: 'test/repo', + sources: [ + { + name: 'api-v1', + path: '/v1/api.md', + }, + { + name: 'api-v2', + path: '/v2/api.md#features', + }, + ], + }, + ] + + const result = normalizeReferenceItems(items) + + expect(result['api-v1']).toBeDefined() + expect(result['api-v2']).toBeDefined() + expect(result['api-v2'].anchor).toBe('features') + }) + + test('overwrites earlier entry on duplicate source name', () => { + const items: ReferenceItem[] = [ + { + sources: [ + { + name: 'config', + path: '/first.md', + }, + { + name: 'config', + path: '/second.md#anchor', + }, + ], + }, + ] + + const result = normalizeReferenceItems(items) + + expect(result['config'].path).toBe('/second.md') + expect(result['config'].anchor).toBe('anchor') + }) + + test('handles multiple items with different repos', () => { + const items: ReferenceItem[] = [ + { + repo: 'repo1', + sources: [{ name: 'api1', path: '/api.md' }], + }, + { + repo: 'repo2', + sources: [{ name: 'api2', path: '/api.md' }], + }, + ] + + const result = normalizeReferenceItems(items) + + expect(result['api1'].repo).toBe('repo1') + expect(result['api2'].repo).toBe('repo2') + }) +}) + +describe('getFrontmatterNode', () => { + test('returns frontmatter node when present at start', () => { + const markdown = '---\ntitle: Test\n---\n\n# Content' + const tree = parseMarkdownWithFrontmatter(markdown) + + const frontmatter = getFrontmatterNode(tree) + + expect(frontmatter).toBeDefined() + expect(frontmatter?.type).toBe('yaml') + }) + + test('returns undefined when no frontmatter at start', () => { + const markdown = '# Content\n\nSome text' + const tree = parseMarkdownWithFrontmatter(markdown) + + const frontmatter = getFrontmatterNode(tree) + + expect(frontmatter).toBeUndefined() + }) + + test('returns undefined when frontmatter not first child', () => { + const markdown = '# Heading\n\n---\ntitle: Test\n---' + const tree = parseMarkdownWithFrontmatter(markdown) + + const frontmatter = getFrontmatterNode(tree) + + expect(frontmatter).toBeUndefined() + }) + + test('returns frontmatter with content preserved', () => { + const markdown = + '---\nkey: value\nlist:\n - item1\n - item2\n---\n\nContent' + const tree = parseMarkdownWithFrontmatter(markdown) + + const frontmatter = getFrontmatterNode(tree) + + expect(frontmatter).toBeDefined() + expect(frontmatter?.type).toBe('yaml') + expect(frontmatter?.value).toBeTruthy() + }) +}) diff --git a/packages/doom/test/remark-lint/_helper.ts b/packages/doom/test/remark-lint/_helper.ts new file mode 100644 index 00000000..486e3e20 --- /dev/null +++ b/packages/doom/test/remark-lint/_helper.ts @@ -0,0 +1,30 @@ +import { Root } from 'mdast' +import remarkFrontmatter from 'remark-frontmatter' +import remarkGfm from 'remark-gfm' +import remarkMdx from 'remark-mdx' +import remarkParse from 'remark-parse' +import remarkStringify from 'remark-stringify' +import type { Plugin } from 'unified' +import { unified } from 'unified' +import { VFile } from 'vfile' + +const processor = unified().use(remarkParse).use(remarkStringify) + +export async function lint(rule: Plugin<[], Root, Root>, markdown: string) { + const file = await processor() + .use(remarkGfm) + .use(remarkFrontmatter) + .use(rule) + .process(new VFile({ value: markdown, path: 'test.md' })) + return file.messages +} + +export async function lintMdx(rule: Plugin<[], Root, Root>, markdown: string) { + const file = await processor() + .use(remarkGfm) + .use(remarkFrontmatter) + .use(remarkMdx) + .use(rule) + .process(new VFile({ value: markdown, path: 'test.mdx' })) + return file.messages +} diff --git a/packages/doom/test/remark-lint/list-item-punctuation.test.ts b/packages/doom/test/remark-lint/list-item-punctuation.test.ts new file mode 100644 index 00000000..63256d44 --- /dev/null +++ b/packages/doom/test/remark-lint/list-item-punctuation.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, test } from '@rstest/core' + +import { listItemPunctuation } from '../../src/remark-lint/list-item-punctuation.ts' + +import { lint } from './_helper.ts' + +describe('list-item-punctuation', () => { + test('allows consistent punctuation', async () => { + const messages = await lint( + listItemPunctuation, + '- item one;\n- item two;\n- item three.', + ) + expect(messages).toHaveLength(0) + }) + test('flags inconsistent punctuation', async () => { + const messages = await lint( + listItemPunctuation, + '- item one\n- item two;\n- item three.', + ) + expect(messages.length).toBeGreaterThan(0) + }) + test('last item should end with period', async () => { + const messages = await lint(listItemPunctuation, '- item one;\n- item two;') + expect(messages.length).toBeGreaterThan(0) + expect(String(messages[0])).toContain('.') + }) + test('skips single-item lists', async () => { + const messages = await lint(listItemPunctuation, '- single item') + expect(messages).toHaveLength(0) + }) +}) diff --git a/packages/doom/test/remark-lint/list-item-size.test.ts b/packages/doom/test/remark-lint/list-item-size.test.ts new file mode 100644 index 00000000..b4f1d905 --- /dev/null +++ b/packages/doom/test/remark-lint/list-item-size.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, test } from '@rstest/core' + +import { listItemSize } from '../../src/remark-lint/list-item-size.ts' + +import { lint } from './_helper.ts' + +describe('list-item-size', () => { + test('allows 10 items', async () => { + const items = Array.from({ length: 10 }, (_, i) => `- item ${i + 1}`).join( + '\n', + ) + const messages = await lint(listItemSize, items) + expect(messages).toHaveLength(0) + }) + test('flags 11 items', async () => { + const items = Array.from({ length: 11 }, (_, i) => `- item ${i + 1}`).join( + '\n', + ) + const messages = await lint(listItemSize, items) + expect(messages).toHaveLength(1) + expect(String(messages[0])).toContain('10') + }) +}) diff --git a/packages/doom/test/remark-lint/list-table-introduction.test.ts b/packages/doom/test/remark-lint/list-table-introduction.test.ts new file mode 100644 index 00000000..37f338e6 --- /dev/null +++ b/packages/doom/test/remark-lint/list-table-introduction.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, test } from '@rstest/core' + +import { listTableIntroduction } from '../../src/remark-lint/list-table-introduction.ts' + +import { lint } from './_helper.ts' + +describe('list-table-introduction', () => { + test('allows list after paragraph', async () => { + const messages = await lint( + listTableIntroduction, + '# Heading\n\nIntroduction paragraph.\n\n- list item\n', + ) + expect(messages).toHaveLength(0) + }) + test('flags list right after heading', async () => { + const messages = await lint( + listTableIntroduction, + '# Heading\n\n- list item\n', + ) + expect(messages).toHaveLength(1) + expect(String(messages[0])).toContain('introduct') + }) +}) diff --git a/packages/doom/test/remark-lint/maximum-link-content-length.test.ts b/packages/doom/test/remark-lint/maximum-link-content-length.test.ts new file mode 100644 index 00000000..55ea484c --- /dev/null +++ b/packages/doom/test/remark-lint/maximum-link-content-length.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, test } from '@rstest/core' + +import { maximumLinkContentLength } from '../../src/remark-lint/maximum-link-content-length.ts' + +import { lint } from './_helper.ts' + +describe('maximum-link-content-length', () => { + test('allows short link text', async () => { + const messages = await lint(maximumLinkContentLength, '[short link](url)') + expect(messages).toHaveLength(0) + }) + test('flags long link text', async () => { + const messages = await lint( + maximumLinkContentLength, + '[This is a very long link text that exceeds forty characters](url)', + ) + expect(messages).toHaveLength(1) + expect(String(messages[0])).toContain('40') + }) + test('skips URL-like text', async () => { + const messages = await lint( + maximumLinkContentLength, + '[https://example.com/very/long/path/that/exceeds/forty/characters](url)', + ) + expect(messages).toHaveLength(0) + }) +}) diff --git a/packages/doom/test/remark-lint/no-deep-heading.test.ts b/packages/doom/test/remark-lint/no-deep-heading.test.ts new file mode 100644 index 00000000..76fd6f2b --- /dev/null +++ b/packages/doom/test/remark-lint/no-deep-heading.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, test } from '@rstest/core' + +import { noDeepHeading } from '../../src/remark-lint/no-deep-heading.ts' + +import { lint } from './_helper.ts' + +describe('no-deep-heading', () => { + test('allows h1-h5', async () => { + const messages = await lint( + noDeepHeading, + '# h1\n## h2\n### h3\n#### h4\n##### h5\n', + ) + expect(messages).toHaveLength(0) + }) + test('flags h6', async () => { + const messages = await lint(noDeepHeading, '###### h6\n') + expect(messages).toHaveLength(1) + expect(String(messages[0])).toContain('level 6') + }) +}) diff --git a/packages/doom/test/remark-lint/no-deep-list.test.ts b/packages/doom/test/remark-lint/no-deep-list.test.ts new file mode 100644 index 00000000..4d1b1618 --- /dev/null +++ b/packages/doom/test/remark-lint/no-deep-list.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, test } from '@rstest/core' + +import { noDeepList } from '../../src/remark-lint/no-deep-list.ts' + +import { lint } from './_helper.ts' + +describe('no-deep-list', () => { + test('allows depth 4', async () => { + const messages = await lint( + noDeepList, + '- level 1\n - level 2\n - level 3\n - level 4\n', + ) + expect(messages).toHaveLength(0) + }) + test('flags depth 5', async () => { + const messages = await lint( + noDeepList, + '- level 1\n - level 2\n - level 3\n - level 4\n - level 5\n', + ) + expect(messages).toHaveLength(1) + expect(String(messages[0])).toContain('4') + }) +}) diff --git a/packages/doom/test/remark-lint/no-empty-table-cell.test.ts b/packages/doom/test/remark-lint/no-empty-table-cell.test.ts new file mode 100644 index 00000000..09b854a2 --- /dev/null +++ b/packages/doom/test/remark-lint/no-empty-table-cell.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, test } from '@rstest/core' + +import { noEmptyTableCell } from '../../src/remark-lint/no-empty-table-cell.ts' + +import { lint } from './_helper.ts' + +describe('no-empty-table-cell', () => { + test('allows filled cells', async () => { + const messages = await lint( + noEmptyTableCell, + '| A | B |\n|---|---|\n| C | D |', + ) + expect(messages).toHaveLength(0) + }) + test('flags empty cells', async () => { + const messages = await lint(noEmptyTableCell, '| A | |\n|---|---|\n| B | |') + expect(messages.length).toBeGreaterThan(0) + expect(String(messages[0])).toContain('empty') + }) +}) diff --git a/packages/doom/test/remark-lint/no-heading-punctuation.test.ts b/packages/doom/test/remark-lint/no-heading-punctuation.test.ts new file mode 100644 index 00000000..5a757804 --- /dev/null +++ b/packages/doom/test/remark-lint/no-heading-punctuation.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, test } from '@rstest/core' + +import { noHeadingPunctuation } from '../../src/remark-lint/no-heading-punctuation.ts' + +import { lint } from './_helper.ts' + +describe('no-heading-punctuation', () => { + test('allows clean headings', async () => { + const messages = await lint(noHeadingPunctuation, '## Title\n') + expect(messages).toHaveLength(0) + }) + test('flags trailing period', async () => { + const messages = await lint(noHeadingPunctuation, '## Title.\n') + expect(messages).toHaveLength(1) + expect(String(messages[0])).toContain('.') + }) + test('allows matched brackets', async () => { + const messages = await lint(noHeadingPunctuation, '## Config (advanced)\n') + expect(messages).toHaveLength(0) + }) +}) diff --git a/packages/doom/test/remark-lint/no-heading-special-characters.test.ts b/packages/doom/test/remark-lint/no-heading-special-characters.test.ts new file mode 100644 index 00000000..2ce61034 --- /dev/null +++ b/packages/doom/test/remark-lint/no-heading-special-characters.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, test } from '@rstest/core' + +import { noHeadingSpecialCharacters } from '../../src/remark-lint/no-heading-special-characters.ts' + +import { lint } from './_helper.ts' + +describe('no-heading-special-characters', () => { + test('allows normal heading', async () => { + const messages = await lint( + noHeadingSpecialCharacters, + '## Normal Heading\n', + ) + expect(messages).toHaveLength(0) + }) + test('flags heading with /', async () => { + const messages = await lint(noHeadingSpecialCharacters, '## Input/Output\n') + expect(messages).toHaveLength(1) + expect(String(messages[0])).toContain('/') + }) +}) diff --git a/packages/doom/test/remark-lint/no-heading-sup-sub.test.ts b/packages/doom/test/remark-lint/no-heading-sup-sub.test.ts new file mode 100644 index 00000000..1b189305 --- /dev/null +++ b/packages/doom/test/remark-lint/no-heading-sup-sub.test.ts @@ -0,0 +1,18 @@ +import { describe, expect, test } from '@rstest/core' + +import { noHeadingSupSub } from '../../src/remark-lint/no-heading-sup-sub.ts' + +import { lint } from './_helper.ts' + +describe('no-heading-sup-sub', () => { + test('allows plain headings', async () => { + const messages = await lint(noHeadingSupSub, '## Title\n') + expect(messages).toHaveLength(0) + }) + test('flags heading with sup', async () => { + const messages = await lint(noHeadingSupSub, '## Title beta\n') + expect(messages).toHaveLength(2) + expect(String(messages[0])).toContain('') + expect(String(messages[1])).toContain('') + }) +}) diff --git a/packages/doom/test/remark-lint/no-multi-open-api-paths.test.ts b/packages/doom/test/remark-lint/no-multi-open-api-paths.test.ts new file mode 100644 index 00000000..0bb7b80b --- /dev/null +++ b/packages/doom/test/remark-lint/no-multi-open-api-paths.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, test } from '@rstest/core' + +import { noMultiOpenAPIPaths } from '../../src/remark-lint/no-multi-open-api-paths.ts' + +import { lintMdx } from './_helper.ts' + +describe('no-multi-open-api-paths', () => { + test('allows single OpenAPIPath', async () => { + const messages = await lintMdx(noMultiOpenAPIPaths, '\n') + expect(messages).toHaveLength(0) + }) + test('flags two OpenAPIPath components', async () => { + const messages = await lintMdx( + noMultiOpenAPIPaths, + '\n\n\n', + ) + expect(messages).toHaveLength(1) + expect(String(messages[0])).toContain('Multiple') + }) +}) diff --git a/packages/doom/test/remark-lint/no-paragraph-indent.test.ts b/packages/doom/test/remark-lint/no-paragraph-indent.test.ts new file mode 100644 index 00000000..e34859c9 --- /dev/null +++ b/packages/doom/test/remark-lint/no-paragraph-indent.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, test } from '@rstest/core' + +import { noParagraphIndent } from '../../src/remark-lint/no-paragraph-indent.ts' + +import { lint } from './_helper.ts' + +describe('no-paragraph-indent', () => { + test('allows unindented paragraph', async () => { + const messages = await lint( + noParagraphIndent, + 'This is a normal paragraph.\n', + ) + expect(messages).toHaveLength(0) + }) + test('flags indented paragraph', async () => { + const messages = await lint( + noParagraphIndent, + ' This is an indented paragraph.\n', + ) + expect(messages).toHaveLength(1) + expect(String(messages[0])).toContain('spaces') + }) +}) diff --git a/packages/doom/test/remark-lint/table-size.test.ts b/packages/doom/test/remark-lint/table-size.test.ts new file mode 100644 index 00000000..9d725c36 --- /dev/null +++ b/packages/doom/test/remark-lint/table-size.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, test } from '@rstest/core' + +import { tableSize } from '../../src/remark-lint/table-size.ts' + +import { lint } from './_helper.ts' + +describe('table-size', () => { + test('allows table with 2+ rows and columns', async () => { + const messages = await lint(tableSize, '| A | B |\n|---|---|\n| C | D |') + expect(messages).toHaveLength(0) + }) + test('flags single-row table', async () => { + const messages = await lint(tableSize, '| A |\n|---|') + expect(messages).toHaveLength(1) + expect(String(messages[0])).toContain('row') + }) + test('flags single-column table', async () => { + const messages = await lint(tableSize, '| A |\n|---|\n| B |') + expect(messages).toHaveLength(1) + expect(String(messages[0])).toContain('column') + }) +}) diff --git a/packages/doom/test/remark-lint/unit-case.test.ts b/packages/doom/test/remark-lint/unit-case.test.ts new file mode 100644 index 00000000..18e1165d --- /dev/null +++ b/packages/doom/test/remark-lint/unit-case.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, test } from '@rstest/core' + +import { unitCase } from '../../src/remark-lint/unit-case.ts' + +import { lint } from './_helper.ts' + +describe('unit-case', () => { + test('allows correct unit casing', async () => { + const messages = await lint(unitCase, 'Storage: 100Ki, Memory: 500M\n') + expect(messages).toHaveLength(0) + }) + test('flags incorrect ki', async () => { + const messages = await lint(unitCase, 'Storage: 100ki\n') + expect(messages).toHaveLength(1) + expect(String(messages[0])).toContain('Ki') + }) + test('flags incorrect gi', async () => { + const messages = await lint(unitCase, 'Storage: 100gi\n') + expect(messages).toHaveLength(1) + expect(String(messages[0])).toContain('Gi') + }) +}) diff --git a/packages/doom/test/tsconfig.json b/packages/doom/test/tsconfig.json new file mode 100644 index 00000000..7f02c0da --- /dev/null +++ b/packages/doom/test/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../../../tsconfig.test.json", + "include": ["**/*.ts"] +} diff --git a/packages/doom/test/utils/helpers.test.ts b/packages/doom/test/utils/helpers.test.ts new file mode 100644 index 00000000..dd49d6bc --- /dev/null +++ b/packages/doom/test/utils/helpers.test.ts @@ -0,0 +1,98 @@ +import path from 'node:path' + +import { describe, expect, test } from '@rstest/core' + +import { baseResolve, pkgResolve } from '../../src/utils/helpers.ts' + +describe('baseResolve', () => { + test('resolves path relative to BASE_DIR', () => { + const result = baseResolve('..', '..') + + expect(result).toBeTruthy() + expect(result).toContain('packages') + }) + + test('resolves single path segment', () => { + const result = baseResolve('utils') + + expect(result).toContain('src') + expect(result).toContain('utils') + }) + + test('resolves multiple path segments', () => { + const result = baseResolve('utils', 'helpers.ts') + + expect(result).toContain('utils') + expect(result).toContain('helpers.ts') + }) + + test('returns absolute path', () => { + const result = baseResolve('test') + + expect(path.isAbsolute(result)).toBe(true) + }) + + test('resolves empty call to BASE_DIR', () => { + const result = baseResolve() + + expect(path.isAbsolute(result)).toBe(true) + expect(result).toContain('doom') + }) + + test('handles parent directory references', () => { + const result = baseResolve('plugins', '..', 'utils') + + expect(result).toContain('utils') + expect(result).not.toContain('plugins') + }) +}) + +describe('pkgResolve', () => { + test('resolves path relative to PKG_DIR', () => { + const result = pkgResolve('src') + + expect(result).toContain('src') + expect(result).toContain('doom') + }) + + test('resolves single path segment', () => { + const result = pkgResolve('package.json') + + expect(result).toContain('package.json') + }) + + test('resolves multiple path segments', () => { + const result = pkgResolve('src', 'utils', 'helpers.ts') + + expect(result).toContain('src') + expect(result).toContain('utils') + expect(result).toContain('helpers.ts') + }) + + test('returns absolute path', () => { + const result = pkgResolve('test') + + expect(path.isAbsolute(result)).toBe(true) + }) + + test('resolves empty call to PKG_DIR', () => { + const result = pkgResolve() + + expect(path.isAbsolute(result)).toBe(true) + expect(result).toContain('doom') + }) + + test('handles parent directory references', () => { + const result = pkgResolve('src', '..', 'package.json') + + expect(result).toContain('package.json') + expect(result).not.toContain('src') + }) + + test('pkgResolve is one level above baseResolve', () => { + const pkg = pkgResolve() + const base = baseResolve() + + expect(pkg).toBe(path.dirname(base)) + }) +}) diff --git a/packages/export/rstest.config.ts b/packages/export/rstest.config.ts new file mode 100644 index 00000000..6b13df28 --- /dev/null +++ b/packages/export/rstest.config.ts @@ -0,0 +1,6 @@ +import { defineConfig } from '@rstest/core' + +export default defineConfig({ + testEnvironment: 'node', + include: ['test/**/*.test.ts'], +}) diff --git a/packages/export/test/export-pdf-core/utils/convertPathToPosix.test.ts b/packages/export/test/export-pdf-core/utils/convertPathToPosix.test.ts new file mode 100644 index 00000000..77fb60f2 --- /dev/null +++ b/packages/export/test/export-pdf-core/utils/convertPathToPosix.test.ts @@ -0,0 +1,46 @@ +import { describe, test, expect } from '@rstest/core' + +import { convertPathToPosix } from '../../../src/export-pdf-core/utils/convertPathToPosix.ts' + +describe('convertPathToPosix', () => { + test('returns path unchanged on non-Windows platform', () => { + const path = '/home/user/document.pdf' + const result = convertPathToPosix(path) + expect(result).toBe(path) + }) + + test('returns relative path unchanged on non-Windows platform', () => { + const path = 'docs/file.pdf' + const result = convertPathToPosix(path) + expect(result).toBe(path) + }) + + test('returns current directory path unchanged on non-Windows platform', () => { + const path = './file.pdf' + const result = convertPathToPosix(path) + expect(result).toBe(path) + }) + + test('returns absolute path with multiple segments unchanged on non-Windows', () => { + const path = '/home/user/docs/file.pdf' + const result = convertPathToPosix(path) + expect(result).toBe(path) + }) + + test('handles empty string', () => { + const result = convertPathToPosix('') + expect(result).toBe('') + }) + + test('handles path with dots', () => { + const path = '../docs/file.pdf' + const result = convertPathToPosix(path) + expect(result).toBe(path) + }) + + test('handles path with trailing slash', () => { + const path = '/home/user/docs/' + const result = convertPathToPosix(path) + expect(result).toBe(path) + }) +}) diff --git a/packages/export/test/export-pdf-core/utils/getUrlLink.test.ts b/packages/export/test/export-pdf-core/utils/getUrlLink.test.ts new file mode 100644 index 00000000..5dee0025 --- /dev/null +++ b/packages/export/test/export-pdf-core/utils/getUrlLink.test.ts @@ -0,0 +1,47 @@ +import { describe, test, expect } from '@rstest/core' + +import { getUrlLink } from '../../../src/export-pdf-core/utils/getUrlLink.ts' + +describe('getUrlLink', () => { + test('extracts link and hash from URL with hash', () => { + const result = getUrlLink('https://example.com/path#anchor') + expect(result.link).toBe('https://example.com/path') + expect(result.hash).toBe('anchor') + }) + + test('returns empty hash when URL has no hash', () => { + const result = getUrlLink('https://example.com/path') + expect(result.link).toBe('https://example.com/path') + expect(result.hash).toBe('') + }) + + test('preserves pathname in link', () => { + const result = getUrlLink('https://example.com/docs/api#section') + expect(result.link).toBe('https://example.com/docs/api') + expect(result.hash).toBe('section') + }) + + test('handles complex query strings', () => { + const result = getUrlLink('https://example.com/path?query=1&other=2#anchor') + expect(result.link).toBe('https://example.com/path') + expect(result.hash).toBe('anchor') + }) + + test('handles file protocol URLs', () => { + const result = getUrlLink('file:///home/user/document.html#section') + expect(result.link).toBe('null/home/user/document.html') + expect(result.hash).toBe('section') + }) + + test('throws TypeError for invalid URL', () => { + expect(() => { + getUrlLink('not a valid url') + }).toThrow(TypeError) + }) + + test('throws TypeError for malformed URL', () => { + expect(() => { + getUrlLink('ht!tp://[invalid') + }).toThrow(TypeError) + }) +}) diff --git a/packages/export/test/html-export-pdf/utils/isValidUrl.test.ts b/packages/export/test/html-export-pdf/utils/isValidUrl.test.ts new file mode 100644 index 00000000..31a79eb9 --- /dev/null +++ b/packages/export/test/html-export-pdf/utils/isValidUrl.test.ts @@ -0,0 +1,44 @@ +import { describe, test, expect } from '@rstest/core' + +import { isValidUrl } from '../../../src/html-export-pdf/utils/isValidUrl.ts' + +describe('isValidUrl', () => { + test('returns true for http protocol', () => { + expect(isValidUrl('http://example.com')).toBe(true) + }) + + test('returns true for https protocol', () => { + expect(isValidUrl('https://example.com')).toBe(true) + }) + + test('returns true for file protocol', () => { + expect(isValidUrl('file:///path/to/file.pdf')).toBe(true) + }) + + test('returns true for data protocol', () => { + expect(isValidUrl('data:text/html,

Hello

')).toBe(true) + }) + + test('returns false for ftp protocol', () => { + expect(isValidUrl('ftp://example.com')).toBe(false) + }) + + test('returns false for plain text', () => { + expect(isValidUrl('example.com')).toBe(false) + }) + + test('returns false for relative path', () => { + expect(isValidUrl('/path/to/file')).toBe(false) + }) + + test('handles case insensitive protocols', () => { + expect(isValidUrl('HTTPS://example.com')).toBe(true) + expect(isValidUrl('HTTP://example.com')).toBe(true) + expect(isValidUrl('FILE:///path/to/file')).toBe(true) + expect(isValidUrl('DATA:text/html,

Hi

')).toBe(true) + }) + + test('returns false for mixed case invalid protocol', () => { + expect(isValidUrl('FtP://example.com')).toBe(false) + }) +}) diff --git a/packages/export/test/html-export-pdf/utils/replaceExt.test.ts b/packages/export/test/html-export-pdf/utils/replaceExt.test.ts new file mode 100644 index 00000000..de4ba9ca --- /dev/null +++ b/packages/export/test/html-export-pdf/utils/replaceExt.test.ts @@ -0,0 +1,60 @@ +import { describe, test, expect } from '@rstest/core' + +import { replaceExt } from '../../../src/html-export-pdf/utils/replaceExt.ts' + +describe('replaceExt', () => { + test('replaces extension with new one', () => { + const result = replaceExt('file.md', '.html') + expect(result).toBe('file.html') + }) + + test('handles path with directories', () => { + const result = replaceExt('dir/file.md', '.html') + expect(result.endsWith('file.html')).toBe(true) + }) + + test('preserves leading dot-slash', () => { + const result = replaceExt('./file.md', '.html') + expect(result).toMatch(/^\.[\\/].*\.html$/) + }) + + test('preserves leading dot-slash in nested paths', () => { + const result = replaceExt('./dir/file.md', '.html') + expect(result).toMatch(/^\.[\\/]/) + expect(result.endsWith('file.html')).toBe(true) + }) + + test('handles empty string', () => { + expect(replaceExt('', '.html')).toBe('') + }) + + test('handles non-string input', () => { + expect(replaceExt(null as unknown as string, '.html')).toBe(null) + }) + + test('handles file without extension', () => { + const result = replaceExt('file', '.html') + expect(result).toBe('file.html') + }) + + test('handles file with multiple dots', () => { + const result = replaceExt('file.tar.gz', '.zip') + expect(result).toBe('file.tar.zip') + }) + + test('preserves directory structure', () => { + const result = replaceExt('path/to/dir/file.md', '.pdf') + expect(result).toContain('path') + expect(result.endsWith('file.pdf')).toBe(true) + }) + + test('replaces extension without adding extra dot', () => { + const result = replaceExt('file.md', 'html') + expect(result).toBe('filehtml') + }) + + test('handles extension with dot in new extension', () => { + const result = replaceExt('file.md', '.tar.gz') + expect(result).toBe('file.tar.gz') + }) +}) diff --git a/packages/export/test/merge-pdfs/formatDate.test.ts b/packages/export/test/merge-pdfs/formatDate.test.ts new file mode 100644 index 00000000..5ee214ae --- /dev/null +++ b/packages/export/test/merge-pdfs/formatDate.test.ts @@ -0,0 +1,75 @@ +import { describe, test, expect } from '@rstest/core' + +import { formatDate } from '../../src/merge-pdfs/formatDate.ts' + +describe('formatDate', () => { + test("formats date in D:YYYYMMDDHHmmSS±HH'mm' pattern", () => { + const date = new Date('2024-01-15T14:30:45Z') + const result = formatDate(date) + expect(result).toMatch(/^D:\d{14}[Z+-]\d{2}'\d{2}'$/) + }) + + test('starts with D: prefix', () => { + const date = new Date() + const result = formatDate(date) + expect(result.startsWith('D:')).toBe(true) + }) + + test('contains year from local time', () => { + const date = new Date(2024, 5, 15, 10, 30, 0) + const result = formatDate(date) + expect(result).toContain('2024') + }) + + test('zero-pads single-digit month', () => { + const date = new Date(2024, 0, 5, 0, 0, 0) + const result = formatDate(date) + expect(result).toContain('D:202401') + }) + + test('zero-pads single-digit day', () => { + const date = new Date(2024, 2, 8, 0, 0, 0) + const result = formatDate(date) + expect(result).toContain('20240308') + }) + + test('zero-pads single-digit hour', () => { + const date = new Date(2024, 2, 15, 9, 0, 0) + const result = formatDate(date) + const hour = result.slice(10, 12) + expect(hour).toBe('09') + }) + + test('zero-pads single-digit minute', () => { + const date = new Date(2024, 2, 15, 14, 5, 30) + const result = formatDate(date) + const minute = result.slice(12, 14) + expect(minute).toBe('05') + }) + + test('zero-pads single-digit second', () => { + const date = new Date(2024, 2, 15, 14, 30, 3) + const result = formatDate(date) + const second = result.slice(14, 16) + expect(second).toBe('03') + }) + + test('includes timezone offset', () => { + const date = new Date() + const result = formatDate(date) + expect(result).toMatch(/[Z+-]\d{2}'\d{2}'$/) + }) + + test('uses local date components', () => { + const date = new Date(2024, 11, 31, 23, 59, 59) + const result = formatDate(date) + expect(result).toContain('20241231') + expect(result).toContain('235959') + }) + + test('handles year boundary correctly', () => { + const date = new Date(2025, 0, 1, 0, 0, 0) + const result = formatDate(date) + expect(result).toContain('D:20250101') + }) +}) diff --git a/packages/export/test/tsconfig.json b/packages/export/test/tsconfig.json new file mode 100644 index 00000000..7f02c0da --- /dev/null +++ b/packages/export/test/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../../../tsconfig.test.json", + "include": ["**/*.ts"] +} diff --git a/rstest.config.ts b/rstest.config.ts new file mode 100644 index 00000000..93cb526a --- /dev/null +++ b/rstest.config.ts @@ -0,0 +1,5 @@ +import { defineConfig } from '@rstest/core' + +export default defineConfig({ + projects: ['packages/*'], +}) diff --git a/tsconfig.json b/tsconfig.json index ca7195c7..30de88eb 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -9,6 +9,7 @@ "@alauda/doom-export": ["./packages/export/src/index.ts"] } }, + "exclude": ["**/test/**"], "references": [ { "path": "./packages/export" diff --git a/tsconfig.test.json b/tsconfig.test.json new file mode 100644 index 00000000..1fef19eb --- /dev/null +++ b/tsconfig.test.json @@ -0,0 +1,15 @@ +{ + "extends": "./tsconfig.base", + "compilerOptions": { + "allowImportingTsExtensions": true, + "noEmit": true, + "rewriteRelativeImportExtensions": false, + "paths": { + "@alauda/doom/config": ["./packages/doom/src/config.ts"], + "@alauda/doom/runtime": ["./packages/doom/src/runtime/index.ts"], + "@alauda/doom/theme": ["./packages/doom/src/theme/index.ts"], + "@alauda/doom-export": ["./packages/export/src/index.ts"] + } + }, + "include": ["packages/*/test/**/*.ts"] +} diff --git a/yarn.lock b/yarn.lock index 05c69c72..46576f13 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1991,7 +1991,7 @@ __metadata: languageName: node linkType: hard -"@napi-rs/wasm-runtime@npm:1.1.1, @napi-rs/wasm-runtime@npm:^1.1.1": +"@napi-rs/wasm-runtime@npm:1.1.1": version: 1.1.1 resolution: "@napi-rs/wasm-runtime@npm:1.1.1" dependencies: @@ -2002,6 +2002,18 @@ __metadata: languageName: node linkType: hard +"@napi-rs/wasm-runtime@npm:1.1.2, @napi-rs/wasm-runtime@npm:^1.1.1": + version: 1.1.2 + resolution: "@napi-rs/wasm-runtime@npm:1.1.2" + dependencies: + "@tybys/wasm-util": "npm:^0.10.1" + peerDependencies: + "@emnapi/core": ^1.7.1 + "@emnapi/runtime": ^1.7.1 + checksum: 10c0/725c30ec9c480a8d0c1a6a4ce31dc6c830365d485e23ad560e143d1cb9db89a0c95fbb5b9d53c07121729817a3683db6f1ab65d7e4f38fa7482a11b15ef6c6fd + languageName: node + linkType: hard + "@napi-rs/wasm-runtime@npm:^0.2.11": version: 0.2.12 resolution: "@napi-rs/wasm-runtime@npm:0.2.12" @@ -2520,6 +2532,23 @@ __metadata: languageName: node linkType: hard +"@rsbuild/core@npm:2.0.0-rc.0": + version: 2.0.0-rc.0 + resolution: "@rsbuild/core@npm:2.0.0-rc.0" + dependencies: + "@rspack/core": "npm:2.0.0-rc.0" + "@swc/helpers": "npm:^0.5.20" + peerDependencies: + core-js: ">= 3.0.0" + peerDependenciesMeta: + core-js: + optional: true + bin: + rsbuild: bin/rsbuild.js + checksum: 10c0/ee8afbe9ae03cf996551a1529e6c1125065be6c36a98bf34235ba2efebc7e5969636030ed5e9a696d34545366b72b20d3b37a91f849552f02bb52aee0c454331 + languageName: node + linkType: hard + "@rsbuild/plugin-react@npm:^1.4.6, @rsbuild/plugin-react@npm:~1.4.6": version: 1.4.6 resolution: "@rsbuild/plugin-react@npm:1.4.6" @@ -2591,6 +2620,13 @@ __metadata: languageName: node linkType: hard +"@rspack/binding-darwin-arm64@npm:2.0.0-rc.0": + version: 2.0.0-rc.0 + resolution: "@rspack/binding-darwin-arm64@npm:2.0.0-rc.0" + conditions: os=darwin & cpu=arm64 + languageName: node + linkType: hard + "@rspack/binding-darwin-x64@npm:2.0.0-beta.9": version: 2.0.0-beta.9 resolution: "@rspack/binding-darwin-x64@npm:2.0.0-beta.9" @@ -2598,6 +2634,13 @@ __metadata: languageName: node linkType: hard +"@rspack/binding-darwin-x64@npm:2.0.0-rc.0": + version: 2.0.0-rc.0 + resolution: "@rspack/binding-darwin-x64@npm:2.0.0-rc.0" + conditions: os=darwin & cpu=x64 + languageName: node + linkType: hard + "@rspack/binding-linux-arm64-gnu@npm:2.0.0-beta.9": version: 2.0.0-beta.9 resolution: "@rspack/binding-linux-arm64-gnu@npm:2.0.0-beta.9" @@ -2605,6 +2648,13 @@ __metadata: languageName: node linkType: hard +"@rspack/binding-linux-arm64-gnu@npm:2.0.0-rc.0": + version: 2.0.0-rc.0 + resolution: "@rspack/binding-linux-arm64-gnu@npm:2.0.0-rc.0" + conditions: os=linux & cpu=arm64 & libc=glibc + languageName: node + linkType: hard + "@rspack/binding-linux-arm64-musl@npm:2.0.0-beta.9": version: 2.0.0-beta.9 resolution: "@rspack/binding-linux-arm64-musl@npm:2.0.0-beta.9" @@ -2612,6 +2662,13 @@ __metadata: languageName: node linkType: hard +"@rspack/binding-linux-arm64-musl@npm:2.0.0-rc.0": + version: 2.0.0-rc.0 + resolution: "@rspack/binding-linux-arm64-musl@npm:2.0.0-rc.0" + conditions: os=linux & cpu=arm64 & libc=musl + languageName: node + linkType: hard + "@rspack/binding-linux-x64-gnu@npm:2.0.0-beta.9": version: 2.0.0-beta.9 resolution: "@rspack/binding-linux-x64-gnu@npm:2.0.0-beta.9" @@ -2619,6 +2676,13 @@ __metadata: languageName: node linkType: hard +"@rspack/binding-linux-x64-gnu@npm:2.0.0-rc.0": + version: 2.0.0-rc.0 + resolution: "@rspack/binding-linux-x64-gnu@npm:2.0.0-rc.0" + conditions: os=linux & cpu=x64 & libc=glibc + languageName: node + linkType: hard + "@rspack/binding-linux-x64-musl@npm:2.0.0-beta.9": version: 2.0.0-beta.9 resolution: "@rspack/binding-linux-x64-musl@npm:2.0.0-beta.9" @@ -2626,6 +2690,13 @@ __metadata: languageName: node linkType: hard +"@rspack/binding-linux-x64-musl@npm:2.0.0-rc.0": + version: 2.0.0-rc.0 + resolution: "@rspack/binding-linux-x64-musl@npm:2.0.0-rc.0" + conditions: os=linux & cpu=x64 & libc=musl + languageName: node + linkType: hard + "@rspack/binding-wasm32-wasi@npm:2.0.0-beta.9": version: 2.0.0-beta.9 resolution: "@rspack/binding-wasm32-wasi@npm:2.0.0-beta.9" @@ -2635,6 +2706,15 @@ __metadata: languageName: node linkType: hard +"@rspack/binding-wasm32-wasi@npm:2.0.0-rc.0": + version: 2.0.0-rc.0 + resolution: "@rspack/binding-wasm32-wasi@npm:2.0.0-rc.0" + dependencies: + "@napi-rs/wasm-runtime": "npm:1.1.2" + conditions: cpu=wasm32 + languageName: node + linkType: hard + "@rspack/binding-win32-arm64-msvc@npm:2.0.0-beta.9": version: 2.0.0-beta.9 resolution: "@rspack/binding-win32-arm64-msvc@npm:2.0.0-beta.9" @@ -2642,6 +2722,13 @@ __metadata: languageName: node linkType: hard +"@rspack/binding-win32-arm64-msvc@npm:2.0.0-rc.0": + version: 2.0.0-rc.0 + resolution: "@rspack/binding-win32-arm64-msvc@npm:2.0.0-rc.0" + conditions: os=win32 & cpu=arm64 + languageName: node + linkType: hard + "@rspack/binding-win32-ia32-msvc@npm:2.0.0-beta.9": version: 2.0.0-beta.9 resolution: "@rspack/binding-win32-ia32-msvc@npm:2.0.0-beta.9" @@ -2649,6 +2736,13 @@ __metadata: languageName: node linkType: hard +"@rspack/binding-win32-ia32-msvc@npm:2.0.0-rc.0": + version: 2.0.0-rc.0 + resolution: "@rspack/binding-win32-ia32-msvc@npm:2.0.0-rc.0" + conditions: os=win32 & cpu=ia32 + languageName: node + linkType: hard + "@rspack/binding-win32-x64-msvc@npm:2.0.0-beta.9": version: 2.0.0-beta.9 resolution: "@rspack/binding-win32-x64-msvc@npm:2.0.0-beta.9" @@ -2656,6 +2750,13 @@ __metadata: languageName: node linkType: hard +"@rspack/binding-win32-x64-msvc@npm:2.0.0-rc.0": + version: 2.0.0-rc.0 + resolution: "@rspack/binding-win32-x64-msvc@npm:2.0.0-rc.0" + conditions: os=win32 & cpu=x64 + languageName: node + linkType: hard + "@rspack/binding@npm:2.0.0-beta.9": version: 2.0.0-beta.9 resolution: "@rspack/binding@npm:2.0.0-beta.9" @@ -2695,6 +2796,45 @@ __metadata: languageName: node linkType: hard +"@rspack/binding@npm:2.0.0-rc.0": + version: 2.0.0-rc.0 + resolution: "@rspack/binding@npm:2.0.0-rc.0" + dependencies: + "@rspack/binding-darwin-arm64": "npm:2.0.0-rc.0" + "@rspack/binding-darwin-x64": "npm:2.0.0-rc.0" + "@rspack/binding-linux-arm64-gnu": "npm:2.0.0-rc.0" + "@rspack/binding-linux-arm64-musl": "npm:2.0.0-rc.0" + "@rspack/binding-linux-x64-gnu": "npm:2.0.0-rc.0" + "@rspack/binding-linux-x64-musl": "npm:2.0.0-rc.0" + "@rspack/binding-wasm32-wasi": "npm:2.0.0-rc.0" + "@rspack/binding-win32-arm64-msvc": "npm:2.0.0-rc.0" + "@rspack/binding-win32-ia32-msvc": "npm:2.0.0-rc.0" + "@rspack/binding-win32-x64-msvc": "npm:2.0.0-rc.0" + dependenciesMeta: + "@rspack/binding-darwin-arm64": + optional: true + "@rspack/binding-darwin-x64": + optional: true + "@rspack/binding-linux-arm64-gnu": + optional: true + "@rspack/binding-linux-arm64-musl": + optional: true + "@rspack/binding-linux-x64-gnu": + optional: true + "@rspack/binding-linux-x64-musl": + optional: true + "@rspack/binding-wasm32-wasi": + optional: true + "@rspack/binding-win32-arm64-msvc": + optional: true + "@rspack/binding-win32-ia32-msvc": + optional: true + "@rspack/binding-win32-x64-msvc": + optional: true + checksum: 10c0/03bad771172c46a685de31b8e7c17f8d1d8b196b1e21a96038c18f5563eb507a680fb1fb31adcaaef466be391cb3b9fb1cb9e437952d89f0bd15f64febb5f2bd + languageName: node + linkType: hard + "@rspack/core@npm:2.0.0-beta.9": version: 2.0.0-beta.9 resolution: "@rspack/core@npm:2.0.0-beta.9" @@ -2712,6 +2852,23 @@ __metadata: languageName: node linkType: hard +"@rspack/core@npm:2.0.0-rc.0": + version: 2.0.0-rc.0 + resolution: "@rspack/core@npm:2.0.0-rc.0" + dependencies: + "@rspack/binding": "npm:2.0.0-rc.0" + peerDependencies: + "@module-federation/runtime-tools": ^0.24.1 || ^2.0.0 + "@swc/helpers": ">=0.5.1" + peerDependenciesMeta: + "@module-federation/runtime-tools": + optional: true + "@swc/helpers": + optional: true + checksum: 10c0/fbd33c26d45696f232c39c97f5ee51bb5f11a11e813b768c50fd1fec99a523207665c0519bb34cca809969d64b7ea1602fa2d16cf3d930af08ebf7c8bf3ee383 + languageName: node + linkType: hard + "@rspack/plugin-react-refresh@npm:^1.6.1": version: 1.6.2 resolution: "@rspack/plugin-react-refresh@npm:1.6.2" @@ -2816,6 +2973,27 @@ __metadata: languageName: node linkType: hard +"@rstest/core@npm:^0.9.6": + version: 0.9.6 + resolution: "@rstest/core@npm:0.9.6" + dependencies: + "@rsbuild/core": "npm:2.0.0-rc.0" + "@types/chai": "npm:^5.2.3" + tinypool: "npm:^2.1.0" + peerDependencies: + happy-dom: ^20.8.3 + jsdom: "*" + peerDependenciesMeta: + happy-dom: + optional: true + jsdom: + optional: true + bin: + rstest: bin/rstest.js + checksum: 10c0/28fefc191f3a713310455f69357d27e982acae8d8df2ec1cc5c6b9eafeb38b21c417b4897afbd0056b0efc2f1e7ca9cffce1b3190a0b914a454ec9013687c85e + languageName: node + linkType: hard + "@shikijs/core@npm:4.0.2": version: 4.0.2 resolution: "@shikijs/core@npm:4.0.2" @@ -3319,6 +3497,16 @@ __metadata: languageName: node linkType: hard +"@types/chai@npm:^5.2.3": + version: 5.2.3 + resolution: "@types/chai@npm:5.2.3" + dependencies: + "@types/deep-eql": "npm:*" + assertion-error: "npm:^2.0.1" + checksum: 10c0/e0ef1de3b6f8045a5e473e867c8565788c444271409d155588504840ad1a53611011f85072188c2833941189400228c1745d78323dac13fcede9c2b28bacfb2f + languageName: node + linkType: hard + "@types/cli-progress@npm:^3.11.6": version: 3.11.6 resolution: "@types/cli-progress@npm:3.11.6" @@ -3618,6 +3806,13 @@ __metadata: languageName: node linkType: hard +"@types/deep-eql@npm:*": + version: 4.0.2 + resolution: "@types/deep-eql@npm:4.0.2" + checksum: 10c0/bf3f811843117900d7084b9d0c852da9a044d12eb40e6de73b552598a6843c21291a8a381b0532644574beecd5e3491c5ff3a0365ab86b15d59862c025384844 + languageName: node + linkType: hard + "@types/ejs@npm:^3.1.5": version: 3.1.5 resolution: "@types/ejs@npm:3.1.5" @@ -4356,6 +4551,7 @@ __metadata: dependencies: "@changesets/changelog-github": "npm:^0.6.0" "@changesets/cli": "npm:^2.30.0" + "@rstest/core": "npm:^0.9.6" "@swc-node/register": "npm:^1.11.1" "@swc/core": "npm:^1.15.24" "@types/cli-progress": "npm:^3.11.6" @@ -4462,6 +4658,13 @@ __metadata: languageName: node linkType: hard +"assertion-error@npm:^2.0.1": + version: 2.0.1 + resolution: "assertion-error@npm:2.0.1" + checksum: 10c0/bbbcb117ac6480138f8c93cf7f535614282dea9dc828f540cdece85e3c665e8f78958b96afac52f29ff883c72638e6a87d469ecc9fe5bc902df03ed24a55dba8 + languageName: node + linkType: hard + "astring@npm:^1.8.0": version: 1.9.0 resolution: "astring@npm:1.9.0" @@ -11530,6 +11733,13 @@ __metadata: languageName: node linkType: hard +"tinypool@npm:^2.1.0": + version: 2.1.0 + resolution: "tinypool@npm:2.1.0" + checksum: 10c0/9fb1c760558c6264e0f4cfde96a63b12450b43f1730fbe6274aa24ddbdf488745c08924d0dea7a1303b47d555416a6415f2113898c69b6ecf731e75ac95238a5 + languageName: node + linkType: hard + "tmp@npm:^0.0.33": version: 0.0.33 resolution: "tmp@npm:0.0.33" From 2268431f1cee30c22e95025eb11c37256977c914 Mon Sep 17 00:00:00 2001 From: JounQin Date: Thu, 9 Apr 2026 16:34:21 +0800 Subject: [PATCH 2/7] chore: add AGENTS.md knowledge base and OpenSpec scaffolding - Update root AGENTS.md with detailed project structure, conventions, and commands - Add per-directory AGENTS.md for doom and export packages - Add OpenSpec skills and commands for change management - Add openspec config and archived change artifacts --- .opencode/command/opsx-apply.md | 152 +++++++++ .opencode/command/opsx-archive.md | 156 +++++++++ .opencode/command/opsx-explore.md | 178 +++++++++++ .opencode/command/opsx-propose.md | 111 +++++++ .../skills/openspec-apply-change/SKILL.md | 159 ++++++++++ .../skills/openspec-archive-change/SKILL.md | 116 +++++++ .opencode/skills/openspec-explore/SKILL.md | 299 ++++++++++++++++++ .opencode/skills/openspec-propose/SKILL.md | 118 +++++++ AGENTS.md | 122 ++++--- .../.openspec.yaml | 2 + .../design.md | 124 ++++++++ .../proposal.md | 37 +++ .../specs/remark-lint-tests/spec.md | 240 ++++++++++++++ .../specs/test-infrastructure/spec.md | 86 +++++ .../specs/utility-tests/spec.md | 182 +++++++++++ .../2026-04-09-add-rstest-unit-tests/tasks.md | 51 +++ openspec/config.yaml | 42 +++ packages/doom/AGENTS.md | 58 ++++ packages/doom/src/cli/AGENTS.md | 40 +++ packages/doom/src/plugins/AGENTS.md | 59 ++++ packages/doom/src/remark-lint/AGENTS.md | 53 ++++ packages/doom/src/runtime/AGENTS.md | 33 ++ packages/doom/src/theme/AGENTS.md | 29 ++ packages/export/AGENTS.md | 52 +++ 24 files changed, 2461 insertions(+), 38 deletions(-) create mode 100644 .opencode/command/opsx-apply.md create mode 100644 .opencode/command/opsx-archive.md create mode 100644 .opencode/command/opsx-explore.md create mode 100644 .opencode/command/opsx-propose.md create mode 100644 .opencode/skills/openspec-apply-change/SKILL.md create mode 100644 .opencode/skills/openspec-archive-change/SKILL.md create mode 100644 .opencode/skills/openspec-explore/SKILL.md create mode 100644 .opencode/skills/openspec-propose/SKILL.md create mode 100644 openspec/changes/archive/2026-04-09-add-rstest-unit-tests/.openspec.yaml create mode 100644 openspec/changes/archive/2026-04-09-add-rstest-unit-tests/design.md create mode 100644 openspec/changes/archive/2026-04-09-add-rstest-unit-tests/proposal.md create mode 100644 openspec/changes/archive/2026-04-09-add-rstest-unit-tests/specs/remark-lint-tests/spec.md create mode 100644 openspec/changes/archive/2026-04-09-add-rstest-unit-tests/specs/test-infrastructure/spec.md create mode 100644 openspec/changes/archive/2026-04-09-add-rstest-unit-tests/specs/utility-tests/spec.md create mode 100644 openspec/changes/archive/2026-04-09-add-rstest-unit-tests/tasks.md create mode 100644 openspec/config.yaml create mode 100644 packages/doom/AGENTS.md create mode 100644 packages/doom/src/cli/AGENTS.md create mode 100644 packages/doom/src/plugins/AGENTS.md create mode 100644 packages/doom/src/remark-lint/AGENTS.md create mode 100644 packages/doom/src/runtime/AGENTS.md create mode 100644 packages/doom/src/theme/AGENTS.md create mode 100644 packages/export/AGENTS.md diff --git a/.opencode/command/opsx-apply.md b/.opencode/command/opsx-apply.md new file mode 100644 index 00000000..6eff0ce1 --- /dev/null +++ b/.opencode/command/opsx-apply.md @@ -0,0 +1,152 @@ +--- +description: Implement tasks from an OpenSpec change (Experimental) +--- + +Implement tasks from an OpenSpec change. + +**Input**: Optionally specify a change name (e.g., `/opsx-apply add-auth`). If omitted, check if it can be inferred from conversation context. If vague or ambiguous you MUST prompt for available changes. + +**Steps** + +1. **Select the change** + + If a name is provided, use it. Otherwise: + - Infer from conversation context if the user mentioned a change + - Auto-select if only one active change exists + - If ambiguous, run `openspec list --json` to get available changes and use the **AskUserQuestion tool** to let the user select + + Always announce: "Using change: " and how to override (e.g., `/opsx-apply `). + +2. **Check status to understand the schema** + + ```bash + openspec status --change "" --json + ``` + + Parse the JSON to understand: + - `schemaName`: The workflow being used (e.g., "spec-driven") + - Which artifact contains the tasks (typically "tasks" for spec-driven, check status for others) + +3. **Get apply instructions** + + ```bash + openspec instructions apply --change "" --json + ``` + + This returns: + - Context file paths (varies by schema) + - Progress (total, complete, remaining) + - Task list with status + - Dynamic instruction based on current state + + **Handle states:** + - If `state: "blocked"` (missing artifacts): show message, suggest using `/opsx-continue` + - If `state: "all_done"`: congratulate, suggest archive + - Otherwise: proceed to implementation + +4. **Read context files** + + Read the files listed in `contextFiles` from the apply instructions output. + The files depend on the schema being used: + - **spec-driven**: proposal, specs, design, tasks + - Other schemas: follow the contextFiles from CLI output + +5. **Show current progress** + + Display: + - Schema being used + - Progress: "N/M tasks complete" + - Remaining tasks overview + - Dynamic instruction from CLI + +6. **Implement tasks (loop until done or blocked)** + + For each pending task: + - Show which task is being worked on + - Make the code changes required + - Keep changes minimal and focused + - Mark task complete in the tasks file: `- [ ]` → `- [x]` + - Continue to next task + + **Pause if:** + - Task is unclear → ask for clarification + - Implementation reveals a design issue → suggest updating artifacts + - Error or blocker encountered → report and wait for guidance + - User interrupts + +7. **On completion or pause, show status** + + Display: + - Tasks completed this session + - Overall progress: "N/M tasks complete" + - If all done: suggest archive + - If paused: explain why and wait for guidance + +**Output During Implementation** + +``` +## Implementing: (schema: ) + +Working on task 3/7: +[...implementation happening...] +✓ Task complete + +Working on task 4/7: +[...implementation happening...] +✓ Task complete +``` + +**Output On Completion** + +``` +## Implementation Complete + +**Change:** +**Schema:** +**Progress:** 7/7 tasks complete ✓ + +### Completed This Session +- [x] Task 1 +- [x] Task 2 +... + +All tasks complete! You can archive this change with `/opsx-archive`. +``` + +**Output On Pause (Issue Encountered)** + +``` +## Implementation Paused + +**Change:** +**Schema:** +**Progress:** 4/7 tasks complete + +### Issue Encountered + + +**Options:** +1.