mirror of
https://github.com/NeuralNomadsAI/CodeNomad.git
synced 2026-10-26 08:11:53 +00:00
## Summary A complete public CodeNomad help site in the app's ten languages, published on GitHub Pages (https://neuralnomadsai.github.io/CodeNomad/) and opened from the app. - **Content:** 25 pages in 8 sections (Start, Workflows, OpenCode setup, Remote access, Customize, Extensions, Desktop, Help), written for OpenCode power users and covering the current version only. It includes everything user-relevant from `README.md`, `packages/server/README.md` (server, network, auth, TLS, Remote Control) and the user-facing dev-docs, with flags, defaults, labels and safety boundaries checked against source. - **Languages:** en, fr, de, es, he (RTL), ja, ne, ru, tr, zh-Hans. Interface labels reuse each locale's own app strings; native desktop menu names, code, commands, flags, paths and anchors stay unchanged. - **Site:** `scripts/build-help.mjs` renders `docs/help/<locale>/<slug>.md` with translated chrome (`docs/help/i18n/`), stable `{#id}` anchors identical across locales, same-article language links with hreflang, a root language chooser, and a first-party offline per-locale search (Ctrl/⌘+K, substring matching for CJK, Devanagari and Hebrew, hidden without JS). No analytics or remote resources. - **Validation:** `npm run test:help` fails on any missing page or string, heading drift, broken link/anchor/asset or invalid search target. `.github/workflows/help-pages.yml` validates PRs and deploys only from `dev`. - **App entry points:** one `open-help` command for the palette and a new **CodeNomad Help** item at the top of the Electron and Tauri Help menus. It opens the help in the invoking window's UI language through the existing external opener, falls back to the root URL for windows without the handler, and reports failures without replay. - Documents the strict boolean environment-variable rule from #959 and the updater entry points from #960. ## Review Delivered as a CodeNomad mission with independent review loops, each to zero findings: - English content audit (5 passes, 14 fixes) and technical gatekeeper (2 passes) before translation. - After translation: QA, UX review, and a final multilingual gatekeeper (35 translation/label findings fixed, then verified), plus a visual fix for the search focus ring and a verified sync with #960. ## Validation - `npm run test:help`: 9/9 (25 pages × 10 locales, strict, including negative tests); `npm run build:help`: 250 pages. - `node scripts/test-help-browser.mjs`: 44/44; Playwright: 500 page visits at 390 and 1280 px, all locales, no overflow, no external requests. - UI help-command and desktop-updates browser tests 19/19; help URL unit tests; Electron menu tests; UI and Electron typechecks; Tauri `cargo test` 272/272.
215 lines
13 KiB
JavaScript
215 lines
13 KiB
JavaScript
import assert from "node:assert/strict"
|
|
import { before, after, test } from "node:test"
|
|
import { createServer } from "node:http"
|
|
import { mkdtemp, mkdir, readFile, rm, stat } from "node:fs/promises"
|
|
import { tmpdir } from "node:os"
|
|
import { extname, join, relative, resolve } from "node:path"
|
|
import { fileURLToPath } from "node:url"
|
|
import { chromium } from "playwright"
|
|
import { buildHelp, TRANSLATION_PROBLEMS } from "./build-help.mjs"
|
|
|
|
const root = fileURLToPath(new URL("../", import.meta.url))
|
|
const args = process.argv.slice(2)
|
|
const contentIndex = args.indexOf("--content")
|
|
const content = contentIndex === -1 ? resolve(root, "docs/help") : resolve(args[contentIndex + 1])
|
|
if (contentIndex !== -1) args.splice(contentIndex, 2)
|
|
assert.deepEqual(args, [], "only --content <source-directory> is supported")
|
|
const screenshotRoot = join(tmpdir(), "codenomad-help")
|
|
let output, server, browser, base, site, screenshots
|
|
|
|
before(async () => {
|
|
const temporaryRoot = process.platform === "win32" ? join(tmpdir(), "opencode") : tmpdir()
|
|
await mkdir(temporaryRoot, { recursive: true })
|
|
output = await mkdtemp(join(temporaryRoot, "codenomad-help-browser-"))
|
|
await mkdir(screenshotRoot, { recursive: true })
|
|
screenshots = await mkdtemp(join(screenshotRoot, "captures-"))
|
|
const built = await buildHelp({ content, output })
|
|
assert.deepEqual(built.problems.filter((problem) => !TRANSLATION_PROBLEMS.has(problem.kind)), [])
|
|
site = built.site
|
|
const types = { ".html": "text/html; charset=utf-8", ".css": "text/css; charset=utf-8", ".js": "text/javascript; charset=utf-8", ".json": "application/json", ".png": "image/png", ".svg": "image/svg+xml", ".jpg": "image/jpeg", ".webp": "image/webp" }
|
|
server = createServer(async (req, res) => {
|
|
try {
|
|
const url = new URL(req.url, "http://localhost")
|
|
if (!url.pathname.startsWith("/CodeNomad/")) throw new Error("not a project-site path")
|
|
let file = resolve(output, decodeURIComponent(url.pathname.slice("/CodeNomad/".length)))
|
|
if (relative(output, file).startsWith("..")) throw new Error("not a help path")
|
|
if ((await stat(file)).isDirectory()) file = join(file, "index.html")
|
|
res.setHeader("Content-Type", types[extname(file)] ?? "application/octet-stream")
|
|
res.end(await readFile(file))
|
|
} catch {
|
|
res.writeHead(404).end()
|
|
}
|
|
})
|
|
await new Promise((ready) => server.listen(0, "127.0.0.1", ready))
|
|
base = `http://127.0.0.1:${server.address().port}/CodeNomad/`
|
|
browser = await chromium.launch({ executablePath: process.env.CODENOMAD_BROWSER_PATH || undefined })
|
|
})
|
|
after(async () => {
|
|
try { await browser?.close() } finally {
|
|
if (server) await new Promise((done) => server.close(done))
|
|
if (output) await rm(output, { recursive: true, force: true })
|
|
}
|
|
})
|
|
|
|
for (const locale of ["en", "he", "ne", "ja", "zh-Hans"]) for (const colorScheme of ["light", "dark"]) for (const [size, viewport] of [
|
|
["desktop", { width: 1440, height: 1000 }], ["intermediate", { width: 1024, height: 900 }], ["narrow", { width: 800, height: 900 }], ["mobile", { width: 390, height: 844 }],
|
|
]) test(`${locale}: ${size} ${colorScheme} chrome, navigation and keyboard access`, async (ctx) => {
|
|
const page = await browser.newPage({ viewport, colorScheme })
|
|
const errors = [], failedRequests = [], remoteRequests = []
|
|
page.on("pageerror", (error) => errors.push(error.message))
|
|
page.on("requestfailed", (request) => failedRequests.push(request.url()))
|
|
page.on("request", (request) => { if (!request.url().startsWith(base)) remoteRequests.push(request.url()) })
|
|
try {
|
|
await page.goto(`${base}${locale}/`)
|
|
await page.evaluate(() => document.fonts.ready)
|
|
assert.equal(await page.locator("html").getAttribute("dir"), locale === "he" ? "rtl" : "ltr")
|
|
assert.equal(await page.locator(".sidebar a[aria-current=page]").first().getAttribute("href"), "index.html")
|
|
assert.ok(await page.locator("article h1").isVisible())
|
|
assert.equal(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth), true, "no page-wide horizontal overflow")
|
|
assert.equal(await page.locator(".brand").evaluate((element) => getComputedStyle(element).borderRadius), "0px")
|
|
if (size === "mobile") {
|
|
assert.equal(await page.locator('.topics').getAttribute('open'), null)
|
|
assert.equal(await page.locator('.outline details').getAttribute('open'), null)
|
|
assert.ok(await page.locator('.table-scroll').evaluateAll((elements) => elements.some(element => element.scrollWidth > element.clientWidth)), 'wide tables scroll locally')
|
|
assert.ok(await page.locator('pre').evaluateAll((elements) => elements.some(element => element.scrollWidth > element.clientWidth)), 'long code scrolls locally')
|
|
for (const selector of ['#help-query', '.search-controls button']) assert.ok((await page.locator(selector).boundingBox()).height >= 44)
|
|
}
|
|
assert.ok(await page.locator(size === 'desktop' ? '.outline-wide' : '.outline summary').isVisible(), 'outline remains available at every width')
|
|
if (size !== 'mobile') assert.ok(await page.locator('.topics-wide a').first().isVisible(), 'desktop navigation is expanded without JavaScript')
|
|
const sidebar = await page.locator(".sidebar").boundingBox(), main = await page.locator("main").boundingBox()
|
|
if (size === "desktop") assert.ok(locale === "he" ? sidebar.x > main.x : sidebar.x < main.x, "sidebar mirrors in RTL")
|
|
const path = join(screenshots, `${locale}-${size}-${colorScheme}.png`)
|
|
await page.screenshot({ path, fullPage: true })
|
|
ctx.diagnostic(path)
|
|
await page.keyboard.press("Tab")
|
|
assert.equal(await page.locator(":focus").getAttribute("class"), "skip-link")
|
|
await page.keyboard.press("Enter")
|
|
assert.equal(await page.locator(":focus").getAttribute("id"), "content")
|
|
await page.locator('.brand').focus()
|
|
await page.keyboard.press('Control+k')
|
|
assert.equal(await page.locator(':focus').getAttribute('id'), 'help-query')
|
|
for (const selector of ['#help-query', '.search-controls button']) {
|
|
const indicator = await page.locator(selector).evaluate(element => {
|
|
const style = getComputedStyle(element)
|
|
return { visible: element.matches(':focus-visible'), width: style.outlineWidth, style: style.outlineStyle, offset: style.outlineOffset, color: style.outlineColor }
|
|
})
|
|
assert.equal(indicator.visible, true)
|
|
assert.equal(indicator.width, '2px')
|
|
assert.equal(indicator.style, 'solid')
|
|
assert.equal(indicator.offset, '-3px', 'search focus stays inside the control, away from labels and neighbours')
|
|
assert.notEqual(indicator.color, 'rgba(0, 0, 0, 0)')
|
|
if (selector === '#help-query') await page.keyboard.press('Tab')
|
|
}
|
|
await page.keyboard.press('Shift+Tab')
|
|
const term = (await page.locator('article h2').first().innerText()).slice(0, 3)
|
|
await page.locator('#help-query').fill(term)
|
|
const queryBox = await page.locator('#help-query').boundingBox()
|
|
await page.mouse.click(queryBox.x + queryBox.width - 10, queryBox.y + queryBox.height / 2)
|
|
assert.equal(await page.locator('#help-query').inputValue(), term, 'the native right-edge cancel affordance must not clear the query')
|
|
await page.locator('#help-results a').first().waitFor()
|
|
assert.ok((await page.locator('#help-results a').count()) > 0, 'substring search supports non-Latin languages')
|
|
await page.keyboard.press('ArrowUp')
|
|
assert.equal(await page.locator('#help-results a[data-selected]').getAttribute('href'), await page.locator('#help-results a').last().getAttribute('href'), 'Up starts at the final result')
|
|
await page.keyboard.press('ArrowDown')
|
|
assert.equal(await page.locator('#help-results a[data-selected]').count(), 1)
|
|
const searchPath = join(screenshots, `${locale}-${size}-${colorScheme}-search.png`)
|
|
await page.screenshot({ path: searchPath, fullPage: true })
|
|
assert.equal(await page.locator('#help-query').evaluate((element) => {
|
|
const event = new KeyboardEvent('keydown', { key: 'ArrowDown', isComposing: true, bubbles: true, cancelable: true })
|
|
element.dispatchEvent(event); return event.defaultPrevented
|
|
}), false, 'composition is not intercepted')
|
|
assert.equal(await page.evaluate(() => {
|
|
const event = new KeyboardEvent('keydown', { key: 'f', ctrlKey: true, bubbles: true, cancelable: true })
|
|
document.dispatchEvent(event); return event.defaultPrevented
|
|
}), false, 'browser find is unchanged')
|
|
await page.keyboard.press('Escape')
|
|
assert.equal(await page.locator(':focus').getAttribute('class'), 'brand')
|
|
assert.equal(await page.locator('.search-results').isVisible(), false)
|
|
await page.keyboard.press('Control+k')
|
|
await page.locator('#help-query').fill('zzzz-no-result-123')
|
|
assert.equal(await page.locator('#help-results a').count(), 0)
|
|
assert.ok((await page.locator('.search-status').innerText()).includes('zzzz-no-result-123'))
|
|
await page.screenshot({ path: join(screenshots, `${locale}-${size}-${colorScheme}-no-results.png`), fullPage: true })
|
|
await page.locator('.search-controls button').click()
|
|
assert.equal(await page.locator('#help-query').inputValue(), '')
|
|
await page.locator('#help-query').fill(term)
|
|
await page.locator('#help-results a').first().waitFor()
|
|
const target = await page.locator('#help-results a').first().getAttribute('href')
|
|
await page.keyboard.press('ArrowDown')
|
|
await page.keyboard.press('Enter')
|
|
await page.waitForURL(`${base}${locale}/${target}`)
|
|
await page.goto(`${base}${locale}/`)
|
|
await page.keyboard.press('Escape')
|
|
await page.locator(".language-menu summary").click()
|
|
assert.equal(await page.locator(".language-menu a").count(), site.locales.length)
|
|
await page.locator('.language-menu a[hreflang="fr"]').click()
|
|
assert.equal(page.url(), `${base}fr/index.html`)
|
|
assert.equal(await page.locator("html").getAttribute("lang"), "fr")
|
|
assert.deepEqual(errors, [])
|
|
assert.deepEqual(failedRequests, [])
|
|
assert.deepEqual(remoteRequests, [], "no third-party runtime requests")
|
|
} finally { await page.close() }
|
|
})
|
|
|
|
test("all English articles fit mobile and desktop widths", async (ctx) => {
|
|
const page = await browser.newPage({ colorScheme: "light" })
|
|
try {
|
|
for (const width of [390, 1440]) {
|
|
await page.setViewportSize({ width, height: 900 })
|
|
for (const slug of site.sections.flatMap(section => section.pages)) {
|
|
await page.goto(`${base}en/${slug}.html`)
|
|
assert.equal(await page.locator("article h1").count(), 1, slug)
|
|
assert.ok(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth), `${slug} overflows at ${width}px`)
|
|
if (slug === "server" && width === 390 || slug === "missions" && width === 1440) {
|
|
const path = join(screenshots, `${slug}-${width}.png`)
|
|
await page.screenshot({ path, fullPage: true })
|
|
ctx.diagnostic(path)
|
|
}
|
|
}
|
|
}
|
|
} finally { await page.close() }
|
|
})
|
|
|
|
test("root redirects to browser language with regional Chinese and legacy Hebrew matching", async () => {
|
|
for (const [language, expected] of [["fr-CA", "fr"], ["zh-TW", "zh-Hans"], ["iw-IL", "he"]]) {
|
|
const context = await browser.newContext()
|
|
await context.addInitScript(`Object.defineProperty(navigator, "languages", { value: [${JSON.stringify(language)}] })`)
|
|
try {
|
|
const page = await context.newPage()
|
|
await page.goto(base)
|
|
await page.waitForURL(`${base}${expected}/`)
|
|
assert.equal(await page.locator("html").getAttribute("lang"), expected)
|
|
} finally { await context.close() }
|
|
}
|
|
})
|
|
|
|
test("root language links and page navigation work without JavaScript", async (ctx) => {
|
|
const context = await browser.newContext({ javaScriptEnabled: false, viewport: { width: 390, height: 844 } })
|
|
try {
|
|
const page = await context.newPage()
|
|
await page.goto(base)
|
|
assert.equal(await page.locator(".language-list a").count(), site.locales.length)
|
|
const path = join(screenshots, "language-chooser-mobile.png")
|
|
await page.screenshot({ path, fullPage: true })
|
|
ctx.diagnostic(path)
|
|
await page.locator('a[hreflang="he"]').click()
|
|
assert.equal(page.url(), `${base}he/`)
|
|
assert.ok(await page.locator("article h1").isVisible())
|
|
assert.equal(await page.locator('.help-search').isVisible(), false)
|
|
await page.locator(".language-menu summary").click()
|
|
await page.locator('.language-menu a[hreflang="en"]').click()
|
|
assert.equal(page.url(), `${base}en/index.html`)
|
|
} finally { await context.close() }
|
|
})
|
|
|
|
test('search failure is actionable and navigation remains usable', async () => {
|
|
const page = await browser.newPage()
|
|
try {
|
|
await page.route('**/search-index.json', (route) => route.fulfill({ status: 503, body: '' }))
|
|
await page.goto(`${base}en/`)
|
|
await page.locator('#help-query').focus()
|
|
await page.locator('#help-query').fill('sessions')
|
|
await page.waitForFunction(() => document.querySelector('.search-status').textContent.includes('Reload'))
|
|
assert.ok(await page.locator('.topics-wide a').first().isVisible())
|
|
} finally { await page.close() }
|
|
})
|