Skip to content

Commit cf5ec8a

Browse files
committed
test: add link integrity checks and CI gates
Add source-level sidebar link tests and a post-build internal href crawl. PR/main CI must pass pnpm test and docs:check; docs-update still commits freely, then runs the same suites report-only into the job summary.
1 parent 3664803 commit cf5ec8a

6 files changed

Lines changed: 572 additions & 5 deletions

File tree

.github/workflows/ci.yaml

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
name: CI
2+
3+
on:
4+
pull_request:
5+
push:
6+
branches:
7+
- main
8+
9+
concurrency:
10+
group: ci-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
11+
cancel-in-progress: true
12+
13+
permissions:
14+
contents: read
15+
16+
jobs:
17+
test:
18+
name: All tests
19+
runs-on: ubuntu-latest
20+
timeout-minutes: 60
21+
steps:
22+
- name: Checkout
23+
uses: actions/checkout@v4
24+
25+
- name: Install pnpm
26+
uses: pnpm/action-setup@v4
27+
with:
28+
version: 11
29+
run_install: false
30+
31+
- name: Set up Node.js
32+
uses: actions/setup-node@v4
33+
with:
34+
node-version: "22"
35+
cache: "pnpm"
36+
37+
- name: Install dependencies
38+
run: pnpm install --frozen-lockfile
39+
40+
- name: Unit tests
41+
run: pnpm test
42+
43+
- name: Docs build + verify (including internal links)
44+
run: pnpm docs:check

.github/workflows/docs-update.yaml

Lines changed: 36 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ permissions:
1111
jobs:
1212
docs-update:
1313
runs-on: ubuntu-latest
14+
timeout-minutes: 360
1415
steps:
1516
- name: Checkout main repository
1617
uses: actions/checkout@v4
@@ -20,9 +21,8 @@ jobs:
2021
- name: Install pnpm
2122
uses: pnpm/action-setup@v4
2223
with:
23-
version: 10
24+
version: 11
2425
run_install: false
25-
standalone: true
2626

2727
- name: Set up Node.js
2828
uses: actions/setup-node@v4
@@ -33,6 +33,7 @@ jobs:
3333
- name: Install dependencies
3434
run: pnpm install --frozen-lockfile
3535

36+
# Sync may commit/push even when the tree would fail tests.
3637
- name: Run Update and Translation Script
3738
env:
3839
GIT_AUTHOR_NAME: "github-actions[bot]"
@@ -41,6 +42,37 @@ jobs:
4142
GOOGLE_API_KEY: ${{ secrets.GOOGLE_API_KEY }}
4243
run: pnpm pipeline:run
4344

44-
- name: Create Summary
45+
# Report-only: never block the sync commit/push.
46+
- name: Post-sync unit tests (report only)
47+
id: unit_tests
48+
continue-on-error: true
49+
run: pnpm test
50+
51+
- name: Post-sync docs check (report only)
52+
id: docs_check
53+
continue-on-error: true
54+
run: pnpm docs:check
55+
56+
- name: Write post-sync test summary
4557
if: always()
46-
run: echo "Workflow finished. Check logs for details." >> $GITHUB_STEP_SUMMARY
58+
env:
59+
UNIT_OUTCOME: ${{ steps.unit_tests.outcome }}
60+
DOCS_OUTCOME: ${{ steps.docs_check.outcome }}
61+
run: |
62+
{
63+
echo "## Docs update summary"
64+
echo ""
65+
echo "| Step | Result |"
66+
echo "| --- | --- |"
67+
echo "| Pipeline (sync/translate/commit) | ${{ job.status }} |"
68+
echo "| \`pnpm test\` | ${UNIT_OUTCOME} |"
69+
echo "| \`pnpm docs:check\` | ${DOCS_OUTCOME} |"
70+
echo ""
71+
if [ "$UNIT_OUTCOME" = "success" ] && [ "$DOCS_OUTCOME" = "success" ]; then
72+
echo "✅ All post-sync tests passed."
73+
else
74+
echo "⚠️ Post-sync tests reported failures. Sync commit is **not** blocked."
75+
echo ""
76+
echo "Open the failed steps above for full logs. Fix via a follow-up PR (CI will require green tests)."
77+
fi
78+
} >> "$GITHUB_STEP_SUMMARY"

package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,10 @@
66
"preinstall": "npx only-allow pnpm",
77
"test": "vitest run",
88
"test:watch": "vitest",
9+
"test:all": "pnpm test && pnpm docs:check",
910
"docs:dev": "vitepress dev docs",
1011
"docs:build": "node --max-old-space-size=8192 node_modules/vitepress/bin/vitepress.js build docs",
11-
"docs:verify": "node scripts/verify-site-output.mjs",
12+
"docs:verify": "node scripts/verify-site-output.mjs && node scripts/verify-internal-links.mjs",
1213
"docs:check": "pnpm docs:build && pnpm docs:verify",
1314
"docs:preview": "vitepress preview docs",
1415
"kotlin-cn:prepare": "node sites/kotlin-cn/scripts/prepare-content.mjs",

scripts/verify-internal-links.mjs

Lines changed: 192 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,192 @@
1+
/**
2+
* Post-build crawl: every internal page href in docs/.vitepress/dist must
3+
* resolve to a rendered HTML file (or index.html). External URLs are ignored.
4+
*
5+
* Run after docs:build (wired into docs:verify).
6+
*/
7+
import { existsSync } from 'node:fs'
8+
import { readdir, readFile } from 'node:fs/promises'
9+
import { dirname, relative, resolve } from 'node:path'
10+
import { fileURLToPath } from 'node:url'
11+
12+
const __dirname = dirname(fileURLToPath(import.meta.url))
13+
const distDir = resolve(__dirname, '../docs/.vitepress/dist')
14+
const SITE_HOSTS = new Set(['openaidoc.org', 'www.openaidoc.org', 'localhost'])
15+
16+
if (!existsSync(distDir)) {
17+
console.error(
18+
`verify-internal-links: dist not found at ${distDir}. Run docs:build first.`
19+
)
20+
process.exit(1)
21+
}
22+
23+
const htmlFiles = await listFiles(distDir, '.html')
24+
/** @type {Map<string, Set<string>>} */
25+
const deadPages = new Map()
26+
let internalRefs = 0
27+
28+
for (const file of htmlFiles) {
29+
const rel = toPosix(relative(distDir, file))
30+
if (rel === '404.html') continue
31+
32+
const html = await readFile(file, 'utf8')
33+
const hrefs = collectHrefs(html)
34+
const fromUrl = fileToUrl(file)
35+
36+
for (const href of hrefs) {
37+
const resolved = resolveInternalPage(fromUrl, href)
38+
if (!resolved) continue
39+
internalRefs++
40+
if (!pageExists(resolved.candidates)) {
41+
if (!deadPages.has(resolved.path)) deadPages.set(resolved.path, new Set())
42+
deadPages.get(resolved.path).add(fromUrl)
43+
}
44+
}
45+
}
46+
47+
if (deadPages.size > 0) {
48+
const ranked = [...deadPages.entries()]
49+
.map(([path, sources]) => ({
50+
path,
51+
sourceCount: sources.size,
52+
sources: [...sources].slice(0, 5),
53+
}))
54+
.sort((a, b) => b.sourceCount - a.sourceCount)
55+
56+
console.error(
57+
`Internal link verification failed: ${ranked.length} dead page path(s), ` +
58+
`${internalRefs} internal page href(s) checked across ${htmlFiles.length} HTML files.\n`
59+
)
60+
for (const item of ranked.slice(0, 80)) {
61+
console.error(
62+
`- ${item.path} (${item.sourceCount} source page(s), e.g. ${item.sources.join(', ')})`
63+
)
64+
}
65+
if (ranked.length > 80) {
66+
console.error(`… and ${ranked.length - 80} more`)
67+
}
68+
process.exit(1)
69+
}
70+
71+
console.log(
72+
`Internal link verification passed: ${htmlFiles.length} HTML pages, ` +
73+
`${internalRefs} internal page href(s), 0 dead targets.`
74+
)
75+
76+
function collectHrefs(html) {
77+
const out = new Set()
78+
const re = /\bhref=["']([^"']+)["']/gi
79+
let match
80+
while ((match = re.exec(html)) !== null) {
81+
out.add(match[1])
82+
}
83+
return out
84+
}
85+
86+
function fileToUrl(file) {
87+
let rel = toPosix(relative(distDir, file))
88+
if (rel === 'index.html') return '/'
89+
if (rel.endsWith('/index.html')) {
90+
return `/${rel.slice(0, -'index.html'.length)}`
91+
}
92+
if (rel.endsWith('.html')) {
93+
return `/${rel.slice(0, -'.html'.length)}`
94+
}
95+
return `/${rel}`
96+
}
97+
98+
/**
99+
* @returns {{ path: string, candidates: string[] } | null}
100+
*/
101+
function resolveInternalPage(fromUrl, href) {
102+
if (
103+
!href ||
104+
href.startsWith('#') ||
105+
href.startsWith('mailto:') ||
106+
href.startsWith('tel:') ||
107+
href.startsWith('data:') ||
108+
href.startsWith('javascript:')
109+
) {
110+
return null
111+
}
112+
113+
let pathWithQuery = href
114+
if (/^https?:\/\//i.test(href) || href.startsWith('//')) {
115+
try {
116+
const url = new URL(href.startsWith('//') ? `https:${href}` : href)
117+
if (!SITE_HOSTS.has(url.hostname)) return null
118+
pathWithQuery = `${url.pathname}${url.search}${url.hash}`
119+
} catch {
120+
return null
121+
}
122+
}
123+
124+
const cut = pathWithQuery.search(/[?#]/)
125+
const pathOnly = cut === -1 ? pathWithQuery : pathWithQuery.slice(0, cut)
126+
if (!pathOnly) return null
127+
128+
const lastSeg = pathOnly.split('/').pop() || ''
129+
// Static assets and leftover .md / api: refs are not page routes for this gate.
130+
if (lastSeg.includes('.') && !lastSeg.endsWith('.html')) {
131+
return null
132+
}
133+
134+
let urlPath = pathOnly
135+
if (!urlPath.startsWith('/')) {
136+
const base = fromUrl.endsWith('/')
137+
? fromUrl
138+
: `${fromUrl.split('/').slice(0, -1).join('/') || ''}/`
139+
urlPath = posixNormalize(`${base}${urlPath}`)
140+
} else {
141+
urlPath = posixNormalize(urlPath)
142+
}
143+
if (!urlPath.startsWith('/')) urlPath = `/${urlPath}`
144+
145+
const candidates = []
146+
if (urlPath.endsWith('/')) {
147+
candidates.push(resolve(distDir, `.${urlPath}`, 'index.html'))
148+
} else if (urlPath.endsWith('.html')) {
149+
candidates.push(resolve(distDir, `.${urlPath}`))
150+
} else {
151+
candidates.push(resolve(distDir, `.${urlPath}.html`))
152+
candidates.push(resolve(distDir, `.${urlPath}`, 'index.html'))
153+
}
154+
155+
return { path: urlPath, candidates }
156+
}
157+
158+
function pageExists(candidates) {
159+
return candidates.some((file) => existsSync(file))
160+
}
161+
162+
function posixNormalize(path) {
163+
const trailingSlash = path.endsWith('/') && path !== '/'
164+
const parts = []
165+
for (const part of path.split('/')) {
166+
if (!part || part === '.') continue
167+
if (part === '..') {
168+
parts.pop()
169+
continue
170+
}
171+
parts.push(part)
172+
}
173+
const body = `/${parts.join('/')}`
174+
if (body === '/') return '/'
175+
return trailingSlash ? `${body}/` : body
176+
}
177+
178+
function toPosix(value) {
179+
return value.replaceAll('\\', '/')
180+
}
181+
182+
async function listFiles(directory, extension) {
183+
const entries = await readdir(directory, { withFileTypes: true })
184+
const nested = await Promise.all(
185+
entries.map(async (entry) => {
186+
const target = resolve(directory, entry.name)
187+
if (entry.isDirectory()) return listFiles(target, extension)
188+
return entry.isFile() && entry.name.endsWith(extension) ? [target] : []
189+
})
190+
)
191+
return nested.flat()
192+
}

0 commit comments

Comments
 (0)