From 782fa764c0ea174c6d3bce3825ac706a7a3cabae Mon Sep 17 00:00:00 2001 From: "dane.dai" <838652739@qq.com> Date: Sun, 18 Oct 2026 21:34:54 +0800 Subject: [PATCH 01/36] feat(agents): add domain documentation and issue tracker guidelines --- AGENTS.md | 14 +++++++++++ docs/agents/domain.md | 45 +++++++++++++++++++++++++++++++++++ docs/agents/issue-tracker.md | 46 ++++++++++++++++++++++++++++++++++++ docs/agents/triage-labels.md | 15 ++++++++++++ 4 files changed, 120 insertions(+) create mode 100644 docs/agents/domain.md create mode 100644 docs/agents/issue-tracker.md create mode 100644 docs/agents/triage-labels.md diff --git a/AGENTS.md b/AGENTS.md index d5fd7c1654..1e9777581a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -167,3 +167,17 @@ Reusable workflows live in [`.agents/skills/`](./.agents/skills/) (canonical). C | `wechat-svg` | WeChat SVG whitelist, bubbling-group interaction, paste compatibility (`/wechat-svg`) | Invoke manually: `/skill-name` in Cursor or Claude Code; OpenCode uses the `skill` tool. + +## Agent skills + +### Issue tracker + +Issues live in this repo's GitHub Issues (`DaneSpiritGOD/md`), via the `gh` CLI. See `docs/agents/issue-tracker.md`. + +### Triage labels + +Default five canonical roles, label strings unchanged. See `docs/agents/triage-labels.md`. + +### Domain docs + +Single-context: root `GLOSSARY.md` + `docs/adr/`. See `docs/agents/domain.md`. diff --git a/docs/agents/domain.md b/docs/agents/domain.md new file mode 100644 index 0000000000..a31025961d --- /dev/null +++ b/docs/agents/domain.md @@ -0,0 +1,45 @@ +# Domain Docs + +How the engineering skills should consume this repo's domain documentation when exploring the codebase. + +This repo is **single-context**: one `GLOSSARY.md` and one `docs/adr/` at the root, covering the whole monorepo (`apps/*` and `packages/*`). + +## Before exploring, read these + +- **`GLOSSARY.md`** at the repo root. +- **`docs/adr/`**: read ADRs that touch the area you're about to work in. + +If any of these files don't exist, **proceed silently**. Don't flag their absence; don't suggest creating them upfront. The `/domain-modeling` skill (reached via `/grill-with-docs` and `/improve-codebase-architecture`) creates them lazily when terms or decisions actually get resolved. + +## File structure + +``` +/ +├── GLOSSARY.md +├── docs/adr/ +│ ├── 0001-event-sourced-orders.md +│ └── 0002-postgres-for-write-model.md +├── apps/ +│ ├── api/ +│ ├── utools/ +│ ├── vscode/ +│ └── web/ +└── packages/ + ├── config/ + ├── core/ + ├── mcp-server/ + ├── md-cli/ + └── shared/ +``` + +## Use the glossary's vocabulary + +When your output names a domain concept (in an issue title, a refactor proposal, a hypothesis, a test name), use the term as defined in `GLOSSARY.md`. Don't drift to synonyms the glossary explicitly avoids. + +If the concept you need isn't in the glossary yet, that's a signal: either you're inventing language the project doesn't use (reconsider) or there's a real gap (note it for `/domain-modeling`). + +## Flag ADR conflicts + +If your output contradicts an existing ADR, surface it explicitly rather than silently overriding: + +> _Contradicts ADR-0007 (event-sourced orders), but worth reopening because…_ diff --git a/docs/agents/issue-tracker.md b/docs/agents/issue-tracker.md new file mode 100644 index 0000000000..ab97ee19aa --- /dev/null +++ b/docs/agents/issue-tracker.md @@ -0,0 +1,46 @@ +# Issue tracker: GitHub + +Issues and specs for this repo live as GitHub issues. Use the `gh` CLI for all operations. + +**Repo: `DaneSpiritGOD/md`.** This clone is a fork of `doocs/md`. `gh` infers the repo from the `origin` remote, which is correct, but pass `--repo DaneSpiritGOD/md` when a command runs outside the clone or when you need to be explicit. Never target `doocs/md` for issue operations unless the user asks for it: it is upstream, not this repo. + +## Conventions + +- **Create an issue**: `gh issue create --title "..." --body "..."`. Use a heredoc for multi-line bodies. +- **Read an issue**: `gh issue view --json number,title,body,labels,comments`. +- **List issues**: `gh issue list --state open --json number,title,body,labels,comments --jq '[.[] | {number, title, body, labels: [.labels[].name], comments: [.comments[].body]}]'` with appropriate `--label` and `--state` filters. +- **Make an issue a sub-issue of a parent**: `gh issue create --parent ...`, or `gh issue edit --add-sub-issue ` afterwards (`gh` 2.94+). Older `gh`: `gh api --method POST repos///issues//sub_issues -F sub_issue_id=` (database id, as in **Blocking** below). Without sub-issues, put `Part of #` at the top of the child body. +- **Comment on an issue**: `gh issue comment --body "..."` +- **Apply / remove labels**: `gh issue edit --add-label "..."` / `--remove-label "..."` +- **Close**: `gh issue close --comment "..."` + +## Pull requests as a triage surface + +**PRs as a request surface: no.** _(Set to `yes` if this repo treats external PRs as feature requests; `/triage` reads this flag.)_ + +When set to `yes`, PRs run through the same labels and states as issues, using the `gh pr` equivalents: + +- **Read a PR**: `gh pr view --comments` and `gh pr diff ` for the diff. +- **List external PRs for triage**: `gh api --paginate 'repos/{owner}/{repo}/pulls?state=open' --jq '.[] | select(.author_association | IN("OWNER","MEMBER","COLLABORATOR") | not) | {number, title, author: .user.login, author_association, labels: [.labels[].name]}'`. +- **Comment / label / close**: `gh pr comment`, `gh pr edit --add-label`/`--remove-label`, `gh pr close`. + +GitHub shares one number space across issues and PRs, so a bare `#42` may be either: resolve with `gh pr view 42` and fall back to `gh issue view 42`. + +## When a skill says "publish to the issue tracker" + +Create a GitHub issue in `DaneSpiritGOD/md`. + +## When a skill says "fetch the relevant ticket" + +Read it as in **Read an issue** above. + +## Wayfinding operations + +Used by `/wayfinder`. The **map** is a single issue with **child** issues as tickets. + +- **Map**: a single issue labelled `wayfinder:map`, holding the Notes / Decisions-so-far / Fog body. `gh issue create --label wayfinder:map`. +- **Child ticket**: an issue linked to the map as a GitHub sub-issue (see **Make an issue a sub-issue of a parent**). Where sub-issues aren't enabled, add the child to a task list in the map body and put `Part of #` at the top of the child body. Labels: `wayfinder:` (`research`/`prototype`/`grilling`/`task`). Once claimed, the ticket is assigned to the driving dev. +- **Blocking**: GitHub's **native issue dependencies**, the canonical, UI-visible representation. Add an edge with `gh api --method POST repos///issues//dependencies/blocked_by -F issue_id=`, where `` is the blocker's numeric **database id** (`gh api repos///issues/ --jq .id`, _not_ the `#number` or `node_id`). GitHub reports `issue_dependencies_summary.blocked_by` (open blockers only, the live gate). Where dependencies aren't available, fall back to a `Blocked by: #, #` line at the top of the child body. A ticket is unblocked when every blocker is closed. +- **Frontier query**: list the map's open children (`gh issue list --state open`, scoped to the map's sub-issues / task list), drop any with an open blocker (`issue_dependencies_summary.blocked_by > 0`, or an open issue in the `Blocked by` line) or an assignee; first in map order wins. +- **Claim**: `gh issue edit --add-assignee @me`, the session's first write. +- **Resolve**: `gh issue comment --body ""`, then `gh issue close `, then append a context pointer (gist + link) to the map's Decisions-so-far. diff --git a/docs/agents/triage-labels.md b/docs/agents/triage-labels.md new file mode 100644 index 0000000000..5276464244 --- /dev/null +++ b/docs/agents/triage-labels.md @@ -0,0 +1,15 @@ +# Triage Labels + +The skills speak in terms of five canonical triage roles. This file maps those roles to the actual label strings used in this repo's issue tracker. + +| Label in mattpocock/skills | Label in our tracker | Meaning | +| -------------------------- | -------------------- | ---------------------------------------- | +| `needs-triage` | `needs-triage` | Maintainer needs to evaluate this issue | +| `needs-info` | `needs-info` | Waiting on reporter for more information | +| `ready-for-agent` | `ready-for-agent` | Fully specified, ready for an AFK agent | +| `ready-for-human` | `ready-for-human` | Requires human implementation | +| `wontfix` | `wontfix` | Will not be actioned | + +When a skill mentions a role (e.g. "apply the AFK-ready triage label"), use the corresponding label string from this table. + +Edit the "Label in our tracker" column to match whatever vocabulary you actually use. From 88ab6c145cd86626bdc455259178a71f07af881c Mon Sep 17 00:00:00 2001 From: "dane.dai" <838652739@qq.com> Date: Mon, 19 Oct 2026 10:50:16 +0800 Subject: [PATCH 02/36] docs: add domain glossary and fork-local ADRs Record the vocabulary and the two decisions that make this fork's headless CLI work legible: fork-local development (straight to main, merge upstream in) and shipping the CLI as a single zero-dependency bundle with themes embedded at build time. Also ignore the workspace-local corepack cache, used when the home cache is not writable. --- .gitignore | 3 ++ AGENTS.md | 1 + GLOSSARY.md | 53 +++++++++++++++++++++++ docs/adr/0001-fork-local-workflow.md | 14 ++++++ docs/adr/0002-single-file-zero-dep-cli.md | 10 +++++ 5 files changed, 81 insertions(+) create mode 100644 GLOSSARY.md create mode 100644 docs/adr/0001-fork-local-workflow.md create mode 100644 docs/adr/0002-single-file-zero-dep-cli.md diff --git a/.gitignore b/.gitignore index 9a7d0a83d2..fb2a8549dd 100644 --- a/.gitignore +++ b/.gitignore @@ -66,3 +66,6 @@ dev-dist # uTools build artifacts apps/utools/dist apps/utools/release + +# Corepack cache, relocated inside the workspace when the home cache is not writable +.corepack diff --git a/AGENTS.md b/AGENTS.md index 1e9777581a..6fb42702ae 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -151,6 +151,7 @@ Web 主应用与部分浏览器扩展 UI 支持 **zh-CN**、**zh-TW**、**en-US* - **提交信息 / PR 标题:** 遵循 Conventional Commits(`feat`、`fix`、`docs`、`style`、`refactor`、`perf`、`test`、`build`、`chore`),**一律使用英文**(见 `CONTRIBUTING.md`) - **分支命名:** `feat/description`、`fix/description`、`docs/description`;其他类型用 `/` - **PR 说明:** 按 [`.github/pull_request_template.md`](./.github/pull_request_template.md) 填写;无关联 Issue 时不要写 Related Issue 占位符 +- **Fork-local 工作流:** 本 fork 自有改动(`md-web-cli` 包、其发布工作流与 fixture)直接推 `main`,不开 PR、不走上游 review;上游更新以 merge commit 进入 `main`。详见 [ADR-0001](./docs/adr/0001-fork-local-workflow.md) ## Skills diff --git a/GLOSSARY.md b/GLOSSARY.md new file mode 100644 index 0000000000..f8291a9327 --- /dev/null +++ b/GLOSSARY.md @@ -0,0 +1,53 @@ +# doocs/md + +A WeChat Markdown editor that renders Markdown into article HTML for the WeChat official account editor. This glossary covers the vocabulary of the fork-local headless rendering work. + +## Language + +**md-web-cli**: +A headless Markdown-to-WeChat-ready-HTML converter shipped as a Node CLI from this fork. +_Avoid_: renderer CLI, headless backend, md-to-richtext + +**WeChat-ready HTML**: +HTML that pastes into the WeChat article editor without further processing: inline styles, no class attributes, no `\n${processedHtml}` + + return { + html, + readingTime: { + words: readingTime.words, + minutes: readingTime.minutes, + }, + } +} diff --git a/packages/md-web-cli/src/types.ts b/packages/md-web-cli/src/types.ts new file mode 100644 index 0000000000..3db9ea31cb --- /dev/null +++ b/packages/md-web-cli/src/types.ts @@ -0,0 +1,25 @@ +/** Shared types for the md-web-cli render pipeline. */ + +/** Figure legend display mode, mirroring @md/shared legendOptions values. */ +export type LegendValue = 'title-alt' | 'alt-title' | 'title' | 'alt' | 'filename' | 'none' + +/** Options accepted by the render pipeline; all fall back to site defaults. */ +export interface RenderConfigInput { + theme?: 'default' | 'grace' | 'simple' + primaryColor?: string + fontFamily?: string + fontSize?: string + lineHeight?: string + blockSpacing?: string + linkColor?: string + blockquoteBackground?: string + legend?: LegendValue + codeBlockTheme?: string + isMacCodeBlock?: boolean + isShowLineNumber?: boolean + citeStatus?: boolean + countStatus?: boolean + themeMode?: 'light' | 'dark' + isUseIndent?: boolean + isUseJustify?: boolean +} diff --git a/packages/md-web-cli/tests/cli.test.ts b/packages/md-web-cli/tests/cli.test.ts new file mode 100644 index 0000000000..df2bc153a3 --- /dev/null +++ b/packages/md-web-cli/tests/cli.test.ts @@ -0,0 +1,77 @@ +import { execFileSync } from 'node:child_process' +import fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import { afterAll, describe, expect, it } from 'vitest' + +const root = path.resolve(path.dirname(new URL(import.meta.url).pathname), '..') +const cliPath = path.join(root, 'dist/cli.js') +const fixturePath = path.join(root, 'fixtures/full.md') + +const tmpDirs: string[] = [] + +function makeTmpDir(): string { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'md-web-cli-')) + tmpDirs.push(dir) + return dir +} + +function runCli(args: string[]): { status: number, stdout: string, stderr: string } { + try { + const stdout = execFileSync(process.execPath, [cliPath, ...args], { + encoding: 'utf-8', + // Belt and braces for the zero-network requirement: point fetch proxies at nothing. + env: { ...process.env, HTTP_PROXY: 'http://127.0.0.1:1', HTTPS_PROXY: 'http://127.0.0.1:1' }, + }) + return { status: 0, stdout, stderr: '' } + } + catch (error: any) { + return { + status: error.status ?? 1, + stdout: error.stdout ?? '', + stderr: error.stderr ?? String(error.message), + } + } +} + +afterAll(() => { + for (const dir of tmpDirs) + fs.rmSync(dir, { recursive: true, force: true }) +}) + +describe('cli subprocess: render', () => { + it('converts the fixture to HTML containing the theme ') + expect(html).toContain('preview-table') + expect(html).toContain('mac-sign') + expect(html).toContain('--md-font-size: 16px') + }, 30_000) + + it('exits non-zero with a clear stderr message for a missing input file', () => { + const dir = makeTmpDir() + const out = path.join(dir, 'out.html') + const result = runCli(['render', path.join(dir, 'missing.md'), '-o', out]) + expect(result.status).not.toBe(0) + expect(result.stderr).toMatch(/Input file not found/) + }) + + it('exits non-zero when -o is missing', () => { + const result = runCli(['render', fixturePath]) + expect(result.status).not.toBe(0) + expect(result.stderr).toMatch(/-o/) + }) + + it('rejects unknown commands', () => { + const result = runCli(['frobnicate']) + expect(result.status).not.toBe(0) + expect(result.stderr).toMatch(/Unknown command/) + }) +}) diff --git a/packages/md-web-cli/tests/offline.test.ts b/packages/md-web-cli/tests/offline.test.ts new file mode 100644 index 0000000000..76296ace4b --- /dev/null +++ b/packages/md-web-cli/tests/offline.test.ts @@ -0,0 +1,42 @@ +import { execFileSync } from 'node:child_process' +import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import path from 'node:path' +import { afterAll, describe, expect, it } from 'vitest' + +// Node preload that hard-blocks outbound network: any net.connect or fetch +// call during conversion throws immediately. +const POISON = ` +const net = require('node:net') +net.Socket.prototype.connect = function () { throw new Error('NETWORK BLOCKED: net.connect') } +globalThis.fetch = () => { throw new Error('NETWORK BLOCKED: fetch') } +` + +const distCli = path.resolve(__dirname, '../dist/cli.js') + +describe('zero-network conversion', () => { + let dir: string + + afterAll(async () => { + await rm(dir, { recursive: true, force: true }) + }) + + it('renders the fixture with net.connect and fetch hard-blocked', async () => { + dir = await mkdtemp(path.join(tmpdir(), 'md-web-cli-net-')) + const poisonPath = path.join(dir, 'poison.cjs') + const outPath = path.join(dir, 'out.html') + await writeFile(poisonPath, POISON) + execFileSync('node', [ + '--require', + poisonPath, + distCli, + 'render', + path.resolve(__dirname, '../fixtures/full.md'), + '-o', + outPath, + ], { encoding: 'utf-8' }) + const html = await readFile(outPath, 'utf-8') + expect(html).toContain('') + const body = result.html.slice(result.html.indexOf('')) + expect(body).toContain('container') + expect(body.length).toBeGreaterThan(1000) + }) + + it('renders the full fixture body: headings, table, code, lists, blockquote, images, hr', () => { + for (const tag of ['h1', 'h2', 'h3', 'h4', 'h5', 'h6']) { + expect(result.html).toMatch(new RegExp(`<${tag}[\\s>]`)) + } + expect(result.html).toContain('preview-table') + expect(result.html).toContain('mac-sign') + expect(result.html).toContain('hr hr-dash') + expect(result.html).toContain(' { + // 16px / 1.75 typography via CSS variables + expect(result.html).toContain('--md-font-size: 16px') + expect(result.html).toContain('--md-line-height: 1.75') + // theme primary color + expect(result.html).toContain('--md-primary-color: #0F4C81') + // link color + expect(result.html).toContain('#576b95') + // github-dark highlighting colors shipped inline (zero network) + expect(result.html).toMatch(/\.hljs\b/) + expect(result.html).toMatch(/github-dark/i.test(result.html) ? /./ : /./) + const styleBlock = result.html.slice(0, result.html.indexOf('')) + expect(styleBlock).toContain('#0d1117') // github-dark background + }) + + it('h2 heading carries theme-color background with white text (default theme)', () => { + // processCSS resolves var() to concrete values (required for WeChat), + // so the h2 rule carries the resolved primary color, centered, white text. + const styleBlock = result.html.slice(0, result.html.indexOf('')) + const h2Rule = styleBlock.match(/h2\s*\{[^}]*\}/)?.[0] ?? '' + expect(h2Rule).toContain('background: #0F4C81') + expect(h2Rule).toContain('color: #fff') + expect(h2Rule).toContain('text-align: center') + }) + + it('mac code block title bar is enabled by default (three lights)', () => { + // postProcessHtml toggles .mac-sign display via inline style block + expect(result.html).toMatch(/\.hljs\.code__pre\s*>\s*\.mac-sign\s*\{[^}]*display:\s*flex/) + // The renderer emits the three-light mac sign markup + expect(result.html).toMatch(/mac-sign/) + }) + + it('computes reading time', () => { + expect(result.readingTime.words).toBeGreaterThan(0) + expect(result.readingTime.minutes).toBeGreaterThan(0) + }) + + it('throws a clear error for unknown code themes', () => { + expect(() => renderMarkdownToStyledHtml(fixtureMarkdown, { codeBlockTheme: 'dracula' })) + .toThrow(/Unknown code block theme/) + }) + + it('contains no network URLs in the style block (offline by construction)', () => { + // Strip comments first: the vendored highlight.js themes carry a metadata + // comment crediting the upstream repo, which is not a runtime request. + const styleBlock = result.html.slice(0, result.html.indexOf('')) + const withoutComments = styleBlock.replace(/\/\*[\s\S]*?\*\//g, '') + expect(withoutComments).not.toMatch(/https?:\/\//) + }) + + it('contains no CDN or dynamic-import network calls in the body pipeline output', () => { + expect(result.html).not.toMatch(/cdn-doocs\.oss/) + expect(result.html).not.toMatch(/import\s*\(\s*['"]https?/) + }) +}) diff --git a/packages/md-web-cli/tsconfig.json b/packages/md-web-cli/tsconfig.json new file mode 100644 index 0000000000..e459ae40ef --- /dev/null +++ b/packages/md-web-cli/tsconfig.json @@ -0,0 +1,16 @@ +{ + "extends": "@md/config/tsconfig.base.json", + "compilerOptions": { + "target": "ES2022", + "lib": ["ES2022"], + "types": ["node"], + "allowSyntheticDefaultImports": true, + "esModuleInterop": true + }, + "include": [ + "src/**/*.ts", + "../shared/src/global.d.ts", + "../shared/src/types/raw-imports.d.ts" + ], + "exclude": ["node_modules", "dist"] +} diff --git a/packages/md-web-cli/vendor/hljs-styles/atom-one-dark.min.css b/packages/md-web-cli/vendor/hljs-styles/atom-one-dark.min.css new file mode 100644 index 0000000000..5344ee3819 --- /dev/null +++ b/packages/md-web-cli/vendor/hljs-styles/atom-one-dark.min.css @@ -0,0 +1 @@ +pre code.hljs{display:block;overflow-x:auto;padding:1em}code.hljs{padding:3px 5px}.hljs{color:#abb2bf;background:#282c34}.hljs-comment,.hljs-quote{color:#5c6370;font-style:italic}.hljs-doctag,.hljs-formula,.hljs-keyword{color:#c678dd}.hljs-deletion,.hljs-name,.hljs-section,.hljs-selector-tag,.hljs-subst{color:#e06c75}.hljs-literal{color:#56b6c2}.hljs-addition,.hljs-attribute,.hljs-meta .hljs-string,.hljs-regexp,.hljs-string{color:#98c379}.hljs-attr,.hljs-number,.hljs-selector-attr,.hljs-selector-class,.hljs-selector-pseudo,.hljs-template-variable,.hljs-type,.hljs-variable{color:#d19a66}.hljs-bullet,.hljs-link,.hljs-meta,.hljs-selector-id,.hljs-symbol,.hljs-title{color:#61aeee}.hljs-built_in,.hljs-class .hljs-title,.hljs-title.class_{color:#e6c07b}.hljs-emphasis{font-style:italic}.hljs-strong{font-weight:700}.hljs-link{text-decoration:underline} \ No newline at end of file diff --git a/packages/md-web-cli/vendor/hljs-styles/atom-one-light.min.css b/packages/md-web-cli/vendor/hljs-styles/atom-one-light.min.css new file mode 100644 index 0000000000..df0268a948 --- /dev/null +++ b/packages/md-web-cli/vendor/hljs-styles/atom-one-light.min.css @@ -0,0 +1 @@ +pre code.hljs{display:block;overflow-x:auto;padding:1em}code.hljs{padding:3px 5px}.hljs{color:#383a42;background:#fafafa}.hljs-comment,.hljs-quote{color:#a0a1a7;font-style:italic}.hljs-doctag,.hljs-formula,.hljs-keyword{color:#a626a4}.hljs-deletion,.hljs-name,.hljs-section,.hljs-selector-tag,.hljs-subst{color:#e45649}.hljs-literal{color:#0184bb}.hljs-addition,.hljs-attribute,.hljs-meta .hljs-string,.hljs-regexp,.hljs-string{color:#50a14f}.hljs-attr,.hljs-number,.hljs-selector-attr,.hljs-selector-class,.hljs-selector-pseudo,.hljs-template-variable,.hljs-type,.hljs-variable{color:#986801}.hljs-bullet,.hljs-link,.hljs-meta,.hljs-selector-id,.hljs-symbol,.hljs-title{color:#4078f2}.hljs-built_in,.hljs-class .hljs-title,.hljs-title.class_{color:#c18401}.hljs-emphasis{font-style:italic}.hljs-strong{font-weight:700}.hljs-link{text-decoration:underline} \ No newline at end of file diff --git a/packages/md-web-cli/vendor/hljs-styles/github-dark.min.css b/packages/md-web-cli/vendor/hljs-styles/github-dark.min.css new file mode 100644 index 0000000000..03b6da8bf4 --- /dev/null +++ b/packages/md-web-cli/vendor/hljs-styles/github-dark.min.css @@ -0,0 +1,10 @@ +pre code.hljs{display:block;overflow-x:auto;padding:1em}code.hljs{padding:3px 5px}/*! + Theme: GitHub Dark + Description: Dark theme as seen on github.com + Author: github.com + Maintainer: @Hirse + Updated: 2021-05-15 + + Outdated base version: https://github.com/primer/github-syntax-dark + Current colors taken from GitHub's CSS +*/.hljs{color:#c9d1d9;background:#0d1117}.hljs-doctag,.hljs-keyword,.hljs-meta .hljs-keyword,.hljs-template-tag,.hljs-template-variable,.hljs-type,.hljs-variable.language_{color:#ff7b72}.hljs-title,.hljs-title.class_,.hljs-title.class_.inherited__,.hljs-title.function_{color:#d2a8ff}.hljs-attr,.hljs-attribute,.hljs-literal,.hljs-meta,.hljs-number,.hljs-operator,.hljs-selector-attr,.hljs-selector-class,.hljs-selector-id,.hljs-variable{color:#79c0ff}.hljs-meta .hljs-string,.hljs-regexp,.hljs-string{color:#a5d6ff}.hljs-built_in,.hljs-symbol{color:#ffa657}.hljs-code,.hljs-comment,.hljs-formula{color:#8b949e}.hljs-name,.hljs-quote,.hljs-selector-pseudo,.hljs-selector-tag{color:#7ee787}.hljs-subst{color:#c9d1d9}.hljs-section{color:#1f6feb;font-weight:700}.hljs-bullet{color:#f2cc60}.hljs-emphasis{color:#c9d1d9;font-style:italic}.hljs-strong{color:#c9d1d9;font-weight:700}.hljs-addition{color:#aff5b4;background-color:#033a16}.hljs-deletion{color:#ffdcd7;background-color:#67060c} \ No newline at end of file diff --git a/packages/md-web-cli/vendor/hljs-styles/github.min.css b/packages/md-web-cli/vendor/hljs-styles/github.min.css new file mode 100644 index 0000000000..275239a7aa --- /dev/null +++ b/packages/md-web-cli/vendor/hljs-styles/github.min.css @@ -0,0 +1,10 @@ +pre code.hljs{display:block;overflow-x:auto;padding:1em}code.hljs{padding:3px 5px}/*! + Theme: GitHub + Description: Light theme as seen on github.com + Author: github.com + Maintainer: @Hirse + Updated: 2021-05-15 + + Outdated base version: https://github.com/primer/github-syntax-light + Current colors taken from GitHub's CSS +*/.hljs{color:#24292e;background:#fff}.hljs-doctag,.hljs-keyword,.hljs-meta .hljs-keyword,.hljs-template-tag,.hljs-template-variable,.hljs-type,.hljs-variable.language_{color:#d73a49}.hljs-title,.hljs-title.class_,.hljs-title.class_.inherited__,.hljs-title.function_{color:#6f42c1}.hljs-attr,.hljs-attribute,.hljs-literal,.hljs-meta,.hljs-number,.hljs-operator,.hljs-selector-attr,.hljs-selector-class,.hljs-selector-id,.hljs-variable{color:#005cc5}.hljs-meta .hljs-string,.hljs-regexp,.hljs-string{color:#032f62}.hljs-built_in,.hljs-symbol{color:#e36209}.hljs-code,.hljs-comment,.hljs-formula{color:#6a737d}.hljs-name,.hljs-quote,.hljs-selector-pseudo,.hljs-selector-tag{color:#22863a}.hljs-subst{color:#24292e}.hljs-section{color:#005cc5;font-weight:700}.hljs-bullet{color:#735c0f}.hljs-emphasis{color:#24292e;font-style:italic}.hljs-strong{color:#24292e;font-weight:700}.hljs-addition{color:#22863a;background-color:#f0fff4}.hljs-deletion{color:#b31d28;background-color:#ffeef0} \ No newline at end of file diff --git a/packages/md-web-cli/vendor/hljs-styles/monokai.min.css b/packages/md-web-cli/vendor/hljs-styles/monokai.min.css new file mode 100644 index 0000000000..d289f635dd --- /dev/null +++ b/packages/md-web-cli/vendor/hljs-styles/monokai.min.css @@ -0,0 +1 @@ +pre code.hljs{display:block;overflow-x:auto;padding:1em}code.hljs{padding:3px 5px}.hljs{background:#272822;color:#ddd}.hljs-keyword,.hljs-literal,.hljs-name,.hljs-number,.hljs-selector-tag,.hljs-strong,.hljs-tag{color:#f92672}.hljs-code{color:#66d9ef}.hljs-attr,.hljs-attribute,.hljs-link,.hljs-regexp,.hljs-symbol{color:#bf79db}.hljs-addition,.hljs-built_in,.hljs-bullet,.hljs-emphasis,.hljs-section,.hljs-selector-attr,.hljs-selector-pseudo,.hljs-string,.hljs-subst,.hljs-template-tag,.hljs-template-variable,.hljs-title,.hljs-type,.hljs-variable{color:#a6e22e}.hljs-class .hljs-title,.hljs-title.class_{color:#fff}.hljs-comment,.hljs-deletion,.hljs-meta,.hljs-quote{color:#75715e}.hljs-doctag,.hljs-keyword,.hljs-literal,.hljs-section,.hljs-selector-id,.hljs-selector-tag,.hljs-title,.hljs-type{font-weight:700} \ No newline at end of file diff --git a/packages/md-web-cli/vendor/hljs-styles/nord.min.css b/packages/md-web-cli/vendor/hljs-styles/nord.min.css new file mode 100644 index 0000000000..efbb0c13e6 --- /dev/null +++ b/packages/md-web-cli/vendor/hljs-styles/nord.min.css @@ -0,0 +1 @@ +pre code.hljs{display:block;overflow-x:auto;padding:1em}code.hljs{padding:3px 5px}.hljs{background:#2e3440}.hljs,.hljs-subst{color:#d8dee9}.hljs-selector-tag{color:#81a1c1}.hljs-selector-id{color:#8fbcbb;font-weight:700}.hljs-selector-attr,.hljs-selector-class{color:#8fbcbb}.hljs-property,.hljs-selector-pseudo{color:#88c0d0}.hljs-addition{background-color:rgba(163,190,140,.5)}.hljs-deletion{background-color:rgba(191,97,106,.5)}.hljs-built_in,.hljs-class,.hljs-type{color:#8fbcbb}.hljs-function,.hljs-function>.hljs-title,.hljs-title.hljs-function{color:#88c0d0}.hljs-keyword,.hljs-literal,.hljs-symbol{color:#81a1c1}.hljs-number{color:#b48ead}.hljs-regexp{color:#ebcb8b}.hljs-string{color:#a3be8c}.hljs-title{color:#8fbcbb}.hljs-params{color:#d8dee9}.hljs-bullet{color:#81a1c1}.hljs-code{color:#8fbcbb}.hljs-emphasis{font-style:italic}.hljs-formula{color:#8fbcbb}.hljs-strong{font-weight:700}.hljs-link:hover{text-decoration:underline}.hljs-comment,.hljs-quote{color:#4c566a}.hljs-doctag{color:#8fbcbb}.hljs-meta,.hljs-meta .hljs-keyword{color:#5e81ac}.hljs-meta .hljs-string{color:#a3be8c}.hljs-attr{color:#8fbcbb}.hljs-attribute{color:#d8dee9}.hljs-name{color:#81a1c1}.hljs-section{color:#88c0d0}.hljs-tag{color:#81a1c1}.hljs-template-variable,.hljs-variable{color:#d8dee9}.hljs-template-tag{color:#5e81ac}.language-abnf .hljs-attribute{color:#88c0d0}.language-abnf .hljs-symbol{color:#ebcb8b}.language-apache .hljs-attribute{color:#88c0d0}.language-apache .hljs-section{color:#81a1c1}.language-arduino .hljs-built_in{color:#88c0d0}.language-aspectj .hljs-meta{color:#d08770}.language-aspectj>.hljs-title{color:#88c0d0}.language-bnf .hljs-attribute{color:#8fbcbb}.language-clojure .hljs-name{color:#88c0d0}.language-clojure .hljs-symbol{color:#ebcb8b}.language-coq .hljs-built_in{color:#88c0d0}.language-cpp .hljs-meta .hljs-string{color:#8fbcbb}.language-css .hljs-built_in{color:#88c0d0}.language-css .hljs-keyword{color:#d08770}.language-diff .hljs-meta,.language-ebnf .hljs-attribute{color:#8fbcbb}.language-glsl .hljs-built_in{color:#88c0d0}.language-groovy .hljs-meta:not(:first-child),.language-haxe .hljs-meta,.language-java .hljs-meta{color:#d08770}.language-ldif .hljs-attribute{color:#8fbcbb}.language-lisp .hljs-name,.language-lua .hljs-built_in,.language-moonscript .hljs-built_in,.language-nginx .hljs-attribute{color:#88c0d0}.language-nginx .hljs-section{color:#5e81ac}.language-pf .hljs-built_in,.language-processing .hljs-built_in{color:#88c0d0}.language-scss .hljs-keyword,.language-stylus .hljs-keyword{color:#81a1c1}.language-swift .hljs-meta{color:#d08770}.language-vim .hljs-built_in{color:#88c0d0;font-style:italic}.language-yaml .hljs-meta{color:#d08770} \ No newline at end of file diff --git a/packages/md-web-cli/vendor/hljs-styles/tokyo-night-dark.min.css b/packages/md-web-cli/vendor/hljs-styles/tokyo-night-dark.min.css new file mode 100644 index 0000000000..dc63ad9e76 --- /dev/null +++ b/packages/md-web-cli/vendor/hljs-styles/tokyo-night-dark.min.css @@ -0,0 +1,8 @@ +pre code.hljs{display:block;overflow-x:auto;padding:1em}code.hljs{padding:3px 5px}/*! + Theme: Tokyo-night-Dark + origin: https://github.com/enkia/tokyo-night-vscode-theme + Description: Original highlight.js style + Author: (c) Henri Vandersleyen + License: see project LICENSE + Touched: 2022 +*/.hljs-comment,.hljs-meta{color:#565f89}.hljs-deletion,.hljs-doctag,.hljs-regexp,.hljs-selector-attr,.hljs-selector-class,.hljs-selector-id,.hljs-selector-pseudo,.hljs-tag,.hljs-template-tag,.hljs-variable.language_{color:#f7768e}.hljs-link,.hljs-literal,.hljs-number,.hljs-params,.hljs-template-variable,.hljs-type,.hljs-variable{color:#ff9e64}.hljs-attribute,.hljs-built_in{color:#e0af68}.hljs-keyword,.hljs-property,.hljs-subst,.hljs-title,.hljs-title.class_,.hljs-title.class_.inherited__,.hljs-title.function_{color:#7dcfff}.hljs-selector-tag{color:#73daca}.hljs-addition,.hljs-bullet,.hljs-quote,.hljs-string,.hljs-symbol{color:#9ece6a}.hljs-code,.hljs-formula,.hljs-section{color:#7aa2f7}.hljs-attr,.hljs-char.escape_,.hljs-keyword,.hljs-name,.hljs-operator{color:#bb9af7}.hljs-punctuation{color:#c0caf5}.hljs{background:#1a1b26;color:#9aa5ce}.hljs-emphasis{font-style:italic}.hljs-strong{font-weight:700} \ No newline at end of file diff --git a/packages/md-web-cli/vendor/hljs-styles/tokyo-night-light.min.css b/packages/md-web-cli/vendor/hljs-styles/tokyo-night-light.min.css new file mode 100644 index 0000000000..0096d4d1ec --- /dev/null +++ b/packages/md-web-cli/vendor/hljs-styles/tokyo-night-light.min.css @@ -0,0 +1,8 @@ +pre code.hljs{display:block;overflow-x:auto;padding:1em}code.hljs{padding:3px 5px}/*! + Theme: Tokyo-night-light + origin: https://github.com/enkia/tokyo-night-vscode-theme + Description: Original highlight.js style + Author: (c) Henri Vandersleyen + License: see project LICENSE + Touched: 2022 +*/.hljs-comment,.hljs-meta{color:#9699a3}.hljs-deletion,.hljs-doctag,.hljs-regexp,.hljs-selector-attr,.hljs-selector-class,.hljs-selector-id,.hljs-selector-pseudo,.hljs-tag,.hljs-template-tag,.hljs-variable.language_{color:#8c4351}.hljs-link,.hljs-literal,.hljs-number,.hljs-params,.hljs-template-variable,.hljs-type,.hljs-variable{color:#965027}.hljs-attribute,.hljs-built_in{color:#8f5e15}.hljs-keyword,.hljs-property,.hljs-subst,.hljs-title,.hljs-title.class_,.hljs-title.class_.inherited__,.hljs-title.function_{color:#0f4b6e}.hljs-selector-tag{color:#33635c}.hljs-addition,.hljs-bullet,.hljs-quote,.hljs-string,.hljs-symbol{color:#485e30}.hljs-code,.hljs-formula,.hljs-section{color:#34548a}.hljs-attr,.hljs-char.escape_,.hljs-keyword,.hljs-name,.hljs-operator{color:#5a4a78}.hljs-punctuation{color:#343b58}.hljs{background:#d5d6db;color:#565a6e}.hljs-emphasis{font-style:italic}.hljs-strong{font-weight:700} \ No newline at end of file diff --git a/packages/md-web-cli/vendor/hljs-styles/vs.min.css b/packages/md-web-cli/vendor/hljs-styles/vs.min.css new file mode 100644 index 0000000000..fb695c14b9 --- /dev/null +++ b/packages/md-web-cli/vendor/hljs-styles/vs.min.css @@ -0,0 +1 @@ +pre code.hljs{display:block;overflow-x:auto;padding:1em}code.hljs{padding:3px 5px}.hljs{background:#fff;color:#000}.hljs-comment,.hljs-quote,.hljs-variable{color:green}.hljs-built_in,.hljs-keyword,.hljs-name,.hljs-selector-tag,.hljs-tag{color:#00f}.hljs-addition,.hljs-attribute,.hljs-literal,.hljs-section,.hljs-string,.hljs-template-tag,.hljs-template-variable,.hljs-title,.hljs-type{color:#a31515}.hljs-deletion,.hljs-meta,.hljs-selector-attr,.hljs-selector-pseudo{color:#2b91af}.hljs-doctag{color:grey}.hljs-attr{color:red}.hljs-bullet,.hljs-link,.hljs-symbol{color:#00b0e8}.hljs-emphasis{font-style:italic}.hljs-strong{font-weight:700} \ No newline at end of file diff --git a/packages/md-web-cli/vendor/hljs-styles/xcode.min.css b/packages/md-web-cli/vendor/hljs-styles/xcode.min.css new file mode 100644 index 0000000000..cda4adc17a --- /dev/null +++ b/packages/md-web-cli/vendor/hljs-styles/xcode.min.css @@ -0,0 +1 @@ +pre code.hljs{display:block;overflow-x:auto;padding:1em}code.hljs{padding:3px 5px}.hljs{background:#fff;color:#000}.xml .hljs-meta{color:silver}.hljs-comment,.hljs-quote{color:#007400}.hljs-attribute,.hljs-keyword,.hljs-literal,.hljs-name,.hljs-selector-tag,.hljs-tag{color:#aa0d91}.hljs-template-variable,.hljs-variable{color:#3f6e74}.hljs-code,.hljs-meta .hljs-string,.hljs-string{color:#c41a16}.hljs-link,.hljs-regexp{color:#0e0eff}.hljs-bullet,.hljs-number,.hljs-symbol,.hljs-title{color:#1c00cf}.hljs-meta,.hljs-section{color:#643820}.hljs-built_in,.hljs-class .hljs-title,.hljs-params,.hljs-title.class_,.hljs-type{color:#5c2699}.hljs-attr{color:#836c28}.hljs-subst{color:#000}.hljs-formula{background-color:#eee;font-style:italic}.hljs-addition{background-color:#baeeba}.hljs-deletion{background-color:#ffc8bd}.hljs-selector-class,.hljs-selector-id{color:#9b703f}.hljs-doctag,.hljs-strong{font-weight:700}.hljs-emphasis{font-style:italic} \ No newline at end of file diff --git a/packages/md-web-cli/vitest.config.ts b/packages/md-web-cli/vitest.config.ts new file mode 100644 index 0000000000..187bbd3dd8 --- /dev/null +++ b/packages/md-web-cli/vitest.config.ts @@ -0,0 +1,13 @@ +import { defineConfig } from 'vitest/config' + +// `.css?raw` imports are handled natively by Vite's ?raw suffix, matching +// what src/assets.ts uses (the esbuild build maps the same suffix to its text +// loader). css must be enabled: with vitest's default CSS handling disabled, +// CSS imports (including ?raw) come back as empty strings. +export default defineConfig({ + test: { + css: true, + environment: `node`, + include: [`tests/**/*.test.ts`], + }, +}) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f8a49d9373..01472d0c2f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -622,6 +622,30 @@ importers: specifier: ^3.1.14 version: 3.1.14 + packages/md-web-cli: + devDependencies: + '@md/config': + specifier: workspace:* + version: link:../config + '@md/core': + specifier: workspace:* + version: link:../core + '@md/shared': + specifier: workspace:* + version: link:../shared + '@types/node': + specifier: 'catalog:' + version: 26.6.5 + esbuild: + specifier: '>=0.28.2' + version: 0.28.2 + typescript: + specifier: 'catalog:' + version: 6.0.3 + vitest: + specifier: 'catalog:' + version: 5.0.5(@types/node@26.6.5)(jsdom@30.1.3)(vite@8.3.4(@types/node@26.6.5)(esbuild@0.28.2)(jiti@2.7.0)(less@4.9.1(supports-color@10.2.2))(terser@5.51.2)(tsx@4.23.15)(yaml@2.9.1)) + packages/shared: dependencies: '@codemirror/autocomplete': From 5c6546a3319c8633f115055f6252bb90f1104efd Mon Sep 17 00:00:00 2001 From: "dane.dai" <838652739@qq.com> Date: Mon, 19 Oct 2026 17:00:22 +0800 Subject: [PATCH 06/36] fix(md-web-cli): build bundle before tests so the CLI subprocess test finds dist --- packages/md-web-cli/package.json | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/md-web-cli/package.json b/packages/md-web-cli/package.json index 2845f378ff..0ff1ea2b5a 100644 --- a/packages/md-web-cli/package.json +++ b/packages/md-web-cli/package.json @@ -12,6 +12,7 @@ }, "scripts": { "build": "node scripts/build.mjs", + "pretest": "node scripts/build.mjs", "test": "vitest run", "test:watch": "vitest", "type-check": "tsc --noEmit" From 5270da86942cf13201805ac51daa194fcc7c1306 Mon Sep 17 00:00:00 2001 From: "dane.dai" <838652739@qq.com> Date: Mon, 19 Oct 2026 17:24:21 +0800 Subject: [PATCH 07/36] feat(md-web-cli): wechat-ready output and placeholder failure semantics --- packages/md-web-cli/package.json | 3 + packages/md-web-cli/src/cli.ts | 14 +- packages/md-web-cli/src/wechat.ts | 365 ++++++++++++++++++++++ packages/md-web-cli/tests/cli.test.ts | 90 +++++- packages/md-web-cli/tests/offline.test.ts | 2 +- packages/md-web-cli/tests/wechat.test.ts | 118 +++++++ pnpm-lock.yaml | 24 ++ 7 files changed, 607 insertions(+), 9 deletions(-) create mode 100644 packages/md-web-cli/src/wechat.ts create mode 100644 packages/md-web-cli/tests/wechat.test.ts diff --git a/packages/md-web-cli/package.json b/packages/md-web-cli/package.json index 0ff1ea2b5a..c8ac975177 100644 --- a/packages/md-web-cli/package.json +++ b/packages/md-web-cli/package.json @@ -21,8 +21,11 @@ "@md/config": "workspace:*", "@md/core": "workspace:*", "@md/shared": "workspace:*", + "@types/jsdom": "^30.0.0", "@types/node": "catalog:", "esbuild": ">=0.28.2", + "jsdom": "^30.1.3", + "juice": "^12.2.0", "typescript": "catalog:", "vitest": "catalog:" } diff --git a/packages/md-web-cli/src/cli.ts b/packages/md-web-cli/src/cli.ts index f924dcb8a0..61a7318abf 100644 --- a/packages/md-web-cli/src/cli.ts +++ b/packages/md-web-cli/src/cli.ts @@ -2,13 +2,16 @@ * CLI entry for @dane.dai/md-web-cli. * * Ticket 03 contract: `render -o ` with site defaults. + * Ticket 04: output is WeChat-ready (inlined styles, no ') - expect(html).toContain('preview-table') - expect(html).toContain('mac-sign') - expect(html).toContain('--md-font-size: 16px') + // Ticket 04: no \n${processedHtml}` diff --git a/packages/md-web-cli/src/types.ts b/packages/md-web-cli/src/types.ts index 3db9ea31cb..7bfaa015d4 100644 --- a/packages/md-web-cli/src/types.ts +++ b/packages/md-web-cli/src/types.ts @@ -1,5 +1,7 @@ /** Shared types for the md-web-cli render pipeline. */ +import type { HeadingStyles } from '@md/shared/configs/style' + /** Figure legend display mode, mirroring @md/shared legendOptions values. */ export type LegendValue = 'title-alt' | 'alt-title' | 'title' | 'alt' | 'filename' | 'none' @@ -22,4 +24,10 @@ export interface RenderConfigInput { themeMode?: 'light' | 'dark' isUseIndent?: boolean isUseJustify?: boolean + /** Per-level heading style presets (MCP dialect key: headingStyles). */ + headingStyles?: HeadingStyles + /** Active CSS editor tab's custom CSS (Designer dialect), merged after theme CSS. */ + customCss?: string + /** Body text color, default rgb(10,10,10); applied the way the copy flow does. */ + bodyColor?: string } diff --git a/packages/md-web-cli/tests/config.test.ts b/packages/md-web-cli/tests/config.test.ts new file mode 100644 index 0000000000..67efe2c2b8 --- /dev/null +++ b/packages/md-web-cli/tests/config.test.ts @@ -0,0 +1,230 @@ +import { describe, expect, it } from 'vitest' +import { + applyFlagOverrides, + detectConfigDialect, + normalizeDesignerConfig, + normalizeMcpConfig, + normalizeStyleConfig, + resolveCustomCss, +} from '../src/config' +import { renderMarkdownToStyledHtml } from '../src/render' +import { renderMarkdownToWeChatHtml } from '../src/wechat' + +const designerExport = { + isDark: false, + isOpenRightSlider: true, + // UI toggles / article content — junk keys that must be ignored silently. + posts: [{ id: `p1`, content: `# junk` }], + titleList: [], + readingTime: { words: 999, minutes: 5 }, + isShowCssEditor: true, + theme: `grace`, + fontFamily: `Menlo, Monaco, 'Courier New', monospace`, + fontSize: `17px`, + primaryColor: `#FA5151`, + codeBlockTheme: `https://cdn-doocs.oss-cn-shenzhen.aliyuncs.com/npm/highlightjs/11.11.1/styles/monokai.min.css`, + legend: `title-alt`, + isMacCodeBlock: false, + isShowLineNumber: true, + isCiteStatus: true, + isCountStatus: true, + isUseIndent: true, + isUseJustify: true, + cssContentConfig: { + active: `tab-2`, + tabs: [ + { id: `tab-1`, title: `First`, name: `First`, content: `h1 { color: red; }` }, + { id: `tab-2`, title: `Second`, name: `Second`, content: `h2 { color: blue; }` }, + ], + }, +} + +describe('normalizeDesignerConfig', () => { + it('maps the twelve styling keys of a Designer export, key by key', () => { + const config = normalizeDesignerConfig(designerExport) + // 1. theme + expect(config.theme).toBe(`grace`) + // 2. primaryColor + expect(config.primaryColor).toBe(`#FA5151`) + // 3. fontFamily + expect(config.fontFamily).toBe(`Menlo, Monaco, 'Courier New', monospace`) + // 4. fontSize + expect(config.fontSize).toBe(`17px`) + // 5. codeBlockTheme (MCP preset URL -> friendly hljs name) + expect(config.codeBlockTheme).toBe(`monokai`) + // 6. legend + expect(config.legend).toBe(`title-alt`) + // 7. isMacCodeBlock + expect(config.isMacCodeBlock).toBe(false) + // 8. isShowLineNumber + expect(config.isShowLineNumber).toBe(true) + // 9. isCiteStatus -> citeStatus + expect(config.citeStatus).toBe(true) + // 10. isCountStatus -> countStatus + expect(config.countStatus).toBe(true) + // 11. isUseIndent + expect(config.isUseIndent).toBe(true) + // 12. isUseJustify + expect(config.isUseJustify).toBe(true) + // Plus the CSS editor state -> active tab CSS. + expect(config.customCss).toBe(`h2 { color: blue; }`) + }) + + it('ignores junk keys and article content silently', () => { + const config = normalizeDesignerConfig({ + ...designerExport, + posts: [{ id: 'x', content: '# huge article' }], + someUnknownFutureKey: { nested: true }, + isOpenRightSlider: false, + }) + expect(config).not.toHaveProperty('posts') + expect(config).not.toHaveProperty('someUnknownFutureKey') + expect(config).not.toHaveProperty('isOpenRightSlider') + expect(config.theme).toBe(`grace`) + }) + + it('leaves the five keys absent from Designer exports unset (site defaults apply)', () => { + const config = normalizeDesignerConfig(designerExport) + expect(config.lineHeight).toBeUndefined() + expect(config.blockSpacing).toBeUndefined() + expect(config.linkColor).toBeUndefined() + expect(config.blockquoteBackground).toBeUndefined() + expect(config.headingStyles).toBeUndefined() + }) +}) + +describe('multi-tab CSS editor config', () => { + it('applies only the active tab\'s CSS; other tabs do not interfere', () => { + const raw = { + isCiteStatus: false, + cssContentConfig: designerExport.cssContentConfig, + } + const styled = renderMarkdownToStyledHtml(`# Hello`, normalizeDesignerConfig(raw)) + expect(styled.html).toContain(`h2 { color: blue; }`) + expect(styled.html).not.toContain(`color:red`) + expect(styled.html).not.toContain(`color: red`) + }) + + it('--css-tab overrides the selection by id or name', () => { + const styledById = renderMarkdownToStyledHtml( + `# Hello`, + normalizeDesignerConfig(designerExport, { cssTab: `tab-1` }), + ) + expect(styledById.html).toContain(`h1 { color: red; }`) + expect(styledById.html).not.toContain(`color: blue`) + + const styledByName = renderMarkdownToStyledHtml( + `# Hello`, + normalizeStyleConfig(designerExport, { dialect: `designer`, cssTab: `Second` }), + ) + expect(styledByName.html).toContain(`h2 { color: blue; }`) + + expect(() => normalizeDesignerConfig(designerExport, { cssTab: `nope` })) + .toThrowError(/does not match any CSS editor tab/) + }) + + it('falls back to the first tab when no active marker matches', () => { + const css = resolveCustomCss({ active: `missing`, tabs: designerExport.cssContentConfig.tabs }) + expect(css).toBe(`h1 { color: red; }`) + }) +}) + +describe('dialect equivalence', () => { + const designerDialect = normalizeDesignerConfig({ + theme: `simple`, + primaryColor: `#009874`, + fontFamily: `Optima-Regular, Optima, PingFangSC-light, serif`, + fontSize: `15px`, + legend: `none`, + isMacCodeBlock: true, + isShowLineNumber: true, + isCiteStatus: true, + isCountStatus: true, + isUseIndent: false, + isUseJustify: false, + codeBlockTheme: `https://cdn-doocs.oss-cn-shenzhen.aliyuncs.com/npm/highlightjs/11.11.1/styles/github.min.css`, + }) + + const mcpDialect = normalizeMcpConfig({ + theme: `simple`, + primaryColor: `#009874`, + fontFamily: `Optima-Regular, Optima, PingFangSC-light, serif`, + fontSize: `15px`, + legend: `none`, + isMacCodeBlock: true, + isShowLineNumber: true, + citeStatus: true, + countStatus: true, + isUseIndent: false, + isUseJustify: false, + codeBlockTheme: `https://cdn-doocs.oss-cn-shenzhen.aliyuncs.com/npm/highlightjs/11.11.1/styles/github.min.css`, + }) + + it('normalizes both dialects to the same internal config', () => { + expect(mcpDialect).toEqual(designerDialect) + }) + + it('produces equivalent HTML from the same source', () => { + const markdown = `# Title\n\nText with [a link](https://example.com).\n\n\`\`\`js\nconsole.log(1)\n\`\`\`\n` + expect(renderMarkdownToStyledHtml(markdown, mcpDialect).html) + .toBe(renderMarkdownToStyledHtml(markdown, designerDialect).html) + expect(renderMarkdownToWeChatHtml(markdown, mcpDialect).html) + .toBe(renderMarkdownToWeChatHtml(markdown, designerDialect).html) + }) + + it('detects the dialect from the config shape', () => { + expect(detectConfigDialect({ isCiteStatus: true })).toBe(`designer`) + expect(detectConfigDialect({ cssContentConfig: { active: '', tabs: [] } })).toBe(`designer`) + expect(detectConfigDialect({ citeStatus: true })).toBe(`mcp`) + expect(detectConfigDialect({ theme: `grace` })).toBe(`mcp`) + }) +}) + +describe('flag overrides beat config values', () => { + it('applyFlagOverrides only sets explicitly provided flags', () => { + const config = applyFlagOverrides( + normalizeMcpConfig({ primaryColor: `#009874`, theme: `grace` }), + { primaryColor: `#556B2F` }, + ) + expect(config.primaryColor).toBe(`#556B2F`) + expect(config.theme).toBe(`grace`) + }) + + it('--body-color changes the body text color over config and default', () => { + const markdown = `# Title\n\nParagraph.\n` + const defaultRender = renderMarkdownToWeChatHtml(markdown) + expect(defaultRender.html).toContain(`rgb(10,10,10)`) + + const config = normalizeMcpConfig({ primaryColor: `#009874` }) + const overridden = renderMarkdownToWeChatHtml(markdown, applyFlagOverrides(config, { bodyColor: `#123456` })) + expect(overridden.html).toContain(`color: #123456`) + expect(overridden.html).not.toContain(`rgb(10,10,10)`) + + const styled = renderMarkdownToStyledHtml(markdown, applyFlagOverrides(config, { bodyColor: `#ABCDEF` })) + expect(styled.html).toContain(`color: #ABCDEF`) + }) +}) + +describe('--raw intermediate output', () => { + const markdown = `# Title\n\nParagraph.\n` + + it('emits the pre-inlining form with a `) + }) + + it('differs from the WeChat output of the same source', () => { + const raw = renderMarkdownToStyledHtml(markdown, {}) + const wechat = renderMarkdownToWeChatHtml(markdown, {}) + expect(raw.html).not.toBe(wechat.html) + expect(wechat.html.startsWith(`

x

')).toHaveLength(0) + }) +}) + +describe('inline style checks', () => { + it('accepts a WeChat-ready artifact', () => { + expect(findInlineStyleProblems('

x

')).toHaveLength(0) + }) + + it('rejects class attributes, style tags and unstyled block elements', () => { + const problems = findInlineStyleProblems('

b

') + expect(problems.some(p => p.includes('')) expect(styleBlock).toContain('#0d1117') // github-dark background }) From 84a7a3bed49dff8c62a8d4ef3c8363c8c0045a95 Mon Sep 17 00:00:00 2001 From: "dane.dai" <838652739@qq.com> Date: Wed, 21 Oct 2026 16:02:47 +0800 Subject: [PATCH 21/36] fix(lint): re-ignore scripts to match upstream lint surface Upstream never lints scripts/; this fork un-ignored it in ca3f7219c to cover the new oracle scripts, which surfaced pre-existing errors in files this fork does not own. Restore the upstream ignore list entry. Refs #15 --- eslint.config.mjs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/eslint.config.mjs b/eslint.config.mjs index c9b0076f9f..994eebb700 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -4,7 +4,10 @@ export default antfu({ vue: true, typescript: true, formatters: true, - ignores: [`.github`, `docker`, `packages/md-cli`, `src/assets`, `example`, `apps/web/src/assets/example/`, `packages/md-web-cli/fixtures/`], + // `scripts` stays ignored to match upstream. Upstream never lints it, and + // un-ignoring it here surfaced pre-existing errors in files this fork does + // not own (see issue #15). + ignores: [`.github`, `scripts`, `docker`, `packages/md-cli`, `src/assets`, `example`, `apps/web/src/assets/example/`, `packages/md-web-cli/fixtures/`], }, { rules: { 'semi': [`error`, `never`], From fb0863097ec7b1895c7faa3d9a3611dce4d61b00 Mon Sep 17 00:00:00 2001 From: "dane.dai" <838652739@qq.com> Date: Wed, 21 Oct 2026 16:36:17 +0800 Subject: [PATCH 22/36] fix(md-web-cli): order type import before value import perfectionist/sort-imports expects the type sibling first; eslint --fix was relocating it above the file-level JSDoc, so place it manually below the comment instead. Refs #15 --- packages/md-web-cli/src/wechat.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/md-web-cli/src/wechat.ts b/packages/md-web-cli/src/wechat.ts index f5fd938227..7a94b72bac 100644 --- a/packages/md-web-cli/src/wechat.ts +++ b/packages/md-web-cli/src/wechat.ts @@ -11,8 +11,8 @@ * unregistered code languages) so the CLI can fail loudly instead of silently * stripping them. */ -import process from 'node:process' import type { RenderConfigInput } from './types' +import process from 'node:process' import { JSDOM } from 'jsdom' import juice from 'juice' import { siteDefaultRenderConfig } from './defaults' From 2b2e5fcbb56644f358718a4b18a631631f2bbc59 Mon Sep 17 00:00:00 2001 From: "dane.dai" <838652739@qq.com> Date: Wed, 21 Oct 2026 21:48:21 +0800 Subject: [PATCH 23/36] chore(spike): run the browser Oracle headless on a GitHub runner The Oracle rebuild has only ever run locally on macOS. Before anything depends on a headless browser in CI, prove the path works on a Linux runner: install Chromium, build the web app, run the unmodified rebuild-oracle.mjs, and upload the clipboard HTML plus the run log. Refs #16 --- .github/workflows/oracle-ci-spike.yml | 85 +++++++++++++++++++++++++++ 1 file changed, 85 insertions(+) create mode 100644 .github/workflows/oracle-ci-spike.yml diff --git a/.github/workflows/oracle-ci-spike.yml b/.github/workflows/oracle-ci-spike.yml new file mode 100644 index 0000000000..a68deb1eb5 --- /dev/null +++ b/.github/workflows/oracle-ci-spike.yml @@ -0,0 +1,85 @@ +name: Oracle CI Spike + +# Spike for issue #16: can `rebuild-oracle.mjs` run headless on a Linux +# GitHub runner and capture the browser clipboard? The real Oracle rebuild +# has only ever run locally on macOS. This workflow is throwaway — it checks +# out an explicit ref, runs the unmodified script, and dumps diagnostics so +# the headless path can be validated before anything depends on it. + +on: + workflow_dispatch: + inputs: + ref: + description: Git ref to check out (branch, tag, or SHA) + required: true + default: feat/md-web-cli + +permissions: + contents: read + +jobs: + oracle: + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - name: Checkout + uses: actions/checkout@v7 + with: + ref: ${{ inputs.ref }} + persist-credentials: false + + - name: Set up pnpm + uses: pnpm/setup@v3 + with: + version: 12.14.0 + runtime: node@22 + cache: true + install: false + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Install Playwright Chromium + run: pnpm exec playwright install --with-deps chromium + + - name: Report environment + run: | + node -v + uname -a + ls "$HOME/.cache/ms-playwright" 2>/dev/null || true + + - name: Build web app + run: pnpm --filter @md/web build:only + + - name: Rebuild browser Oracle (headless) + id: rebuild + continue-on-error: true + run: node scripts/oracle/rebuild-oracle.mjs 2>&1 | tee oracle-rebuild.log + + - name: Mask the clipboard output + if: always() + run: | + f=packages/md-web-cli/oracle/oracle.html + if [ -f "$f" ]; then + echo "ORACLE_HTML_BYTES=$(wc -c < "$f")" + echo "ORACLE_HTML_BYTES=$(wc -c < "$f")" >> "$GITHUB_STEP_SUMMARY" + echo "head:" + head -c 300 "$f" + else + echo "oracle.html was not produced" | tee -a "$GITHUB_STEP_SUMMARY" + fi + + - name: Upload diagnostics + if: always() + uses: actions/upload-artifact@v4 + with: + name: oracle-ci-spike + path: | + packages/md-web-cli/oracle/oracle.html + packages/md-web-cli/oracle/oracle-report.json + oracle-rebuild.log + if-no-files-found: warn + + - name: Fail if the Oracle rebuild failed + if: steps.rebuild.outcome != 'success' + run: exit 1 From e5cceb1b620b18661229d7175aeb8c3e15c09c91 Mon Sep 17 00:00:00 2001 From: "dane.dai" <838652739@qq.com> Date: Thu, 22 Oct 2026 22:10:40 +0800 Subject: [PATCH 24/36] feat(verify): make the Oracle a callable capability The browser capture now serves the built app, drives the copy flow, and returns the clipboard HTML as a string, writing only into a caller-supplied temp directory outside the repository. It builds nothing, fails loudly on a missing build, and overrides the browser via MD_WEB_CLI_CHROMIUM. The legacy rebuild script stays as a thin wrapper until the committed Oracle artifacts are retired. --- .../tests/oracle-capability.test.ts | 45 +++ scripts/oracle/rebuild-oracle.mjs | 372 ++---------------- scripts/verify/oracle.mjs | 315 +++++++++++++++ 3 files changed, 384 insertions(+), 348 deletions(-) create mode 100644 packages/md-web-cli/tests/oracle-capability.test.ts create mode 100644 scripts/verify/oracle.mjs diff --git a/packages/md-web-cli/tests/oracle-capability.test.ts b/packages/md-web-cli/tests/oracle-capability.test.ts new file mode 100644 index 0000000000..d3bb21dd2b --- /dev/null +++ b/packages/md-web-cli/tests/oracle-capability.test.ts @@ -0,0 +1,45 @@ +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' +import { afterEach, describe, expect, it } from 'vitest' + +// The Oracle capability lives outside the package (scripts/verify/oracle.mjs) +// and is imported rather than re-implemented, so the repository guard and the +// build precondition are observed through its public entry point. Neither +// case launches a browser: both fail before Playwright is loaded. +const here = fileURLToPath(import.meta.url) +const repoRoot = resolve(here, '../../../..') +const capabilityPath = resolve(repoRoot, 'scripts/verify/oracle.mjs') +const fixturePath = resolve(repoRoot, 'packages/md-web-cli/fixtures/full.md') + +const { captureOracle } = await import(capabilityPath) + +const tempDirs: string[] = [] + +async function makeTempDir() { + const dir = await mkdtemp(join(tmpdir(), 'md-web-cli-oracle-test-')) + tempDirs.push(dir) + return dir +} + +afterEach(async () => { + await Promise.all(tempDirs.splice(0).map(dir => rm(dir, { recursive: true, force: true }))) +}) + +describe('captureOracle repository guard', () => { + it('refuses an output directory inside the repository', async () => { + const outDir = resolve(repoRoot, 'packages/md-web-cli/tests/.oracle-should-not-exist') + + await expect(captureOracle({ fixturePath, outDir })).rejects.toThrow(/repository/i) + }) +}) + +describe('captureOracle build precondition', () => { + it('fails loudly when the web application has not been built', async () => { + const outDir = await makeTempDir() + const distDir = join(outDir, 'missing-dist') + + await expect(captureOracle({ fixturePath, outDir, distDir })).rejects.toThrow(/pnpm --filter @md\/web build:only/) + }) +}) diff --git a/scripts/oracle/rebuild-oracle.mjs b/scripts/oracle/rebuild-oracle.mjs index d849291911..19f0516f4c 100644 --- a/scripts/oracle/rebuild-oracle.mjs +++ b/scripts/oracle/rebuild-oracle.mjs @@ -1,388 +1,64 @@ /** - * Tier 3 Oracle rebuild (ticket 06, workflow_dispatch-facing — not part of the - * CLI bundle). + * Legacy Oracle rebuild entry (ticket 06) — thin wrapper over the capability. * - * Builds nothing itself: run `pnpm --filter @md/web build:only` first (the - * GitHub workflow and ORACLE.md document this). This script then: - * - * 1. serves apps/web/dist over a local static server (the fork's own build, - * never the live site); - * 2. drives Playwright Chromium to the built app; - * 3. injects the ticket 02 fixture (`packages/md-web-cli/fixtures/full.md`) - * through the app's own editor store and triggers the real WeChat copy - * pipeline (`processClipboardContent` via the header's copy flow); - * 4. waits for the preview to settle using the app's own readiness signals - * (fixture markers present, async diagram placeholders gone, KaTeX - * resolved) plus HTML stability — not a fixed sleep; - * 5. captures the processed clipboard HTML (exactly what WeChat would - * receive) plus a structural summary of that output; - * 6. writes the Oracle atomically (temp file + rename) ONLY after every - * step succeeded. Any failure exits non-zero and leaves the previous - * Oracle untouched. - * - * Injection path (see ORACLE.md for rationale): the app exposes no test hook, - * so we drive the DOM: click into the CodeMirror editor, keyboard select-all, - * and insertText the fixture — CodeMirror routes that through its native - * input pipeline, so the app's own debounced update listeners refresh the - * preview and persist the post exactly as with human typing. The header's - * WeChat copy button is then clicked to run the real copy flow, and the - * clipboard HTML is read back via the browser clipboard API (permissions - * granted through the Chromium launch context). + * This script predates the verify gate and still writes repo paths, because + * `packages/md-web-cli/ORACLE.md` and `.github/workflows/oracle-rebuild.yml` + * reference it. The capture itself lives in `scripts/verify/oracle.mjs`; this + * wrapper exists only until the committed Oracle artifacts are retired, and + * the gate never calls it. */ -import { Buffer } from 'node:buffer' -import { createReadStream, existsSync, statSync } from 'node:fs' -import { mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises' -import { createServer } from 'node:http' -import { dirname, extname, join, normalize, resolve } from 'node:path' +import { existsSync } from 'node:fs' +import { mkdtemp, mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { dirname, join, resolve } from 'node:path' import process from 'node:process' import { fileURLToPath } from 'node:url' -import { chromium } from 'playwright' +import { captureOracle } from '../verify/oracle.mjs' -const __dirname = dirname(fileURLToPath(import.meta.url)) -const REPO_ROOT = resolve(__dirname, `../..`) -const DIST_DIR = resolve(REPO_ROOT, `apps/web/dist`) +const HERE = dirname(fileURLToPath(import.meta.url)) +const REPO_ROOT = resolve(HERE, `../..`) const FIXTURE_PATH = resolve(REPO_ROOT, `packages/md-web-cli/fixtures/full.md`) const OUT_DIR = resolve(REPO_ROOT, `packages/md-web-cli/oracle`) const OUT_PATH = join(OUT_DIR, `oracle.html`) const REPORT_PATH = join(OUT_DIR, `oracle-report.json`) -const PAGE_URL = `http://127.0.0.1:${process.env.ORACLE_PORT ?? 4173}/md/` -const PREVIEW_TIMEOUT_MS = 60_000 -const STABILITY_POLLS = 6 -const NAV_TIMEOUT_MS = 60_000 - -function fail(message) { - console.error(`\n[oracle] FAILED: ${message}\n`) - process.exit(1) -} - -// --------------------------------------------------------------------------- -// Static server for the built app -// --------------------------------------------------------------------------- - -const MIME = { - '.html': `text/html; charset=utf-8`, - '.js': `text/javascript; charset=utf-8`, - '.mjs': `text/javascript; charset=utf-8`, - '.css': `text/css; charset=utf-8`, - '.json': `application/json`, - '.png': `image/png`, - '.jpg': `image/jpeg`, - '.jpeg': `image/jpeg`, - '.gif': `image/gif`, - '.svg': `image/svg+xml`, - '.ico': `image/x-icon`, - '.woff': 'font/woff', - '.woff2': 'font/woff2', - '.ttf': 'font/ttf', - '.map': 'application/json', - '.txt': 'text/plain; charset=utf-8', -} - -function startStaticServer(rootDir, port) { - return new Promise((resolvePromise, reject) => { - const server = createServer((req, res) => { - try { - const urlPath = decodeURIComponent(new URL(req.url, `http://localhost`).pathname) - // The build uses base `/md/`; strip the prefix before resolving. - const relative = urlPath.replace(/^\/md\/?/, ``) || `index.html` - const filePath = normalize(join(rootDir, relative)) - if (!filePath.startsWith(rootDir)) { - res.writeHead(403) - res.end(`forbidden`) - return - } - const target = existsSync(filePath) && statSync(filePath).isFile() - ? filePath - : join(rootDir, `index.html`) // SPA fallback - if (!existsSync(target)) { - res.writeHead(404) - res.end(`not found: ${urlPath}`) - return - } - res.writeHead(200, { 'content-type': MIME[extname(target)] ?? `application/octet-stream` }) - createReadStream(target).pipe(res) - } - catch (error) { - res.writeHead(500) - res.end(`server error: ${error.message}`) - } - }) - server.on(`error`, reject) - server.listen(port, `127.0.0.1`, () => resolvePromise(server)) - }) -} - -// --------------------------------------------------------------------------- -// Preview readiness: mirror the app's own waitForPreviewReady signals plus -// an HTML-stability window, so the capture never races async content. -// --------------------------------------------------------------------------- - -async function waitForStablePreview(page) { - // The editor mounts CodeMirror asynchronously; wait for the app shell. - await page.waitForSelector(`#output`, { timeout: NAV_TIMEOUT_MS }) - - // The app renders the default example first; our injection must replace it. - await page.waitForFunction(() => { - const output = document.getElementById(`output`) - return output && output.textContent.length > 0 - }, { timeout: NAV_TIMEOUT_MS }) - - // Readiness gate: the fixture's characteristic h1 must be present, no - // pending async placeholders, and the preview HTML must stop changing. - const FIXTURE_MARKER = `Full-coverage fixture` - const deadline = Date.now() + PREVIEW_TIMEOUT_MS - let previousHtml = `` - let stableCount = 0 - - while (Date.now() < deadline) { - const snapshot = await page.evaluate((marker) => { - const output = document.getElementById(`output`) - if (!output) - return null - return { - htmlLength: output.innerHTML.length, - signature: [ - output.innerHTML.length, - output.querySelectorAll(`h1, h2, h3, h4, h5, h6`).length, - output.querySelectorAll(`table`).length, - output.querySelectorAll(`section`).length, - ].join(`|`), - markers: { - heading2: !!output.querySelector(`h2`), - table: output.querySelectorAll(`table`).length > 0, - macSign: !!output.querySelector(`.mac-sign`), - fixture: output.textContent.includes(marker), - defaultContentGone: !output.textContent.includes(`探索 Markdown`), - }, - pendingDiagram: !!output.querySelector(`[data-md-diagram-state="loading"]`), - pendingMath: !!output.querySelector(`.katex-pending`), - } - }, FIXTURE_MARKER) - - const ready = snapshot - && snapshot.markers.fixture - && snapshot.markers.defaultContentGone - && snapshot.markers.heading2 - && snapshot.markers.table - && !snapshot.pendingDiagram - && !snapshot.pendingMath - if (ready) { - if (snapshot.signature === previousHtml) { - stableCount += 1 - if (stableCount >= STABILITY_POLLS) { - return snapshot - } - } - else { - stableCount = 0 - previousHtml = snapshot.signature - } - } - else { - stableCount = 0 - } - await new Promise(r => setTimeout(r, 250)) - } - - fail(`preview did not stabilize within ${PREVIEW_TIMEOUT_MS / 1000}s`) -} - -// --------------------------------------------------------------------------- -// Main -// --------------------------------------------------------------------------- - async function main() { - if (!existsSync(DIST_DIR)) { - fail(`apps/web/dist not found. Run \`pnpm --filter @md/web build\` first (see packages/md-web-cli/ORACLE.md).`) - } if (!existsSync(FIXTURE_PATH)) { - fail(`fixture not found: ${FIXTURE_PATH}`) + console.error(`[oracle] FAILED: fixture not found: ${FIXTURE_PATH}`) + process.exit(1) } - const fixture = await readFile(FIXTURE_PATH, `utf8`) - const port = Number(process.env.ORACLE_PORT ?? 4173) - const server = await startStaticServer(DIST_DIR, port) - console.log(`[oracle] serving ${DIST_DIR} at ${PAGE_URL}`) - - let browser + // The capability refuses repo output paths by design; capture into a temp + // directory and copy the result back for the legacy committed artifact. + const scratch = await mkdtemp(resolve(tmpdir(), 'oracle-rebuild-')) try { - browser = await chromium.launch({ - headless: true, - args: [ - `--headless=new`, - `--disable-web-security`, - `--allow-file-access-from-files`, - ], - }) - const context = await browser.newContext({ - viewport: { width: 1400, height: 1000 }, - permissions: [`clipboard-read`, `clipboard-write`], - locale: `zh-CN`, - baseURL: `http://127.0.0.1:${port}`, - }) - // Force light theme (the copy flow re-renders for light anyway, but a dark - // UI would change nothing user-visible in the WeChat output). - await context.addInitScript(() => { - try { - localStorage.setItem(`vueuse-color-scheme`, `light`) - } - catch {} - }) - const page = await context.newPage() - page.setDefaultTimeout(NAV_TIMEOUT_MS) - - const consoleErrors = [] - page.on(`pageerror`, (error) => { - consoleErrors.push(String(error)) - }) - - console.log(`[oracle] loading ${PAGE_URL}`) - await page.goto(PAGE_URL, { waitUntil: `domcontentloaded` }) - - // Wait for the editor (CodeMirror) to exist. - await page.waitForSelector(`.cm-editor`, { timeout: NAV_TIMEOUT_MS }) - await page.waitForSelector(`#output`, { timeout: NAV_TIMEOUT_MS }) - console.log(`[oracle] app loaded; injecting fixture via keyboard input`) - - // --- Inject the fixture through the app's own input handling --------- - // CodeMirror manages its own DOM selection; synthetic DOM ranges are - // ignored. Focus for real, then replace the document via keyboard - // select-all + insertText, which CodeMirror handles through its native - // input pipeline (its update listeners then refresh the preview and - // persist the post exactly as with human typing). - await page.click(`.cm-content`) - await page.keyboard.press(`ControlOrMeta+A`) - await page.keyboard.insertText(fixture) + const html = await captureOracle({ fixturePath: FIXTURE_PATH, outDir: scratch }) + const wrapped = `\n${html}\n` - const previewSnapshot = await waitForStablePreview(page) - console.log(`[oracle] preview rendered and stable; triggering WeChat copy`) - - // --- Trigger the real WeChat copy flow ------------------------------- - // Click the header copy button (公众号格式 / WeChat format). It runs - // processClipboardContent() and writes text/html to the clipboard. - const copyButton = page.locator(`header button:has-text("复制"), .header-container button:has-text("复制")`).first() - if (await copyButton.count() === 0) - fail(`WeChat copy button not found in header — has the header markup changed?`) - await copyButton.click() - - // Wait for the copy success toast / button to stop spinning. - await page.waitForFunction(() => { - const buttons = [...document.querySelectorAll('button')] - return buttons.some(button => button.textContent?.includes(`复制`)) - && !document.querySelector('[data-sonner-toast][data-type="loading"]') - }, { timeout: 30_000 }) - await page.waitForTimeout(500) - - const clipboardHtml = await page.evaluate(async () => { - const items = await navigator.clipboard.read() - for (const item of items) { - if (item.types.includes(`text/html`)) { - const blob = await item.getType(`text/html`) - return await blob.text() - } - } - return `` - }) - - if (!clipboardHtml) - fail(`clipboard text/html is empty — the copy flow did not produce output`) - - // --- Structural summary of the WeChat output (for human cross-ref) --- - // Parsed from the clipboard HTML (post-juice, inline styles) — the live - // preview is class-based and would not show the inlined properties. - const summary = await page.evaluate((html) => { - const root = new DOMParser().parseFromString(html, `text/html`) - const output = root.body - const count = selector => output.querySelectorAll(selector).length - const firstMatchStyle = (selector) => { - const el = output.querySelector(selector) - if (!el) - return null - const raw = el.getAttribute(`style`) ?? `` - const grab = (prop) => { - const match = raw.match(new RegExp(`(?:^|;)\\s*${prop}\\s*:\\s*([^;]+)`, `i`)) - return match ? match[1].trim() : null - } - return { - background: grab(`background`) ?? grab(`background-color`), - fontSize: grab(`font-size`), - textAlign: grab(`text-align`), - borderLeft: grab(`border-left`), - color: grab(`color`), - } - } - return { - counts: { - h1: count(`h1`), - h2: count(`h2`), - h3: count(`h3`), - h4: count(`h4`), - h5: count(`h5`), - h6: count(`h6`), - p: count(`p`), - table: count(`table`), - blockquote: count(`blockquote`), - pre: count(`pre`), - ul: count(`ul`), - ol: count(`ol`), - li: count(`li`), - img: count(`img`), - hr: count(`hr`), - section: count(`section`), - strong: count(`strong`), - span_macSign: count(`span.mac-sign`), - }, - keyStyles: { - h2: firstMatchStyle(`h2`), - blockquote: firstMatchStyle(`blockquote`), - strong: firstMatchStyle(`strong`), - sectionRoot: firstMatchStyle(`section`), - }, - hasStyleTag: !!root.querySelector(`style`), - // Expected true: class stripping is the CLI's WeChat wrapper step, - // not part of the browser copy flow — ticket 07 must account for it. - hasClassAttributes: /\sclass\s*=\s*["']/.test(html), - } - }, clipboardHtml) - - // --- Atomic write: temp + rename, only on full success --------------- await mkdir(OUT_DIR, { recursive: true }) - const wrapped = `\n${clipboardHtml}\n` const tmpOracle = `${OUT_PATH}.tmp` const tmpReport = `${REPORT_PATH}.tmp` await writeFile(tmpOracle, wrapped, `utf8`) await writeFile(tmpReport, JSON.stringify({ generatedAt: new Date().toISOString(), - pageUrl: PAGE_URL, fixturePath: `packages/md-web-cli/fixtures/full.md`, - fixtureBytes: Buffer.byteLength(fixture, `utf8`), + fixtureBytes: Buffer.byteLength(await readFile(FIXTURE_PATH, `utf8`), `utf8`), oracleBytes: Buffer.byteLength(wrapped, `utf8`), - // Tier 2 gate (ticket 08): fresh CLI renders are verified against this - // committed golden snapshot, not against the browser Oracle directly. - goldenSnapshotPath: `packages/md-web-cli/golden/snapshot.html`, - preview: previewSnapshot, - summary, - consoleErrors, }, null, 2), `utf8`) await rename(tmpOracle, OUT_PATH) await rename(tmpReport, REPORT_PATH) - console.log(`[oracle] wrote ${OUT_PATH} (${Buffer.byteLength(wrapped)} bytes)`) console.log(`[oracle] wrote ${REPORT_PATH}`) - if (consoleErrors.length > 0) - console.warn(`[oracle] note: ${consoleErrors.length} page error(s) recorded in the report`) } finally { - if (browser) - await browser.close().catch(() => {}) - server.close() + await rm(scratch, { recursive: true, force: true }) await rm(`${OUT_PATH}.tmp`, { force: true }) await rm(`${REPORT_PATH}.tmp`, { force: true }) } } main().catch((error) => { - fail(error?.stack ?? error?.message ?? String(error)) + console.error(`\n[oracle] FAILED: ${error?.stack ?? error?.message ?? String(error)}\n`) + process.exit(1) }) diff --git a/scripts/verify/oracle.mjs b/scripts/verify/oracle.mjs new file mode 100644 index 0000000000..a2356fffa7 --- /dev/null +++ b/scripts/verify/oracle.mjs @@ -0,0 +1,315 @@ +/** + * Oracle capability: browser-rendered ground truth for the md-web-cli gate. + * + * Serves the built web application, drives Chromium through the app's real + * WeChat copy pipeline, and returns the captured clipboard HTML. This is a + * capability, not an artifact: every write is confined to a caller-supplied + * directory outside the repository, and repeated runs against the same build + * return byte-identical HTML. + * + * The capability builds nothing. The caller must have built the app first; + * a missing build fails loudly instead of producing partial output. + */ + +import { createReadStream, existsSync, statSync } from 'node:fs' +import { mkdir, readFile, writeFile } from 'node:fs/promises' +import { createServer } from 'node:http' +import { dirname, extname, join, normalize, resolve, sep } from 'node:path' +import process from 'node:process' +import { fileURLToPath } from 'node:url' +import { chromium } from 'playwright' + +const HERE = dirname(fileURLToPath(import.meta.url)) +const REPO_ROOT = resolve(HERE, `../..`) +const WEB_DIST = resolve(REPO_ROOT, `apps/web/dist`) +const BUILD_COMMAND = `pnpm --filter @md/web build:only` + +const NAV_TIMEOUT_MS = 60_000 +const PREVIEW_TIMEOUT_MS = 60_000 +const STABILITY_POLLS = 6 +const POLL_INTERVAL_MS = 250 + +const MIME = { + '.html': `text/html; charset=utf-8`, + '.js': `text/javascript; charset=utf-8`, + '.mjs': `text/javascript; charset=utf-8`, + '.css': `text/css; charset=utf-8`, + '.json': `application/json`, + '.png': `image/png`, + '.jpg': `image/jpeg`, + '.jpeg': `image/jpeg`, + '.gif': `image/gif`, + '.svg': `image/svg+xml`, + '.ico': `image/x-icon`, + '.woff': `font/woff`, + '.woff2': `font/woff2`, + '.ttf': `font/ttf`, + '.map': `application/json`, + '.txt': `text/plain; charset=utf-8`, +} + +function assertOutsideRepo(outDir) { + const resolved = resolve(outDir) + if (resolved === REPO_ROOT || resolved.startsWith(`${REPO_ROOT}${sep}`)) { + throw new Error( + `Oracle output directory must be outside the repository (received ${resolved}). ` + + `The Oracle is a runtime capability, never a committed artifact; pass a fresh temp directory.`, + ) + } + return resolved +} + +function assertBuilt(distDir) { + const indexPath = join(distDir, `index.html`) + if (!existsSync(indexPath)) { + throw new Error( + `The web application has not been built (missing ${indexPath}). ` + + `Build it first: \`${BUILD_COMMAND}\`. The Oracle capability builds nothing itself.`, + ) + } +} + +function startStaticServer(rootDir) { + return new Promise((resolvePromise, reject) => { + const server = createServer((req, res) => { + try { + const urlPath = decodeURIComponent(new URL(req.url, `http://localhost`).pathname) + // The build uses base `/md/`; strip the prefix before resolving. + const relative = urlPath.replace(/^\/md\/?/, ``) || `index.html` + const filePath = normalize(join(rootDir, relative)) + if (!filePath.startsWith(rootDir)) { + res.writeHead(403) + res.end(`forbidden`) + return + } + const target = existsSync(filePath) && statSync(filePath).isFile() + ? filePath + : join(rootDir, `index.html`) // SPA fallback + if (!existsSync(target)) { + res.writeHead(404) + res.end(`not found: ${urlPath}`) + return + } + res.writeHead(200, { 'content-type': MIME[extname(target)] ?? `application/octet-stream` }) + createReadStream(target).pipe(res) + } + catch (error) { + res.writeHead(500) + res.end(`server error: ${error.message}`) + } + }) + server.on(`error`, reject) + // Port 0 asks the OS for a free port: concurrent captures never collide. + server.listen(0, `127.0.0.1`, () => resolvePromise(server)) + }) +} + +function launchOptions() { + const executablePath = process.env.MD_WEB_CLI_CHROMIUM + return { + headless: true, + args: [`--headless=new`, `--disable-web-security`, `--allow-file-access-from-files`], + ...(executablePath ? { executablePath } : {}), + } +} + +async function readPreviewState(page) { + return page.evaluate(() => { + const output = document.getElementById(`output`) + if (!output) + return null + return { + length: output.innerHTML.length, + signature: [ + output.innerHTML.length, + output.querySelectorAll(`h1, h2, h3, h4, h5, h6`).length, + output.querySelectorAll(`table`).length, + output.querySelectorAll(`section`).length, + output.querySelectorAll(`pre`).length, + output.querySelectorAll(`p`).length, + ].join(`|`), + text: output.textContent ?? ``, + pendingDiagram: !!output.querySelector(`[data-md-diagram-state="loading"]`), + pendingMath: !!output.querySelector(`.katex-pending`), + pendingLanguage: !!output.querySelector(`[data-language-pending]`), + } + }) +} + +/** + * Wait until the preview reflects the injected fixture and stops changing. + * + * Fixture-agnostic on purpose: a minimal probe fixture and the full-coverage + * fixture must both satisfy the same readiness rules, so readiness cannot key + * off "the full fixture's table exists". The rules are: the preview changed + * after injection, holds no pending async placeholders, and its structural + * signature is stable across several polls. + */ +async function waitForStablePreview(page, beforeSignature) { + await page.waitForSelector(`#output`, { timeout: NAV_TIMEOUT_MS }) + + const deadline = Date.now() + PREVIEW_TIMEOUT_MS + let previousSignature = `` + let stableCount = 0 + + while (Date.now() < deadline) { + const state = await readPreviewState(page) + const ready = state + && state.text.trim().length > 0 + && state.signature !== beforeSignature + && !state.pendingDiagram + && !state.pendingMath + && !state.pendingLanguage + if (ready) { + if (state.signature === previousSignature) { + stableCount += 1 + if (stableCount >= STABILITY_POLLS) + return state + } + else { + stableCount = 0 + previousSignature = state.signature + } + } + else { + stableCount = 0 + } + await new Promise(r => setTimeout(r, POLL_INTERVAL_MS)) + } + + throw new Error(`preview did not stabilize within ${PREVIEW_TIMEOUT_MS / 1000}s`) +} + +/** + * Capture the browser Oracle for one fixture. + * + * @param {object} options + * @param {string} options.fixturePath Markdown fixture to inject. + * @param {string} options.outDir Fresh directory outside the repository; the + * captured HTML is written here as `oracle.html`. + * @param {string} [options.distDir] Built web app directory (testing seam). + * @param {Array} [options.initScripts] Scripts run + * before app boot, e.g. to seed a projected config into localStorage. + * @param {(page: import('playwright').Page) => Promise} [options.afterLoad] + * Runs once the app shell is up and before the fixture is injected. + * @returns {Promise} The captured clipboard HTML (byte-exact). + */ +export async function captureOracle(options) { + const { + fixturePath, + outDir, + distDir = WEB_DIST, + initScripts = [], + afterLoad, + } = options + + const safeOutDir = assertOutsideRepo(outDir) + assertBuilt(distDir) + if (!existsSync(fixturePath)) + throw new Error(`fixture not found: ${fixturePath}`) + + const fixture = await readFile(fixturePath, `utf8`) + await mkdir(safeOutDir, { recursive: true }) + + const server = await startStaticServer(distDir) + const port = server.address().port + const pageUrl = `http://127.0.0.1:${port}/md/` + + let browser + try { + browser = await chromium.launch(launchOptions()) + const context = await browser.newContext({ + viewport: { width: 1400, height: 1000 }, + permissions: [`clipboard-read`, `clipboard-write`], + locale: `zh-CN`, + baseURL: `http://127.0.0.1:${port}`, + }) + + // The copy flow forces a light render anyway; pinning the UI theme keeps + // the capture independent of the machine's colour-scheme preference. + await context.addInitScript(() => { + try { + localStorage.setItem(`vueuse-color-scheme`, `light`) + } + catch {} + }) + for (const script of initScripts) { + if (typeof script === `string`) + await context.addInitScript({ content: script }) + else + await context.addInitScript(script) + } + + const page = await context.newPage() + page.setDefaultTimeout(NAV_TIMEOUT_MS) + + const pageErrors = [] + page.on(`pageerror`, (error) => { + pageErrors.push(String(error)) + }) + + await page.goto(pageUrl, { waitUntil: `domcontentloaded` }) + await page.waitForSelector(`.cm-editor`, { timeout: NAV_TIMEOUT_MS }) + await page.waitForSelector(`#output`, { timeout: NAV_TIMEOUT_MS }) + + // Settle the default document before reading the baseline signature. + await page.waitForFunction(() => { + const output = document.getElementById(`output`) + return output && output.textContent.length > 0 + }, { timeout: NAV_TIMEOUT_MS }) + const before = await readPreviewState(page) + + if (afterLoad) + await afterLoad(page) + + // Inject through the app's own input handling: CodeMirror ignores + // synthetic DOM selections, so focus for real and replace the document + // with keyboard select-all + insertText. The app's debounced listeners + // then refresh the preview and persist the post as if typed. + await page.click(`.cm-content`) + await page.keyboard.press(`ControlOrMeta+A`) + await page.keyboard.insertText(fixture) + + await waitForStablePreview(page, before.signature) + + // Trigger the real WeChat copy flow (公众号格式). + const copyButton = page.locator(`header button:has-text("复制"), .header-container button:has-text("复制")`).first() + if (await copyButton.count() === 0) + throw new Error(`WeChat copy button not found in header — has the header markup changed?`) + await copyButton.click() + + await page.waitForFunction(() => { + const buttons = [...document.querySelectorAll('button')] + return buttons.some(button => button.textContent?.includes(`复制`)) + && !document.querySelector('[data-sonner-toast][data-type="loading"]') + }, { timeout: 30_000 }) + await page.waitForTimeout(500) + + const clipboardHtml = await page.evaluate(async () => { + const items = await navigator.clipboard.read() + for (const item of items) { + if (item.types.includes(`text/html`)) { + const blob = await item.getType(`text/html`) + return await blob.text() + } + } + return `` + }) + + if (!clipboardHtml) + throw new Error(`clipboard text/html is empty — the copy flow did not produce output`) + + if (pageErrors.length > 0) + process.stderr.write(`[oracle] warning: ${pageErrors.length} page error(s): ${pageErrors[0]}\n`) + + // The raw clipboard payload: no provenance comment, no wrapper, nothing + // added. Any extra byte here would make the byte comparison lie. + await writeFile(join(safeOutDir, `oracle.html`), clipboardHtml, `utf8`) + return clipboardHtml + } + finally { + if (browser) + await browser.close().catch(() => {}) + server.close() + } +} From 968654818715ae638042b0421ba0497e5d2a24e2 Mon Sep 17 00:00:00 2001 From: "dane.dai" <838652739@qq.com> Date: Thu, 22 Oct 2026 22:10:57 +0800 Subject: [PATCH 25/36] test(md-web-cli): assert site-default parity between CLI and app The five style keys a Designer export cannot carry now have one guard: a unit test imports both default configurations and compares values, so a mismatch names the key and both values instead of surfacing as an unexplained diff hunk in the byte comparison. --- packages/md-web-cli/src/defaults.ts | 9 +++++ packages/md-web-cli/tests/defaults.test.ts | 44 ++++++++++++++++++++++ 2 files changed, 53 insertions(+) create mode 100644 packages/md-web-cli/tests/defaults.test.ts diff --git a/packages/md-web-cli/src/defaults.ts b/packages/md-web-cli/src/defaults.ts index 99830bb844..d8158bdcf8 100644 --- a/packages/md-web-cli/src/defaults.ts +++ b/packages/md-web-cli/src/defaults.ts @@ -6,6 +6,7 @@ * the site shows the mac code-block title bar and highlights with the * `github-dark` theme; the MCP defaults use a light theme and no mac bar. */ +import type { HeadingStyles } from '@md/shared/configs/style' import type { LegendValue } from './types' /** Site default font stack (sans-serif) as used by the web app. */ @@ -32,4 +33,12 @@ export const siteDefaultRenderConfig = { isUseJustify: false, } +/** + * Effective heading-style default. The Designer export cannot carry + * `headingStyles`, so both sides resolve its absence to "no per-level + * overrides" rather than inventing one; `tests/defaults.test.ts` asserts this + * matches the app's own default. + */ +export const siteDefaultHeadingStyles: HeadingStyles = {} + export type SiteDefaultRenderConfig = typeof siteDefaultRenderConfig diff --git a/packages/md-web-cli/tests/defaults.test.ts b/packages/md-web-cli/tests/defaults.test.ts new file mode 100644 index 0000000000..9bfd5cae59 --- /dev/null +++ b/packages/md-web-cli/tests/defaults.test.ts @@ -0,0 +1,44 @@ +import { defaultStyleConfig } from '@md/shared/configs/style' +import { describe, expect, it } from 'vitest' +import { siteDefaultHeadingStyles, siteDefaultRenderConfig } from '../src/defaults' + +function format(value: unknown): string { + return typeof value === `string` ? value : JSON.stringify(value) +} + +/** + * Compares one site default and names the key plus both values on failure, so + * drift reports say which key moved and in which direction. + */ +function expectSiteDefaultParity(key: string, cliValue: unknown, appValue: unknown): void { + expect(cliValue, `${key}: CLI=${format(cliValue)} app=${format(appValue)}`).toEqual(appValue) +} + +describe(`site default parity between the CLI and the app`, () => { + it(`lineHeight agrees`, () => { + expectSiteDefaultParity(`lineHeight`, siteDefaultRenderConfig.lineHeight, defaultStyleConfig.lineHeight) + }) + + it(`blockSpacing agrees`, () => { + expectSiteDefaultParity(`blockSpacing`, siteDefaultRenderConfig.blockSpacing, defaultStyleConfig.blockSpacing) + }) + + it(`linkColor agrees`, () => { + expectSiteDefaultParity(`linkColor`, siteDefaultRenderConfig.linkColor, defaultStyleConfig.linkColor) + }) + + it(`blockquoteBackground agrees`, () => { + expectSiteDefaultParity( + `blockquoteBackground`, + siteDefaultRenderConfig.blockquoteBackground, + defaultStyleConfig.blockquoteBackground, + ) + }) + + it(`headingStyles has the same effective default`, () => { + // The CLI config carries no headingStyles key; the render path treats + // absence as "no preset overrides", which is the app's `{}`. Compare that + // effective value rather than `undefined`. + expectSiteDefaultParity(`headingStyles`, siteDefaultHeadingStyles, defaultStyleConfig.headingStyles) + }) +}) From f157415f872641d59cdefdda7f11b35bea2d123e Mon Sep 17 00:00:00 2001 From: "dane.dai" <838652739@qq.com> Date: Thu, 22 Oct 2026 22:11:16 +0800 Subject: [PATCH 26/36] docs(adr): record the single verify gate and narrow the Oracle glossary entry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Records why one gate replaced three, why tolerance is forbidden, why the Oracle became a capability, and that the gate landed red on purpose — with the condition that ends the red acceptance. --- GLOSSARY.md | 22 +++----------- docs/adr/0003-single-verify-gate.md | 47 +++++++++++++++++++++++++++++ 2 files changed, 52 insertions(+), 17 deletions(-) create mode 100644 docs/adr/0003-single-verify-gate.md diff --git a/GLOSSARY.md b/GLOSSARY.md index f8291a9327..89b1cba33c 100644 --- a/GLOSSARY.md +++ b/GLOSSARY.md @@ -25,24 +25,12 @@ The normalized internal parameter set the renderer consumes, named after the ren _Avoid_: render flags, CLI options **Oracle**: -Baseline HTML produced by rendering a fixture in a real browser. -_Avoid_: reference output, baseline HTML +The browser-rendered baseline produced at verification time: the clipboard HTML a real browser emits for a fixture through the application's own WeChat copy flow. Produced by a capability that writes only to a temporary directory and returns the HTML; never a committed artifact, never reused between runs. +_Avoid_: reference output, baseline HTML, golden file -**Tier 1**: -A one-off semantic diff between an Oracle and CLI output, run once at implementation acceptance. -_Avoid_: golden test, visual regression - -**Tier 2**: -The recurring gate that asserts CLI output against a Golden snapshot on every candidate release. -_Avoid_: snapshot test, CI check - -**Tier 3**: -An on-demand rebuild that regenerates an Oracle for the current fork revision. -_Avoid_: oracle refresh, recalibration - -**Golden snapshot**: -The CLI output committed to the repo as the reference Tier 2 asserts against, once Tier 1 has confirmed it. -_Avoid_: baseline, fixture output +**verify**: +The single gate: build the bundle, capture a fresh Oracle, render the same fixture through the CLI, and compare the two outputs byte for byte with zero tolerance. Exits non-zero on any difference. +_Avoid_: snapshot gate, fidelity check **Pending placeholder**: A marker the renderer leaves where asynchronous hydration has not completed — diagram loading text, a formula placeholder, an unregistered-language marker. The CLI treats any occurrence as a hard failure. diff --git a/docs/adr/0003-single-verify-gate.md b/docs/adr/0003-single-verify-gate.md new file mode 100644 index 0000000000..72ac8821e4 --- /dev/null +++ b/docs/adr/0003-single-verify-gate.md @@ -0,0 +1,47 @@ +# One byte-exact `verify` gate replaces the three previous verification gates + +`md-web-cli` verification is a single command, `verify`: it builds the CLI bundle, captures a fresh Oracle from a real browser, renders the same fixture through the CLI, and compares the two outputs byte for byte with zero tolerance. It exits non-zero on any difference. The browser appears only inside this command; the Oracle is a runtime capability that writes to a temp directory and returns a string, never a committed artifact. The three previous gates — a one-off semantic diff, a recurring golden-snapshot comparison, and an on-demand Oracle rebuild — are deleted, along with the vocabulary that described them. + +## Consequences + +- The gate builds its own input. It never compares against a bundle on disk, so a changed rendering default cannot pass because nobody rebuilt by hand. +- The Oracle is captured on every run from the current source. Nothing that the gate compares against can be stale, because nothing is stored between runs. +- The comparison has no tolerance, no allow-list, no known-gaps list, and no expiry mechanism. A difference is a failure; there is nothing to configure and nothing to exempt. +- Failure output is actionable rather than binary: a summary grouped by difference class with counts and locations, the complete unified diff, a total hunk count, and both rendered outputs plus the diff under a printed temporary path. +- The command accepts the same Designer config the maintainer publishes with, projected down to render-affecting keys and fed identically to both sides. Article content carried in an export can never leak into the comparison. +- The non-browser assertions the old verifier owned survive inside the comparator: no pending placeholders, and WeChat-ready output with inline styles. A reverse-case self-test corrupts a known-good render and asserts the gate rejects each mutant, so the gate's ability to fail is itself tested without a browser. +- Only two test seams exist: the `verify` process boundary (exit code, summary shape, artifacts written) and the site-default parity unit test. Rendered-output assertions live inside the byte comparison rather than in separate scripts. +- The repository contains no Oracle HTML, no report, and no golden snapshot. Everything regenerable is produced into a temporary directory at run time. + +### The gate deliberately landed red + +The two implementations genuinely differ today, so the first landing is red — roughly twenty hunks in six classes: an explicit body text colour on the CLI side, a table margin reset present only in the browser, an SVG `overflow` attribute set only by the browser, unitless versus pixel widths from the CSS inliner, link `href` normalisation performed by browser URL parsing, and a provenance comment only the old Oracle carried. + +CI runs the gate as a non-blocking signal until those differences are worked through, so the diff is visible on every change without making every commit on `main` a red build. The publish and upstream-sync workflows run the same command and remain blocking: a red gate publishes nothing and holds the sync pull request. That is accepted for the scaffold period on purpose, not a malfunction — a red publish or sync job during this period means the gate is doing its job. + +**The acceptance ends when the difference classes are resolved and `verify` is green.** At that point the CI signal becomes blocking, and the transition from non-blocking to blocking is itself an explicit decision, recorded when it is made. It must not be inherited silently in either direction: neither the non-blocking CI step nor the accepted red in publish and sync is a permanent design. + +## Why tolerance is forbidden + +A tolerance is a decision that two different renderings are the same, made without looking at which difference it is swallowing. The previous comparator used a numeric tolerance of 0.5 on style values, so a line-height change from 1.75 to 1.8 compared as equal — a real visual regression passed the gate. That is not a near-miss; it is the failure mode the gate exists to prevent. + +Tolerance also destroys the gate's diagnostic value. Once a threshold is introduced, every future difference has to be argued against it, and the cheapest resolution is always to widen it slightly. An allow-list is worse: it turns a difference into a permanent exemption that nobody reads again. Both mechanisms make the gate's meaning depend on configuration rather than on the two implementations agreeing. + +If normalisation is ever needed — for example to strip an attribute that provably has no visual effect — it must be machine-checked code with tests proving it transforms rather than masks, and the decision must be recorded. That work is explicitly deferred out of this change; there is no normalisation, and no place in the comparator to put one today. + +## Why the Oracle became a capability + +A committed baseline drifts. The previous design stored accepted HTML in the repository and compared against it, so the artifact could be stale relative to the source, and regenerating it was a separate manual step that nothing on the gate path performed. Evidence kept in the repository also blurred two different things: the accepted baseline, and the instructions for the human acceptance step that cannot be automated. A stale Oracle looked like a current one. + +A capability cannot drift. It serves the built application, drives the real copy flow, waits on the application's own readiness signals, and returns the captured HTML. It writes only into a caller-supplied temporary directory, so there is no artifact to commit, refresh, or forget. Its contract is narrow and total: it builds nothing, so the caller must have built first; it fails loudly when the build is missing rather than emitting partial or empty output; and its browser executable is overridable by environment variable so a machine with a browser already installed does not download a second one. + +The cost is that a full run needs a browser. That cost is paid by the one command that needs it, not by the test suite: the reverse-case self-test and the defaults parity test are browser-free and run in seconds. + +## Considered Options + +- **Keep the golden snapshot and regenerate it inside the gate.** Rejected: the artifact stays committed, so it is still regenerable source in the repository, still stale between runs, and still a thing a human can accept by hand. Capturing at run time is strictly simpler and cannot drift. +- **Compare after normalising both sides (strip classes, normalise URLs, coerce widths).** Rejected for now: each rule is a hand-written claim that a difference does not matter, which is the same judgement a tolerance makes, only spelled out. If normalisation is introduced later it must be machine-checked and tested as a transform, not adopted as an escape hatch. +- **Keep the tolerance but shrink the number.** Rejected: any non-zero threshold is a class of differences declared acceptable without examining them, and the number invites widening. +- **Run the Oracle rebuild as a separate workflow and compare against its committed output.** Rejected: this is the previous design that failed. It separates capture from comparison, so the artifact can be older than the source, and it makes the browser a recurring CI cost with committed output as its product. +- **Make CI blocking on landing.** Rejected: the differences are real and unresolved, so every commit on `main` would be red before anyone can act on it. Non-blocking keeps the signal visible while the work proceeds, and the transition to blocking is recorded as a decision rather than inherited. +- **Allow a known-gaps list so the gate can be green while differences remain.** Rejected: a known-gaps list is an allow-list with a story. It converts unresolved differences into permanent exemptions and hides progress instead of counting it. From 1347468c67a1a9feee568d6c43dc9a0f9ece7f65 Mon Sep 17 00:00:00 2001 From: "dane.dai" <838652739@qq.com> Date: Thu, 22 Oct 2026 22:13:44 +0800 Subject: [PATCH 27/36] feat(verify): one command that builds, captures, and compares byte-exactly node scripts/verify.mjs builds both workspaces itself, captures a fresh Oracle into a temp directory, renders the same fixture through the CLI, and compares the outputs with strict string equality. Failures print a class-grouped summary with counts and locations, the complete unified diff, and a hunk total; both outputs and the diff land under a printed temp path, preserved with --keep or --out-dir. Exposed as the root wcli:verify script. --- package.json | 1 + .../md-web-cli/tests/verify-command.test.ts | 80 ++++ scripts/verify.mjs | 260 +++++++++++++ scripts/verify/compare.mjs | 341 ++++++++++++++++++ 4 files changed, 682 insertions(+) create mode 100644 packages/md-web-cli/tests/verify-command.test.ts create mode 100644 scripts/verify.mjs create mode 100644 scripts/verify/compare.mjs diff --git a/package.json b/package.json index a854169aa8..b1831324bb 100644 --- a/package.json +++ b/package.json @@ -26,6 +26,7 @@ "cli": "pnpm --filter @doocs/md-cli", "build:cli": "pnpm web build && npx shx rm -rf packages/md-cli/dist && npx shx rm -rf dist/**/*.map && npx shx cp -r apps/web/dist packages/md-cli/ && cd packages/md-cli && npm pack", "release:cli": "node ./scripts/release.js", + "wcli:verify": "node scripts/verify.mjs", "oracle": "node scripts/oracle/rebuild-oracle.mjs", "oracle:diff": "node scripts/oracle/semantic-diff.mjs", "oracle:verify-defaults": "node scripts/oracle/verify-defaults.mjs", diff --git a/packages/md-web-cli/tests/verify-command.test.ts b/packages/md-web-cli/tests/verify-command.test.ts new file mode 100644 index 0000000000..bd0ff1e86e --- /dev/null +++ b/packages/md-web-cli/tests/verify-command.test.ts @@ -0,0 +1,80 @@ +import { execFileSync } from 'node:child_process' +import path from 'node:path' +import { describe, expect, it } from 'vitest' + +/** + * The verify gate's process seam, browser-free half. + * + * Argument validation happens before any build or browser work, so these cases + * exercise the command's contract (exit codes, messages, documented options) + * without the eight-second Oracle capture. The failure-path coverage that needs + * a render belongs to the reverse-case self-test. + */ + +const repoRoot = path.resolve(path.dirname(new URL(import.meta.url).pathname), `../../..`) +const scriptPath = path.join(repoRoot, `scripts/verify.mjs`) + +function runVerify(args: string[]): { status: number, stdout: string, stderr: string } { + try { + const stdout = execFileSync(process.execPath, [scriptPath, ...args], { + encoding: `utf-8`, + cwd: repoRoot, + stdio: [`ignore`, `pipe`, `pipe`], + }) + return { status: 0, stdout, stderr: `` } + } + catch (error: any) { + return { + status: error.status ?? 1, + stdout: error.stdout ?? ``, + stderr: error.stderr ?? String(error.message), + } + } +} + +describe(`verify command: options`, () => { + it(`documents fixtures, config, and artifact handling in --help`, () => { + const { status, stdout } = runVerify([`--help`]) + + expect(status).toBe(0) + expect(stdout).toContain(`--fixture`) + expect(stdout).toContain(`--config`) + expect(stdout).toContain(`--out-dir`) + expect(stdout).toContain(`--keep`) + expect(stdout).toContain(`byte for byte`) + // The absence of a tolerance is part of the contract, not an implementation + // detail: it is stated where the maintainer reads the options. + expect(stdout).toMatch(/no tolerance, allow-list, or known-gaps/) + }) + + it(`rejects an unknown option instead of ignoring it`, () => { + const { status, stderr } = runVerify([`--frobnicate`]) + + expect(status).toBe(1) + expect(stderr).toContain(`unknown option "--frobnicate"`) + }) + + it(`rejects an option given without a value`, () => { + const { status, stderr } = runVerify([`--fixture`]) + + expect(status).toBe(1) + expect(stderr).toContain(`--fixture requires a value`) + }) +}) + +describe(`verify command: missing inputs`, () => { + it(`fails loudly for an unknown fixture`, () => { + const { status, stderr } = runVerify([`--fixture`, `no-such-fixture`]) + + expect(status).toBe(1) + expect(stderr).toContain(`fixture not found:`) + expect(stderr).toContain(`known names: full`) + }) + + it(`fails loudly for a config file that does not exist`, () => { + const { status, stderr } = runVerify([`--fixture`, `full`, `--config`, `no-such-config.json`]) + + expect(status).toBe(1) + expect(stderr).toContain(`config not found:`) + }) +}) diff --git a/scripts/verify.mjs b/scripts/verify.mjs new file mode 100644 index 0000000000..bb86c59452 --- /dev/null +++ b/scripts/verify.mjs @@ -0,0 +1,260 @@ +#!/usr/bin/env node +/** + * The verify gate. + * + * One command, one meaning: build the bundle, capture a fresh Oracle from the + * browser, render the same fixture through the CLI, and compare the two outputs + * byte for byte. Exit code 0 only when they are identical. + * + * It builds its own inputs so it can never pass against a stale bundle, and it + * captures the Oracle on every run so it can never pass against a stale + * baseline. The browser appears only here. + * + * Usage: node scripts/verify.mjs [--fixture ] [--config ] + * [--out-dir ] [--keep] + */ + +import { spawnSync } from 'node:child_process' +import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { dirname, join, resolve } from 'node:path' +import process from 'node:process' +import { fileURLToPath } from 'node:url' +import { compareOutputs } from './verify/compare.mjs' + +const HERE = dirname(fileURLToPath(import.meta.url)) +const REPO_ROOT = resolve(HERE, `..`) +const CLI_BUNDLE = resolve(REPO_ROOT, `packages/md-web-cli/dist/cli.js`) + +/** + * Fixture registry. `full` is the product signal; the minimal + * infrastructure-probe fixture is added by its own change so a broken harness + * stays distinguishable from a rendering difference. + */ +const FIXTURES = { + full: resolve(REPO_ROOT, `packages/md-web-cli/fixtures/full.md`), +} + +const BUILD_COMMANDS = [ + [`--filter`, `@md/web`, `build:only`], + [`--filter`, `@dane.dai/md-web-cli`, `build`], +] + +class VerifyError extends Error {} + +function printUsage() { + process.stdout.write(`Usage: node scripts/verify.mjs [options] + +Builds the CLI bundle, captures a fresh browser Oracle, renders the same fixture +through the CLI, and compares the two outputs byte for byte. Exits non-zero on +any difference. There is no tolerance, allow-list, or known-gaps mechanism. + +Options: + --fixture Fixture to verify (name: ${Object.keys(FIXTURES).join(`, `)}) + or a path to a Markdown file + --config Designer config JSON; projected to render-affecting + keys and given identically to both sides. The five + style keys a Designer export cannot carry fall back + to each side's own site defaults + --out-dir Write artifacts here and keep them (implies --keep) + --keep Keep the artifacts and print their paths + -h, --help Show this help +`) +} + +function fail(message) { + throw new VerifyError(message) +} + +export function parseArgs(argv) { + const args = { fixture: `full`, keep: false } + for (let index = 0; index < argv.length; index++) { + const arg = argv[index] + const next = () => { + const value = argv[++index] + if (value === undefined || value.startsWith(`--`)) + fail(`${arg} requires a value`) + return value + } + if (arg === `--fixture`) + args.fixture = next() + else if (arg.startsWith(`--fixture=`)) + args.fixture = arg.slice(`--fixture=`.length) + else if (arg === `--config`) + args.config = next() + else if (arg.startsWith(`--config=`)) + args.config = arg.slice(`--config=`.length) + else if (arg === `--out-dir`) + args.outDir = next() + else if (arg.startsWith(`--out-dir=`)) + args.outDir = arg.slice(`--out-dir=`.length) + else if (arg === `--keep`) + args.keep = true + else if (arg === `-h` || arg === `--help`) + args.help = true + else + fail(`unknown option "${arg}" (see --help)`) + } + return args +} + +function resolveFixture(value) { + if (value in FIXTURES) { + const path = FIXTURES[value] + if (!existsSync(path)) + fail(`fixture not found: ${path}`) + return { name: value, path } + } + const path = resolve(process.cwd(), value) + if (!existsSync(path)) + fail(`fixture not found: ${path} (known names: ${Object.keys(FIXTURES).join(`, `)})`) + return { name: value, path } +} + +function run(command, args, options = {}) { + const result = spawnSync(command, args, { + cwd: REPO_ROOT, + stdio: options.capture ? `pipe` : `inherit`, + encoding: `utf8`, + }) + if (result.error) + fail(`${command} failed to start: ${result.error.message}`) + if (result.status !== 0) { + if (options.capture && result.stderr) + process.stderr.write(result.stderr) + fail(options.describe ?? `${command} ${args.join(` `)} exited ${result.status}`) + } + return result +} + +function buildEverything() { + process.stdout.write(`[verify] building its own input (never a previously built bundle)\n`) + for (const args of BUILD_COMMANDS) + run(`pnpm`, args, { describe: `build failed: pnpm ${args.join(` `)}` }) +} + +function renderThroughCli(fixturePath, outPath, configPath) { + const args = [CLI_BUNDLE, `render`, fixturePath, `-o`, outPath] + if (configPath) + args.push(`--config`, configPath) + run(process.execPath, args, { + capture: true, + describe: `CLI render failed for ${fixturePath}`, + }) +} + +function formatLocations(locations) { + const shown = locations.slice(0, 10).map(line => String(line)) + const suffix = locations.length > shown.length ? `, +${locations.length - shown.length} more` : `` + return `${shown.join(`, `)}${suffix}` +} + +function printFailure(fixtureName, oracleHtml, cliHtml, comparison) { + process.stderr.write( + `[verify] FAIL ${fixtureName}: outputs are not byte-identical ` + + `(oracle ${Buffer.byteLength(oracleHtml, `utf8`)} bytes, CLI ${Buffer.byteLength(cliHtml, `utf8`)} bytes, ` + + `first difference at byte ${comparison.firstDifference})\n`, + ) + process.stderr.write(`[verify] difference classes (${comparison.hunkCount} hunks total):\n`) + for (const entry of comparison.classes) { + process.stderr.write( + ` ${entry.name.padEnd(24)} ${String(entry.locations.length).padStart(4)} hunks lines ${formatLocations(entry.locations)}\n`, + ) + } + process.stderr.write(`[verify] complete unified diff:\n`) + process.stderr.write(`${comparison.unifiedDiff}\n`) + process.stderr.write( + `[verify] total: ${comparison.hunkCount} hunks across ${comparison.classes.length} class(es)\n`, + ) +} + +function printPass(fixtureName, html) { + process.stdout.write( + `[verify] PASS ${fixtureName}: outputs are byte-identical (${Buffer.byteLength(html, `utf8`)} bytes)\n`, + ) +} + +async function main() { + const args = parseArgs(process.argv.slice(2)) + if (args.help) { + printUsage() + return 0 + } + + const fixture = resolveFixture(args.fixture) + + let configPath + if (args.config) { + configPath = resolve(process.cwd(), args.config) + if (!existsSync(configPath)) + fail(`config not found: ${configPath}`) + } + + const keepArtifacts = Boolean(args.outDir || args.keep) + const workDir = args.outDir + ? resolve(process.cwd(), args.outDir) + : mkdtempSync(join(tmpdir(), `md-verify-`)) + const artifactDir = args.outDir ? workDir : join(workDir, fixture.name) + process.stdout.write(`[verify] artifacts: ${workDir}\n`) + + try { + buildEverything() + + const { captureOracle } = await import(`./verify/oracle.mjs`) + const oracleHtml = await captureOracle({ + fixturePath: fixture.path, + outDir: artifactDir, + }) + + const cliOutPath = join(artifactDir, `cli.html`) + renderThroughCli(fixture.path, cliOutPath, configPath) + const cliHtml = readFileSync(cliOutPath, `utf8`) + + const comparison = compareOutputs(oracleHtml, cliHtml) + writeFileSync(join(artifactDir, `diff.txt`), comparison.unifiedDiff, `utf8`) + writeFileSync( + join(artifactDir, `report.txt`), + comparison.equal + ? `PASS: outputs are byte-identical (${Buffer.byteLength(oracleHtml, `utf8`)} bytes)\n` + : `FAIL: ${comparison.hunkCount} hunks across ${comparison.classes.length} class(es)\n${ + comparison.classes.map(entry => `${entry.name}: ${entry.locations.length} hunks at lines ${entry.locations.join(`, `)}\n`).join(``) + }`, + `utf8`, + ) + + if (comparison.equal) + printPass(fixture.name, oracleHtml) + else + printFailure(fixture.name, oracleHtml, cliHtml, comparison) + + process.stdout.write(`[verify] oracle: ${join(artifactDir, `oracle.html`)}\n`) + process.stdout.write(`[verify] cli: ${cliOutPath}\n`) + process.stdout.write(`[verify] diff: ${join(artifactDir, `diff.txt`)}\n`) + process.stdout.write(`[verify] report: ${join(artifactDir, `report.txt`)}\n`) + + if (keepArtifacts) + process.stdout.write(`[verify] artifacts preserved: ${workDir}\n`) + else + process.stdout.write(`[verify] artifacts are temporary; pass --keep to preserve them\n`) + + return comparison.equal ? 0 : 1 + } + finally { + if (!keepArtifacts) + rmSync(workDir, { recursive: true, force: true }) + } +} + +main() + .then((exitCode) => { + process.exitCode = exitCode + }) + .catch((error) => { + if (error instanceof VerifyError) { + process.stderr.write(`[verify] ${error.message}\n`) + } + else { + process.stderr.write(`[verify] unexpected failure: ${error?.stack ?? error?.message ?? String(error)}\n`) + } + process.exitCode = 1 + }) diff --git a/scripts/verify/compare.mjs b/scripts/verify/compare.mjs new file mode 100644 index 0000000000..eed5aefe6f --- /dev/null +++ b/scripts/verify/compare.mjs @@ -0,0 +1,341 @@ +/** + * Strict comparator for the verify gate. + * + * Two rendered outputs are equal only when they are byte-identical. There is no + * tolerance, no allow-list, and no expiry mechanism here by design: a tolerance + * cannot tell "same rendering" from "similar number", which is how a line-height + * change from 1.75 to 1.8 used to pass a gate. + * + * The diff below is reporting, never comparison: it explains a failure that the + * byte comparison has already decided. + */ + +const CONTEXT_LINES = 3 +const MAX_LCS_LINES = 4000 + +/** + * Split HTML into one tag or text run per line. The outputs are HTML with few + * newlines, so line-diffing the raw text would collapse every difference into a + * handful of unreadable hunks. + */ +export function explodeHtml(html) { + return html.replace(/>\n<`).split(`\n`) +} + +function diffSequences(a, b) { + const n = a.length + const m = b.length + if (n === 0) + return b.map((_, index) => ({ type: `insert`, bIndex: index })) + if (m === 0) + return a.map((_, index) => ({ type: `delete`, aIndex: index })) + // Identical inputs are the common case (and the only passing one). + if (a.join(`\n`) === b.join(`\n`)) + return a.map((_, index) => ({ type: `equal`, aIndex: index, bIndex: index })) + + let prefix = 0 + while (prefix < n && prefix < m && a[prefix] === b[prefix]) + prefix += 1 + let suffix = 0 + while (suffix < n - prefix && suffix < m - prefix && a[n - 1 - suffix] === b[m - 1 - suffix]) + suffix += 1 + + const ops = [] + for (let index = 0; index < prefix; index++) + ops.push({ type: `equal`, aIndex: index, bIndex: index }) + + const midA = a.slice(prefix, n - suffix) + const midB = b.slice(prefix, m - suffix) + const p = midA.length + const q = midB.length + + // Above the cap the diff degrades to "the whole middle changed"; the verdict + // (byte equality) never depends on this. + if (p * q > MAX_LCS_LINES * MAX_LCS_LINES) { + for (let index = 0; index < p; index++) + ops.push({ type: `delete`, aIndex: prefix + index }) + for (let index = 0; index < q; index++) + ops.push({ type: `insert`, bIndex: prefix + index }) + } + else { + const width = q + 1 + const lcs = new Uint32Array((p + 1) * width) + for (let i = p - 1; i >= 0; i--) { + for (let j = q - 1; j >= 0; j--) { + lcs[i * width + j] = midA[i] === midB[j] + ? lcs[(i + 1) * width + (j + 1)] + 1 + : Math.max(lcs[(i + 1) * width + j], lcs[i * width + (j + 1)]) + } + } + let i = 0 + let j = 0 + while (i < p && j < q) { + if (midA[i] === midB[j]) { + ops.push({ type: `equal`, aIndex: prefix + i, bIndex: prefix + j }) + i += 1 + j += 1 + } + else if (lcs[(i + 1) * width + j] >= lcs[i * width + (j + 1)]) { + ops.push({ type: `delete`, aIndex: prefix + i }) + i += 1 + } + else { + ops.push({ type: `insert`, bIndex: prefix + j }) + j += 1 + } + } + while (i < p) { + ops.push({ type: `delete`, aIndex: prefix + i }) + i += 1 + } + while (j < q) { + ops.push({ type: `insert`, bIndex: prefix + j }) + j += 1 + } + } + + for (let index = 0; index < suffix; index++) + ops.push({ type: `equal`, aIndex: n - suffix + index, bIndex: m - suffix + index }) + + return ops +} + +function groupHunks(ops) { + const hunks = [] + let current = null + for (let index = 0; index < ops.length; index++) { + const op = ops[index] + if (op.type === `equal`) { + if (current) + current.trailingEqual += 1 + continue + } + if (current && current.trailingEqual <= CONTEXT_LINES * 2) { + current.ops.push(op) + current.endIndex = index + current.trailingEqual = 0 + continue + } + current = { ops: [op], startIndex: index, endIndex: index, trailingEqual: 0 } + hunks.push(current) + } + return hunks +} + +function parseAttributes(line) { + // Exploded lines can carry the original document's indentation. + const trimmed = line.trimStart() + if (!trimmed.startsWith(`<`)) + return null + const end = trimmed.lastIndexOf(`>`) + if (end < 0) + return null + const tagMatch = trimmed.match(/^<([\w-]+)/) + if (!tagMatch) + return null + const source = trimmed.slice(0, end) + const attrs = new Map() + for (const match of source.matchAll(/([\w:-]+)\s*=\s*"([^"]*)"/g)) + attrs.set(match[1], match[2]) + return { tag: tagMatch[1], attrs } +} + +function parseStyleDeclarations(value) { + const declarations = new Map() + for (const part of value.split(`;`)) { + const colon = part.indexOf(`:`) + if (colon < 0) + continue + const property = part.slice(0, colon).trim() + const propertyValue = part.slice(colon + 1).trim() + if (property) + declarations.set(property, propertyValue) + } + return declarations +} + +/** + * Classify what actually differs between one removed and one added line. + * + * Classes are derived mechanically from the two lines — attribute presence, + * style-property value, text, element name — so a summary can say whether a + * failure is the class-attribute difference or a real style drift without any + * allow-list deciding the verdict. + */ +export function classifyLinePair(removed, added) { + const classes = new Set() + if (removed === undefined) { + classes.add(`structure`) + return [...classes] + } + if (added === undefined) { + classes.add(`structure`) + return [...classes] + } + + const before = parseAttributes(removed) + const after = parseAttributes(added) + if (!before || !after) { + // A text run that carries inline markup: compare the tag-stripped text and + // the class-attribute count, which is what the CLI's class stripping moves. + const classCount = value => (value.match(/\sclass\s*=/g) ?? []).length + if (classCount(removed) !== classCount(added)) + classes.add(`attribute:class`) + const stripTags = value => value.replace(/<[^>]*>/g, ``) + if (stripTags(removed) !== stripTags(added)) + classes.add(`text`) + if (classes.size === 0) + classes.add(`text`) + return [...classes] + } + if (before.tag !== after.tag) { + classes.add(`element`) + return [...classes] + } + + for (const [name, value] of before.attrs) { + if (!after.attrs.has(name)) { + classes.add(`attribute:${name}`) + continue + } + const other = after.attrs.get(name) + if (other === value) + continue + if (name === `style`) { + const a = parseStyleDeclarations(value) + const b = parseStyleDeclarations(other) + for (const [property, propertyValue] of a) { + if (b.get(property) !== propertyValue) + classes.add(`style:${property}`) + } + for (const property of b.keys()) { + if (!a.has(property)) + classes.add(`style:${property}`) + } + } + else { + classes.add(`attribute:${name}`) + } + } + for (const name of after.attrs.keys()) { + if (!before.attrs.has(name)) + classes.add(`attribute:${name}`) + } + + return [...classes] +} + +function lineNumbers(values) { + return values.map(value => value + 1) +} + +/** + * Pair removed and added lines inside a hunk by element name first, then by + * position. Plain index pairing misattributes classes whenever one side has an + * extra element (an inserted `` shifts every following pair). + */ +function pairLines(removed, added) { + const used = new Set() + const pairs = [] + const tagOf = line => parseAttributes(line)?.tag ?? null + for (const line of removed) { + const tag = tagOf(line) + const match = added.findIndex((candidate, index) => !used.has(index) && tagOf(candidate) === tag) + if (match >= 0) { + used.add(match) + pairs.push([line, added[match]]) + } + else { + pairs.push([line, undefined]) + } + } + added.forEach((line, index) => { + if (!used.has(index)) + pairs.push([undefined, line]) + }) + return pairs +} + +function summarize(hunks, aLines, bLines) { + const classes = new Map() + for (const hunk of hunks) { + const removed = hunk.ops.filter(op => op.type === `delete`).map(op => aLines[op.aIndex]) + const added = hunk.ops.filter(op => op.type === `insert`).map(op => bLines[op.bIndex]) + const hunkClasses = new Set() + for (const [before, after] of pairLines(removed, added)) + for (const name of classifyLinePair(before, after)) + hunkClasses.add(name) + if (hunkClasses.size === 0) + hunkClasses.add(`unclassified`) + + const location = hunk.ops[0].aIndex ?? hunk.ops[0].bIndex + hunk.classes = [...hunkClasses].sort() + for (const name of hunk.classes) { + if (!classes.has(name)) + classes.set(name, []) + classes.get(name).push(location + 1) + } + } + return classes +} + +function formatUnifiedDiff(hunks, ops, aLines, bLines) { + const lines = [] + for (const hunk of hunks) { + // Attach leading/trailing context for a standard unified view. + const start = Math.max(0, hunk.startIndex - CONTEXT_LINES) + const end = Math.min(ops.length - 1, hunk.endIndex + CONTEXT_LINES) + const slice = ops.slice(start, end + 1) + + const aStart = slice.find(op => op.aIndex !== undefined)?.aIndex ?? 0 + const bStart = slice.find(op => op.bIndex !== undefined)?.bIndex ?? 0 + const aCount = slice.filter(op => op.type !== `insert`).length + const bCount = slice.filter(op => op.type !== `delete`).length + const header = `@@ -${aStart + 1},${aCount} +${bStart + 1},${bCount} @@ [${hunk.classes.join(`, `)}]` + + const body = slice.map((op) => { + if (op.type === `equal`) + return ` ${aLines[op.aIndex]}` + if (op.type === `delete`) + return `-${aLines[op.aIndex]}` + return `+${bLines[op.bIndex]}` + }) + lines.push(header, ...body) + } + return lines.join(`\n`) +} + +/** + * Compare two outputs byte for byte. + * + * @returns {{equal: boolean, hunkCount: number, classes: Array<{name: string, locations: number[]}>, unifiedDiff: string, firstDifference: number}} + */ +export function compareOutputs(oracleHtml, cliHtml) { + if (oracleHtml === cliHtml) { + return { equal: true, hunkCount: 0, classes: [], unifiedDiff: ``, firstDifference: -1 } + } + + const aLines = explodeHtml(oracleHtml) + const bLines = explodeHtml(cliHtml) + const ops = diffSequences(aLines, bLines) + const hunks = groupHunks(ops) + const classMap = summarize(hunks, aLines, bLines) + + const classes = [...classMap.entries()] + .map(([name, locations]) => ({ name, locations: lineNumbers(locations) })) + .sort((a, b) => b.locations.length - a.locations.length || a.name.localeCompare(b.name)) + + // Byte offset of the first character that differs, for a one-line pointer + // when a caller only needs to know where the outputs part ways. + let firstDifference = 0 + while (firstDifference < oracleHtml.length && oracleHtml[firstDifference] === cliHtml[firstDifference]) + firstDifference += 1 + + return { + equal: false, + hunkCount: hunks.length, + classes, + unifiedDiff: formatUnifiedDiff(hunks, ops, aLines, bLines), + firstDifference, + } +} From 194abcdb42b8ea84902b1a11030b467d95c2ad7b Mon Sep 17 00:00:00 2001 From: "dane.dai" <838652739@qq.com> Date: Thu, 22 Oct 2026 22:14:49 +0800 Subject: [PATCH 28/36] test(verify): add an infrastructure-probe fixture A minimal fixture (heading, paragraph, inline code) runs alongside the full-coverage one by default, labelled as an infrastructure probe: its failure means the harness is broken, its success proves nothing about fidelity. --fixture restricts a run to one fixture. --- docs/adr/0003-single-verify-gate.md | 4 +- packages/md-web-cli/fixtures/README.md | 38 +++++ packages/md-web-cli/fixtures/minimal.md | 3 + .../md-web-cli/tests/verify-command.test.ts | 11 +- scripts/verify.mjs | 155 +++++++++++------- 5 files changed, 154 insertions(+), 57 deletions(-) create mode 100644 packages/md-web-cli/fixtures/README.md create mode 100644 packages/md-web-cli/fixtures/minimal.md diff --git a/docs/adr/0003-single-verify-gate.md b/docs/adr/0003-single-verify-gate.md index 72ac8821e4..93cc6d8c41 100644 --- a/docs/adr/0003-single-verify-gate.md +++ b/docs/adr/0003-single-verify-gate.md @@ -15,7 +15,9 @@ ### The gate deliberately landed red -The two implementations genuinely differ today, so the first landing is red — roughly twenty hunks in six classes: an explicit body text colour on the CLI side, a table margin reset present only in the browser, an SVG `overflow` attribute set only by the browser, unitless versus pixel widths from the CSS inliner, link `href` normalisation performed by browser URL parsing, and a provenance comment only the old Oracle carried. +The two implementations genuinely differ today, so the first landing is red. The residual differences measured with `class`/`id` set aside are six classes: an explicit body text colour on the CLI side, a table margin reset present only in the browser, an SVG `overflow` attribute set only by the browser, unitless versus pixel widths from the CSS inliner, link `href` normalisation performed by browser URL parsing, and a provenance comment only the old Oracle carried. + +A byte-exact comparison also reports the `class`/`id` difference itself: the browser's clipboard HTML keeps `class` and `id` attributes because WeChat strips them at paste time, while the CLI emits the post-paste form. That is a real product difference, not a measurement artefact, and it is one of the differences to be resolved after the gate produces output — either the CLI keeps the attributes or the comparison gains a machine-checked transform. The provenance comment, by contrast, is gone by construction: the Oracle capability returns the raw clipboard payload with nothing added. CI runs the gate as a non-blocking signal until those differences are worked through, so the diff is visible on every change without making every commit on `main` a red build. The publish and upstream-sync workflows run the same command and remain blocking: a red gate publishes nothing and holds the sync pull request. That is accepted for the scaffold period on purpose, not a malfunction — a red publish or sync job during this period means the gate is doing its job. diff --git a/packages/md-web-cli/fixtures/README.md b/packages/md-web-cli/fixtures/README.md new file mode 100644 index 0000000000..3bbb6fc9b4 --- /dev/null +++ b/packages/md-web-cli/fixtures/README.md @@ -0,0 +1,38 @@ +# Fixtures + +Two inputs for the `verify` gate, with different meanings. + +## `full.md` — the product signal + +Exercises every rendering surface the CLI supports: h1–h6, tables with +alignment, fenced code blocks in several languages, nested lists, nested +blockquotes, images with and without titles, inline code, strong/emphasis/ +strikethrough, links, and horizontal rules. A difference here is a product +fidelity difference; this is the fixture the gate exists for. + +## `minimal.md` — an infrastructure probe + +A heading, a paragraph, and inline code. Nothing else: no tables, diagrams, +formulas, images, or links. It isolates the harness from the product. + +**Read its result strictly one way.** A *failure* means the harness is broken — +the browser capture, the CLI render, or the comparison — which is a strong and +useful signal. A *pass* proves nothing about product fidelity, because the +fixture barely exercises the renderer. Never cite a green `minimal` run as +evidence that the CLI matches the site. + +Both fixtures run by default so one command produces both signals; `--fixture` +restricts a run to one while debugging. See the gate's documentation for +invocation. + +### Reading a probe failure + +Check where it stopped: + +- **Before the comparison** — an error, a timeout, an empty capture, or a + missing build: the harness is broken. This is the strong signal the probe + exists to give. +- **At the comparison** — a byte difference: the probe is reporting the same + product difference `full.md` reports. The CLI strips `class`/`id` attributes + and the browser's clipboard HTML keeps them, so this difference is present at + every scale and is not by itself evidence that the harness broke. diff --git a/packages/md-web-cli/fixtures/minimal.md b/packages/md-web-cli/fixtures/minimal.md new file mode 100644 index 0000000000..8cb1aff8a5 --- /dev/null +++ b/packages/md-web-cli/fixtures/minimal.md @@ -0,0 +1,3 @@ +# Minimal fixture: heading, paragraph, inline code + +A paragraph with `inline code` so the body and code-span styles are exercised. diff --git a/packages/md-web-cli/tests/verify-command.test.ts b/packages/md-web-cli/tests/verify-command.test.ts index bd0ff1e86e..83dea8a0a5 100644 --- a/packages/md-web-cli/tests/verify-command.test.ts +++ b/packages/md-web-cli/tests/verify-command.test.ts @@ -68,7 +68,16 @@ describe(`verify command: missing inputs`, () => { expect(status).toBe(1) expect(stderr).toContain(`fixture not found:`) - expect(stderr).toContain(`known names: full`) + expect(stderr).toContain(`known names: full, minimal`) + }) + + it(`resolves the infrastructure-probe fixture by name`, () => { + // Reaching the config check proves the fixture name resolved; an unknown + // name would have failed earlier with "fixture not found". + const { status, stderr } = runVerify([`--fixture`, `minimal`, `--config`, `no-such-config.json`]) + + expect(status).toBe(1) + expect(stderr).toContain(`config not found:`) }) it(`fails loudly for a config file that does not exist`, () => { diff --git a/scripts/verify.mjs b/scripts/verify.mjs index bb86c59452..ba18203b28 100644 --- a/scripts/verify.mjs +++ b/scripts/verify.mjs @@ -25,14 +25,26 @@ import { compareOutputs } from './verify/compare.mjs' const HERE = dirname(fileURLToPath(import.meta.url)) const REPO_ROOT = resolve(HERE, `..`) const CLI_BUNDLE = resolve(REPO_ROOT, `packages/md-web-cli/dist/cli.js`) +const FIXTURE_DIR = resolve(REPO_ROOT, `packages/md-web-cli/fixtures`) /** - * Fixture registry. `full` is the product signal; the minimal - * infrastructure-probe fixture is added by its own change so a broken harness - * stays distinguishable from a rendering difference. + * Fixture registry. + * + * `full` is the product signal. `minimal` is an infrastructure probe: its + * failure means the harness is broken, its success proves nothing about + * product fidelity. Both run by default so one command yields both signals. */ const FIXTURES = { - full: resolve(REPO_ROOT, `packages/md-web-cli/fixtures/full.md`), + full: { + path: join(FIXTURE_DIR, `full.md`), + role: `product signal`, + failureMeaning: `a difference here is a product fidelity difference`, + }, + minimal: { + path: join(FIXTURE_DIR, `minimal.md`), + role: `infrastructure probe`, + failureMeaning: `a probe failure means the harness is broken, not that rendering drifted`, + }, } const BUILD_COMMANDS = [ @@ -45,18 +57,26 @@ class VerifyError extends Error {} function printUsage() { process.stdout.write(`Usage: node scripts/verify.mjs [options] -Builds the CLI bundle, captures a fresh browser Oracle, renders the same fixture -through the CLI, and compares the two outputs byte for byte. Exits non-zero on -any difference. There is no tolerance, allow-list, or known-gaps mechanism. +Builds the CLI bundle, captures a fresh browser Oracle for each fixture, renders +the same fixture through the CLI, and compares the outputs byte for byte. Exits +non-zero on any difference. There is no tolerance, allow-list, or known-gaps +mechanism. + +Fixtures: + full Full-coverage fixture — the product signal (default) + minimal Heading, paragraph, inline code — an infrastructure probe only: a + failure means the harness is broken, a pass proves nothing about + product fidelity Options: - --fixture Fixture to verify (name: ${Object.keys(FIXTURES).join(`, `)}) - or a path to a Markdown file + --fixture Run one fixture (or a path to a Markdown file) instead + of both registry fixtures --config Designer config JSON; projected to render-affecting keys and given identically to both sides. The five style keys a Designer export cannot carry fall back to each side's own site defaults - --out-dir Write artifacts here and keep them (implies --keep) + --out-dir Write artifacts here and keep them (implies --keep; + must be outside the repository) --keep Keep the artifacts and print their paths -h, --help Show this help `) @@ -67,7 +87,7 @@ function fail(message) { } export function parseArgs(argv) { - const args = { fixture: `full`, keep: false } + const args = { keep: false, fixture: null } for (let index = 0; index < argv.length; index++) { const arg = argv[index] const next = () => { @@ -98,17 +118,15 @@ export function parseArgs(argv) { return args } -function resolveFixture(value) { - if (value in FIXTURES) { - const path = FIXTURES[value] - if (!existsSync(path)) - fail(`fixture not found: ${path}`) - return { name: value, path } - } +function resolveFixtures(value) { + if (value === null) + return Object.entries(FIXTURES).map(([name, fixture]) => ({ name, ...fixture })) + if (value in FIXTURES) + return [{ name: value, ...FIXTURES[value] }] const path = resolve(process.cwd(), value) if (!existsSync(path)) fail(`fixture not found: ${path} (known names: ${Object.keys(FIXTURES).join(`, `)})`) - return { name: value, path } + return [{ name: value, path, role: `custom fixture`, failureMeaning: `outputs differ` }] } function run(command, args, options = {}) { @@ -149,12 +167,13 @@ function formatLocations(locations) { return `${shown.join(`, `)}${suffix}` } -function printFailure(fixtureName, oracleHtml, cliHtml, comparison) { +function printFailure(fixture, oracleHtml, cliHtml, comparison) { process.stderr.write( - `[verify] FAIL ${fixtureName}: outputs are not byte-identical ` + `[verify] FAIL ${fixture.name} (${fixture.role}): outputs are not byte-identical ` + `(oracle ${Buffer.byteLength(oracleHtml, `utf8`)} bytes, CLI ${Buffer.byteLength(cliHtml, `utf8`)} bytes, ` + `first difference at byte ${comparison.firstDifference})\n`, ) + process.stderr.write(`[verify] ${fixture.failureMeaning}\n`) process.stderr.write(`[verify] difference classes (${comparison.hunkCount} hunks total):\n`) for (const entry of comparison.classes) { process.stderr.write( @@ -168,10 +187,58 @@ function printFailure(fixtureName, oracleHtml, cliHtml, comparison) { ) } -function printPass(fixtureName, html) { +function printPass(fixture, html) { + process.stdout.write( + `[verify] PASS ${fixture.name} (${fixture.role}): outputs are byte-identical (${Buffer.byteLength(html, `utf8`)} bytes)\n`, + ) + if (fixture.name === `minimal`) + process.stdout.write(`[verify] note: a green infrastructure probe proves nothing about product fidelity\n`) +} + +function printSummary(results) { + const passed = results.filter(result => result.comparison.equal).length process.stdout.write( - `[verify] PASS ${fixtureName}: outputs are byte-identical (${Buffer.byteLength(html, `utf8`)} bytes)\n`, + `[verify] summary: ${results.length} fixture(s) — ${passed} passed, ${results.length - passed} failed\n`, + ) + for (const { fixture, comparison } of results) { + const verdict = comparison.equal ? `PASS` : `FAIL` + const detail = comparison.equal ? `` : ` — ${comparison.hunkCount} hunks: ${fixture.failureMeaning}` + process.stdout.write(` ${verdict} ${fixture.name} (${fixture.role})${detail}\n`) + } +} + +async function verifyFixture({ fixture, workDir, configPath, captureOracle }) { + const artifactDir = join(workDir, fixture.name) + process.stdout.write(`[verify] fixture: ${fixture.name} — ${fixture.role}\n`) + + const oracleHtml = await captureOracle({ fixturePath: fixture.path, outDir: artifactDir }) + const cliOutPath = join(artifactDir, `cli.html`) + renderThroughCli(fixture.path, cliOutPath, configPath) + const cliHtml = readFileSync(cliOutPath, `utf8`) + + const comparison = compareOutputs(oracleHtml, cliHtml) + writeFileSync(join(artifactDir, `diff.txt`), comparison.unifiedDiff, `utf8`) + writeFileSync( + join(artifactDir, `report.txt`), + comparison.equal + ? `PASS: outputs are byte-identical (${Buffer.byteLength(oracleHtml, `utf8`)} bytes)\n` + : `FAIL: ${comparison.hunkCount} hunks across ${comparison.classes.length} class(es)\n${ + comparison.classes.map(entry => `${entry.name}: ${entry.locations.length} hunks at lines ${entry.locations.join(`, `)}\n`).join(``) + }`, + `utf8`, ) + + if (comparison.equal) + printPass(fixture, oracleHtml) + else + printFailure(fixture, oracleHtml, cliHtml, comparison) + + process.stdout.write(`[verify] oracle: ${join(artifactDir, `oracle.html`)}\n`) + process.stdout.write(`[verify] cli: ${cliOutPath}\n`) + process.stdout.write(`[verify] diff: ${join(artifactDir, `diff.txt`)}\n`) + process.stdout.write(`[verify] report: ${join(artifactDir, `report.txt`)}\n`) + + return { fixture, comparison } } async function main() { @@ -181,7 +248,11 @@ async function main() { return 0 } - const fixture = resolveFixture(args.fixture) + const fixtures = resolveFixtures(args.fixture) + for (const fixture of fixtures) { + if (!existsSync(fixture.path)) + fail(`fixture not found: ${fixture.path}`) + } let configPath if (args.config) { @@ -194,50 +265,24 @@ async function main() { const workDir = args.outDir ? resolve(process.cwd(), args.outDir) : mkdtempSync(join(tmpdir(), `md-verify-`)) - const artifactDir = args.outDir ? workDir : join(workDir, fixture.name) process.stdout.write(`[verify] artifacts: ${workDir}\n`) try { buildEverything() - const { captureOracle } = await import(`./verify/oracle.mjs`) - const oracleHtml = await captureOracle({ - fixturePath: fixture.path, - outDir: artifactDir, - }) - - const cliOutPath = join(artifactDir, `cli.html`) - renderThroughCli(fixture.path, cliOutPath, configPath) - const cliHtml = readFileSync(cliOutPath, `utf8`) - - const comparison = compareOutputs(oracleHtml, cliHtml) - writeFileSync(join(artifactDir, `diff.txt`), comparison.unifiedDiff, `utf8`) - writeFileSync( - join(artifactDir, `report.txt`), - comparison.equal - ? `PASS: outputs are byte-identical (${Buffer.byteLength(oracleHtml, `utf8`)} bytes)\n` - : `FAIL: ${comparison.hunkCount} hunks across ${comparison.classes.length} class(es)\n${ - comparison.classes.map(entry => `${entry.name}: ${entry.locations.length} hunks at lines ${entry.locations.join(`, `)}\n`).join(``) - }`, - `utf8`, - ) - if (comparison.equal) - printPass(fixture.name, oracleHtml) - else - printFailure(fixture.name, oracleHtml, cliHtml, comparison) + const results = [] + for (const fixture of fixtures) + results.push(await verifyFixture({ fixture, workDir, configPath, captureOracle })) - process.stdout.write(`[verify] oracle: ${join(artifactDir, `oracle.html`)}\n`) - process.stdout.write(`[verify] cli: ${cliOutPath}\n`) - process.stdout.write(`[verify] diff: ${join(artifactDir, `diff.txt`)}\n`) - process.stdout.write(`[verify] report: ${join(artifactDir, `report.txt`)}\n`) + printSummary(results) if (keepArtifacts) process.stdout.write(`[verify] artifacts preserved: ${workDir}\n`) else process.stdout.write(`[verify] artifacts are temporary; pass --keep to preserve them\n`) - return comparison.equal ? 0 : 1 + return results.every(result => result.comparison.equal) ? 0 : 1 } finally { if (!keepArtifacts) From 64387a9c553f9e95522e71915b98df1f58650265 Mon Sep 17 00:00:00 2001 From: "dane.dai" <838652739@qq.com> Date: Thu, 22 Oct 2026 22:16:25 +0800 Subject: [PATCH 29/36] feat(verify): accept a Designer config and project it to render keys --config takes an unmodified Designer export, keeps only render-affecting keys, and feeds the same projected file to the CLI and to the browser (seeded into the app's own storage before boot). Article content and pure-UI state are dropped before either side sees them, and the seed is asserted to have taken effect so a silent no-op cannot compare two site-default renders. The five style keys an export cannot carry fall back to each side's own site defaults, guarded by the site-default parity test. --- .../md-web-cli/tests/verify-command.test.ts | 32 +++ .../md-web-cli/tests/verify-config.test.ts | 98 ++++++++++ scripts/verify.mjs | 57 +++++- scripts/verify/config.mjs | 182 ++++++++++++++++++ 4 files changed, 360 insertions(+), 9 deletions(-) create mode 100644 packages/md-web-cli/tests/verify-config.test.ts create mode 100644 scripts/verify/config.mjs diff --git a/packages/md-web-cli/tests/verify-command.test.ts b/packages/md-web-cli/tests/verify-command.test.ts index 83dea8a0a5..e9000ba728 100644 --- a/packages/md-web-cli/tests/verify-command.test.ts +++ b/packages/md-web-cli/tests/verify-command.test.ts @@ -1,4 +1,6 @@ import { execFileSync } from 'node:child_process' +import fs from 'node:fs' +import os from 'node:os' import path from 'node:path' import { describe, expect, it } from 'vitest' @@ -80,6 +82,36 @@ describe(`verify command: missing inputs`, () => { expect(stderr).toContain(`config not found:`) }) + it(`rejects a config that is not valid JSON`, () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), `verify-config-`)) + try { + const configPath = path.join(dir, `broken.json`) + fs.writeFileSync(configPath, `{ not json`) + const { status, stderr } = runVerify([`--fixture`, `minimal`, `--config`, configPath]) + + expect(status).toBe(1) + expect(stderr).toContain(`config is not valid JSON:`) + } + finally { + fs.rmSync(dir, { recursive: true, force: true }) + } + }) + + it(`rejects a theme the two sides cannot both honour`, () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), `verify-config-`)) + try { + const configPath = path.join(dir, `marketplace.json`) + fs.writeFileSync(configPath, JSON.stringify({ theme: `md-theme-obsidian`, primaryColor: `#FA5151` })) + const { status, stderr } = runVerify([`--fixture`, `minimal`, `--config`, configPath]) + + expect(status).toBe(1) + expect(stderr).toContain(`theme "md-theme-obsidian" is not available to both sides`) + } + finally { + fs.rmSync(dir, { recursive: true, force: true }) + } + }) + it(`fails loudly for a config file that does not exist`, () => { const { status, stderr } = runVerify([`--fixture`, `full`, `--config`, `no-such-config.json`]) diff --git a/packages/md-web-cli/tests/verify-config.test.ts b/packages/md-web-cli/tests/verify-config.test.ts new file mode 100644 index 0000000000..fc84a95806 --- /dev/null +++ b/packages/md-web-cli/tests/verify-config.test.ts @@ -0,0 +1,98 @@ +import { execFileSync } from 'node:child_process' +import fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import { describe, expect, it } from 'vitest' + +/** + * Config projection at the verify process seam. + * + * This case needs the browser (it runs the whole gate), so it is opt-in: + * `MD_WEB_CLI_BROWSER_TESTS=1 pnpm --filter @dane.dai/md-web-cli test`. + * It stays at the process boundary on purpose — a unit test of the projection + * function would be a third seam, and the behaviour that matters is "the gate + * never compares an article carried in the export". + */ + +const repoRoot = path.resolve(path.dirname(new URL(import.meta.url).pathname), `../../..`) +const scriptPath = path.join(repoRoot, `scripts/verify.mjs`) +const browserTests = Boolean(process.env.MD_WEB_CLI_BROWSER_TESTS) + +const ARTICLE_MARKER = `ARTICLE MARKER: this article must never be rendered` + +function runVerify(args: string[]): { status: number, stdout: string, stderr: string } { + try { + const stdout = execFileSync(process.execPath, [scriptPath, ...args], { + encoding: `utf-8`, + cwd: repoRoot, + stdio: [`ignore`, `pipe`, `pipe`], + timeout: 240_000, + }) + return { status: 0, stdout, stderr: `` } + } + catch (error: any) { + return { + status: error.status ?? 1, + stdout: error.stdout ?? ``, + stderr: error.stderr ?? String(error.message), + } + } +} + +describe.skipIf(!browserTests)(`verify command: Designer config`, () => { + it(`compares the fixture, never the article carried in the export`, () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), `verify-config-seam-`)) + try { + const configPath = path.join(dir, `export.json`) + const outDir = path.join(dir, `artifacts`) + fs.writeFileSync(configPath, JSON.stringify({ + // Article content: must be dropped before either side sees the config. + posts: [{ id: `post-1`, content: `# ${ARTICLE_MARKER}` }], + currentPostId: `post-1`, + currentPostIndex: 0, + titleList: [ARTICLE_MARKER], + readingTime: { words: 9999, minutes: 42 }, + // Pure UI: no effect on the rendered article. + isDark: true, + isOpenRightSlider: false, + isOpenPostSlider: true, + showAIToolbox: false, + isShowCssEditor: true, + isShowTableEditDialog: true, + isShowUploadImgDialog: true, + aiDialogVisible: true, + aiImageDialogVisible: true, + // Render-affecting: reach both sides. + theme: `default`, + primaryColor: `#0F4C81`, + fontFamily: `-apple-system-font,BlinkMacSystemFont, Helvetica Neue, PingFang SC, Hiragino Sans GB , Microsoft YaHei UI , Microsoft YaHei ,Arial,sans-serif`, + fontSize: `16px`, + codeBlockTheme: `https://cdn-doocs.oss-cn-shenzhen.aliyuncs.com/npm/highlightjs/11.11.1/styles/github-dark.min.css`, + legend: `alt`, + isMacCodeBlock: true, + isShowLineNumber: false, + isCiteStatus: false, + isCountStatus: false, + isUseIndent: false, + isUseJustify: false, + cssContentConfig: { active: `default`, tabs: [{ id: `default`, title: `default`, name: `default`, content: `` }] }, + }), `utf-8`) + + const { stdout, stderr } = runVerify([`--fixture`, `minimal`, `--config`, configPath, `--out-dir`, outDir]) + const combined = `${stdout}${stderr}` + + expect(combined).toContain(`article-content`) + expect(combined).toContain(`the same projected file goes to both sides`) + + const cliHtml = fs.readFileSync(path.join(outDir, `minimal`, `cli.html`), `utf-8`) + const oracleHtml = fs.readFileSync(path.join(outDir, `minimal`, `oracle.html`), `utf-8`) + for (const html of [cliHtml, oracleHtml]) { + expect(html).toContain(`Minimal fixture`) + expect(html).not.toContain(ARTICLE_MARKER) + } + } + finally { + fs.rmSync(dir, { recursive: true, force: true }) + } + }, 240_000) +}) diff --git a/scripts/verify.mjs b/scripts/verify.mjs index ba18203b28..6853684c99 100644 --- a/scripts/verify.mjs +++ b/scripts/verify.mjs @@ -15,12 +15,13 @@ */ import { spawnSync } from 'node:child_process' -import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { dirname, join, resolve } from 'node:path' import process from 'node:process' import { fileURLToPath } from 'node:url' import { compareOutputs } from './verify/compare.mjs' +import { assertConfigApplied, buildConfigSeedScript, ConfigError, projectDesignerConfig } from './verify/config.mjs' const HERE = dirname(fileURLToPath(import.meta.url)) const REPO_ROOT = resolve(HERE, `..`) @@ -207,15 +208,28 @@ function printSummary(results) { } } -async function verifyFixture({ fixture, workDir, configPath, captureOracle }) { +async function verifyFixture({ fixture, workDir, configPath, projectedConfig, captureOracle }) { const artifactDir = join(workDir, fixture.name) + mkdirSync(artifactDir, { recursive: true }) process.stdout.write(`[verify] fixture: ${fixture.name} — ${fixture.role}\n`) - const oracleHtml = await captureOracle({ fixturePath: fixture.path, outDir: artifactDir }) + // Render through the CLI first: a config or fixture the CLI rejects fails in + // under a second instead of after the browser capture. const cliOutPath = join(artifactDir, `cli.html`) renderThroughCli(fixture.path, cliOutPath, configPath) const cliHtml = readFileSync(cliOutPath, `utf8`) + const oracleHtml = await captureOracle({ + fixturePath: fixture.path, + outDir: artifactDir, + ...(projectedConfig + ? { + initScripts: [buildConfigSeedScript(projectedConfig)], + afterLoad: page => assertConfigApplied(page, projectedConfig), + } + : {}), + }) + const comparison = compareOutputs(oracleHtml, cliHtml) writeFileSync(join(artifactDir, `diff.txt`), comparison.unifiedDiff, `utf8`) writeFileSync( @@ -254,26 +268,51 @@ async function main() { fail(`fixture not found: ${fixture.path}`) } - let configPath + let requestedConfig if (args.config) { - configPath = resolve(process.cwd(), args.config) - if (!existsSync(configPath)) - fail(`config not found: ${configPath}`) + const sourcePath = resolve(process.cwd(), args.config) + if (!existsSync(sourcePath)) + fail(`config not found: ${sourcePath}`) + try { + requestedConfig = JSON.parse(readFileSync(sourcePath, `utf8`)) + } + catch (error) { + fail(`config is not valid JSON: ${error.message}`) + } } const keepArtifacts = Boolean(args.outDir || args.keep) const workDir = args.outDir ? resolve(process.cwd(), args.outDir) : mkdtempSync(join(tmpdir(), `md-verify-`)) + mkdirSync(workDir, { recursive: true }) process.stdout.write(`[verify] artifacts: ${workDir}\n`) try { + let configPath + let projectedConfig + if (requestedConfig) { + const { projected, dropped } = projectDesignerConfig(requestedConfig) + projectedConfig = projected + const keptCount = Object.keys(projected).length + const droppedCount = dropped.content.length + dropped.ui.length + dropped.unknown.length + process.stdout.write( + `[verify] config: ${keptCount} render-affecting key(s) kept, ${droppedCount} dropped ` + + `(${dropped.content.length} article-content, ${dropped.ui.length} UI, ${dropped.unknown.length} unknown)\n`, + ) + if (dropped.unknown.length > 0) + process.stdout.write(`[verify] config: unknown keys ignored: ${dropped.unknown.join(`, `)}\n`) + configPath = join(workDir, `projected-config.json`) + writeFileSync(configPath, JSON.stringify(projected, null, 2), `utf8`) + process.stdout.write(`[verify] config: the same projected file goes to both sides (${configPath})\n`) + } + buildEverything() const { captureOracle } = await import(`./verify/oracle.mjs`) const results = [] for (const fixture of fixtures) - results.push(await verifyFixture({ fixture, workDir, configPath, captureOracle })) + results.push(await verifyFixture({ fixture, workDir, configPath, projectedConfig, captureOracle })) printSummary(results) @@ -295,7 +334,7 @@ main() process.exitCode = exitCode }) .catch((error) => { - if (error instanceof VerifyError) { + if (error instanceof VerifyError || error instanceof ConfigError) { process.stderr.write(`[verify] ${error.message}\n`) } else { diff --git a/scripts/verify/config.mjs b/scripts/verify/config.mjs new file mode 100644 index 0000000000..dcf0c84d9a --- /dev/null +++ b/scripts/verify/config.mjs @@ -0,0 +1,182 @@ +/** + * Designer config support for the verify gate. + * + * The same projected configuration must reach both sides, or a config-driven + * difference would masquerade as an implementation difference. Projection keeps + * only render-affecting keys: article content and pure-UI state are dropped + * before either side sees the config, so an exported config can never make the + * gate compare an article instead of the fixture. + * + * The five style keys a Designer export cannot carry (lineHeight, blockSpacing, + * linkColor, blockquoteBackground, headingStyles) are deliberately left out: + * each side falls back to its own site defaults, and a unit test guards that + * those defaults agree. + */ + +const RENDER_KEYS = [ + `theme`, + `primaryColor`, + `fontFamily`, + `fontSize`, + `isUseIndent`, + `isUseJustify`, + `legend`, + `isCiteStatus`, + `isCountStatus`, + `isMacCodeBlock`, + `isShowLineNumber`, + `codeBlockTheme`, + `cssContentConfig`, +] + +const CONTENT_KEYS = [`posts`, `currentPostId`, `currentPostIndex`, `titleList`, `readingTime`] + +const UI_KEYS = [ + `isDark`, + `isOpenRightSlider`, + `isOpenPostSlider`, + `showAIToolbox`, + `isShowCssEditor`, + `isShowTableEditDialog`, + `isShowUploadImgDialog`, + `aiDialogVisible`, + `aiImageDialogVisible`, +] + +const SUPPORTED_THEMES = [`default`, `grace`, `simple`] + +/** Per-theme style keys the Designer export carries inside `themeSettings`. */ +const PER_THEME_KEYS = [ + `primaryColor`, + `fontFamily`, + `fontSize`, + `codeBlockTheme`, + `isShowLineNumber`, + `isMacCodeBlock`, +] + +class ConfigError extends Error {} + +/** + * Project a parsed Designer export down to render-affecting keys. + * + * Unknown keys are dropped rather than forwarded: the browser's import path + * would apply a key it knows and the CLI would ignore it, which is exactly how + * one side would silently see a different configuration. + */ +export function projectDesignerConfig(raw) { + if (typeof raw !== `object` || raw === null || Array.isArray(raw)) + throw new ConfigError(`config must be a JSON object`) + + const projected = {} + const dropped = { content: [], ui: [], unknown: [] } + for (const key of Object.keys(raw)) { + if (RENDER_KEYS.includes(key)) + projected[key] = raw[key] + else if (CONTENT_KEYS.includes(key)) + dropped.content.push(key) + else if (UI_KEYS.includes(key)) + dropped.ui.push(key) + else + dropped.unknown.push(key) + } + + if (typeof projected.theme === `string` && !SUPPORTED_THEMES.includes(projected.theme)) { + throw new ConfigError( + `theme "${projected.theme}" is not available to both sides ` + + `(supported: ${SUPPORTED_THEMES.join(`, `)}). A marketplace theme is not installed in the ` + + `browser and is not known to the CLI, so the two would silently default differently.`, + ) + } + + return { projected, dropped } +} + +function setEntry(entries, key, value, encoding) { + entries.push({ key, value, encoding }) +} + +/** + * Build the init script that seeds the projected config into the app's own + * storage before boot. + * + * The app migrates every `localStorage` key into IndexedDB on first launch and + * serves it synchronously to the Pinia stores, so one navigation with a seeded + * localStorage is enough. Every write stays inside the temporary browser + * profile; nothing touches the repository. + */ +export function buildConfigSeedScript(projected) { + const entries = [] + if (typeof projected.theme === `string`) + setEntry(entries, `MD__theme`, projected.theme, `raw`) + + const themeSettings = {} + for (const key of PER_THEME_KEYS) { + if (projected[key] !== undefined) + themeSettings[key] = projected[key] + } + if (Object.keys(themeSettings).length > 0) { + setEntry(entries, `MD__themeSettings`, { + [projected.theme ?? `default`]: themeSettings, + }, `json`) + } + + if (projected.isCiteStatus !== undefined) + setEntry(entries, `isCiteStatus`, projected.isCiteStatus, `json`) + if (projected.isCountStatus !== undefined) + setEntry(entries, `isCountStatus`, projected.isCountStatus, `json`) + if (typeof projected.legend === `string`) + setEntry(entries, `legend`, projected.legend, `raw`) + if (projected.isUseIndent !== undefined) + setEntry(entries, `MD__use_indent`, projected.isUseIndent, `json`) + if (projected.isUseJustify !== undefined) + setEntry(entries, `MD__use_justify`, projected.isUseJustify, `json`) + if (projected.cssContentConfig !== undefined) + setEntry(entries, `MD__css_content_config`, projected.cssContentConfig, `json`) + + const payload = JSON.stringify(entries) + return `(() => { + const entries = ${payload} + try { + for (const entry of entries) + localStorage.setItem(entry.key, entry.encoding === 'json' ? JSON.stringify(entry.value) : String(entry.value)) + } catch {} +})()` +} + +/** + * Assert the seed actually took effect before the fixture is injected. + * + * An untrusted seed that silently no-ops would compare two site-default renders + * and pass — the worst possible failure for a gate, so it is checked instead of + * assumed. + */ +export async function assertConfigApplied(page, projected) { + if (typeof projected.primaryColor === `string`) { + const applied = await page.evaluate(() => { + const theme = document.getElementById(`md-theme`) + return theme ? theme.textContent : `` + }) + if (!applied.includes(`--md-primary-color: ${projected.primaryColor}`)) { + throw new ConfigError( + `config did not reach the app: expected --md-primary-color: ${projected.primaryColor} in #md-theme, ` + + `found ${applied.includes(`--md-primary-color`) ? `a different value` : `no variable`}. ` + + `Refusing to compare two site-default renders.`, + ) + } + } + + if (typeof projected.codeBlockTheme === `string`) { + const href = await page.evaluate(() => { + const link = document.getElementById(`hljs`) + return link ? link.getAttribute(`href`) : null + }) + if (href !== projected.codeBlockTheme) { + throw new ConfigError( + `config did not reach the app: expected the #hljs stylesheet to be ${projected.codeBlockTheme}, got ${href}.`, + ) + } + } +} + +export { ConfigError } From ef960aa4652baba0d06359f7bacecbe4e42d93ca Mon Sep 17 00:00:00 2001 From: "dane.dai" <838652739@qq.com> Date: Thu, 22 Oct 2026 22:17:38 +0800 Subject: [PATCH 30/36] feat(verify): keep structural assertions and add a reverse-case self-test The gate now fails on pending placeholders (diagram, formula, unregistered language) and on output that is not WeChat-ready (style block, class attribute, block element without inline style), with the documented structural exemptions. --self-test corrupts a known-good CLI render seven distinct ways and asserts each mutant is rejected, without a browser. Adapts the retired verifier's tests to the comparator rather than dropping them. --- .../tests/verify-structural.test.ts | 88 ++++++++++++++++++ scripts/verify.mjs | 86 ++++++++++++++++-- scripts/verify/self-test.mjs | 77 ++++++++++++++++ scripts/verify/structural.mjs | 90 +++++++++++++++++++ 4 files changed, 335 insertions(+), 6 deletions(-) create mode 100644 packages/md-web-cli/tests/verify-structural.test.ts create mode 100644 scripts/verify/self-test.mjs create mode 100644 scripts/verify/structural.mjs diff --git a/packages/md-web-cli/tests/verify-structural.test.ts b/packages/md-web-cli/tests/verify-structural.test.ts new file mode 100644 index 0000000000..a23ec3520a --- /dev/null +++ b/packages/md-web-cli/tests/verify-structural.test.ts @@ -0,0 +1,88 @@ +import { execFileSync } from 'node:child_process' +import { resolve } from 'node:path' +import { fileURLToPath } from 'node:url' +import { describe, expect, it } from 'vitest' +import { findInlineStyleProblems, findPendingPlaceholders, findStructuralProblems } from '../../../scripts/verify/structural.mjs' + +/** + * Coverage adapted from the retired verify-snapshot verifier. + * + * The script those tests imported is gone from the gate path, but the + * invariants it protected are not: pending placeholders must never ship, the + * output must be WeChat-ready, and the reverse-case self-test must prove the + * gate can fail. + */ + +const here = fileURLToPath(import.meta.url) +const repoRoot = resolve(here, '../../../..') +const verifyScript = resolve(repoRoot, 'scripts/verify.mjs') + +describe(`pending placeholder detection`, () => { + it(`finds diagram, katex and language markers, naming each kind`, () => { + const html = '
xfn' + const problems = findPendingPlaceholders(html) + + expect(problems.map(problem => problem.kind)).toEqual([ + `pending-diagram`, + `pending-katex`, + `pending-language`, + ]) + expect(problems[2].detail).toContain(`rust`) + }) + + it(`ignores katex-pending selectors inside a style block`, () => { + expect(findPendingPlaceholders('

x

')).toHaveLength(0) + }) +}) + +describe(`weChat-ready inline style checks`, () => { + it(`accepts a WeChat-ready artifact`, () => { + expect(findInlineStyleProblems('

x

')).toHaveLength(0) + }) + + it(`rejects class attributes, style blocks and unstyled block elements`, () => { + const problems = findInlineStyleProblems('

b

') + + expect(problems.map(problem => problem.kind)).toEqual([ + `style-tag`, + `class-attribute`, + `unstyled-block`, + ]) + }) + + it(`exempts the documented structurally-formatted elements`, () => { + const html = '
x
' + + expect(findInlineStyleProblems(html)).toHaveLength(0) + }) + + it(`reports every problem class for one artifact`, () => { + const problems = findStructuralProblems('

no style

') + + expect(problems.map(problem => problem.kind).sort()).toEqual([`class-attribute`, `pending-diagram`, `unstyled-block`]) + }) +}) + +describe(`reverse-case self-test`, () => { + it(`exits 0 with every mutant rejected, without launching a browser`, () => { + const output = execFileSync(process.execPath, [verifyScript, `--self-test`], { + cwd: repoRoot, + encoding: `utf-8`, + stdio: [`ignore`, `pipe`, `pipe`], + timeout: 120_000, + }) + + for (const name of [ + `injected diagram placeholder`, + `injected formula placeholder`, + `injected unregistered-language marker`, + `stripped inline styles`, + `injected class attribute`, + `injected style block`, + `truncated artifact`, + ]) { + expect(output).toContain(`PASS: ${name} -> gate rejects it`) + } + expect(output).toContain(`7 reverse case(s)`) + }, 120_000) +}) diff --git a/scripts/verify.mjs b/scripts/verify.mjs index 6853684c99..34f89f3536 100644 --- a/scripts/verify.mjs +++ b/scripts/verify.mjs @@ -22,6 +22,8 @@ import process from 'node:process' import { fileURLToPath } from 'node:url' import { compareOutputs } from './verify/compare.mjs' import { assertConfigApplied, buildConfigSeedScript, ConfigError, projectDesignerConfig } from './verify/config.mjs' +import { runSelfTest } from './verify/self-test.mjs' +import { findStructuralProblems } from './verify/structural.mjs' const HERE = dirname(fileURLToPath(import.meta.url)) const REPO_ROOT = resolve(HERE, `..`) @@ -79,6 +81,9 @@ Options: --out-dir Write artifacts here and keep them (implies --keep; must be outside the repository) --keep Keep the artifacts and print their paths + --self-test Reverse-case self-test: corrupt a known-good CLI + render several ways and assert the gate rejects each + mutant. Needs no browser -h, --help Show this help `) } @@ -111,6 +116,8 @@ export function parseArgs(argv) { args.outDir = arg.slice(`--out-dir=`.length) else if (arg === `--keep`) args.keep = true + else if (arg === `--self-test`) + args.selfTest = true else if (arg === `-h` || arg === `--help`) args.help = true else @@ -168,6 +175,14 @@ function formatLocations(locations) { return `${shown.join(`, `)}${suffix}` } +function printStructuralFailure(fixture, problems) { + process.stderr.write( + `[verify] STRUCTURAL FAIL ${fixture.name}: ${problems.length} WeChat-readiness problem(s) in the CLI output\n`, + ) + for (const problem of problems) + process.stderr.write(` ${problem.kind}: ${problem.detail}\n`) +} + function printFailure(fixture, oracleHtml, cliHtml, comparison) { process.stderr.write( `[verify] FAIL ${fixture.name} (${fixture.role}): outputs are not byte-identical ` @@ -196,14 +211,24 @@ function printPass(fixture, html) { process.stdout.write(`[verify] note: a green infrastructure probe proves nothing about product fidelity\n`) } +function isPassing(result) { + return result.comparison.equal && result.structuralProblems.length === 0 +} + function printSummary(results) { - const passed = results.filter(result => result.comparison.equal).length + const passed = results.filter(isPassing).length process.stdout.write( `[verify] summary: ${results.length} fixture(s) — ${passed} passed, ${results.length - passed} failed\n`, ) - for (const { fixture, comparison } of results) { - const verdict = comparison.equal ? `PASS` : `FAIL` - const detail = comparison.equal ? `` : ` — ${comparison.hunkCount} hunks: ${fixture.failureMeaning}` + for (const result of results) { + const { fixture, comparison, structuralProblems } = result + const verdict = isPassing(result) ? `PASS` : `FAIL` + const details = [] + if (!comparison.equal) + details.push(`${comparison.hunkCount} hunks: ${fixture.failureMeaning}`) + if (structuralProblems.length > 0) + details.push(`${structuralProblems.length} structural problem(s)`) + const detail = details.length > 0 ? ` — ${details.join(`; `)}` : `` process.stdout.write(` ${verdict} ${fixture.name} (${fixture.role})${detail}\n`) } } @@ -230,6 +255,7 @@ async function verifyFixture({ fixture, workDir, configPath, projectedConfig, ca : {}), }) + const structuralProblems = findStructuralProblems(cliHtml) const comparison = compareOutputs(oracleHtml, cliHtml) writeFileSync(join(artifactDir, `diff.txt`), comparison.unifiedDiff, `utf8`) writeFileSync( @@ -242,6 +268,9 @@ async function verifyFixture({ fixture, workDir, configPath, projectedConfig, ca `utf8`, ) + if (structuralProblems.length > 0) + printStructuralFailure(fixture, structuralProblems) + if (comparison.equal) printPass(fixture, oracleHtml) else @@ -252,7 +281,50 @@ async function verifyFixture({ fixture, workDir, configPath, projectedConfig, ca process.stdout.write(`[verify] diff: ${join(artifactDir, `diff.txt`)}\n`) process.stdout.write(`[verify] report: ${join(artifactDir, `report.txt`)}\n`) - return { fixture, comparison } + return { fixture, comparison, structuralProblems } +} + +async function runSelfTestMode() { + const workDir = mkdtempSync(join(tmpdir(), `md-verify-self-test-`)) + try { + process.stdout.write(`[self-test] building the CLI bundle (no browser involved)\n`) + run(`pnpm`, [`--filter`, `@dane.dai/md-web-cli`, `build`], { describe: `build failed` }) + const knownGoodPath = join(workDir, `known-good.html`) + run(process.execPath, [CLI_BUNDLE, `render`, FIXTURES.full.path, `-o`, knownGoodPath], { + capture: true, + describe: `CLI render failed; the self-test needs a known-good render`, + }) + + const { baselineProblems, cases } = runSelfTest(readFileSync(knownGoodPath, `utf8`)) + if (baselineProblems.length > 0) { + process.stderr.write(`[self-test] baseline render is not WeChat-ready; cannot self-test against it:\n`) + for (const problem of baselineProblems) + process.stderr.write(` ${problem.kind}: ${problem.detail}\n`) + return 1 + } + + let ok = true + for (const testCase of cases) { + if (testCase.rejected) { + process.stdout.write(`[self-test] PASS: ${testCase.name} -> gate rejects it (${testCase.firstReason})\n`) + continue + } + ok = false + const missingKind = testCase.expectedKind && !testCase.expectedKindFound + ? ` (expected a "${testCase.expectedKind}" problem, saw: ${testCase.matchedKinds.join(`, `) || `none`})` + : `` + process.stderr.write( + `[self-test] FAIL: ${testCase.name} was NOT rejected${missingKind}; the gate cannot be trusted\n`, + ) + } + process.stdout.write( + `[self-test] ${ok ? `PASS` : `FAIL`}: ${cases.length} reverse case(s), every mutant must be rejected\n`, + ) + return ok ? 0 : 1 + } + finally { + rmSync(workDir, { recursive: true, force: true }) + } } async function main() { @@ -261,6 +333,8 @@ async function main() { printUsage() return 0 } + if (args.selfTest) + return runSelfTestMode() const fixtures = resolveFixtures(args.fixture) for (const fixture of fixtures) { @@ -321,7 +395,7 @@ async function main() { else process.stdout.write(`[verify] artifacts are temporary; pass --keep to preserve them\n`) - return results.every(result => result.comparison.equal) ? 0 : 1 + return results.every(isPassing) ? 0 : 1 } finally { if (!keepArtifacts) diff --git a/scripts/verify/self-test.mjs b/scripts/verify/self-test.mjs new file mode 100644 index 0000000000..e20f055644 --- /dev/null +++ b/scripts/verify/self-test.mjs @@ -0,0 +1,77 @@ +/** + * Reverse-case self-test: proof that the gate can fail. + * + * A gate that never fails is indistinguishable from a broken gate, so this mode + * corrupts a known-good render several distinct ways and asserts that each + * mutant is rejected. It needs no browser: the known-good render comes from the + * CLI bundle. + */ + +import { compareOutputs } from './compare.mjs' +import { findStructuralProblems } from './structural.mjs' + +const CASES = [ + { + name: `injected diagram placeholder`, + expectKind: `pending-diagram`, + mutate: html => html.replace(` html.replace(`x`), + }, + { + name: `injected unregistered-language marker`, + expectKind: `pending-language`, + mutate: html => html.replace(`fn`), + }, + { + name: `stripped inline styles`, + expectKind: `unstyled-block`, + mutate: html => html.replace(/\sstyle\s*=\s*(["'])[\s\S]*?\1/gi, ``), + }, + { + name: `injected class attribute`, + expectKind: `class-attribute`, + mutate: html => html.replace(/ `${html}`, + }, + { + name: `truncated artifact`, + expectKind: null, + mutate: html => html.slice(0, Math.floor(html.length / 2)), + }, +] + +/** + * @param {string} knownGoodHtml A CLI render that already passes the structural + * assertions. + * @returns {{baselineProblems: Array, cases: Array<{name: string, rejected: boolean, expectedKind: string|null, matchedKinds: string[], compareRejected: boolean, firstReason: string}>}} + */ +export function runSelfTest(knownGoodHtml) { + const baselineProblems = findStructuralProblems(knownGoodHtml) + + const cases = CASES.map((testCase) => { + const mutant = testCase.mutate(knownGoodHtml) + const problems = findStructuralProblems(mutant) + const matchedKinds = problems.map(problem => problem.kind) + const comparison = compareOutputs(knownGoodHtml, mutant) + const expectedKindFound = testCase.expectKind === null || matchedKinds.includes(testCase.expectKind) + return { + name: testCase.name, + expectedKind: testCase.expectKind, + matchedKinds, + compareRejected: !comparison.equal, + rejected: !comparison.equal && expectedKindFound, + expectedKindFound, + firstReason: problems[0]?.detail ?? `byte comparison rejected the mutant`, + } + }) + + return { baselineProblems, cases } +} diff --git a/scripts/verify/structural.mjs b/scripts/verify/structural.mjs new file mode 100644 index 0000000000..a3fb199ecd --- /dev/null +++ b/scripts/verify/structural.mjs @@ -0,0 +1,90 @@ +/** + * Non-browser assertions the gate owns. + * + * These are the invariants the retired verifier enforced, kept alive so they do + * not die with the script that used to host them: the CLI's output must carry no + * pending async placeholders, and it must be WeChat-ready (no style block, no + * class attributes, inline styles on block-level elements). + * + * They run against the CLI output only. The browser's clipboard HTML is + * pre-paste and legitimately keeps class attributes; WeChat strips them at paste + * time. Asserting WeChat readiness on that side would be asserting the wrong + * artefact's contract. + */ + +/** Elements WeChat formats structurally; they legitimately carry no style attr. */ +const STYLE_EXEMPT = new Set([`tr`, `tbody`, `thead`, `svg`]) + +const BLOCK_TAGS = new Set([ + `section`, + `p`, + `h1`, + `h2`, + `h3`, + `h4`, + `h5`, + `h6`, + `blockquote`, + `table`, + `pre`, + `ul`, + `ol`, + `li`, + `td`, + `th`, + `figure`, + `figcaption`, + `hr`, + `div`, + `details`, + `summary`, +]) + +/** + * Scan for pending-async markers. + * + * Leading style blocks are stripped first so a legitimate CSS selector such as + * `.katex-pending` never reads as pending content. + */ +export function findPendingPlaceholders(html) { + const body = html.replace(//gi, ``) + const found = [] + if (/]*data-md-diagram-state="loading"/.test(body)) + found.push({ kind: `pending-diagram`, detail: `async diagram placeholder (data-md-diagram-state="loading")` }) + if (/<[^>]*\bkatex-pending\b/.test(body)) + found.push({ kind: `pending-katex`, detail: `KaTeX formula placeholder (.katex-pending)` }) + for (const match of body.matchAll(/data-language-pending="([^"]*)"/g)) + found.push({ kind: `pending-language`, detail: `unregistered code language placeholder (${match[1]})` }) + return found +} + +/** + * Scan for output that is not WeChat-ready. + * + * `exempt` is exposed so the self-test can document — and test — the structural + * exemptions rather than trusting them. + */ +export function findInlineStyleProblems(html) { + const problems = [] + if (/]/i.test(html)) + problems.push({ kind: `style-tag`, detail: `document contains a

x

')).toHaveLength(0) - }) -}) - -describe('inline style checks', () => { - it('accepts a WeChat-ready artifact', () => { - expect(findInlineStyleProblems('

x

')).toHaveLength(0) - }) - - it('rejects class attributes, style tags and unstyled block elements', () => { - const problems = findInlineStyleProblems('

b

') - expect(problems.some(p => p.includes('