From 1be20af0e1d55f34d15c107c16193c6ee8d53252 Mon Sep 17 00:00:00 2001 From: steve02081504 Date: Sat, 1 Aug 2026 18:54:02 +0800 Subject: [PATCH 01/11] 1 --- src/public/pages/AGENTS.md | 2 + .../pages/scripts/components/jsonEditor.mjs | 32 ++++++++++---- src/public/pages/scripts/test/test_watch.mjs | 10 ++++- .../parts/shells/config/public/index.html | 2 +- .../parts/shells/config/public/index.mjs | 8 ++-- .../shells/config/test/frontend/fixtures.mjs | 8 ++++ .../config/test/frontend/jsonEditor.spec.mjs | 20 +++++++++ .../shells/config/test/frontend/phases.mjs | 6 +++ .../test/frontend/playwright.config.mjs | 8 ++++ .../parts/shells/config/test/frontend/run.mjs | 18 ++++++++ .../parts/shells/config/test/manifest.json | 42 +++++++++++++++++++ .../shells/config/test/node_bootstrap.mjs | 7 ++++ .../parts/shells/discordbot/public/index.mjs | 8 ++-- .../test/frontend/jsonEditor.spec.mjs | 20 +++++++++ .../discordbot/test/frontend/phases.mjs | 1 + .../shells/discordbot/test/manifest.json | 13 +++++- .../serviceSourceManage/public/index.html | 3 +- .../serviceSourceManage/public/index.mjs | 8 ++-- .../test/frontend/fixtures.mjs | 8 ++++ .../test/frontend/jsonEditor.spec.mjs | 20 +++++++++ .../test/frontend/phases.mjs | 6 +++ .../test/frontend/playwright.config.mjs | 8 ++++ .../serviceSourceManage/test/frontend/run.mjs | 18 ++++++++ .../serviceSourceManage/test/manifest.json | 42 +++++++++++++++++++ .../test/node_bootstrap.mjs | 7 ++++ .../parts/shells/telegrambot/public/index.mjs | 8 ++-- .../test/frontend/jsonEditor.spec.mjs | 20 +++++++++ .../telegrambot/test/frontend/phases.mjs | 1 + .../shells/telegrambot/test/manifest.json | 13 +++++- .../parts/shells/wechatbot/public/index.mjs | 9 ++-- .../test/frontend/jsonEditor.spec.mjs | 20 +++++++++ .../shells/wechatbot/test/frontend/phases.mjs | 1 + .../parts/shells/wechatbot/test/manifest.json | 13 +++++- src/scripts/test/docs/playwright.md | 6 ++- src/scripts/test/playwright/json_editor.mjs | 30 +++++++++++++ 35 files changed, 409 insertions(+), 37 deletions(-) create mode 100644 src/public/parts/shells/config/test/frontend/fixtures.mjs create mode 100644 src/public/parts/shells/config/test/frontend/jsonEditor.spec.mjs create mode 100644 src/public/parts/shells/config/test/frontend/phases.mjs create mode 100644 src/public/parts/shells/config/test/frontend/playwright.config.mjs create mode 100644 src/public/parts/shells/config/test/frontend/run.mjs create mode 100644 src/public/parts/shells/config/test/manifest.json create mode 100644 src/public/parts/shells/config/test/node_bootstrap.mjs create mode 100644 src/public/parts/shells/discordbot/test/frontend/jsonEditor.spec.mjs create mode 100644 src/public/parts/shells/serviceSourceManage/test/frontend/fixtures.mjs create mode 100644 src/public/parts/shells/serviceSourceManage/test/frontend/jsonEditor.spec.mjs create mode 100644 src/public/parts/shells/serviceSourceManage/test/frontend/phases.mjs create mode 100644 src/public/parts/shells/serviceSourceManage/test/frontend/playwright.config.mjs create mode 100644 src/public/parts/shells/serviceSourceManage/test/frontend/run.mjs create mode 100644 src/public/parts/shells/serviceSourceManage/test/manifest.json create mode 100644 src/public/parts/shells/serviceSourceManage/test/node_bootstrap.mjs create mode 100644 src/public/parts/shells/telegrambot/test/frontend/jsonEditor.spec.mjs create mode 100644 src/public/parts/shells/wechatbot/test/frontend/jsonEditor.spec.mjs create mode 100644 src/scripts/test/playwright/json_editor.mjs diff --git a/src/public/pages/AGENTS.md b/src/public/pages/AGENTS.md index 1d58d638a..51d397de2 100644 --- a/src/public/pages/AGENTS.md +++ b/src/public/pages/AGENTS.md @@ -25,6 +25,7 @@ Markdown convertor traps (rehype order, `{:lang}`, trust tiers): [markdown-notes - **`template.mjs`**: `renderTemplate` / `mountTemplate` / `renderTemplateAsHtmlString` / `withTemplates(path, fn)`. Cross-shell shared modules must **not** call bare `usingTemplates` — use `withTemplates` or direct DOM. - **`dialog.mjs`**: `openDialogFromTemplate` / `pickFromDialog`. Templates supply `modal-box` (+ optional `modal-backdrop`) only — do not nest another ``. - **`promptDialog.mjs`**: shared DaisyUI `promptText` / `promptTextArea` / `confirmAction`. Prefer over `window.prompt` / `confirm` / shell-local copies. **First argument is always an i18n key**; optional third arg is interpolation params. Do not pass `geti18n(...)` strings. Modal title is **`h2`** (page already has `h1`; DaisyUI docs’ `h3` skips a level and trips axe `heading-order`). +- **`components/jsonEditor.mjs`**: `createJsonEditor(container, options)` wraps `vanilla-jsoneditor` (≥3.13). **`options.ariaLabel` is required and must be an i18n key** (resolved + refreshed via `setLocalizeLogic`); do not pass `geti18n(...)` strings. Optional `onSave` is Ctrl+S only — not a library prop. - **`components/positionContextMenu.mjs`** + **`components/contextMenuDismiss.mjs`**: shared floating-menu placement / dismiss. - **`contentReveal/`**: `wrapSensitiveMediaHtml`, `wrapContentWarningHtml`, `bindContentReveal`. - **`translate.mjs`**: `mountTranslationBlock`, `requestTranslation`, `resolveTargetLang` (-> `primaryLocale()`). @@ -50,6 +51,7 @@ Markdown convertor traps (rehype order, `{:lang}`, trust tiers): [markdown-notes - **`lib/base64.mjs`**: `arrayBufferToBase64` / `blobToBase64` — reuse for upload bodies; do not copy per shell. - **`lib/svgInliner.mjs`**: Inline `.svg` `` for `currentColor`. Put `svg-inliner-ignore` on user/media avatars so they stay `` (inlining untrusted SVG activates scripts). - **`user-content`**: boolean attr on user/dynamic text & inputs (post/reply bodies, message bubbles, composer fields, …). `test_watch` locale-script scan hides `[user-content]` — same opt-out style as `svg-inliner-ignore`. +- **`aria-ignore`**: boolean attr; `test_watch` axe `exclude` (`ARIA_IGNORE`). Third-party / temporarily unfixable subtrees only — not a license to leave our own chrome broken. JSON editor sets it pending [svelte-jsoneditor#584](https://github.com/josdejong/svelte-jsoneditor/issues/584). - **Transitional a11y**: `test_watch` reports axe hits immediately (no confirm buffer). Incomplete / not-yet-shown UI must use `aria-hidden` / `inert` / `hidden` (not bare `opacity: 0`); text updates should be atomic. Fix the product — do not soften the watcher. - **`credentialManager.mjs`** / **`host/urlDataTransfer.mjs`**: Secure credential encryption/transfer; Catbox upload/download via `host/catbox.mjs`. diff --git a/src/public/pages/scripts/components/jsonEditor.mjs b/src/public/pages/scripts/components/jsonEditor.mjs index f5450fc5e..df0ce9409 100644 --- a/src/public/pages/scripts/components/jsonEditor.mjs +++ b/src/public/pages/scripts/components/jsonEditor.mjs @@ -1,29 +1,43 @@ -import { createJSONEditor as base } from 'https://cdn.jsdelivr.net/npm/vanilla-jsoneditor@2/standalone.js' +import { createJSONEditor as base } from 'https://cdn.jsdelivr.net/npm/vanilla-jsoneditor@3/standalone.js' +import { geti18n, setLocalizeLogic } from '../i18n/index.mjs' import { onThemeChange } from '../theme/index.mjs' /** * 创建一个 JSON 编辑器。 * @param {HTMLElement} jsonEditorContainer - JSON 编辑器的容器元素。 - * @param {object} options - 选项。 - * @returns {import('vanilla-jsoneditor').JSONEditor} JSON 编辑器实例。 + * @param {object} options - 选项(其余字段透传给 vanilla-jsoneditor)。 + * @param {string} options.ariaLabel - 编辑器 `aria-label` 的 i18n key。 + * @param {(json: unknown) => void} [options.onSave] - Ctrl+S 保存回调。 + * @returns {import('npm:vanilla-jsoneditor').JSONEditor} JSON 编辑器实例。 */ export function createJsonEditor(jsonEditorContainer, options) { + const { ariaLabel: ariaLabelKey, onSave, ...editorProps } = options + + // TODO: drop aria-ignore when https://github.com/josdejong/svelte-jsoneditor/issues/584 lands + jsonEditorContainer.toggleAttribute('aria-ignore', true) + const result = base({ target: jsonEditorContainer, props: { - mode: 'code', + mode: 'text', indentation: '\t', - ...options + ...editorProps, + ariaLabel: geti18n(ariaLabelKey), } }) - // ctrl+s 保存 - document.addEventListener('keydown', e => { + + setLocalizeLogic(jsonEditorContainer, () => { + result.updateProps({ ariaLabel: geti18n(ariaLabelKey) }) + }) + + if (onSave) document.addEventListener('keydown', e => { if (e.ctrlKey && e.key === 's') { - options.onSave(result.get().json || JSON.parse(result.get().text)) + onSave(result.get().json || JSON.parse(result.get().text)) e.preventDefault() } }) + onThemeChange( (theme, isDark) => { if (isDark) jsonEditorContainer.classList.add('jse-theme-dark') @@ -52,7 +66,7 @@ export function createJsonEditor(jsonEditorContainer, options) { { const jse_style = document.createElement('link') jse_style.rel = 'stylesheet' - jse_style.href = 'https://cdn.jsdelivr.net/npm/vanilla-jsoneditor@2/themes/jse-theme-dark.min.css' + jse_style.href = 'https://cdn.jsdelivr.net/npm/vanilla-jsoneditor@3/themes/jse-theme-dark.css' jse_style.crossorigin = 'anonymous' document.head.prepend(jse_style) } diff --git a/src/public/pages/scripts/test/test_watch.mjs b/src/public/pages/scripts/test/test_watch.mjs index f1123d039..7a8a7fb1e 100644 --- a/src/public/pages/scripts/test/test_watch.mjs +++ b/src/public/pages/scripts/test/test_watch.mjs @@ -1,6 +1,7 @@ /** * 测试环境页面监视:import 即启动(无导出)。 * - axe-core 无障碍:真实 DOM 变脏时每 0.5s 扫,静止则停;命中即报 + * - `[aria-ignore]`:axe `exclude`(第三方 / 暂不可修;`ARIA_IGNORE`,同 `svg-inliner-ignore` 风格) * - 语种轮换:每秒在 zh-CN / ja-JP / en-UK 间切换,并用 `\p{Script=…}` 查错语字符 * (轮换自身的 DOM 写入不计入 a11y dirty,否则扫描定时器永远停不下来) * @@ -14,6 +15,11 @@ */ import axe from 'https://esm.sh/axe-core' +/** + * 第三方 / 暂不可修子树:axe `exclude`(布尔属性,用法同 `svg-inliner-ignore` / `user-content`)。 + */ +export const ARIA_IGNORE = 'aria-ignore' + const A11Y_PREFIX = '[test:a11y]' const LOCALE_PREFIX = '[test:locale]' /** DOM 仍在变时的 a11y 扫描间隔 */ @@ -211,7 +217,9 @@ async function runLocaleScriptCheck(locale) { */ async function runA11y() { if (!localeReady) return - const results = await axe.run(document, { + const results = await axe.run({ + exclude: `[${ARIA_IGNORE}]`, + }, { resultTypes: ['violations'], iframes: false, // 对比度 / 仅靠颜色区分链接会逼改视觉层级,不纳入硬失败 diff --git a/src/public/parts/shells/config/public/index.html b/src/public/parts/shells/config/public/index.html index 3adb58480..d9df499f3 100644 --- a/src/public/parts/shells/config/public/index.html +++ b/src/public/parts/shells/config/public/index.html @@ -35,7 +35,7 @@

- + diff --git a/src/public/parts/shells/config/public/index.mjs b/src/public/parts/shells/config/public/index.mjs index ca866de04..f9805778a 100644 --- a/src/public/parts/shells/config/public/index.mjs +++ b/src/public/parts/shells/config/public/index.mjs @@ -104,18 +104,18 @@ async function loadEditor(partpath) { if (!jsonEditor) jsonEditor = createJsonEditor(jsonEditorContainer, { - label: geti18n('part_config.editor.jsonEditor'), + ariaLabel: 'part_config.editor.jsonEditor.aria-label', readOnly: true, /** * JSON 更新时的回调函数。 * @param {any} updatedContent - 更新后的内容。 * @param {any} previousContent - 之前的内容。 * @param {object} root0 - 根对象。 - * @param {any} root0.error - 错误。 + * @param {any} root0.contentErrors - 内容错误。 * @param {any} root0.patchResult - 补丁结果。 */ - onChange: (updatedContent, previousContent, { error, patchResult }) => { - if (error) return + onChange: (updatedContent, previousContent, { contentErrors, patchResult }) => { + if (contentErrors) return isDirty = true let data try { data = getEditorData() } catch (e) { return } diff --git a/src/public/parts/shells/config/test/frontend/fixtures.mjs b/src/public/parts/shells/config/test/frontend/fixtures.mjs new file mode 100644 index 000000000..4aa9ec7bc --- /dev/null +++ b/src/public/parts/shells/config/test/frontend/fixtures.mjs @@ -0,0 +1,8 @@ +import { ms } from 'fount/scripts/ms.mjs' +import { createFountFixtures } from 'fount/scripts/test/playwright/fixtures.mjs' + +/** Config 前端 E2E fixture(隔离节点)。 */ +export const { test, expect } = createFountFixtures({ + locale: 'zh-CN', + isolated: { shellLabel: 'Config', timeout: ms('3m') }, +}) diff --git a/src/public/parts/shells/config/test/frontend/jsonEditor.spec.mjs b/src/public/parts/shells/config/test/frontend/jsonEditor.spec.mjs new file mode 100644 index 000000000..5a4abafdd --- /dev/null +++ b/src/public/parts/shells/config/test/frontend/jsonEditor.spec.mjs @@ -0,0 +1,20 @@ +/** + * Config shell JSON 编辑器 aria-label。 + */ +import { expectJsonEditorAriaLabel } from 'fount/scripts/test/playwright/json_editor.mjs' + +import { test, expect } from './fixtures.mjs' + +test.describe('Config JSON editor a11y', () => { + test('editor mounts with i18n aria-label when partpath is set', async ({ page, baseUrl }) => { + await page.goto(`${baseUrl}/parts/shells:config/?partpath=shells/config`, { + waitUntil: 'domcontentloaded', + }) + await expectJsonEditorAriaLabel( + page, + '#jsonEditor', + 'part_config.editor.jsonEditor.aria-label', + expect, + ) + }) +}) diff --git a/src/public/parts/shells/config/test/frontend/phases.mjs b/src/public/parts/shells/config/test/frontend/phases.mjs new file mode 100644 index 000000000..f6ba96b14 --- /dev/null +++ b/src/public/parts/shells/config/test/frontend/phases.mjs @@ -0,0 +1,6 @@ +/** + * @type {Array<{ name: string, testMatch: string[] }>} + */ +export const phases = [ + { name: 'jsonEditor', testMatch: ['jsonEditor.spec.mjs'] }, +] diff --git a/src/public/parts/shells/config/test/frontend/playwright.config.mjs b/src/public/parts/shells/config/test/frontend/playwright.config.mjs new file mode 100644 index 000000000..4c1fe9a40 --- /dev/null +++ b/src/public/parts/shells/config/test/frontend/playwright.config.mjs @@ -0,0 +1,8 @@ +import { createPhasedPlaywrightConfig } from 'fount/scripts/test/playwright/config.mjs' + +import { phases } from './phases.mjs' + +/** + * Config 前端 Playwright 配置。 + */ +export default await createPhasedPlaywrightConfig(import.meta.url, phases, { timeout: 120_000 }) diff --git a/src/public/parts/shells/config/test/frontend/run.mjs b/src/public/parts/shells/config/test/frontend/run.mjs new file mode 100644 index 000000000..fdc2fad73 --- /dev/null +++ b/src/public/parts/shells/config/test/frontend/run.mjs @@ -0,0 +1,18 @@ +/** + * Config 前端 Playwright driver。 + */ +import { dirname, join } from 'node:path' +import process from 'node:process' +import { fileURLToPath } from 'node:url' + +import { runShellFrontendTests } from 'fount/scripts/test/playwright/shell_frontend.mjs' + +const testDir = dirname(fileURLToPath(import.meta.url)) + +process.exit(await runShellFrontendTests({ + configPath: join(testDir, 'playwright.config.mjs'), + testUsername: 'config-fe-user', + apiKeyPrefix: 'fount-config-fe-key', + loadParts: ['shells/config'], + bootstrapPath: join(testDir, '../node_bootstrap.mjs'), +})) diff --git a/src/public/parts/shells/config/test/manifest.json b/src/public/parts/shells/config/test/manifest.json new file mode 100644 index 000000000..7e4a6339d --- /dev/null +++ b/src/public/parts/shells/config/test/manifest.json @@ -0,0 +1,42 @@ +{ + "id": "shells/config", + "triggerSets": { + "frontendShared": [ + "src/public/parts/shells/config/public/**", + "src/public/parts/shells/config/test/frontend/**", + "src/scripts/test/playwright/**", + "src/public/pages/scripts/test/**", + "src/public/pages/scripts/components/jsonEditor.mjs" + ] + }, + "suites": [ + { + "name": "frontend", + "dependsOn": [ + "server:live" + ], + "trigger": "frontendShared", + "subtests": [ + { + "name": "jsonEditor", + "triggers": [ + "src/public/parts/shells/config/test/frontend/jsonEditor.spec.mjs", + "src/public/parts/shells/config/test/frontend/fixtures.mjs", + "src/public/parts/shells/config/public/index.{html,mjs}", + "src/public/pages/scripts/components/jsonEditor.mjs", + "src/scripts/test/playwright/json_editor.mjs" + ] + } + ], + "run": [ + "deno", + "run", + "--allow-scripts", + "--allow-all", + "-c", + "./deno.json", + "./src/public/parts/shells/config/test/frontend/run.mjs" + ] + } + ] +} diff --git a/src/public/parts/shells/config/test/node_bootstrap.mjs b/src/public/parts/shells/config/test/node_bootstrap.mjs new file mode 100644 index 000000000..e04bad4d8 --- /dev/null +++ b/src/public/parts/shells/config/test/node_bootstrap.mjs @@ -0,0 +1,7 @@ +/** + * Config shell live / 前端测试节点 bootstrap。 + * @param {string} username 测试用户名 + */ +export default async function bootstrap(username) { + void username +} diff --git a/src/public/parts/shells/discordbot/public/index.mjs b/src/public/parts/shells/discordbot/public/index.mjs index 4783322f5..3f0aa5a7f 100644 --- a/src/public/parts/shells/discordbot/public/index.mjs +++ b/src/public/parts/shells/discordbot/public/index.mjs @@ -129,17 +129,17 @@ async function loadBotConfig(botname) { if (!configEditor) configEditor = createJsonEditor(configEditorContainer, { - label: geti18n('discord_bots.configCard.labels.config'), + ariaLabel: 'discord_bots.configCard.jsonEditor.aria-label', /** * JSON 更新时的回调函数。 * @param {any} updatedContent - 更新后的内容。 * @param {any} previousContent - 之前的内容。 * @param {object} root0 - 根对象。 - * @param {any} root0.error - 错误。 + * @param {any} root0.contentErrors - 内容错误。 * @param {any} root0.patchResult - 补丁结果。 */ - onChange: (updatedContent, previousContent, { error, patchResult }) => { - if (!error) isDirty = true + onChange: (updatedContent, previousContent, { contentErrors, patchResult }) => { + if (!contentErrors) isDirty = true }, onSave: handleSaveConfig }) diff --git a/src/public/parts/shells/discordbot/test/frontend/jsonEditor.spec.mjs b/src/public/parts/shells/discordbot/test/frontend/jsonEditor.spec.mjs new file mode 100644 index 000000000..9e4ffab42 --- /dev/null +++ b/src/public/parts/shells/discordbot/test/frontend/jsonEditor.spec.mjs @@ -0,0 +1,20 @@ +/** + * Discord bot JSON 编辑器 aria-label。 + */ +import { expectJsonEditorAriaLabel } from 'fount/scripts/test/playwright/json_editor.mjs' + +import { test, expect } from './fixtures.mjs' + +test.describe('Discord bot JSON editor a11y', () => { + test('config editor mounts with i18n aria-label', async ({ page, baseUrl }) => { + await page.goto(`${baseUrl}/parts/shells:discordbot/?name=aria-json-editor`, { + waitUntil: 'domcontentloaded', + }) + await expectJsonEditorAriaLabel( + page, + '#config-editor', + 'discord_bots.configCard.jsonEditor.aria-label', + expect, + ) + }) +}) diff --git a/src/public/parts/shells/discordbot/test/frontend/phases.mjs b/src/public/parts/shells/discordbot/test/frontend/phases.mjs index c2029fee3..7a6b0eb72 100644 --- a/src/public/parts/shells/discordbot/test/frontend/phases.mjs +++ b/src/public/parts/shells/discordbot/test/frontend/phases.mjs @@ -3,4 +3,5 @@ */ export const phases = [ { name: 'smoke', testMatch: ['smoke.spec.mjs'] }, + { name: 'jsonEditor', testMatch: ['jsonEditor.spec.mjs'] }, ] diff --git a/src/public/parts/shells/discordbot/test/manifest.json b/src/public/parts/shells/discordbot/test/manifest.json index 6a081d51f..d0a227cf6 100644 --- a/src/public/parts/shells/discordbot/test/manifest.json +++ b/src/public/parts/shells/discordbot/test/manifest.json @@ -20,7 +20,8 @@ "src/public/parts/shells/discordbot/public/**", "src/public/parts/shells/discordbot/test/frontend/**", "src/scripts/test/playwright/**", - "src/public/pages/scripts/test/**" + "src/public/pages/scripts/test/**", + "src/public/pages/scripts/components/jsonEditor.mjs" ] }, "suites": [ @@ -72,6 +73,16 @@ "src/public/parts/shells/discordbot/test/frontend/fixtures.mjs", "src/public/parts/shells/discordbot/public/index.{html,mjs}" ] + }, + { + "name": "jsonEditor", + "triggers": [ + "src/public/parts/shells/discordbot/test/frontend/jsonEditor.spec.mjs", + "src/public/parts/shells/discordbot/test/frontend/fixtures.mjs", + "src/public/parts/shells/discordbot/public/index.{html,mjs}", + "src/public/pages/scripts/components/jsonEditor.mjs", + "src/scripts/test/playwright/json_editor.mjs" + ] } ], "run": [ diff --git a/src/public/parts/shells/serviceSourceManage/public/index.html b/src/public/parts/shells/serviceSourceManage/public/index.html index 483ff9b3a..54942c0b6 100644 --- a/src/public/parts/shells/serviceSourceManage/public/index.html +++ b/src/public/parts/shells/serviceSourceManage/public/index.html @@ -23,7 +23,8 @@ -
+
+

diff --git a/src/public/parts/shells/serviceSourceManage/public/index.mjs b/src/public/parts/shells/serviceSourceManage/public/index.mjs index a71c0ad89..d185d9c2c 100644 --- a/src/public/parts/shells/serviceSourceManage/public/index.mjs +++ b/src/public/parts/shells/serviceSourceManage/public/index.mjs @@ -615,17 +615,17 @@ await initTranslations('serviceSource_manager') usingTemplates('/parts/shells:serviceSourceManage/src/templates') jsonEditor = createJsonEditor(jsonEditorContainer, { - label: geti18n('serviceSource_manager.configTitle'), + ariaLabel: 'serviceSource_manager.jsonEditor.aria-label', /** * 处理 JSON 更新。 * @param {any} updatedContent - 更新后的内容。 * @param {any} previousContent - 之前的内容。 * @param {object} root0 - 根对象。 - * @param {any} root0.error - 错误。 + * @param {any} root0.contentErrors - 内容错误。 * @param {any} root0.patchResult - 补丁结果。 */ - onChange: (updatedContent, previousContent, { error, patchResult }) => { - if (error) return + onChange: (updatedContent, previousContent, { contentErrors, patchResult }) => { + if (contentErrors) return isDirty = true let data try { data = getEditorData() } catch (e) { return } diff --git a/src/public/parts/shells/serviceSourceManage/test/frontend/fixtures.mjs b/src/public/parts/shells/serviceSourceManage/test/frontend/fixtures.mjs new file mode 100644 index 000000000..8647e3d2d --- /dev/null +++ b/src/public/parts/shells/serviceSourceManage/test/frontend/fixtures.mjs @@ -0,0 +1,8 @@ +import { ms } from 'fount/scripts/ms.mjs' +import { createFountFixtures } from 'fount/scripts/test/playwright/fixtures.mjs' + +/** Service source manager 前端 E2E fixture(隔离节点)。 */ +export const { test, expect } = createFountFixtures({ + locale: 'zh-CN', + isolated: { shellLabel: 'ServiceSourceManage', timeout: ms('3m') }, +}) diff --git a/src/public/parts/shells/serviceSourceManage/test/frontend/jsonEditor.spec.mjs b/src/public/parts/shells/serviceSourceManage/test/frontend/jsonEditor.spec.mjs new file mode 100644 index 000000000..3f071e761 --- /dev/null +++ b/src/public/parts/shells/serviceSourceManage/test/frontend/jsonEditor.spec.mjs @@ -0,0 +1,20 @@ +/** + * Service source manager JSON 编辑器 aria-label。 + */ +import { expectJsonEditorAriaLabel } from 'fount/scripts/test/playwright/json_editor.mjs' + +import { test, expect } from './fixtures.mjs' + +test.describe('Service source manager JSON editor a11y', () => { + test('editor mounts with i18n aria-label on page load', async ({ page, baseUrl }) => { + await page.goto(`${baseUrl}/parts/shells:serviceSourceManage/`, { + waitUntil: 'domcontentloaded', + }) + await expectJsonEditorAriaLabel( + page, + '#jsonEditor', + 'serviceSource_manager.jsonEditor.aria-label', + expect, + ) + }) +}) diff --git a/src/public/parts/shells/serviceSourceManage/test/frontend/phases.mjs b/src/public/parts/shells/serviceSourceManage/test/frontend/phases.mjs new file mode 100644 index 000000000..f6ba96b14 --- /dev/null +++ b/src/public/parts/shells/serviceSourceManage/test/frontend/phases.mjs @@ -0,0 +1,6 @@ +/** + * @type {Array<{ name: string, testMatch: string[] }>} + */ +export const phases = [ + { name: 'jsonEditor', testMatch: ['jsonEditor.spec.mjs'] }, +] diff --git a/src/public/parts/shells/serviceSourceManage/test/frontend/playwright.config.mjs b/src/public/parts/shells/serviceSourceManage/test/frontend/playwright.config.mjs new file mode 100644 index 000000000..8857cd0e1 --- /dev/null +++ b/src/public/parts/shells/serviceSourceManage/test/frontend/playwright.config.mjs @@ -0,0 +1,8 @@ +import { createPhasedPlaywrightConfig } from 'fount/scripts/test/playwright/config.mjs' + +import { phases } from './phases.mjs' + +/** + * Service source manager 前端 Playwright 配置。 + */ +export default await createPhasedPlaywrightConfig(import.meta.url, phases, { timeout: 120_000 }) diff --git a/src/public/parts/shells/serviceSourceManage/test/frontend/run.mjs b/src/public/parts/shells/serviceSourceManage/test/frontend/run.mjs new file mode 100644 index 000000000..0b4d6824f --- /dev/null +++ b/src/public/parts/shells/serviceSourceManage/test/frontend/run.mjs @@ -0,0 +1,18 @@ +/** + * Service source manager 前端 Playwright driver。 + */ +import { dirname, join } from 'node:path' +import process from 'node:process' +import { fileURLToPath } from 'node:url' + +import { runShellFrontendTests } from 'fount/scripts/test/playwright/shell_frontend.mjs' + +const testDir = dirname(fileURLToPath(import.meta.url)) + +process.exit(await runShellFrontendTests({ + configPath: join(testDir, 'playwright.config.mjs'), + testUsername: 'ssrc-fe-user', + apiKeyPrefix: 'fount-ssrc-fe-key', + loadParts: ['shells/serviceSourceManage'], + bootstrapPath: join(testDir, '../node_bootstrap.mjs'), +})) diff --git a/src/public/parts/shells/serviceSourceManage/test/manifest.json b/src/public/parts/shells/serviceSourceManage/test/manifest.json new file mode 100644 index 000000000..79c7d016a --- /dev/null +++ b/src/public/parts/shells/serviceSourceManage/test/manifest.json @@ -0,0 +1,42 @@ +{ + "id": "shells/serviceSourceManage", + "triggerSets": { + "frontendShared": [ + "src/public/parts/shells/serviceSourceManage/public/**", + "src/public/parts/shells/serviceSourceManage/test/frontend/**", + "src/scripts/test/playwright/**", + "src/public/pages/scripts/test/**", + "src/public/pages/scripts/components/jsonEditor.mjs" + ] + }, + "suites": [ + { + "name": "frontend", + "dependsOn": [ + "server:live" + ], + "trigger": "frontendShared", + "subtests": [ + { + "name": "jsonEditor", + "triggers": [ + "src/public/parts/shells/serviceSourceManage/test/frontend/jsonEditor.spec.mjs", + "src/public/parts/shells/serviceSourceManage/test/frontend/fixtures.mjs", + "src/public/parts/shells/serviceSourceManage/public/index.{html,mjs}", + "src/public/pages/scripts/components/jsonEditor.mjs", + "src/scripts/test/playwright/json_editor.mjs" + ] + } + ], + "run": [ + "deno", + "run", + "--allow-scripts", + "--allow-all", + "-c", + "./deno.json", + "./src/public/parts/shells/serviceSourceManage/test/frontend/run.mjs" + ] + } + ] +} diff --git a/src/public/parts/shells/serviceSourceManage/test/node_bootstrap.mjs b/src/public/parts/shells/serviceSourceManage/test/node_bootstrap.mjs new file mode 100644 index 000000000..c87dab37d --- /dev/null +++ b/src/public/parts/shells/serviceSourceManage/test/node_bootstrap.mjs @@ -0,0 +1,7 @@ +/** + * Service source manager live / 前端测试节点 bootstrap。 + * @param {string} username 测试用户名 + */ +export default async function bootstrap(username) { + void username +} diff --git a/src/public/parts/shells/telegrambot/public/index.mjs b/src/public/parts/shells/telegrambot/public/index.mjs index fea41eda1..a1df59ab5 100644 --- a/src/public/parts/shells/telegrambot/public/index.mjs +++ b/src/public/parts/shells/telegrambot/public/index.mjs @@ -127,19 +127,19 @@ async function loadBotConfig(botname) { if (!configEditor) configEditor = createJsonEditor(configEditorContainer, { + ariaLabel: 'telegram_bots.configCard.jsonEditor.aria-label', /** * 处理配置更改。 * @param {any} updatedContent - 更新后的内容。 * @param {any} previousContent - 之前的内容。 * @param {object} root0 - 根对象。 - * @param {any} root0.error - 错误。 + * @param {any} root0.contentErrors - 内容错误。 * @param {any} root0.patchResult - 补丁结果。 */ - onChange: (updatedContent, previousContent, { error, patchResult }) => { - if (!error) isDirty = true + onChange: (updatedContent, previousContent, { contentErrors, patchResult }) => { + if (!contentErrors) isDirty = true }, onSave: handleSaveConfig, - label: geti18n('telegram_bots.configCard.labels.config') }) configEditor.set({ json: config.config || {} }) diff --git a/src/public/parts/shells/telegrambot/test/frontend/jsonEditor.spec.mjs b/src/public/parts/shells/telegrambot/test/frontend/jsonEditor.spec.mjs new file mode 100644 index 000000000..a9f6a96cb --- /dev/null +++ b/src/public/parts/shells/telegrambot/test/frontend/jsonEditor.spec.mjs @@ -0,0 +1,20 @@ +/** + * Telegram bot JSON 编辑器 aria-label。 + */ +import { expectJsonEditorAriaLabel } from 'fount/scripts/test/playwright/json_editor.mjs' + +import { test, expect } from './fixtures.mjs' + +test.describe('Telegram bot JSON editor a11y', () => { + test('config editor mounts with i18n aria-label', async ({ page, baseUrl }) => { + await page.goto(`${baseUrl}/parts/shells:telegrambot/?name=aria-json-editor`, { + waitUntil: 'domcontentloaded', + }) + await expectJsonEditorAriaLabel( + page, + '#config-editor', + 'telegram_bots.configCard.jsonEditor.aria-label', + expect, + ) + }) +}) diff --git a/src/public/parts/shells/telegrambot/test/frontend/phases.mjs b/src/public/parts/shells/telegrambot/test/frontend/phases.mjs index c2029fee3..7a6b0eb72 100644 --- a/src/public/parts/shells/telegrambot/test/frontend/phases.mjs +++ b/src/public/parts/shells/telegrambot/test/frontend/phases.mjs @@ -3,4 +3,5 @@ */ export const phases = [ { name: 'smoke', testMatch: ['smoke.spec.mjs'] }, + { name: 'jsonEditor', testMatch: ['jsonEditor.spec.mjs'] }, ] diff --git a/src/public/parts/shells/telegrambot/test/manifest.json b/src/public/parts/shells/telegrambot/test/manifest.json index 45cb4d168..41ce8f80b 100644 --- a/src/public/parts/shells/telegrambot/test/manifest.json +++ b/src/public/parts/shells/telegrambot/test/manifest.json @@ -20,7 +20,8 @@ "src/public/parts/shells/telegrambot/public/**", "src/public/parts/shells/telegrambot/test/frontend/**", "src/scripts/test/playwright/**", - "src/public/pages/scripts/test/**" + "src/public/pages/scripts/test/**", + "src/public/pages/scripts/components/jsonEditor.mjs" ] }, "suites": [ @@ -72,6 +73,16 @@ "src/public/parts/shells/telegrambot/test/frontend/fixtures.mjs", "src/public/parts/shells/telegrambot/public/index.{html,mjs}" ] + }, + { + "name": "jsonEditor", + "triggers": [ + "src/public/parts/shells/telegrambot/test/frontend/jsonEditor.spec.mjs", + "src/public/parts/shells/telegrambot/test/frontend/fixtures.mjs", + "src/public/parts/shells/telegrambot/public/index.{html,mjs}", + "src/public/pages/scripts/components/jsonEditor.mjs", + "src/scripts/test/playwright/json_editor.mjs" + ] } ], "run": [ diff --git a/src/public/parts/shells/wechatbot/public/index.mjs b/src/public/parts/shells/wechatbot/public/index.mjs index db4e8c390..f69fbcec8 100644 --- a/src/public/parts/shells/wechatbot/public/index.mjs +++ b/src/public/parts/shells/wechatbot/public/index.mjs @@ -160,17 +160,16 @@ async function loadBotConfig(botname) { if (!configEditor) configEditor = createJsonEditor(configEditorContainer, { - label: geti18n('wechat_bots.configCard.labels.config'), + ariaLabel: 'wechat_bots.configCard.jsonEditor.aria-label', /** * JSON 编辑器内容变更:无语法错误时标记为脏。 * @param {any} updatedContent 编辑器更新后的内容 * @param {any} previousContent 编辑器更新前的内容 * @param {object} root0 解构参数对象 - * @param {any} root0.error 错误对象 - * @returns {any} 返回值 + * @param {any} root0.contentErrors 内容错误 */ - onChange: (updatedContent, previousContent, { error }) => { - if (!error) isDirty = true + onChange: (updatedContent, previousContent, { contentErrors }) => { + if (!contentErrors) isDirty = true }, onSave: handleSaveConfig }) diff --git a/src/public/parts/shells/wechatbot/test/frontend/jsonEditor.spec.mjs b/src/public/parts/shells/wechatbot/test/frontend/jsonEditor.spec.mjs new file mode 100644 index 000000000..e982a31c5 --- /dev/null +++ b/src/public/parts/shells/wechatbot/test/frontend/jsonEditor.spec.mjs @@ -0,0 +1,20 @@ +/** + * WeChat bot JSON 编辑器 aria-label。 + */ +import { expectJsonEditorAriaLabel } from 'fount/scripts/test/playwright/json_editor.mjs' + +import { test, expect } from './fixtures.mjs' + +test.describe('WeChat bot JSON editor a11y', () => { + test('config editor mounts with i18n aria-label', async ({ page, baseUrl }) => { + await page.goto(`${baseUrl}/parts/shells:wechatbot/?name=aria-json-editor`, { + waitUntil: 'domcontentloaded', + }) + await expectJsonEditorAriaLabel( + page, + '#config-editor', + 'wechat_bots.configCard.jsonEditor.aria-label', + expect, + ) + }) +}) diff --git a/src/public/parts/shells/wechatbot/test/frontend/phases.mjs b/src/public/parts/shells/wechatbot/test/frontend/phases.mjs index c2029fee3..7a6b0eb72 100644 --- a/src/public/parts/shells/wechatbot/test/frontend/phases.mjs +++ b/src/public/parts/shells/wechatbot/test/frontend/phases.mjs @@ -3,4 +3,5 @@ */ export const phases = [ { name: 'smoke', testMatch: ['smoke.spec.mjs'] }, + { name: 'jsonEditor', testMatch: ['jsonEditor.spec.mjs'] }, ] diff --git a/src/public/parts/shells/wechatbot/test/manifest.json b/src/public/parts/shells/wechatbot/test/manifest.json index 4c9e0d2a2..eb36c8936 100644 --- a/src/public/parts/shells/wechatbot/test/manifest.json +++ b/src/public/parts/shells/wechatbot/test/manifest.json @@ -20,7 +20,8 @@ "src/public/parts/shells/wechatbot/public/**", "src/public/parts/shells/wechatbot/test/frontend/**", "src/scripts/test/playwright/**", - "src/public/pages/scripts/test/**" + "src/public/pages/scripts/test/**", + "src/public/pages/scripts/components/jsonEditor.mjs" ] }, "suites": [ @@ -72,6 +73,16 @@ "src/public/parts/shells/wechatbot/test/frontend/fixtures.mjs", "src/public/parts/shells/wechatbot/public/index.{html,mjs}" ] + }, + { + "name": "jsonEditor", + "triggers": [ + "src/public/parts/shells/wechatbot/test/frontend/jsonEditor.spec.mjs", + "src/public/parts/shells/wechatbot/test/frontend/fixtures.mjs", + "src/public/parts/shells/wechatbot/public/index.{html,mjs}", + "src/public/pages/scripts/components/jsonEditor.mjs", + "src/scripts/test/playwright/json_editor.mjs" + ] } ], "run": [ diff --git a/src/scripts/test/docs/playwright.md b/src/scripts/test/docs/playwright.md index c1b77d72b..290383144 100644 --- a/src/scripts/test/docs/playwright.md +++ b/src/scripts/test/docs/playwright.md @@ -20,11 +20,15 @@ API helpers in `playwright/api.mjs`: `withApiRequest`, `fetchViewerEntityHash`, `cdn_cache.mjs` (wired into `createFountFixtures` / Pages fixtures `context`): memory + `data/test/cdn_cache` disk reuse for GET/HEAD to `esm.sh` / `api.iconify.design` / `cdn.jsdelivr.net`, cutting cross-case network flakiness. Cache keys include method so GET and HEAD never share an entry. Cached GET fulfill headers drop `content-encoding` and set `content-length` to the plaintext body size; HEAD fulfills keep the upstream headers (empty body must not rewrite `content-length`). Requests with `Range` bypass the cache. Only 2xx/3xx are cached; 4xx stay live (bad Iconify names still count as noise). `FOUNT_TEST_CDN_CACHE=0` disables. `route.fetch` is fine for CDN URLs; do not fetch same-origin local URLs that way (see Social EVFS stub). +## JSON editor a11y + +`json_editor.mjs` → `expectJsonEditorAriaLabel(page, containerSelector, i18nKey, expect)` asserts `vanilla-jsoneditor` text-mode `.cm-content` `aria-label` matches `geti18n(key)`. Product wrappers must pass an i18n key as `createJsonEditor(..., { ariaLabel })` — see pages `AGENTS.md`. + ## Network diagnostics `browser_diagnostics.mjs` (wired in `createFountFixtures` / `createPagesFixtures`): `response ≥ 400` / `requestfailed` → `[browser:network]` noise → imperfect wave; `pageerror`, `[test:…]` console (from `scripts/test/test_watch.mjs`), and `[i18n:missing]` (from `geti18n`, no dedup) hard-fail. `net::ERR_BLOCKED_BY_ORB` is dropped (Opaque Response Blocking; display via `` etc. usually fine). Child-frame `SecurityError` is ignored via CDP structured fields only (`exception.className` + frame ≠ main; `isIgnoredChildFrameSecurityError`) — no summary-text parsing. Covers sandboxed third-party embeds (WPT requires `navigator.serviceWorker` to throw there). Missing `className` → not ignored. Main-frame `SecurityError` still hard-fails. Pages fixtures ignore `/api/ping` and `:8930` installer probe failures only (no fount node). Do not gate product code on `fount.test.enabled` to paper over these; fix our own `serviceWorker` access with try/catch, and only allowlist what third parties / probe topology force. **URLs are logged as-is** — do not sanitize/redact in diagnostics; fixtures and tests must not put durable secrets in URLs (cookie session or ephemeral suite keys only). Locale load goes through i18n `loadLocaleData` / `setLanguage` (Pages static JSON; fount API) — do not fetch `/api/getlocaledata` from test code. `pages_server.close` calls `closeAllConnections()` before `close()` — otherwise keep-alive sockets can hang the driver past the idle watchdog. -`test_watch.mjs` after locale gate: (1) axe-core — MutationObserver marks dirty on **real** app DOM changes, scans every 0.5s while dirty, stops when quiet, reports on first hit (no confirm buffer — transitional UI must stay a11y-safe via `aria-hidden`/`inert`/`hidden` and atomic text updates); locale rotation / `pageText()` hide of `[user-content]` are wrapped in `withIgnoredMutations` so they do **not** keep the a11y timer alive; (2) locale rotation — every 1s cycles `zh-CN` → `ja-JP` → `en-UK` (skip when `fount.test.localeHold > 0`), scanning `document.title` + `body.innerText` (hiding `[user-content]` — mark user/dynamic text & inputs like `svg-inliner-ignore`) with `\p{Script=Han|Hiragana|Katakana}` (en: no CJK/kana; zh: no kana; ja: Han chars that appear in zh-CN bundle but not ja-JP — Unicode has no `\p{Hans}`). Playwright teardown calls `cycleLocales()` via `waitForLocaleCycle` (per-locale script check + one immediate a11y scan). Hard-fail on `[test:a11y]` / `[test:locale]` except axe `color-contrast` and `link-in-text-block`. Prefer `[data-i18n="…"]` selectors over locale-specific visible text. UI chrome must use `data-i18n` / `setLocalizeLogic` so locale rotation does not leave stale `geti18n()` strings. +`test_watch.mjs` after locale gate: (1) axe-core — MutationObserver marks dirty on **real** app DOM changes, scans every 0.5s while dirty, stops when quiet, reports on first hit (no confirm buffer — transitional UI must stay a11y-safe via `aria-hidden`/`inert`/`hidden` and atomic text updates); `exclude: [aria-ignore]` skips marked third-party / temporarily unfixable subtrees (`ARIA_IGNORE`; same opt-out style as `svg-inliner-ignore` / `user-content`); locale rotation / `pageText()` hide of `[user-content]` are wrapped in `withIgnoredMutations` so they do **not** keep the a11y timer alive; (2) locale rotation — every 1s cycles `zh-CN` → `ja-JP` → `en-UK` (skip when `fount.test.localeHold > 0`), scanning `document.title` + `body.innerText` (hiding `[user-content]` — mark user/dynamic text & inputs like `svg-inliner-ignore`) with `\p{Script=Han|Hiragana|Katakana}` (en: no CJK/kana; zh: no kana; ja: Han chars that appear in zh-CN bundle but not ja-JP — Unicode has no `\p{Hans}`). Playwright teardown calls `cycleLocales()` via `waitForLocaleCycle` (per-locale script check + one immediate a11y scan). Hard-fail on `[test:a11y]` / `[test:locale]` except axe `color-contrast` and `link-in-text-block`. Prefer `[data-i18n="…"]` selectors over locale-specific visible text. UI chrome must use `data-i18n` / `setLocalizeLogic` so locale rotation does not leave stale `geti18n()` strings. **Visibility traps**: a control with only `aria-label` and no glyph/text/`width`/`height` has a 0×0 box — Playwright `toBeVisible` reports `hidden` even when the `hidden` class is off. Icon-only clear/close buttons need a visible `×`/SVG (or explicit size). diff --git a/src/scripts/test/playwright/json_editor.mjs b/src/scripts/test/playwright/json_editor.mjs new file mode 100644 index 000000000..cd6932451 --- /dev/null +++ b/src/scripts/test/playwright/json_editor.mjs @@ -0,0 +1,30 @@ +/** + * 断言 JSON 编辑器(vanilla-jsoneditor text mode)的 aria-label 与 i18n 一致。 + * 会暂挂 `fount.test.localeHold`,避免语言轮换与断言竞态。 + * @param {import('@playwright/test').Page} page 页面 + * @param {string} containerSelector 编辑器容器选择器 + * @param {string} i18nKey aria-label 的 i18n key + * @param {import('@playwright/test').Expect} expect Playwright expect + * @returns {Promise} + */ +export async function expectJsonEditorAriaLabel(page, containerSelector, i18nKey, expect) { + await page.evaluate(() => { + globalThis.fount.test.localeHold = (globalThis.fount.test.localeHold || 0) + 1 + }) + try { + const content = page.locator(`${containerSelector} .cm-content`) + await expect(content).toBeVisible({ timeout: 30_000 }) + + const expected = await page.evaluate(async key => { + const { geti18n } = await import('/scripts/i18n/index.mjs') + return geti18n(key) + }, i18nKey) + expect(expected, `i18n key resolves: ${i18nKey}`).not.toBe(i18nKey) + await expect(content).toHaveAttribute('aria-label', expected) + } + finally { + await page.evaluate(() => { + globalThis.fount.test.localeHold = Math.max(0, (globalThis.fount.test.localeHold || 1) - 1) + }) + } +} From 12d0e8d507323cfcc08d6be8515161245f262d9e Mon Sep 17 00:00:00 2001 From: steve02081504 Date: Sat, 1 Aug 2026 19:13:32 +0800 Subject: [PATCH 02/11] 1 --- .github/pages/test/frontend/fixtures.mjs | 10 + src/public/pages/AGENTS.md | 2 +- .../pages/scripts/components/jsonEditor.mjs | 4 +- src/public/pages/scripts/test/test_watch.mjs | 49 +++- src/scripts/test/AGENTS.md | 4 +- src/scripts/test/core/github_issue.mjs | 21 ++ src/scripts/test/docs/playwright.md | 4 +- src/scripts/test/hub/apis/github_issue.mjs | 51 +++++ src/scripts/test/hub/apis/health.mjs | 15 ++ src/scripts/test/hub/apis/shared_store.mjs | 39 ++++ src/scripts/test/hub/base_url.mjs | 17 ++ src/scripts/test/hub/clients/github_issue.mjs | 24 ++ src/scripts/test/hub/clients/shared_store.mjs | 67 ++++++ src/scripts/test/hub/index.mjs | 54 +++++ src/scripts/test/manifest.json | 21 ++ src/scripts/test/playwright/fixtures.mjs | 10 + src/scripts/test/playwright/github_issue.mjs | 38 ++++ src/scripts/test/runner/index.mjs | 212 +++++++++--------- .../test/selftest/github_issue.test.mjs | 24 ++ src/scripts/test/selftest/test_hub.test.mjs | 53 +++++ 20 files changed, 610 insertions(+), 109 deletions(-) create mode 100644 src/scripts/test/core/github_issue.mjs create mode 100644 src/scripts/test/hub/apis/github_issue.mjs create mode 100644 src/scripts/test/hub/apis/health.mjs create mode 100644 src/scripts/test/hub/apis/shared_store.mjs create mode 100644 src/scripts/test/hub/base_url.mjs create mode 100644 src/scripts/test/hub/clients/github_issue.mjs create mode 100644 src/scripts/test/hub/clients/shared_store.mjs create mode 100644 src/scripts/test/hub/index.mjs create mode 100644 src/scripts/test/playwright/github_issue.mjs create mode 100644 src/scripts/test/selftest/github_issue.test.mjs create mode 100644 src/scripts/test/selftest/test_hub.test.mjs diff --git a/.github/pages/test/frontend/fixtures.mjs b/.github/pages/test/frontend/fixtures.mjs index 1decef498..894f470ec 100644 --- a/.github/pages/test/frontend/fixtures.mjs +++ b/.github/pages/test/frontend/fixtures.mjs @@ -5,6 +5,7 @@ import { test as base, expect } from '@playwright/test' import { createBrowserDiagnostics, waitForLocaleCycle } from 'fount/scripts/test/playwright/browser_diagnostics.mjs' import { installCdnResponseCache } from 'fount/scripts/test/playwright/cdn_cache.mjs' import { requireTestBaseUrl } from 'fount/scripts/test/playwright/env.mjs' +import { assertAriaIgnoreIssues } from 'fount/scripts/test/playwright/github_issue.mjs' /** * Pages 前端 E2E fixture:`baseUrl` + 已注入 `fount.test.enabled` 的 `context` / `page`。 @@ -46,6 +47,14 @@ export function createPagesFixtures(options = {}) { globalThis.fount.test ??= {} globalThis.fount.test.enabled = true }) + const hubUrl = (process.env.FOUNT_TEST_HUB_URL || '').trim() + if (hubUrl) { + await context.addInitScript(url => { + globalThis.fount ??= {} + globalThis.fount.test ??= {} + globalThis.fount.test.hubUrl = url + }, hubUrl) + } await use(context) await context.close() }, @@ -61,6 +70,7 @@ export function createPagesFixtures(options = {}) { await use(page) // 收尾:中日英脚本检查 + 每语种一轮 a11y;未挂载 test_watch 则跳过 await waitForLocaleCycle(page).catch(() => { /* 未挂载 test_watch 则跳过 */ }) + await assertAriaIgnoreIssues(page) diagnostics.flushNetworkDiagnostics() expect(diagnostics.pageErrors, 'unexpected browser page errors').toEqual([]) expect(diagnostics.testWatchErrors, 'unexpected test_watch console output').toEqual([]) diff --git a/src/public/pages/AGENTS.md b/src/public/pages/AGENTS.md index 51d397de2..43440359f 100644 --- a/src/public/pages/AGENTS.md +++ b/src/public/pages/AGENTS.md @@ -51,7 +51,7 @@ Markdown convertor traps (rehype order, `{:lang}`, trust tiers): [markdown-notes - **`lib/base64.mjs`**: `arrayBufferToBase64` / `blobToBase64` — reuse for upload bodies; do not copy per shell. - **`lib/svgInliner.mjs`**: Inline `.svg` `` for `currentColor`. Put `svg-inliner-ignore` on user/media avatars so they stay `` (inlining untrusted SVG activates scripts). - **`user-content`**: boolean attr on user/dynamic text & inputs (post/reply bodies, message bubbles, composer fields, …). `test_watch` locale-script scan hides `[user-content]` — same opt-out style as `svg-inliner-ignore`. -- **`aria-ignore`**: boolean attr; `test_watch` axe `exclude` (`ARIA_IGNORE`). Third-party / temporarily unfixable subtrees only — not a license to leave our own chrome broken. JSON editor sets it pending [svelte-jsoneditor#584](https://github.com/josdejong/svelte-jsoneditor/issues/584). +- **`aria-ignore`**: attr value **must** be a GitHub issue URL (`aria-ignore="https://github.com/owner/repo/issues/n"`). `test_watch` axe-`exclude`s the subtree and flags missing/bad URLs; with `fount.test.hubUrl` (from `FOUNT_TEST_HUB_URL`, fixed `127.0.0.1:8903`) also fails closed issues in-page. Playwright teardown `assertAriaIgnoreIssues` hits the same hub. No hub / hub down → treat as still open. JSON editor: [svelte-jsoneditor#584](https://github.com/josdejong/svelte-jsoneditor/issues/584). - **Transitional a11y**: `test_watch` reports axe hits immediately (no confirm buffer). Incomplete / not-yet-shown UI must use `aria-hidden` / `inert` / `hidden` (not bare `opacity: 0`); text updates should be atomic. Fix the product — do not soften the watcher. - **`credentialManager.mjs`** / **`host/urlDataTransfer.mjs`**: Secure credential encryption/transfer; Catbox upload/download via `host/catbox.mjs`. diff --git a/src/public/pages/scripts/components/jsonEditor.mjs b/src/public/pages/scripts/components/jsonEditor.mjs index df0ce9409..eede5b2f1 100644 --- a/src/public/pages/scripts/components/jsonEditor.mjs +++ b/src/public/pages/scripts/components/jsonEditor.mjs @@ -14,8 +14,8 @@ import { onThemeChange } from '../theme/index.mjs' export function createJsonEditor(jsonEditorContainer, options) { const { ariaLabel: ariaLabelKey, onSave, ...editorProps } = options - // TODO: drop aria-ignore when https://github.com/josdejong/svelte-jsoneditor/issues/584 lands - jsonEditorContainer.toggleAttribute('aria-ignore', true) + // Temporary: toolbar .fa-icon presentation-role-conflict until upstream fix + jsonEditorContainer.setAttribute('aria-ignore', 'https://github.com/josdejong/svelte-jsoneditor/issues/584') const result = base({ target: jsonEditorContainer, diff --git a/src/public/pages/scripts/test/test_watch.mjs b/src/public/pages/scripts/test/test_watch.mjs index 7a8a7fb1e..dcf02361b 100644 --- a/src/public/pages/scripts/test/test_watch.mjs +++ b/src/public/pages/scripts/test/test_watch.mjs @@ -1,7 +1,8 @@ /** * 测试环境页面监视:import 即启动(无导出)。 * - axe-core 无障碍:真实 DOM 变脏时每 0.5s 扫,静止则停;命中即报 - * - `[aria-ignore]`:axe `exclude`(第三方 / 暂不可修;`ARIA_IGNORE`,同 `svg-inliner-ignore` 风格) + * - `[aria-ignore="https://github.com/…/issues/n"]`:axe `exclude`;须带 issue URL + * (缺省 / 非法格式在此报;issue 已关闭经 `fount.test.hubUrl` → 测试 hub,或 Playwright 收尾硬失败) * - 语种轮换:每秒在 zh-CN / ja-JP / en-UK 间切换,并用 `\p{Script=…}` 查错语字符 * (轮换自身的 DOM 写入不计入 a11y dirty,否则扫描定时器永远停不下来) * @@ -16,10 +17,14 @@ import axe from 'https://esm.sh/axe-core' /** - * 第三方 / 暂不可修子树:axe `exclude`(布尔属性,用法同 `svg-inliner-ignore` / `user-content`)。 + * 第三方 / 暂不可修子树:axe `exclude`。 + * 属性值必须是跟踪上游修复的 GitHub issue URL(`aria-ignore="https://github.com/…/issues/n"`)。 */ export const ARIA_IGNORE = 'aria-ignore' +/** GitHub issue URL(路径以 /issues/{n} 结尾) */ +const GITHUB_ISSUE_URL_RE = /^https:\/\/github\.com\/[^/]+\/[^/]+\/issues\/\d+\/?(?:[?#].*)?$/i + const A11Y_PREFIX = '[test:a11y]' const LOCALE_PREFIX = '[test:locale]' /** DOM 仍在变时的 a11y 扫描间隔 */ @@ -212,11 +217,51 @@ async function runLocaleScriptCheck(locale) { globalThis.fount.test.watchLastRun = Date.now() } +/** + * 校验 `[aria-ignore]`:缺 URL / 非法格式立刻报;有 hub 时额外查关闭态。 + * @returns {Promise} + */ +async function checkAriaIgnores() { + const nodes = document.querySelectorAll(`[${ARIA_IGNORE}]`) + const hub = String(globalThis.fount?.test?.hubUrl || '').replace(/\/$/, '') + for (const el of nodes) { + const url = (el.getAttribute(ARIA_IGNORE) || '').trim() + const where = el.id ? `#${el.id}` : el.className || el.tagName + if (!url) { + const key = `aria-ignore-missing\t${where}` + if (printedKeys.has(key)) continue + printedKeys.add(key) + console.error(A11Y_PREFIX, 'aria-ignore-missing-url', where, 'aria-ignore requires a GitHub issue URL') + continue + } + if (!GITHUB_ISSUE_URL_RE.test(url)) { + const key = `aria-ignore-bad-url\t${url}` + if (printedKeys.has(key)) continue + printedKeys.add(key) + console.error(A11Y_PREFIX, 'aria-ignore-bad-url', where, url) + continue + } + if (!hub) continue + try { + const res = await fetch(`${hub}/github-issue?url=${encodeURIComponent(url)}`) + if (!res.ok) continue + const data = await res.json() + if (data?.closed !== true) continue + const key = `aria-ignore-closed\t${url}` + if (printedKeys.has(key)) continue + printedKeys.add(key) + console.error(A11Y_PREFIX, 'aria-ignore-closed', where, url) + } + catch { /* hub 不可达:留给 Playwright 收尾再判 */ } + } +} + /** * @returns {Promise} */ async function runA11y() { if (!localeReady) return + await checkAriaIgnores() const results = await axe.run({ exclude: `[${ARIA_IGNORE}]`, }, { diff --git a/src/scripts/test/AGENTS.md b/src/scripts/test/AGENTS.md index 058e624e7..f7fc5c7f9 100644 --- a/src/scripts/test/AGENTS.md +++ b/src/scripts/test/AGENTS.md @@ -22,7 +22,8 @@ Host keep-awake / sleep interrupts: [docs/host-keep-awake.md](docs/host-keep-awa - **`--no-parallel`**: serial dispatch **and** inner concurrency = 1. **Default for agents on Windows** / local verification ([denoland/deno#35804](https://github.com/denoland/deno/issues/35804)). See [resource-scheduling.md](docs/resource-scheduling.md). - **`dependsOn`**: downstream `blocked(by)` when a dependency is not green-capable. Optimistic overlap while hard deps run: [resource-scheduling.md](docs/resource-scheduling.md). - **Live driver**: `live/runner.mjs` — ephemeral nodes, `FOUNT_TEST_NODE_*` env, teardown after. Launch/ping failures return exit 1. Non-worker `env.mjs` sets `process.exitCode = 1` on `unhandledRejection`/`uncaughtException` — otherwise a logged rejection exits 0 (**passed with noise**). -- **Libs**: import from `core/`, `live/`, `runner/`, `playwright/` — do not reimplement HTTP/WS/state helpers. +- **Test hub**: parent `runTests` binds Express on `http://127.0.0.1:8903` (`hub/index.mjs`), sets `FOUNT_TEST_HUB_URL`. Suites inherit via env; Playwright injects `fount.test.hubUrl`. Route modules in `hub/apis/` (`health`, `github_issue`, `shared_store`); fetch clients in `hub/clients/`. No hub / hub down → issue still open / store miss. +- **Libs**: import from `core/`, `hub/`, `live/`, `runner/`, `playwright/` — do not reimplement HTTP/WS/state helpers. - **Shell module graph**: `shellLoadProbe.mjs` — path resolve + **named export** check (`missingNamed`). Bot/chat/social integration probes assert `missingNamed === []`. When a shell imports another part's `public/shared`, put that shared glob on the consumer suite's triggers. ## Taxonomy @@ -70,5 +71,6 @@ Manifest id = domain (`server`, `testkit`, `p2p`, `shells/chat`, …). - **Keep-awake**: wrappers keep the machine awake during runs — [host-keep-awake.md](docs/host-keep-awake.md). Opt out: `FOUNT_TEST_ALLOW_SLEEP=1`. - **OOM / heap**: [heap-snapshots.md](docs/heap-snapshots.md). - **Deno panic auto-report**: `core/deno_panic.mjs` → GitHub issue on `denoland/deno` (if `gh` + auth); dedup `data/test/deno_panics.json`. Override via `FOUNT_DENO_PANIC_REPO`. `testkit` excluded. +- **GitHub issue probe**: `core/github_issue.mjs` → `parseGithubIssueUrl` (pure). Closed-state via `hub/apis/github_issue.mjs` (`gh` only there) + `hub/clients/github_issue.mjs`. Playwright `assertAriaIgnoreIssues` + browser `test_watch` (when `fount.test.hubUrl` set) hard-fail closed `[aria-ignore]` URLs. No hub / `gh` down → still open. - **Selftests**: `fount test testkit`. Fixtures: `selftest/fixtures.mjs` (`makeSuite` / `makeStateEntry`). Keep manifest id `testkit`. - **Naming**: readable identifiers (`context` not `ctx`). Suite/file/`Deno.test` names use domain semantics — never planning milestone codes. diff --git a/src/scripts/test/core/github_issue.mjs b/src/scripts/test/core/github_issue.mjs new file mode 100644 index 000000000..a7d4b6c9b --- /dev/null +++ b/src/scripts/test/core/github_issue.mjs @@ -0,0 +1,21 @@ +/** + * GitHub issue URL 解析(纯函数,Deno / Node 皆可)。 + */ + +/** + * 解析 `https://github.com/{owner}/{repo}/issues/{n}`。 + * @param {string} url issue URL + * @returns {{ owner: string, repo: string, number: string } | null} 解析结果 + */ +export function parseGithubIssueUrl(url) { + try { + const parsed = new URL(String(url || '').trim()) + if (parsed.hostname !== 'github.com') return null + const match = parsed.pathname.match(/^\/([^/]+)\/([^/]+)\/issues\/(\d+)\/?$/) + if (!match) return null + return { owner: match[1], repo: match[2], number: match[3] } + } + catch { + return null + } +} diff --git a/src/scripts/test/docs/playwright.md b/src/scripts/test/docs/playwright.md index 290383144..6c2c294a4 100644 --- a/src/scripts/test/docs/playwright.md +++ b/src/scripts/test/docs/playwright.md @@ -8,7 +8,7 @@ Day-to-day: [AGENTS.md](../AGENTS.md) Taxonomy → Frontend. ## Fixtures -`createFountFixtures({ locale, isolated? })` — `isolated` registers `FOUNT_TEST_USERNAME` + `assertIsolatedFrontendTest` (Chat/Social/Cabinet). Locale `addInitScript` wraps `localStorage` in try/catch (`about:blank` / sandboxed frames throw `SecurityError`). +`createFountFixtures({ locale, isolated? })` — `isolated` registers `FOUNT_TEST_USERNAME` + `assertIsolatedFrontendTest` (Chat/Social/Cabinet). Locale `addInitScript` wraps `localStorage` in try/catch (`about:blank` / sandboxed frames throw `SecurityError`). When `FOUNT_TEST_HUB_URL` is set (parent hub `127.0.0.1:8903`), fixtures also inject `fount.test.hubUrl` for in-page hub clients / `test_watch`. API helpers in `playwright/api.mjs`: `withApiRequest`, `fetchViewerEntityHash`, `createChatTestGroup`. Prefer these over local `request.newContext` loops. @@ -28,7 +28,7 @@ API helpers in `playwright/api.mjs`: `withApiRequest`, `fetchViewerEntityHash`, `browser_diagnostics.mjs` (wired in `createFountFixtures` / `createPagesFixtures`): `response ≥ 400` / `requestfailed` → `[browser:network]` noise → imperfect wave; `pageerror`, `[test:…]` console (from `scripts/test/test_watch.mjs`), and `[i18n:missing]` (from `geti18n`, no dedup) hard-fail. `net::ERR_BLOCKED_BY_ORB` is dropped (Opaque Response Blocking; display via `` etc. usually fine). Child-frame `SecurityError` is ignored via CDP structured fields only (`exception.className` + frame ≠ main; `isIgnoredChildFrameSecurityError`) — no summary-text parsing. Covers sandboxed third-party embeds (WPT requires `navigator.serviceWorker` to throw there). Missing `className` → not ignored. Main-frame `SecurityError` still hard-fails. Pages fixtures ignore `/api/ping` and `:8930` installer probe failures only (no fount node). Do not gate product code on `fount.test.enabled` to paper over these; fix our own `serviceWorker` access with try/catch, and only allowlist what third parties / probe topology force. **URLs are logged as-is** — do not sanitize/redact in diagnostics; fixtures and tests must not put durable secrets in URLs (cookie session or ephemeral suite keys only). Locale load goes through i18n `loadLocaleData` / `setLanguage` (Pages static JSON; fount API) — do not fetch `/api/getlocaledata` from test code. `pages_server.close` calls `closeAllConnections()` before `close()` — otherwise keep-alive sockets can hang the driver past the idle watchdog. -`test_watch.mjs` after locale gate: (1) axe-core — MutationObserver marks dirty on **real** app DOM changes, scans every 0.5s while dirty, stops when quiet, reports on first hit (no confirm buffer — transitional UI must stay a11y-safe via `aria-hidden`/`inert`/`hidden` and atomic text updates); `exclude: [aria-ignore]` skips marked third-party / temporarily unfixable subtrees (`ARIA_IGNORE`; same opt-out style as `svg-inliner-ignore` / `user-content`); locale rotation / `pageText()` hide of `[user-content]` are wrapped in `withIgnoredMutations` so they do **not** keep the a11y timer alive; (2) locale rotation — every 1s cycles `zh-CN` → `ja-JP` → `en-UK` (skip when `fount.test.localeHold > 0`), scanning `document.title` + `body.innerText` (hiding `[user-content]` — mark user/dynamic text & inputs like `svg-inliner-ignore`) with `\p{Script=Han|Hiragana|Katakana}` (en: no CJK/kana; zh: no kana; ja: Han chars that appear in zh-CN bundle but not ja-JP — Unicode has no `\p{Hans}`). Playwright teardown calls `cycleLocales()` via `waitForLocaleCycle` (per-locale script check + one immediate a11y scan). Hard-fail on `[test:a11y]` / `[test:locale]` except axe `color-contrast` and `link-in-text-block`. Prefer `[data-i18n="…"]` selectors over locale-specific visible text. UI chrome must use `data-i18n` / `setLocalizeLogic` so locale rotation does not leave stale `geti18n()` strings. +`test_watch.mjs` after locale gate: (1) axe-core — MutationObserver marks dirty on **real** app DOM changes, scans every 0.5s while dirty, stops when quiet, reports on first hit (no confirm buffer — transitional UI must stay a11y-safe via `aria-hidden`/`inert`/`hidden` and atomic text updates); `exclude: [aria-ignore]` skips marked third-party / temporarily unfixable subtrees (`ARIA_IGNORE`). Value **must** be a GitHub issue URL; `test_watch` flags missing/bad format; with `fount.test.hubUrl` also probes closed state via the test hub. Playwright page teardown `assertAriaIgnoreIssues` (`playwright/github_issue.mjs` → hub client) hard-fails closed issues. No hub / hub down → still open.; locale rotation / `pageText()` hide of `[user-content]` are wrapped in `withIgnoredMutations` so they do **not** keep the a11y timer alive; (2) locale rotation — every 1s cycles `zh-CN` → `ja-JP` → `en-UK` (skip when `fount.test.localeHold > 0`), scanning `document.title` + `body.innerText` (hiding `[user-content]` — mark user/dynamic text & inputs like `svg-inliner-ignore`) with `\p{Script=Han|Hiragana|Katakana}` (en: no CJK/kana; zh: no kana; ja: Han chars that appear in zh-CN bundle but not ja-JP — Unicode has no `\p{Hans}`). Playwright teardown calls `cycleLocales()` via `waitForLocaleCycle` (per-locale script check + one immediate a11y scan). Hard-fail on `[test:a11y]` / `[test:locale]` except axe `color-contrast` and `link-in-text-block`. Prefer `[data-i18n="…"]` selectors over locale-specific visible text. UI chrome must use `data-i18n` / `setLocalizeLogic` so locale rotation does not leave stale `geti18n()` strings. **Visibility traps**: a control with only `aria-label` and no glyph/text/`width`/`height` has a 0×0 box — Playwright `toBeVisible` reports `hidden` even when the `hidden` class is off. Icon-only clear/close buttons need a visible `×`/SVG (or explicit size). diff --git a/src/scripts/test/hub/apis/github_issue.mjs b/src/scripts/test/hub/apis/github_issue.mjs new file mode 100644 index 000000000..2a8e27c26 --- /dev/null +++ b/src/scripts/test/hub/apis/github_issue.mjs @@ -0,0 +1,51 @@ +/** + * `GET /github-issue?url=` — 本进程缓存 + `gh issue view`。 + */ +import { Router } from 'npm:express' +import { execFile } from 'npm:@steve02081504/exec' + +import { parseGithubIssueUrl } from '../../core/github_issue.mjs' + +/** + * @param {string} url GitHub issue URL + * @returns {Promise} 已关闭为 true;gh 失败视为未关闭 + */ +async function probeGithubIssueClosed(url) { + const parsed = parseGithubIssueUrl(url) + if (!parsed) return false + try { + const result = await execFile('gh', [ + 'issue', 'view', parsed.number, + '--repo', `${parsed.owner}/${parsed.repo}`, + '--json', 'state', + ]) + if (result.code !== 0) return false + const raw = (result.stdout ?? result.stdall ?? '').trim() + if (!raw) return false + const data = JSON.parse(raw) + return String(data.state || '').toUpperCase() === 'CLOSED' + } + catch { + return false + } +} + +/** + * @returns {import('npm:express').Router} 路由 + */ +export function createGithubIssueRouter() { + /** @type {Map} */ + const closedCache = new Map() + const router = Router() + + router.get('/github-issue', async (req, res) => { + const issueUrl = String(req.query.url || '').trim() + if (!parseGithubIssueUrl(issueUrl)) + return res.json({ closed: false }) + if (!closedCache.has(issueUrl)) + closedCache.set(issueUrl, await probeGithubIssueClosed(issueUrl)) + res.json({ closed: closedCache.get(issueUrl) === true }) + }) + + return router +} diff --git a/src/scripts/test/hub/apis/health.mjs b/src/scripts/test/hub/apis/health.mjs new file mode 100644 index 000000000..c161fd939 --- /dev/null +++ b/src/scripts/test/hub/apis/health.mjs @@ -0,0 +1,15 @@ +/** + * `GET /health` + */ +import { Router } from 'npm:express' + +/** + * @returns {import('npm:express').Router} 路由 + */ +export function createHealthRouter() { + const router = Router() + router.get('/health', (_req, res) => { + res.json({ ok: true }) + }) + return router +} diff --git a/src/scripts/test/hub/apis/shared_store.mjs b/src/scripts/test/hub/apis/shared_store.mjs new file mode 100644 index 000000000..d07f8337d --- /dev/null +++ b/src/scripts/test/hub/apis/shared_store.mjs @@ -0,0 +1,39 @@ +/** + * `GET|PUT|DELETE /shared-store/:namespace/:key` — 本次 hub 生命周期内前后端共享 KV。 + */ +import { Router } from 'npm:express' + +/** + * @returns {import('npm:express').Router} 路由 + */ +export function createSharedStoreRouter() { + /** @type {Map} `${namespace}\\0${key}` → JSON 值 */ + const store = new Map() + const router = Router() + + /** + * @param {string} namespace 分区 + * @param {string} key 键 + * @returns {string} Map 键 + */ + const storeKey = (namespace, key) => `${namespace}\0${key}` + + router.get('/shared-store/:namespace/:key', (req, res) => { + const key = storeKey(req.params.namespace, req.params.key) + if (!store.has(key)) + return res.status(404).json({ error: 'missing' }) + res.json(store.get(key)) + }) + + router.put('/shared-store/:namespace/:key', (req, res) => { + store.set(storeKey(req.params.namespace, req.params.key), req.body) + res.sendStatus(204) + }) + + router.delete('/shared-store/:namespace/:key', (req, res) => { + store.delete(storeKey(req.params.namespace, req.params.key)) + res.sendStatus(204) + }) + + return router +} diff --git a/src/scripts/test/hub/base_url.mjs b/src/scripts/test/hub/base_url.mjs new file mode 100644 index 000000000..8496bbd23 --- /dev/null +++ b/src/scripts/test/hub/base_url.mjs @@ -0,0 +1,17 @@ +/** + * 测试 hub 根 URL(Deno / Node / 浏览器共用)。 + * `process.env.FOUNT_TEST_HUB_URL` 或 `globalThis.fount.test.hubUrl`;皆无则空串。 + */ + +/** + * @returns {string} hub base(无尾斜杠);不可用时为空串 + */ +export function getTestHubBaseUrl() { + const fromEnv = typeof process !== 'undefined' && process.env?.FOUNT_TEST_HUB_URL + ? String(process.env.FOUNT_TEST_HUB_URL).trim() + : '' + const fromPage = globalThis.fount?.test?.hubUrl + ? String(globalThis.fount.test.hubUrl).trim() + : '' + return (fromEnv || fromPage).replace(/\/$/, '') +} diff --git a/src/scripts/test/hub/clients/github_issue.mjs b/src/scripts/test/hub/clients/github_issue.mjs new file mode 100644 index 000000000..94fc71dc4 --- /dev/null +++ b/src/scripts/test/hub/clients/github_issue.mjs @@ -0,0 +1,24 @@ +/** + * GitHub issue 关闭态客户端(经 hub `GET /github-issue`)。 + */ +import { getTestHubBaseUrl } from '../base_url.mjs' + +/** + * issue 是否已关闭(无 hub / 失败 → false)。 + * @param {string} issueUrl GitHub issue URL + * @returns {Promise} 已关闭为 true + */ +export async function isGithubIssueClosed(issueUrl) { + const base = getTestHubBaseUrl() + const url = String(issueUrl || '').trim() + if (!base || !url) return false + try { + const res = await fetch(`${base}/github-issue?url=${encodeURIComponent(url)}`) + if (!res.ok) return false + const data = await res.json() + return data?.closed === true + } + catch { + return false + } +} diff --git a/src/scripts/test/hub/clients/shared_store.mjs b/src/scripts/test/hub/clients/shared_store.mjs new file mode 100644 index 000000000..5edc23026 --- /dev/null +++ b/src/scripts/test/hub/clients/shared_store.mjs @@ -0,0 +1,67 @@ +/** + * 共享 KV 客户端(经 hub `/shared-store/:namespace/:key`)。 + */ +import { getTestHubBaseUrl } from '../base_url.mjs' + +/** + * 读本次测试运行共享 KV。 + * @param {string} namespace 分区(如 `cdn`、`fixture`) + * @param {string} key 键 + * @returns {Promise} 值;缺失 / 无 hub 为 undefined + */ +export async function hubSharedStoreGet(namespace, key) { + const base = getTestHubBaseUrl() + if (!base) return undefined + try { + const res = await fetch(`${base}/shared-store/${encodeURIComponent(namespace)}/${encodeURIComponent(key)}`) + if (res.status === 404) return undefined + if (!res.ok) return undefined + return await res.json() + } + catch { + return undefined + } +} + +/** + * 写本次测试运行共享 KV。 + * @param {string} namespace 分区 + * @param {string} key 键 + * @param {unknown} value JSON 可序列化值 + * @returns {Promise} 是否写入成功 + */ +export async function hubSharedStoreSet(namespace, key, value) { + const base = getTestHubBaseUrl() + if (!base) return false + try { + const res = await fetch(`${base}/shared-store/${encodeURIComponent(namespace)}/${encodeURIComponent(key)}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(value), + }) + return res.ok || res.status === 204 + } + catch { + return false + } +} + +/** + * 删本次测试运行共享 KV 条目。 + * @param {string} namespace 分区 + * @param {string} key 键 + * @returns {Promise} 是否删除成功(无 hub 为 false) + */ +export async function hubSharedStoreDelete(namespace, key) { + const base = getTestHubBaseUrl() + if (!base) return false + try { + const res = await fetch(`${base}/shared-store/${encodeURIComponent(namespace)}/${encodeURIComponent(key)}`, { + method: 'DELETE', + }) + return res.ok || res.status === 204 + } + catch { + return false + } +} diff --git a/src/scripts/test/hub/index.mjs b/src/scripts/test/hub/index.mjs new file mode 100644 index 000000000..35298177c --- /dev/null +++ b/src/scripts/test/hub/index.mjs @@ -0,0 +1,54 @@ +/** + * fount test 进程内共享 hub(Express):固定 `127.0.0.1:8903`。 + * 父进程 `runTests` 启动;各 API 见 `hub/apis/`。 + */ +import express from 'npm:express' + +import { createGithubIssueRouter } from './apis/github_issue.mjs' +import { createHealthRouter } from './apis/health.mjs' +import { createSharedStoreRouter } from './apis/shared_store.mjs' + +/** 测试 hub 固定端口(避开生产 8931 / live 测试口)。 */ +export const TEST_HUB_PORT = 8903 + +/** 默认 base URL(无尾斜杠)。 */ +export const TEST_HUB_DEFAULT_URL = `http://127.0.0.1:${TEST_HUB_PORT}` + +/** + * 启动测试 hub(仅 `127.0.0.1`)。 + * @param {object} [options] 选项 + * @param {number} [options.port=TEST_HUB_PORT] 端口 + * @returns {Promise<{ url: string, close: () => Promise }>} hub 句柄 + */ +export async function startTestHub({ port = TEST_HUB_PORT } = {}) { + const app = express() + app.use((_req, res, next) => { + res.setHeader('Access-Control-Allow-Origin', '*') + res.setHeader('Access-Control-Allow-Methods', 'GET, PUT, DELETE, OPTIONS') + res.setHeader('Access-Control-Allow-Headers', 'Content-Type') + if (_req.method === 'OPTIONS') return res.sendStatus(204) + next() + }) + app.use(express.json({ limit: '4mb' })) + app.use(createHealthRouter()) + app.use(createGithubIssueRouter()) + app.use(createSharedStoreRouter()) + app.use((_req, res) => { + res.status(404).json({ error: 'not found' }) + }) + + const server = await new Promise((resolve, reject) => { + const httpServer = app.listen(port, '127.0.0.1', () => resolve(httpServer)) + httpServer.once('error', reject) + }) + + return { + url: `http://127.0.0.1:${port}`, + /** + * @returns {Promise} + */ + close: () => new Promise((resolve, reject) => { + server.close(err => err ? reject(err) : resolve()) + }), + } +} diff --git a/src/scripts/test/manifest.json b/src/scripts/test/manifest.json index 50367df78..b036b9df7 100644 --- a/src/scripts/test/manifest.json +++ b/src/scripts/test/manifest.json @@ -226,6 +226,27 @@ "src/scripts/test/core/paths.mjs" ] }, + { + "name": "github_issue", + "run": [ + "deno", + "test", + "--no-check", + "--allow-scripts", + "--allow-all", + "-c", + "./deno.json", + "./src/scripts/test/selftest/github_issue.test.mjs", + "./src/scripts/test/selftest/test_hub.test.mjs" + ], + "triggers": [ + "src/scripts/test/selftest/github_issue.test.mjs", + "src/scripts/test/selftest/test_hub.test.mjs", + "src/scripts/test/core/github_issue.mjs", + "src/scripts/test/hub/**", + "src/scripts/test/playwright/github_issue.mjs" + ] + }, { "name": "run_timing", "run": [ diff --git a/src/scripts/test/playwright/fixtures.mjs b/src/scripts/test/playwright/fixtures.mjs index a402e1b60..55dd7208b 100644 --- a/src/scripts/test/playwright/fixtures.mjs +++ b/src/scripts/test/playwright/fixtures.mjs @@ -6,6 +6,7 @@ import { loginWithApiKey } from './auth.mjs' import { createBrowserDiagnostics, waitForLocaleCycle } from './browser_diagnostics.mjs' import { installCdnResponseCache } from './cdn_cache.mjs' import { requireTestBaseUrl } from './env.mjs' +import { assertAriaIgnoreIssues } from './github_issue.mjs' import { assertIsolatedFrontendTest } from './guards.mjs' /** @@ -69,6 +70,14 @@ export function createFountFixtures(options = {}) { globalThis.fount.test ??= {} globalThis.fount.test.enabled = true }) + const hubUrl = (process.env.FOUNT_TEST_HUB_URL || '').trim() + if (hubUrl) { + await context.addInitScript(url => { + globalThis.fount ??= {} + globalThis.fount.test ??= {} + globalThis.fount.test.hubUrl = url + }, hubUrl) + } await use(context) await context.close() }, @@ -85,6 +94,7 @@ export function createFountFixtures(options = {}) { await use(page) // 收尾:中日英脚本检查 + 每语种一轮 a11y;未挂载 test_watch 则跳过 await waitForLocaleCycle(page).catch(() => { /* 未挂载 test_watch 则跳过 */ }) + await assertAriaIgnoreIssues(page) diagnostics.flushNetworkDiagnostics() expect(diagnostics.pageErrors, 'unexpected browser page errors').toEqual([]) expect(diagnostics.testWatchErrors, 'unexpected test_watch console output').toEqual([]) diff --git a/src/scripts/test/playwright/github_issue.mjs b/src/scripts/test/playwright/github_issue.mjs new file mode 100644 index 000000000..39c5e7989 --- /dev/null +++ b/src/scripts/test/playwright/github_issue.mjs @@ -0,0 +1,38 @@ +/** + * Playwright 收尾:断言页面 `[aria-ignore]` 仍指向未关闭的 GitHub issue。 + * 关闭态经测试 hub(`FOUNT_TEST_HUB_URL`)查询;无 hub / 探测失败 → 视为仍打开。 + */ +import { parseGithubIssueUrl } from '../core/github_issue.mjs' +import { isGithubIssueClosed } from '../hub/clients/github_issue.mjs' + +export { isGithubIssueClosed } + +/** + * 收集页面 `[aria-ignore]`,缺 URL / 非 issue URL / issue 已关闭则抛错。 + * @param {import('@playwright/test').Page} page 页面 + * @returns {Promise} + */ +export async function assertAriaIgnoreIssues(page) { + const entries = await page.evaluate(() => [...document.querySelectorAll('[aria-ignore]')].map(el => ({ + url: (el.getAttribute('aria-ignore') || '').trim(), + where: el.id ? `#${el.id}` : String(el.className || el.tagName), + }))) + if (!entries.length) return + + /** @type {string[]} */ + const problems = [] + for (const { url, where } of entries) { + if (!url) { + problems.push(`${where}: aria-ignore requires a GitHub issue URL`) + continue + } + if (!parseGithubIssueUrl(url)) { + problems.push(`${where}: bad aria-ignore URL ${url}`) + continue + } + if (await isGithubIssueClosed(url)) + problems.push(`${where}: issue closed — remove aria-ignore (${url})`) + } + if (problems.length) + throw new Error(`aria-ignore:\n${problems.join('\n')}`) +} diff --git a/src/scripts/test/runner/index.mjs b/src/scripts/test/runner/index.mjs index f65bb004e..694e1d9a6 100644 --- a/src/scripts/test/runner/index.mjs +++ b/src/scripts/test/runner/index.mjs @@ -38,6 +38,7 @@ import { writeState, writeStateMarkdown, } from '../core/state.mjs' +import { startTestHub } from '../hub/index.mjs' import { auditTriggerCoverage } from '../core/trigger_audit.mjs' import { buildVerdicts } from '../core/verdict.mjs' @@ -647,73 +648,38 @@ export async function runTests(options = {}) { console.logI18n('fountConsole.test.manifestMatched', { ids: manifestIds.join(', ') }) } - if (options.runAll || explicitSuites) { - const selection = selectExplicitOrAll({ - filtered, - state, - manifestIds, - runAll: options.runAll === true, - subtestFilterByKey, - }) - if (selection.action === 'exit') { - if (explicitSuites) { - console.logI18n('fountConsole.test.noMatchingSuites') - const scope = manifestIds?.length - ? allSuites.filter(s => manifestIds.includes(s.manifestId)) - : allSuites - console.errorI18n('fountConsole.test.available', { - ids: topoSortSuites(scope, allSuites).map(s => s.id).join(', '), - }) - return 2 + const hub = await startTestHub() + process.env.FOUNT_TEST_HUB_URL = hub.url + try { + if (options.runAll || explicitSuites) { + const selection = selectExplicitOrAll({ + filtered, + state, + manifestIds, + runAll: options.runAll === true, + subtestFilterByKey, + }) + if (selection.action === 'exit') { + if (explicitSuites) { + console.logI18n('fountConsole.test.noMatchingSuites') + const scope = manifestIds?.length + ? allSuites.filter(s => manifestIds.includes(s.manifestId)) + : allSuites + console.errorI18n('fountConsole.test.available', { + ids: topoSortSuites(scope, allSuites).map(s => s.id).join(', '), + }) + return 2 + } + return 0 } - return 0 - } - console.logI18n('fountConsole.test.selectedSuites', { - selected: selection.goalKeys.size, - total: allSuites.length, - }) - const committedChangedByKey = await buildCommittedChangedByKey(REPO_ROOT, allSuites, state) - const verdicts = buildVerdicts(allSuites, state, committedChangedByKey, uncommittedHashes) - return executeWave({ - selection, - verdicts, - byKey, - allSuites, - state, - commitHash, - uncommittedHash, - globalBudget, - options, - runId, - command, - subtestFilterByKey, - triggerWarnings, - }) - } - - // 默认:imperfect(含 fresh noisy)→ outdated 循环;failed/blocked/noisy/pending 即退 1 - // commit / trigger 指纹只在套件 slot 处理完后写入(run → upsertSuiteRun;reuse → refreshEntryFingerprint), - // 禁止波次开始前批量对齐——否则 Ctrl+C 会把未跑套件标成已在当前 HEAD 验证过。 - for (; ;) { - const committedChangedByKey = await buildCommittedChangedByKey(REPO_ROOT, allSuites, state) - const verdicts = buildVerdicts(allSuites, state, committedChangedByKey, uncommittedHashes) - - const imperfect = selectImperfectWave({ - verdicts, - state, - allSuites, - scope: filtered, - commitHash, - uncommittedHash, - }) - if (imperfect.action === 'run') { - console.logI18n('fountConsole.test.continueImperfect', { count: imperfect.goalKeys.size }) console.logI18n('fountConsole.test.selectedSuites', { - selected: imperfect.goalKeys.size, + selected: selection.goalKeys.size, total: allSuites.length, }) - const code = await executeWave({ - selection: imperfect, + const committedChangedByKey = await buildCommittedChangedByKey(REPO_ROOT, allSuites, state) + const verdicts = buildVerdicts(allSuites, state, committedChangedByKey, uncommittedHashes) + return await executeWave({ + selection, verdicts, byKey, allSuites, @@ -727,53 +693,97 @@ export async function runTests(options = {}) { subtestFilterByKey, triggerWarnings, }) - if (code !== 0) return code - continue } - const outdated = selectOutdatedWave({ - verdicts, - scope: filtered, - allSuites, - committedChangedByKey, - commitHash, - uncommittedHash, - state, - }) - if (outdated.action === 'run') { - console.logI18n('fountConsole.test.outdatedSelected', { count: outdated.goalKeys.size }) - console.logI18n('fountConsole.test.selectedSuites', { - selected: outdated.goalKeys.size, - total: allSuites.length, - }) - const code = await executeWave({ - selection: outdated, + // 默认:imperfect(含 fresh noisy)→ outdated 循环;failed/blocked/noisy/pending 即退 1 + // commit / trigger 指纹只在套件 slot 处理完后写入(run → upsertSuiteRun;reuse → refreshEntryFingerprint), + // 禁止波次开始前批量对齐——否则 Ctrl+C 会把未跑套件标成已在当前 HEAD 验证过。 + for (; ;) { + const committedChangedByKey = await buildCommittedChangedByKey(REPO_ROOT, allSuites, state) + const verdicts = buildVerdicts(allSuites, state, committedChangedByKey, uncommittedHashes) + + const imperfect = selectImperfectWave({ verdicts, - byKey, - allSuites, state, + allSuites, + scope: filtered, commitHash, uncommittedHash, - globalBudget, - options, - runId, - command, - subtestFilterByKey, - triggerWarnings, }) - if (code !== 0) return code - continue - } + if (imperfect.action === 'run') { + console.logI18n('fountConsole.test.continueImperfect', { count: imperfect.goalKeys.size }) + console.logI18n('fountConsole.test.selectedSuites', { + selected: imperfect.goalKeys.size, + total: allSuites.length, + }) + const code = await executeWave({ + selection: imperfect, + verdicts, + byKey, + allSuites, + state, + commitHash, + uncommittedHash, + globalBudget, + options, + runId, + command, + subtestFilterByKey, + triggerWarnings, + }) + if (code !== 0) return code + continue + } - const noisyKeys = listFreshNoisyKeys(verdicts, filtered) - if (noisyKeys.length) { - console.logI18n('fountConsole.test.noisyOnlyRemain', { - count: noisyKeys.length, - suites: noisyKeys.join(', '), + const outdated = selectOutdatedWave({ + verdicts, + scope: filtered, + allSuites, + committedChangedByKey, + commitHash, + uncommittedHash, + state, }) - return 1 + if (outdated.action === 'run') { + console.logI18n('fountConsole.test.outdatedSelected', { count: outdated.goalKeys.size }) + console.logI18n('fountConsole.test.selectedSuites', { + selected: outdated.goalKeys.size, + total: allSuites.length, + }) + const code = await executeWave({ + selection: outdated, + verdicts, + byKey, + allSuites, + state, + commitHash, + uncommittedHash, + globalBudget, + options, + runId, + command, + subtestFilterByKey, + triggerWarnings, + }) + if (code !== 0) return code + continue + } + + const noisyKeys = listFreshNoisyKeys(verdicts, filtered) + if (noisyKeys.length) { + console.logI18n('fountConsole.test.noisyOnlyRemain', { + count: noisyKeys.length, + suites: noisyKeys.join(', '), + }) + return 1 + } + console.logI18n('fountConsole.test.nothingToContinue') + return 0 } - console.logI18n('fountConsole.test.nothingToContinue') - return 0 + } + finally { + await hub.close().catch(() => { /* 关口失败不盖过测试退出码 */ }) + if (process.env.FOUNT_TEST_HUB_URL === hub.url) + delete process.env.FOUNT_TEST_HUB_URL } } diff --git a/src/scripts/test/selftest/github_issue.test.mjs b/src/scripts/test/selftest/github_issue.test.mjs new file mode 100644 index 000000000..e2ddc15c4 --- /dev/null +++ b/src/scripts/test/selftest/github_issue.test.mjs @@ -0,0 +1,24 @@ +/** + * `parseGithubIssueUrl`(纯解析,不打 gh)。 + */ +import { assertEquals } from 'jsr:@std/assert' + +import { parseGithubIssueUrl } from '../core/github_issue.mjs' + +Deno.test('parseGithubIssueUrl accepts canonical issue URLs', () => { + assertEquals(parseGithubIssueUrl('https://github.com/josdejong/svelte-jsoneditor/issues/584'), { + owner: 'josdejong', + repo: 'svelte-jsoneditor', + number: '584', + }) + assertEquals( + parseGithubIssueUrl('https://github.com/josdejong/svelte-jsoneditor/issues/584#issuecomment-1'), + { owner: 'josdejong', repo: 'svelte-jsoneditor', number: '584' }, + ) +}) + +Deno.test('parseGithubIssueUrl rejects non-issue URLs', () => { + assertEquals(parseGithubIssueUrl('https://github.com/josdejong/svelte-jsoneditor/pull/580'), null) + assertEquals(parseGithubIssueUrl('https://example.com/issues/1'), null) + assertEquals(parseGithubIssueUrl(''), null) +}) diff --git a/src/scripts/test/selftest/test_hub.test.mjs b/src/scripts/test/selftest/test_hub.test.mjs new file mode 100644 index 000000000..8f2e9cbf7 --- /dev/null +++ b/src/scripts/test/selftest/test_hub.test.mjs @@ -0,0 +1,53 @@ +/** + * 测试 hub(固定生产口 8903;本自测用 18903 避开父进程 hub)。 + */ +import { assertEquals } from 'jsr:@std/assert' +import process from 'node:process' + +import { getTestHubBaseUrl } from '../hub/base_url.mjs' +import { isGithubIssueClosed } from '../hub/clients/github_issue.mjs' +import { hubSharedStoreDelete, hubSharedStoreGet, hubSharedStoreSet } from '../hub/clients/shared_store.mjs' +import { startTestHub } from '../hub/index.mjs' + +/** 自测专用端口,避开 `fount test` 父进程占用的 8903。 */ +const SELFTEST_HUB_PORT = 18903 + +Deno.test('test hub health + shared-store + github-issue invalid URL', async () => { + const previous = process.env.FOUNT_TEST_HUB_URL + const hub = await startTestHub({ port: SELFTEST_HUB_PORT }) + process.env.FOUNT_TEST_HUB_URL = hub.url + try { + assertEquals(getTestHubBaseUrl(), hub.url) + + const health = await fetch(`${hub.url}/health`) + assertEquals(health.status, 200) + assertEquals(await health.json(), { ok: true }) + + assertEquals(await hubSharedStoreGet('demo', 'k'), undefined) + assertEquals(await hubSharedStoreSet('demo', 'k', { n: 1 }), true) + assertEquals(await hubSharedStoreGet('demo', 'k'), { n: 1 }) + assertEquals(await hubSharedStoreDelete('demo', 'k'), true) + assertEquals(await hubSharedStoreGet('demo', 'k'), undefined) + + assertEquals(await isGithubIssueClosed('https://example.com/issues/1'), false) + assertEquals(await isGithubIssueClosed(''), false) + } + finally { + await hub.close() + if (previous === undefined) delete process.env.FOUNT_TEST_HUB_URL + else process.env.FOUNT_TEST_HUB_URL = previous + } +}) + +Deno.test('isGithubIssueClosed without hub is false', async () => { + const previous = process.env.FOUNT_TEST_HUB_URL + delete process.env.FOUNT_TEST_HUB_URL + try { + assertEquals(getTestHubBaseUrl(), '') + assertEquals(await isGithubIssueClosed('https://github.com/josdejong/svelte-jsoneditor/issues/584'), false) + } + finally { + if (previous === undefined) delete process.env.FOUNT_TEST_HUB_URL + else process.env.FOUNT_TEST_HUB_URL = previous + } +}) From 132eae9ad12ff2b0b1d0805eaf049941d3d8147e Mon Sep 17 00:00:00 2001 From: steve02081504 Date: Sat, 1 Aug 2026 19:15:23 +0800 Subject: [PATCH 03/11] 1 --- src/public/locales/ar-SA.json | 12 ++++++++++++ src/public/locales/de-DE.json | 12 ++++++++++++ src/public/locales/emoji.json | 12 ++++++++++++ src/public/locales/en-UK.json | 12 ++++++++++++ src/public/locales/es-ES.json | 12 ++++++++++++ src/public/locales/fr-FR.json | 12 ++++++++++++ src/public/locales/hi-IN.json | 12 ++++++++++++ src/public/locales/is-IS.json | 12 ++++++++++++ src/public/locales/it-IT.json | 12 ++++++++++++ src/public/locales/ja-JP.json | 12 ++++++++++++ src/public/locales/ko-KR.json | 12 ++++++++++++ src/public/locales/lzh.json | 12 ++++++++++++ src/public/locales/nl-NL.json | 12 ++++++++++++ src/public/locales/pt-PT.json | 12 ++++++++++++ src/public/locales/ru-RU.json | 12 ++++++++++++ src/public/locales/uk-UA.json | 12 ++++++++++++ src/public/locales/vi-VN.json | 12 ++++++++++++ src/public/locales/zh-CN.json | 12 ++++++++++++ src/public/locales/zh-TW.json | 12 ++++++++++++ 19 files changed, 228 insertions(+) diff --git a/src/public/locales/ar-SA.json b/src/public/locales/ar-SA.json index 61e93dc08..291324e3b 100644 --- a/src/public/locales/ar-SA.json +++ b/src/public/locales/ar-SA.json @@ -3659,6 +3659,9 @@ } }, "configTitle": "تكوين مصدر الخدمة", + "jsonEditor": { + "aria-label": "محرر JSON لتكوين مصدر الخدمة" + }, "needsConfigReminder": "بعد تكوين مصدر الخدمة، تحتاج عمومًا إلى ملء اسم مصدر الخدمة في الحقل المقابل في تكوين المكون الذي يستخدم مصدر الخدمة هذا.", "needsConfigLink": "انتقل إلى تكوين القطعة", "subtypeSelect": { @@ -3787,6 +3790,9 @@ "botToken": "رمز بوت Telegram", "config": "تهيئة" }, + "jsonEditor": { + "aria-label": "محرر JSON لتكوين البوت" + }, "botTokenInput": { "placeholder": "الرجاء إدخال رمز بوت Telegram" }, @@ -3867,6 +3873,9 @@ "apiKey": "مفتاح API لـ Discord", "config": "تهيئة" }, + "jsonEditor": { + "aria-label": "محرر JSON لتكوين البوت" + }, "apiKeyInput": { "placeholder": "الرجاء إدخال مفتاح API" }, @@ -3946,6 +3955,9 @@ "botToken": "رمز iLink Bot", "config": "الإعدادات" }, + "jsonEditor": { + "aria-label": "محرر JSON لتكوين البوت" + }, "apiBaseUrlInput": { "placeholder": "اتركه فارغًا للقيمة الافتراضية https://ilinkai.weixin.qq.com" }, diff --git a/src/public/locales/de-DE.json b/src/public/locales/de-DE.json index a1b34243a..0d3715ee3 100644 --- a/src/public/locales/de-DE.json +++ b/src/public/locales/de-DE.json @@ -3659,6 +3659,9 @@ } }, "configTitle": "Konfiguration der Dienstquelle", + "jsonEditor": { + "aria-label": "JSON-Editor für die Dienstquellenkonfiguration" + }, "needsConfigReminder": "Nachdem die Dienstquelle konfiguriert wurde, müssen Sie im Allgemeinen den Namen der Dienstquelle in das entsprechende Feld in der Konfiguration der Komponente eingeben, die diese Dienstquelle verwendet.", "needsConfigLink": "Gehen Sie zur Widget-Konfiguration", "subtypeSelect": { @@ -3787,6 +3790,9 @@ "botToken": "Telegram Bot-Token", "config": "Konfiguration" }, + "jsonEditor": { + "aria-label": "JSON-Editor für die Bot-Konfiguration" + }, "botTokenInput": { "placeholder": "Telegram Bot-Token eingeben" }, @@ -3867,6 +3873,9 @@ "apiKey": "Discord API-Schlüssel", "config": "Konfiguration" }, + "jsonEditor": { + "aria-label": "JSON-Editor für die Bot-Konfiguration" + }, "apiKeyInput": { "placeholder": "API-Schlüssel eingeben" }, @@ -3946,6 +3955,9 @@ "botToken": "iLink-Bot-Token", "config": "Konfiguration" }, + "jsonEditor": { + "aria-label": "JSON-Editor für die Bot-Konfiguration" + }, "apiBaseUrlInput": { "placeholder": "Leer lassen für Standard https://ilinkai.weixin.qq.com" }, diff --git a/src/public/locales/emoji.json b/src/public/locales/emoji.json index 11807b07f..1ead025a2 100644 --- a/src/public/locales/emoji.json +++ b/src/public/locales/emoji.json @@ -3659,6 +3659,9 @@ } }, "configTitle": "🚰⚙️", + "jsonEditor": { + "aria-label": "📝🚰⚙️" + }, "needsConfigReminder": "🚰✅ ➡️ 📝🚰📛 ➡️ 🧩⚙️", "needsConfigLink": "➡️🧩⚙️", "subtypeSelect": { @@ -3787,6 +3790,9 @@ "botToken": "🔑", "config": "⚙️" }, + "jsonEditor": { + "aria-label": "📝🤖⚙️" + }, "botTokenInput": { "placeholder": "✍️🔑" }, @@ -3867,6 +3873,9 @@ "apiKey": "🔑", "config": "⚙️" }, + "jsonEditor": { + "aria-label": "📝🤖⚙️" + }, "apiKeyInput": { "placeholder": "✍️🔑" }, @@ -3946,6 +3955,9 @@ "botToken": "🔑", "config": "⚙️" }, + "jsonEditor": { + "aria-label": "📝🤖⚙️" + }, "apiBaseUrlInput": { "placeholder": "⬜️↩️ 🌐🔗" }, diff --git a/src/public/locales/en-UK.json b/src/public/locales/en-UK.json index 597a7498d..42bc1f2c4 100644 --- a/src/public/locales/en-UK.json +++ b/src/public/locales/en-UK.json @@ -3654,6 +3654,9 @@ } }, "configTitle": "Service source configuration", + "jsonEditor": { + "aria-label": "Service source configuration JSON editor" + }, "needsConfigReminder": "After the service source is configured, you generally need to fill in the service source name in the corresponding field in the configuration of the component that uses this service source.", "needsConfigLink": "Go to widget configuration", "subtypeSelect": { @@ -3782,6 +3785,9 @@ "botToken": "Telegram Bot Token", "config": "Configuration" }, + "jsonEditor": { + "aria-label": "Bot configuration JSON editor" + }, "botTokenInput": { "placeholder": "Enter Telegram Bot Token" }, @@ -3862,6 +3868,9 @@ "apiKey": "Discord API Key", "config": "Configuration" }, + "jsonEditor": { + "aria-label": "Bot configuration JSON editor" + }, "apiKeyInput": { "placeholder": "Enter API Key" }, @@ -3941,6 +3950,9 @@ "botToken": "iLink Bot token", "config": "Configuration" }, + "jsonEditor": { + "aria-label": "Bot configuration JSON editor" + }, "apiBaseUrlInput": { "placeholder": "Leave blank for default https://ilinkai.weixin.qq.com" }, diff --git a/src/public/locales/es-ES.json b/src/public/locales/es-ES.json index e458b93da..dbbd8f3d5 100644 --- a/src/public/locales/es-ES.json +++ b/src/public/locales/es-ES.json @@ -3659,6 +3659,9 @@ } }, "configTitle": "Configuración de la fuente de servicio", + "jsonEditor": { + "aria-label": "Editor JSON de configuración de la fuente de servicio" + }, "needsConfigReminder": "Una vez configurada la fuente de servicio, generalmente debe completar el nombre de la fuente de servicio en el campo correspondiente en la configuración del componente que utiliza esta fuente de servicio.", "needsConfigLink": "Ir a configuración de widgets", "subtypeSelect": { @@ -3787,6 +3790,9 @@ "botToken": "Token de Bot de Telegram", "config": "Configuración" }, + "jsonEditor": { + "aria-label": "Editor JSON de configuración del bot" + }, "botTokenInput": { "placeholder": "Introduce el Token de Bot de Telegram" }, @@ -3867,6 +3873,9 @@ "apiKey": "Clave de API de Discord", "config": "Configuración" }, + "jsonEditor": { + "aria-label": "Editor JSON de configuración del bot" + }, "apiKeyInput": { "placeholder": "Introduce la clave de API de Discord" }, @@ -3946,6 +3955,9 @@ "botToken": "Token iLink Bot", "config": "Configuración" }, + "jsonEditor": { + "aria-label": "Editor JSON de configuración del bot" + }, "apiBaseUrlInput": { "placeholder": "En blanco para usar https://ilinkai.weixin.qq.com por defecto" }, diff --git a/src/public/locales/fr-FR.json b/src/public/locales/fr-FR.json index 5b266a869..e5eca31a9 100644 --- a/src/public/locales/fr-FR.json +++ b/src/public/locales/fr-FR.json @@ -3659,6 +3659,9 @@ } }, "configTitle": "Configuration des sources de services", + "jsonEditor": { + "aria-label": "Éditeur JSON de configuration de la source de service" + }, "needsConfigReminder": "Une fois la source de service configurée, vous devez généralement renseigner le nom de la source de service dans le champ correspondant dans la configuration du composant qui utilise cette source de service.", "needsConfigLink": "Aller à la configuration des widgets", "subtypeSelect": { @@ -3787,6 +3790,9 @@ "botToken": "Jeton du bot Telegram", "config": "Configuration" }, + "jsonEditor": { + "aria-label": "Éditeur JSON de configuration du bot" + }, "botTokenInput": { "placeholder": "Saisissez le jeton du bot Telegram" }, @@ -3867,6 +3873,9 @@ "apiKey": "Clé API Discord", "config": "Configuration" }, + "jsonEditor": { + "aria-label": "Éditeur JSON de configuration du bot" + }, "apiKeyInput": { "placeholder": "Saisir la clé API" }, @@ -3946,6 +3955,9 @@ "botToken": "Jeton iLink Bot", "config": "Configuration" }, + "jsonEditor": { + "aria-label": "Éditeur JSON de configuration du bot" + }, "apiBaseUrlInput": { "placeholder": "Laisser vide pour https://ilinkai.weixin.qq.com par défaut" }, diff --git a/src/public/locales/hi-IN.json b/src/public/locales/hi-IN.json index 5091f313b..ae4ec65e2 100644 --- a/src/public/locales/hi-IN.json +++ b/src/public/locales/hi-IN.json @@ -3659,6 +3659,9 @@ } }, "configTitle": "सेवा स्रोत विन्यास", + "jsonEditor": { + "aria-label": "सेवा स्रोत कॉन्फ़िगरेशन JSON संपादक" + }, "needsConfigReminder": "सेवा स्रोत कॉन्फ़िगर होने के बाद, आपको आम तौर पर इस सेवा स्रोत का उपयोग करने वाले घटक के कॉन्फ़िगरेशन में संबंधित फ़ील्ड में सेवा स्रोत का नाम भरना होगा।", "needsConfigLink": "विजेट कॉन्फ़िगरेशन पर जाएं", "subtypeSelect": { @@ -3787,6 +3790,9 @@ "botToken": "टेलीग्राम बॉट टोकन", "config": "कॉन्फ़िगरेशन" }, + "jsonEditor": { + "aria-label": "बॉट कॉन्फ़िगरेशन JSON संपादक" + }, "botTokenInput": { "placeholder": "टेलीग्राम बॉट टोकन दर्ज करें" }, @@ -3867,6 +3873,9 @@ "apiKey": "डिस्कॉर्ड API कुंजी", "config": "कॉन्फ़िगरेशन" }, + "jsonEditor": { + "aria-label": "बॉट कॉन्फ़िगरेशन JSON संपादक" + }, "apiKeyInput": { "placeholder": "API कुंजी दर्ज करें" }, @@ -3946,6 +3955,9 @@ "botToken": "iLink बॉट टोकन", "config": "कॉन्फ़िगरेशन" }, + "jsonEditor": { + "aria-label": "बॉट कॉन्फ़िगरेशन JSON संपादक" + }, "apiBaseUrlInput": { "placeholder": "डिफ़ॉल्ट https://ilinkai.weixin.qq.com के लिए खाली छोड़ें" }, diff --git a/src/public/locales/is-IS.json b/src/public/locales/is-IS.json index 67cb5ba54..15f0570ab 100644 --- a/src/public/locales/is-IS.json +++ b/src/public/locales/is-IS.json @@ -3659,6 +3659,9 @@ } }, "configTitle": "Uppsetning þjónustugjafa", + "jsonEditor": { + "aria-label": "JSON-stillingaritill þjónustugjafa" + }, "needsConfigReminder": "Eftir að þjónustugjafinn hefur verið stilltur þarftu almennt að fylla út heiti þjónustugjafans í samsvarandi reit í uppsetningu íhlutans sem notar þennan þjónustugjafa.", "needsConfigLink": "Farðu í stillingar græju", "subtypeSelect": { @@ -3787,6 +3790,9 @@ "botToken": "Telegram Bot Token", "config": "Stillingar" }, + "jsonEditor": { + "aria-label": "JSON-stillingaritill bots" + }, "botTokenInput": { "placeholder": "Sláðu inn Telegram Bot Token" }, @@ -3867,6 +3873,9 @@ "apiKey": "Discord API lykill", "config": "Stillingar" }, + "jsonEditor": { + "aria-label": "JSON-stillingaritill bots" + }, "apiKeyInput": { "placeholder": "Sláðu inn API lykil" }, @@ -3946,6 +3955,9 @@ "botToken": "iLink Bot-teikn", "config": "Stillingar" }, + "jsonEditor": { + "aria-label": "JSON-stillingaritill bots" + }, "apiBaseUrlInput": { "placeholder": "Skildu autt fyrir sjálfgefna https://ilinkai.weixin.qq.com" }, diff --git a/src/public/locales/it-IT.json b/src/public/locales/it-IT.json index 112a88db5..bf9338789 100644 --- a/src/public/locales/it-IT.json +++ b/src/public/locales/it-IT.json @@ -3659,6 +3659,9 @@ } }, "configTitle": "Configurazione dell'origine del servizio", + "jsonEditor": { + "aria-label": "Editor JSON di configurazione dell'origine del servizio" + }, "needsConfigReminder": "Dopo aver configurato l'origine del servizio, in genere è necessario inserire il nome dell'origine del servizio nel campo corrispondente nella configurazione del componente che utilizza questa origine del servizio.", "needsConfigLink": "Vai alla configurazione del componente", "subtypeSelect": { @@ -3787,6 +3790,9 @@ "botToken": "Token Bot Telegram", "config": "Configurazione" }, + "jsonEditor": { + "aria-label": "Editor JSON di configurazione del bot" + }, "botTokenInput": { "placeholder": "Inserisci token Bot Telegram" }, @@ -3867,6 +3873,9 @@ "apiKey": "Chiave API Discord", "config": "Configurazione" }, + "jsonEditor": { + "aria-label": "Editor JSON di configurazione del bot" + }, "apiKeyInput": { "placeholder": "Inserisci chiave API" }, @@ -3946,6 +3955,9 @@ "botToken": "Token iLink Bot", "config": "Configurazione" }, + "jsonEditor": { + "aria-label": "Editor JSON di configurazione del bot" + }, "apiBaseUrlInput": { "placeholder": "Lascia vuoto per https://ilinkai.weixin.qq.com predefinito" }, diff --git a/src/public/locales/ja-JP.json b/src/public/locales/ja-JP.json index 4ab1556c0..6bb20fd98 100644 --- a/src/public/locales/ja-JP.json +++ b/src/public/locales/ja-JP.json @@ -3659,6 +3659,9 @@ } }, "configTitle": "サービスソースの設定", + "jsonEditor": { + "aria-label": "サービスソース設定のJSONエディター" + }, "needsConfigReminder": "サービスソースを設定した後、通常は、このサービスソースを使用するコンポーネントの設定内の対応するフィールドにサービスソース名を入力する必要があります。", "needsConfigLink": "ウィジェット設定に移動します", "subtypeSelect": { @@ -3787,6 +3790,9 @@ "botToken": "Telegramボットトークン", "config": "設定" }, + "jsonEditor": { + "aria-label": "ボット設定のJSONエディター" + }, "botTokenInput": { "placeholder": "Telegramボットトークンを入力" }, @@ -3867,6 +3873,9 @@ "apiKey": "Discord APIキー", "config": "設定" }, + "jsonEditor": { + "aria-label": "ボット設定のJSONエディター" + }, "apiKeyInput": { "placeholder": "APIキーを入力" }, @@ -3946,6 +3955,9 @@ "botToken": "iLink Bot トークン", "config": "設定" }, + "jsonEditor": { + "aria-label": "ボット設定のJSONエディター" + }, "apiBaseUrlInput": { "placeholder": "空欄で既定の https://ilinkai.weixin.qq.com を使用" }, diff --git a/src/public/locales/ko-KR.json b/src/public/locales/ko-KR.json index de224e90b..8bf8dd61b 100644 --- a/src/public/locales/ko-KR.json +++ b/src/public/locales/ko-KR.json @@ -3659,6 +3659,9 @@ } }, "configTitle": "서비스 소스 설정", + "jsonEditor": { + "aria-label": "서비스 소스 설정 JSON 편집기" + }, "needsConfigReminder": "서비스 소스를 설정한 후, 일반적으로 이 서비스 소스를 사용하는 구성 요소 설정의 해당 필드에 서비스 소스 이름을 입력해야 합니다.", "needsConfigLink": "위젯 구성으로 이동", "subtypeSelect": { @@ -3787,6 +3790,9 @@ "botToken": "텔레그램 봇 토큰", "config": "설정" }, + "jsonEditor": { + "aria-label": "봇 설정 JSON 편집기" + }, "botTokenInput": { "placeholder": "텔레그램 봇 토큰을 입력하세요" }, @@ -3867,6 +3873,9 @@ "apiKey": "Discord API 키", "config": "설정" }, + "jsonEditor": { + "aria-label": "봇 설정 JSON 편집기" + }, "apiKeyInput": { "placeholder": "API 키를 입력하세요" }, @@ -3946,6 +3955,9 @@ "botToken": "iLink 봇 토큰", "config": "구성" }, + "jsonEditor": { + "aria-label": "봇 설정 JSON 편집기" + }, "apiBaseUrlInput": { "placeholder": "비워 두면 기본값 https://ilinkai.weixin.qq.com 사용" }, diff --git a/src/public/locales/lzh.json b/src/public/locales/lzh.json index d9573c969..f9c53b413 100644 --- a/src/public/locales/lzh.json +++ b/src/public/locales/lzh.json @@ -3659,6 +3659,9 @@ } }, "configTitle": "源流樞機", + "jsonEditor": { + "aria-label": "源流樞機 JSON 樞機" + }, "needsConfigReminder": "源流既設,常需於用此源流之部件樞機中,填其名於相應之處。", "needsConfigLink": "往設部件", "subtypeSelect": { @@ -3787,6 +3790,9 @@ "botToken": "Telegram 靈偶信物", "config": "樞機" }, + "jsonEditor": { + "aria-label": "靈偶樞機 JSON 樞機" + }, "botTokenInput": { "placeholder": "請入 Telegram 靈偶信物" }, @@ -3867,6 +3873,9 @@ "apiKey": "Discord 符鑰", "config": "樞機" }, + "jsonEditor": { + "aria-label": "靈偶樞機 JSON 樞機" + }, "apiKeyInput": { "placeholder": "請入符鑰" }, @@ -3946,6 +3955,9 @@ "botToken": "iLink 靈偶信物", "config": "樞機" }, + "jsonEditor": { + "aria-label": "靈偶樞機 JSON 樞機" + }, "apiBaseUrlInput": { "placeholder": "留空則用預設 https://ilinkai.weixin.qq.com" }, diff --git a/src/public/locales/nl-NL.json b/src/public/locales/nl-NL.json index 0e5c3c9bb..bd6d29ca0 100644 --- a/src/public/locales/nl-NL.json +++ b/src/public/locales/nl-NL.json @@ -3659,6 +3659,9 @@ } }, "configTitle": "Configuratie van de servicebron", + "jsonEditor": { + "aria-label": "JSON-configuratie-editor voor servicebron" + }, "needsConfigReminder": "Nadat de servicebron is geconfigureerd, moet u doorgaans de naam van de servicebron invullen in het overeenkomstige veld in de configuratie van het onderdeel dat deze servicebron gebruikt.", "needsConfigLink": "Ga naar widgetconfiguratie", "subtypeSelect": { @@ -3787,6 +3790,9 @@ "botToken": "Telegram Bot Token", "config": "Configuratie" }, + "jsonEditor": { + "aria-label": "JSON-configuratie-editor voor bot" + }, "botTokenInput": { "placeholder": "Voer Telegram Bot Token in" }, @@ -3867,6 +3873,9 @@ "apiKey": "Discord API-sleutel", "config": "Configuratie" }, + "jsonEditor": { + "aria-label": "JSON-configuratie-editor voor bot" + }, "apiKeyInput": { "placeholder": "Voer API-sleutel in" }, @@ -3946,6 +3955,9 @@ "botToken": "iLink Bot-token", "config": "Configuratie" }, + "jsonEditor": { + "aria-label": "JSON-configuratie-editor voor bot" + }, "apiBaseUrlInput": { "placeholder": "Leeg laten voor standaard https://ilinkai.weixin.qq.com" }, diff --git a/src/public/locales/pt-PT.json b/src/public/locales/pt-PT.json index cdc00195b..a89b178c1 100644 --- a/src/public/locales/pt-PT.json +++ b/src/public/locales/pt-PT.json @@ -3659,6 +3659,9 @@ } }, "configTitle": "Configuração da fonte de serviço", + "jsonEditor": { + "aria-label": "Editor JSON de configuração da fonte de serviço" + }, "needsConfigReminder": "Depois que a fonte de serviço for configurada, geralmente será necessário preencher o nome da fonte de serviço no campo correspondente na configuração do componente que usa essa fonte de serviço.", "needsConfigLink": "Vá para a configuração do widget", "subtypeSelect": { @@ -3787,6 +3790,9 @@ "botToken": "Token do Bot Telegram", "config": "Configuração" }, + "jsonEditor": { + "aria-label": "Editor JSON de configuração do bot" + }, "botTokenInput": { "placeholder": "Introduza o Token do Bot Telegram" }, @@ -3867,6 +3873,9 @@ "apiKey": "Chave da API Discord", "config": "Configuração" }, + "jsonEditor": { + "aria-label": "Editor JSON de configuração do bot" + }, "apiKeyInput": { "placeholder": "Introduza a Chave da API" }, @@ -3946,6 +3955,9 @@ "botToken": "Token iLink Bot", "config": "Configuração" }, + "jsonEditor": { + "aria-label": "Editor JSON de configuração do bot" + }, "apiBaseUrlInput": { "placeholder": "Em branco para o predefinido https://ilinkai.weixin.qq.com" }, diff --git a/src/public/locales/ru-RU.json b/src/public/locales/ru-RU.json index 05773d848..c22413893 100644 --- a/src/public/locales/ru-RU.json +++ b/src/public/locales/ru-RU.json @@ -3659,6 +3659,9 @@ } }, "configTitle": "Конфигурация источника службы", + "jsonEditor": { + "aria-label": "JSON-редактор конфигурации источника службы" + }, "needsConfigReminder": "После настройки источника службы обычно необходимо ввести имя источника службы в соответствующее поле в конфигурации компонента, использующего этот источник службы.", "needsConfigLink": "Перейти к настройке виджета", "subtypeSelect": { @@ -3787,6 +3790,9 @@ "botToken": "Токен Telegram бота", "config": "Конфигурация" }, + "jsonEditor": { + "aria-label": "JSON-редактор конфигурации бота" + }, "botTokenInput": { "placeholder": "Введите токен Telegram бота" }, @@ -3867,6 +3873,9 @@ "apiKey": "API-ключ Discord", "config": "Конфигурация" }, + "jsonEditor": { + "aria-label": "JSON-редактор конфигурации бота" + }, "apiKeyInput": { "placeholder": "Введите API-ключ" }, @@ -3946,6 +3955,9 @@ "botToken": "Токен iLink Bot", "config": "Конфигурация" }, + "jsonEditor": { + "aria-label": "JSON-редактор конфигурации бота" + }, "apiBaseUrlInput": { "placeholder": "Пусто — по умолчанию https://ilinkai.weixin.qq.com" }, diff --git a/src/public/locales/uk-UA.json b/src/public/locales/uk-UA.json index e1ff8a851..a79fa2828 100644 --- a/src/public/locales/uk-UA.json +++ b/src/public/locales/uk-UA.json @@ -3659,6 +3659,9 @@ } }, "configTitle": "Конфігурація джерела служби", + "jsonEditor": { + "aria-label": "JSON-редактор конфігурації джерела служби" + }, "needsConfigReminder": "Після налаштування джерела служби зазвичай потрібно заповнити назву джерела служби у відповідному полі конфігурації компонента, який використовує це джерело служби.", "needsConfigLink": "Перейдіть до конфігурації віджета", "subtypeSelect": { @@ -3787,6 +3790,9 @@ "botToken": "Telegram Bot Token", "config": "Конфігурація" }, + "jsonEditor": { + "aria-label": "JSON-редактор конфігурації бота" + }, "botTokenInput": { "placeholder": "Введіть Telegram Bot Token" }, @@ -3867,6 +3873,9 @@ "apiKey": "Discord API ключ", "config": "Конфігурація" }, + "jsonEditor": { + "aria-label": "JSON-редактор конфігурації бота" + }, "apiKeyInput": { "placeholder": "Введіть API ключ" }, @@ -3946,6 +3955,9 @@ "botToken": "Токен iLink Bot", "config": "Конфігурація" }, + "jsonEditor": { + "aria-label": "JSON-редактор конфігурації бота" + }, "apiBaseUrlInput": { "placeholder": "Порожньо — типово https://ilinkai.weixin.qq.com" }, diff --git a/src/public/locales/vi-VN.json b/src/public/locales/vi-VN.json index 7de5dd011..7bb8f4902 100644 --- a/src/public/locales/vi-VN.json +++ b/src/public/locales/vi-VN.json @@ -3659,6 +3659,9 @@ } }, "configTitle": "Cấu hình nguồn dịch vụ", + "jsonEditor": { + "aria-label": "Trình soạn thảo JSON cấu hình nguồn dịch vụ" + }, "needsConfigReminder": "Sau khi định cấu hình nguồn dịch vụ, thông thường bạn cần điền tên nguồn dịch vụ vào trường tương ứng trong cấu hình của thành phần sử dụng nguồn dịch vụ này.", "needsConfigLink": "Đi tới cấu hình tiện ích", "subtypeSelect": { @@ -3787,6 +3790,9 @@ "botToken": "Token Bot Telegram", "config": "Cấu hình" }, + "jsonEditor": { + "aria-label": "Trình soạn thảo JSON cấu hình bot" + }, "botTokenInput": { "placeholder": "Nhập Token Bot Telegram" }, @@ -3867,6 +3873,9 @@ "apiKey": "Khóa API Discord", "config": "Cấu hình" }, + "jsonEditor": { + "aria-label": "Trình soạn thảo JSON cấu hình bot" + }, "apiKeyInput": { "placeholder": "Nhập Khóa API" }, @@ -3946,6 +3955,9 @@ "botToken": "Token iLink Bot", "config": "Cấu hình" }, + "jsonEditor": { + "aria-label": "Trình soạn thảo JSON cấu hình bot" + }, "apiBaseUrlInput": { "placeholder": "Để trống để dùng mặc định https://ilinkai.weixin.qq.com" }, diff --git a/src/public/locales/zh-CN.json b/src/public/locales/zh-CN.json index 024a25b7d..61b1128fa 100644 --- a/src/public/locales/zh-CN.json +++ b/src/public/locales/zh-CN.json @@ -3663,6 +3663,9 @@ } }, "configTitle": "服务源配置", + "jsonEditor": { + "aria-label": "服务源配置 JSON 编辑器" + }, "needsConfigReminder": "服务源配置好后,一般需要在使用此服务源的组件的配置中的对应字段填写服务源名称。", "needsConfigLink": "前往组件配置", "subtypeSelect": { @@ -3791,6 +3794,9 @@ "botToken": "Telegram Bot Token", "config": "配置" }, + "jsonEditor": { + "aria-label": "Bot 配置 JSON 编辑器" + }, "botTokenInput": { "placeholder": "请输入 Telegram Bot Token" }, @@ -3871,6 +3877,9 @@ "apiKey": "Discord API 密钥", "config": "配置" }, + "jsonEditor": { + "aria-label": "Bot 配置 JSON 编辑器" + }, "apiKeyInput": { "placeholder": "请输入 API 密钥" }, @@ -3950,6 +3959,9 @@ "botToken": "iLink Bot Token", "config": "配置" }, + "jsonEditor": { + "aria-label": "Bot 配置 JSON 编辑器" + }, "apiBaseUrlInput": { "placeholder": "留空则使用默认 https://ilinkai.weixin.qq.com" }, diff --git a/src/public/locales/zh-TW.json b/src/public/locales/zh-TW.json index 1f859ab3c..b1d1a8e83 100644 --- a/src/public/locales/zh-TW.json +++ b/src/public/locales/zh-TW.json @@ -3658,6 +3658,9 @@ } }, "configTitle": "服務來源設定", + "jsonEditor": { + "aria-label": "服務來源設定 JSON 編輯器" + }, "needsConfigReminder": "服務來源設定好後,一般需要在使用此服務來源的元件的設定中的對應欄位填入服務來源名稱。", "needsConfigLink": "前往組件設定", "subtypeSelect": { @@ -3786,6 +3789,9 @@ "botToken": "Telegram Bot Token", "config": "設定" }, + "jsonEditor": { + "aria-label": "Bot 設定 JSON 編輯器" + }, "botTokenInput": { "placeholder": "請輸入 Telegram Bot Token" }, @@ -3866,6 +3872,9 @@ "apiKey": "Discord API 金鑰", "config": "設定" }, + "jsonEditor": { + "aria-label": "Bot 設定 JSON 編輯器" + }, "apiKeyInput": { "placeholder": "請輸入 API 金鑰" }, @@ -3945,6 +3954,9 @@ "botToken": "iLink Bot Token", "config": "設定" }, + "jsonEditor": { + "aria-label": "Bot 設定 JSON 編輯器" + }, "apiBaseUrlInput": { "placeholder": "留空則使用預設 https://ilinkai.weixin.qq.com" }, From 3bb9a862160c35afb77277564a587af203451839 Mon Sep 17 00:00:00 2001 From: Taromati2 Date: Sat, 1 Aug 2026 11:17:34 +0000 Subject: [PATCH 04/11] file update~ --- src/decl/locale_data.ts | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src/decl/locale_data.ts b/src/decl/locale_data.ts index dc4000df2..381172668 100644 --- a/src/decl/locale_data.ts +++ b/src/decl/locale_data.ts @@ -3631,6 +3631,9 @@ export type LocaleData = { } } configTitle: string + jsonEditor: { + 'aria-label': string + } needsConfigReminder: string needsConfigLink: string subtypeSelect: { @@ -3759,6 +3762,9 @@ export type LocaleData = { botToken: string config: string } + jsonEditor: { + 'aria-label': string + } botTokenInput: { placeholder: string } @@ -3839,6 +3845,9 @@ export type LocaleData = { apiKey: string config: string } + jsonEditor: { + 'aria-label': string + } apiKeyInput: { placeholder: string } @@ -3918,6 +3927,9 @@ export type LocaleData = { botToken: string config: string } + jsonEditor: { + 'aria-label': string + } apiBaseUrlInput: { placeholder: string } From cb6a4058d230959442764d52d2ce1ca3654d1c38 Mon Sep 17 00:00:00 2001 From: steve02081504 Date: Sat, 1 Aug 2026 20:10:25 +0800 Subject: [PATCH 05/11] 1 --- .github/pages/test/manifest.json | 1 - src/public/locales/lzh.json | 8 ++--- src/public/pages/AGENTS.md | 2 +- .../pages/scripts/components/jsonEditor.mjs | 17 +++++++-- src/public/pages/scripts/test/test_watch.mjs | 6 ++-- .../serviceGenerators/AI/proxy/display.mjs | 2 +- .../parts/shells/config/public/index.html | 2 +- .../parts/shells/config/public/index.mjs | 13 ++----- .../parts/shells/config/test/manifest.json | 3 +- .../parts/shells/discordbot/public/index.mjs | 2 +- .../shells/discordbot/test/manifest.json | 3 +- .../serviceSourceManage/public/index.mjs | 13 ++----- .../test/frontend/phases.mjs | 1 + .../serviceSourceManage/test/manifest.json | 3 +- .../parts/shells/telegrambot/public/index.mjs | 6 ++-- .../shells/telegrambot/test/manifest.json | 3 +- .../parts/shells/wechatbot/public/index.mjs | 3 +- .../parts/shells/wechatbot/test/manifest.json | 3 +- src/scripts/test/AGENTS.md | 3 +- src/scripts/test/core/github_issue.mjs | 13 ++----- src/scripts/test/hub/apis/github_issue.mjs | 24 +++++++------ src/scripts/test/hub/base_url.mjs | 15 ++++---- src/scripts/test/hub/clients/github_issue.mjs | 9 +++-- src/scripts/test/hub/clients/shared_store.mjs | 36 +++++++++++-------- src/scripts/test/hub/index.mjs | 18 +++++++--- src/scripts/test/manifest.json | 2 +- src/scripts/test/runner/index.mjs | 5 ++- 27 files changed, 115 insertions(+), 101 deletions(-) diff --git a/.github/pages/test/manifest.json b/.github/pages/test/manifest.json index a69b07fc7..6061eca68 100644 --- a/.github/pages/test/manifest.json +++ b/.github/pages/test/manifest.json @@ -15,7 +15,6 @@ "triggers": [ ".github/pages/**", "src/public/pages/scripts/**", - "src/public/locales/**", "imgs/**", "src/scripts/test/playwright/**", ".esh/commands/pages-server.mjs" diff --git a/src/public/locales/lzh.json b/src/public/locales/lzh.json index f9c53b413..557a2f8e7 100644 --- a/src/public/locales/lzh.json +++ b/src/public/locales/lzh.json @@ -3660,7 +3660,7 @@ }, "configTitle": "源流樞機", "jsonEditor": { - "aria-label": "源流樞機 JSON 樞機" + "aria-label": "源流樞機 JSON 編輯器" }, "needsConfigReminder": "源流既設,常需於用此源流之部件樞機中,填其名於相應之處。", "needsConfigLink": "往設部件", @@ -3791,7 +3791,7 @@ "config": "樞機" }, "jsonEditor": { - "aria-label": "靈偶樞機 JSON 樞機" + "aria-label": "靈偶樞機 JSON 編輯器" }, "botTokenInput": { "placeholder": "請入 Telegram 靈偶信物" @@ -3874,7 +3874,7 @@ "config": "樞機" }, "jsonEditor": { - "aria-label": "靈偶樞機 JSON 樞機" + "aria-label": "靈偶樞機 JSON 編輯器" }, "apiKeyInput": { "placeholder": "請入符鑰" @@ -3956,7 +3956,7 @@ "config": "樞機" }, "jsonEditor": { - "aria-label": "靈偶樞機 JSON 樞機" + "aria-label": "靈偶樞機 JSON 編輯器" }, "apiBaseUrlInput": { "placeholder": "留空則用預設 https://ilinkai.weixin.qq.com" diff --git a/src/public/pages/AGENTS.md b/src/public/pages/AGENTS.md index 43440359f..1dedcf1bd 100644 --- a/src/public/pages/AGENTS.md +++ b/src/public/pages/AGENTS.md @@ -25,7 +25,7 @@ Markdown convertor traps (rehype order, `{:lang}`, trust tiers): [markdown-notes - **`template.mjs`**: `renderTemplate` / `mountTemplate` / `renderTemplateAsHtmlString` / `withTemplates(path, fn)`. Cross-shell shared modules must **not** call bare `usingTemplates` — use `withTemplates` or direct DOM. - **`dialog.mjs`**: `openDialogFromTemplate` / `pickFromDialog`. Templates supply `modal-box` (+ optional `modal-backdrop`) only — do not nest another ``. - **`promptDialog.mjs`**: shared DaisyUI `promptText` / `promptTextArea` / `confirmAction`. Prefer over `window.prompt` / `confirm` / shell-local copies. **First argument is always an i18n key**; optional third arg is interpolation params. Do not pass `geti18n(...)` strings. Modal title is **`h2`** (page already has `h1`; DaisyUI docs’ `h3` skips a level and trips axe `heading-order`). -- **`components/jsonEditor.mjs`**: `createJsonEditor(container, options)` wraps `vanilla-jsoneditor` (≥3.13). **`options.ariaLabel` is required and must be an i18n key** (resolved + refreshed via `setLocalizeLogic`); do not pass `geti18n(...)` strings. Optional `onSave` is Ctrl+S only — not a library prop. +- **`components/jsonEditor.mjs`**: `createJsonEditor(container, options)` wraps `vanilla-jsoneditor` (≥3.13). **`options.ariaLabel` is required and must be an i18n key** (resolved + refreshed via `setLocalizeLogic`); do not pass `geti18n(...)` strings. Optional `onSave` is Ctrl+S only — not a library prop. Keep native `get()`/`set()` (`Content`); use **`getJson()`** for parsed values (`{ json }` as-is; `{ text }` → `jsonrepair` then `JSON.parse`). - **`components/positionContextMenu.mjs`** + **`components/contextMenuDismiss.mjs`**: shared floating-menu placement / dismiss. - **`contentReveal/`**: `wrapSensitiveMediaHtml`, `wrapContentWarningHtml`, `bindContentReveal`. - **`translate.mjs`**: `mountTranslationBlock`, `requestTranslation`, `resolveTargetLang` (-> `primaryLocale()`). diff --git a/src/public/pages/scripts/components/jsonEditor.mjs b/src/public/pages/scripts/components/jsonEditor.mjs index eede5b2f1..9becda08e 100644 --- a/src/public/pages/scripts/components/jsonEditor.mjs +++ b/src/public/pages/scripts/components/jsonEditor.mjs @@ -1,4 +1,5 @@ import { createJSONEditor as base } from 'https://cdn.jsdelivr.net/npm/vanilla-jsoneditor@3/standalone.js' +import { jsonrepair } from 'https://esm.sh/jsonrepair' import { geti18n, setLocalizeLogic } from '../i18n/index.mjs' import { onThemeChange } from '../theme/index.mjs' @@ -8,8 +9,8 @@ import { onThemeChange } from '../theme/index.mjs' * @param {HTMLElement} jsonEditorContainer - JSON 编辑器的容器元素。 * @param {object} options - 选项(其余字段透传给 vanilla-jsoneditor)。 * @param {string} options.ariaLabel - 编辑器 `aria-label` 的 i18n key。 - * @param {(json: unknown) => void} [options.onSave] - Ctrl+S 保存回调。 - * @returns {import('npm:vanilla-jsoneditor').JSONEditor} JSON 编辑器实例。 + * @param {(json: unknown) => void} [options.onSave] - Ctrl+S 保存回调(传入 `getJson()`)。 + * @returns {import('npm:vanilla-jsoneditor').JSONEditor & { getJson: () => unknown }} 编辑器实例(保留原生 `get`/`set`,另附 `getJson`)。 */ export function createJsonEditor(jsonEditorContainer, options) { const { ariaLabel: ariaLabelKey, onSave, ...editorProps } = options @@ -27,14 +28,24 @@ export function createJsonEditor(jsonEditorContainer, options) { } }) + /** + * 取解析后的 JSON;仅有 text 时经 jsonrepair 再 parse。 + * @returns {unknown} JSON 值 + */ + result.getJson = () => { + const content = result.get() + if ('json' in content) return content.json + return JSON.parse(jsonrepair(content.text)) + } + setLocalizeLogic(jsonEditorContainer, () => { result.updateProps({ ariaLabel: geti18n(ariaLabelKey) }) }) if (onSave) document.addEventListener('keydown', e => { if (e.ctrlKey && e.key === 's') { - onSave(result.get().json || JSON.parse(result.get().text)) e.preventDefault() + onSave(result.getJson()) } }) diff --git a/src/public/pages/scripts/test/test_watch.mjs b/src/public/pages/scripts/test/test_watch.mjs index dcf02361b..2750fc606 100644 --- a/src/public/pages/scripts/test/test_watch.mjs +++ b/src/public/pages/scripts/test/test_watch.mjs @@ -243,7 +243,9 @@ async function checkAriaIgnores() { } if (!hub) continue try { - const res = await fetch(`${hub}/github-issue?url=${encodeURIComponent(url)}`) + const res = await fetch(`${hub}/github-issue?url=${encodeURIComponent(url)}`, { + signal: AbortSignal.timeout(10_000), + }) if (!res.ok) continue const data = await res.json() if (data?.closed !== true) continue @@ -252,7 +254,7 @@ async function checkAriaIgnores() { printedKeys.add(key) console.error(A11Y_PREFIX, 'aria-ignore-closed', where, url) } - catch { /* hub 不可达:留给 Playwright 收尾再判 */ } + catch { /* hub 不可达 / 超时:留给 Playwright 收尾再判 */ } } } diff --git a/src/public/parts/serviceGenerators/AI/proxy/display.mjs b/src/public/parts/serviceGenerators/AI/proxy/display.mjs index 52c2593a7..d0d98b160 100644 --- a/src/public/parts/serviceGenerators/AI/proxy/display.mjs +++ b/src/public/parts/serviceGenerators/AI/proxy/display.mjs @@ -158,7 +158,7 @@ function applyConfigFromEntry(editors, entry) { if (!editors?.json) return let config try { - config = editors.json.get().json || JSON.parse(editors.json.get().text) + config = editors.json.getJson() } catch { return diff --git a/src/public/parts/shells/config/public/index.html b/src/public/parts/shells/config/public/index.html index d9df499f3..4a3885fe3 100644 --- a/src/public/parts/shells/config/public/index.html +++ b/src/public/parts/shells/config/public/index.html @@ -35,7 +35,7 @@

- +
diff --git a/src/public/parts/shells/config/public/index.mjs b/src/public/parts/shells/config/public/index.mjs index f9805778a..5096c6306 100644 --- a/src/public/parts/shells/config/public/index.mjs +++ b/src/public/parts/shells/config/public/index.mjs @@ -35,15 +35,6 @@ let isDirty = false */ let onJsonUpdate = () => 0 -/** - * 读取 JSON 编辑器中的配置数据。 - * @returns {object} 当前 JSON 配置。 - */ -function getEditorData() { - const content = jsonEditor.get() - return content.json ?? JSON.parse(content.text) -} - /** * 禁用编辑器和保存按钮。 */ @@ -118,7 +109,7 @@ async function loadEditor(partpath) { if (contentErrors) return isDirty = true let data - try { data = getEditorData() } catch (e) { return } + try { data = jsonEditor.getJson() } catch (e) { return } onJsonUpdate({ info: { partpath: activePartPath @@ -188,7 +179,7 @@ async function saveConfig() { saveButton.disabled = true try { - const data = getEditorData() + const data = jsonEditor.getJson() await saveConfigData(activePartPath, data) isDirty = false diff --git a/src/public/parts/shells/config/test/manifest.json b/src/public/parts/shells/config/test/manifest.json index 7e4a6339d..45c51308f 100644 --- a/src/public/parts/shells/config/test/manifest.json +++ b/src/public/parts/shells/config/test/manifest.json @@ -24,7 +24,8 @@ "src/public/parts/shells/config/test/frontend/fixtures.mjs", "src/public/parts/shells/config/public/index.{html,mjs}", "src/public/pages/scripts/components/jsonEditor.mjs", - "src/scripts/test/playwright/json_editor.mjs" + "src/scripts/test/playwright/json_editor.mjs", + "src/public/locales/**" ] } ], diff --git a/src/public/parts/shells/discordbot/public/index.mjs b/src/public/parts/shells/discordbot/public/index.mjs index 3f0aa5a7f..f743f5d9d 100644 --- a/src/public/parts/shells/discordbot/public/index.mjs +++ b/src/public/parts/shells/discordbot/public/index.mjs @@ -242,7 +242,7 @@ async function handleSaveConfig() { const config = { token: tokenInput.value, char: charSelectDropdown.dataset.value, - config: configEditor.get().json || JSON.parse(configEditor.get().text), + config: configEditor.getJson(), } saveStatusIcon.src = 'https://api.iconify.design/line-md/loading-loop.svg' diff --git a/src/public/parts/shells/discordbot/test/manifest.json b/src/public/parts/shells/discordbot/test/manifest.json index d0a227cf6..379a2c0b3 100644 --- a/src/public/parts/shells/discordbot/test/manifest.json +++ b/src/public/parts/shells/discordbot/test/manifest.json @@ -81,7 +81,8 @@ "src/public/parts/shells/discordbot/test/frontend/fixtures.mjs", "src/public/parts/shells/discordbot/public/index.{html,mjs}", "src/public/pages/scripts/components/jsonEditor.mjs", - "src/scripts/test/playwright/json_editor.mjs" + "src/scripts/test/playwright/json_editor.mjs", + "src/public/locales/**" ] } ], diff --git a/src/public/parts/shells/serviceSourceManage/public/index.mjs b/src/public/parts/shells/serviceSourceManage/public/index.mjs index d185d9c2c..72e7171a2 100644 --- a/src/public/parts/shells/serviceSourceManage/public/index.mjs +++ b/src/public/parts/shells/serviceSourceManage/public/index.mjs @@ -369,15 +369,6 @@ function syncEditorState() { else disableEditor() } -/** - * 读取 JSON 编辑器中的配置数据。 - * @returns {object} 当前 JSON 配置。 - */ -function getEditorData() { - const content = jsonEditor.get() - return content.json ?? JSON.parse(content.text) -} - /** * 更新 JSON 编辑器的内容并触发更新回调。 * @param {object} data - 要设置到编辑器中的 JSON 数据。 @@ -531,7 +522,7 @@ async function saveFile() { saveButton.disabled = true try { - const config = getEditorData() + const config = jsonEditor.getJson() const generator = generatorSelect.value await setServiceSourceFile(activeFile, { @@ -628,7 +619,7 @@ jsonEditor = createJsonEditor(jsonEditorContainer, { if (contentErrors) return isDirty = true let data - try { data = getEditorData() } catch (e) { return } + try { data = jsonEditor.getJson() } catch (e) { return } onJsonUpdate({ data, containers: { diff --git a/src/public/parts/shells/serviceSourceManage/test/frontend/phases.mjs b/src/public/parts/shells/serviceSourceManage/test/frontend/phases.mjs index f6ba96b14..bd3f19ef9 100644 --- a/src/public/parts/shells/serviceSourceManage/test/frontend/phases.mjs +++ b/src/public/parts/shells/serviceSourceManage/test/frontend/phases.mjs @@ -1,4 +1,5 @@ /** + * Playwright 测试阶段配置。 * @type {Array<{ name: string, testMatch: string[] }>} */ export const phases = [ diff --git a/src/public/parts/shells/serviceSourceManage/test/manifest.json b/src/public/parts/shells/serviceSourceManage/test/manifest.json index 79c7d016a..e79d6d036 100644 --- a/src/public/parts/shells/serviceSourceManage/test/manifest.json +++ b/src/public/parts/shells/serviceSourceManage/test/manifest.json @@ -24,7 +24,8 @@ "src/public/parts/shells/serviceSourceManage/test/frontend/fixtures.mjs", "src/public/parts/shells/serviceSourceManage/public/index.{html,mjs}", "src/public/pages/scripts/components/jsonEditor.mjs", - "src/scripts/test/playwright/json_editor.mjs" + "src/scripts/test/playwright/json_editor.mjs", + "src/public/locales/**" ] } ], diff --git a/src/public/parts/shells/telegrambot/public/index.mjs b/src/public/parts/shells/telegrambot/public/index.mjs index a1df59ab5..27f9c238c 100644 --- a/src/public/parts/shells/telegrambot/public/index.mjs +++ b/src/public/parts/shells/telegrambot/public/index.mjs @@ -227,9 +227,9 @@ function handleToggleToken() { async function handleSaveConfig() { if (!selectedBot) return - let editorContent + let configJson try { - editorContent = configEditor.get() + configJson = configEditor.getJson() } catch (err) { showToastI18n('error', 'telegram_bots.alerts.invalidJsonConfig', { error: err.message }) @@ -239,7 +239,7 @@ async function handleSaveConfig() { const config = { token: tokenInput.value, char: charSelectDropdown.dataset.value, - config: editorContent.json || JSON.parse(editorContent.text || '{}'), + config: configJson, } saveStatusIcon.src = 'https://api.iconify.design/line-md/loading-loop.svg' diff --git a/src/public/parts/shells/telegrambot/test/manifest.json b/src/public/parts/shells/telegrambot/test/manifest.json index 41ce8f80b..d499c658a 100644 --- a/src/public/parts/shells/telegrambot/test/manifest.json +++ b/src/public/parts/shells/telegrambot/test/manifest.json @@ -81,7 +81,8 @@ "src/public/parts/shells/telegrambot/test/frontend/fixtures.mjs", "src/public/parts/shells/telegrambot/public/index.{html,mjs}", "src/public/pages/scripts/components/jsonEditor.mjs", - "src/scripts/test/playwright/json_editor.mjs" + "src/scripts/test/playwright/json_editor.mjs", + "src/public/locales/**" ] } ], diff --git a/src/public/parts/shells/wechatbot/public/index.mjs b/src/public/parts/shells/wechatbot/public/index.mjs index f69fbcec8..3669a0272 100644 --- a/src/public/parts/shells/wechatbot/public/index.mjs +++ b/src/public/parts/shells/wechatbot/public/index.mjs @@ -300,12 +300,11 @@ async function withButtonFeedback(button, statusIcon, action) { async function handleSaveConfig() { if (!selectedBot) return - const editorContent = configEditor.get() const config = { token: tokenInput.value, apiBaseUrl: apiBaseUrlInput.value.trim(), char: charSelectDropdown.dataset.value, - config: editorContent.json || JSON.parse(editorContent.text), + config: configEditor.getJson(), } await withButtonFeedback(saveConfigButton, saveStatusIcon, async () => { diff --git a/src/public/parts/shells/wechatbot/test/manifest.json b/src/public/parts/shells/wechatbot/test/manifest.json index eb36c8936..48e1fb1cb 100644 --- a/src/public/parts/shells/wechatbot/test/manifest.json +++ b/src/public/parts/shells/wechatbot/test/manifest.json @@ -81,7 +81,8 @@ "src/public/parts/shells/wechatbot/test/frontend/fixtures.mjs", "src/public/parts/shells/wechatbot/public/index.{html,mjs}", "src/public/pages/scripts/components/jsonEditor.mjs", - "src/scripts/test/playwright/json_editor.mjs" + "src/scripts/test/playwright/json_editor.mjs", + "src/public/locales/**" ] } ], diff --git a/src/scripts/test/AGENTS.md b/src/scripts/test/AGENTS.md index f7fc5c7f9..0ee44cdb5 100644 --- a/src/scripts/test/AGENTS.md +++ b/src/scripts/test/AGENTS.md @@ -71,6 +71,7 @@ Manifest id = domain (`server`, `testkit`, `p2p`, `shells/chat`, …). - **Keep-awake**: wrappers keep the machine awake during runs — [host-keep-awake.md](docs/host-keep-awake.md). Opt out: `FOUNT_TEST_ALLOW_SLEEP=1`. - **OOM / heap**: [heap-snapshots.md](docs/heap-snapshots.md). - **Deno panic auto-report**: `core/deno_panic.mjs` → GitHub issue on `denoland/deno` (if `gh` + auth); dedup `data/test/deno_panics.json`. Override via `FOUNT_DENO_PANIC_REPO`. `testkit` excluded. -- **GitHub issue probe**: `core/github_issue.mjs` → `parseGithubIssueUrl` (pure). Closed-state via `hub/apis/github_issue.mjs` (`gh` only there) + `hub/clients/github_issue.mjs`. Playwright `assertAriaIgnoreIssues` + browser `test_watch` (when `fount.test.hubUrl` set) hard-fail closed `[aria-ignore]` URLs. No hub / `gh` down → still open. +- **GitHub issue probe**: `core/github_issue.mjs` → `parseGithubIssueUrl` (pure). Closed-state via `hub/apis/github_issue.mjs` (`gh` + `AbortSignal.timeout`) + `hub/clients/github_issue.mjs` / browser `test_watch` (bounded fetch timeout → treat as open). Playwright `assertAriaIgnoreIssues` hard-fail closed `[aria-ignore]` URLs. No hub / `gh` down → still open. Selftest suite: `testkit:test_hub`. +- **Locale triggers**: put `src/public/locales/**` on **jsonEditor subtests** (aria-label asserts), not suite-level `frontendShared` — otherwise every frontend subtest goes stale. Locale-only waves also hit `checks:i18n_*`; do not hang locales on Pages / chat / social / cabinet frontends unless a subtest asserts copy. - **Selftests**: `fount test testkit`. Fixtures: `selftest/fixtures.mjs` (`makeSuite` / `makeStateEntry`). Keep manifest id `testkit`. - **Naming**: readable identifiers (`context` not `ctx`). Suite/file/`Deno.test` names use domain semantics — never planning milestone codes. diff --git a/src/scripts/test/core/github_issue.mjs b/src/scripts/test/core/github_issue.mjs index a7d4b6c9b..132f5aa2f 100644 --- a/src/scripts/test/core/github_issue.mjs +++ b/src/scripts/test/core/github_issue.mjs @@ -8,14 +8,7 @@ * @returns {{ owner: string, repo: string, number: string } | null} 解析结果 */ export function parseGithubIssueUrl(url) { - try { - const parsed = new URL(String(url || '').trim()) - if (parsed.hostname !== 'github.com') return null - const match = parsed.pathname.match(/^\/([^/]+)\/([^/]+)\/issues\/(\d+)\/?$/) - if (!match) return null - return { owner: match[1], repo: match[2], number: match[3] } - } - catch { - return null - } + const match = String(url).match(/^https:\/\/github\.com\/([^/]+)\/([^/]+)\/issues\/(\d+)/) + if (!match) return null + return { owner: match[1], repo: match[2], number: match[3] } } diff --git a/src/scripts/test/hub/apis/github_issue.mjs b/src/scripts/test/hub/apis/github_issue.mjs index 2a8e27c26..c818d9938 100644 --- a/src/scripts/test/hub/apis/github_issue.mjs +++ b/src/scripts/test/hub/apis/github_issue.mjs @@ -1,29 +1,30 @@ /** * `GET /github-issue?url=` — 本进程缓存 + `gh issue view`。 */ -import { Router } from 'npm:express' import { execFile } from 'npm:@steve02081504/exec' +import { Router } from 'npm:express' import { parseGithubIssueUrl } from '../../core/github_issue.mjs' +/** `gh issue view` 有界超时(毫秒)。 */ +const GH_ISSUE_VIEW_TIMEOUT_MS = 15_000 + /** - * @param {string} url GitHub issue URL + * @param {{ owner: string, repo: string, number: string }} parsed 已解析的 issue * @returns {Promise} 已关闭为 true;gh 失败视为未关闭 */ -async function probeGithubIssueClosed(url) { - const parsed = parseGithubIssueUrl(url) - if (!parsed) return false +async function probeGithubIssueClosed(parsed) { try { const result = await execFile('gh', [ 'issue', 'view', parsed.number, '--repo', `${parsed.owner}/${parsed.repo}`, '--json', 'state', - ]) + ], { signal: AbortSignal.timeout(GH_ISSUE_VIEW_TIMEOUT_MS) }) if (result.code !== 0) return false - const raw = (result.stdout ?? result.stdall ?? '').trim() + const raw = String(result.stdout || '').trim() if (!raw) return false const data = JSON.parse(raw) - return String(data.state || '').toUpperCase() === 'CLOSED' + return data.state === 'CLOSED' } catch { return false @@ -40,11 +41,12 @@ export function createGithubIssueRouter() { router.get('/github-issue', async (req, res) => { const issueUrl = String(req.query.url || '').trim() - if (!parseGithubIssueUrl(issueUrl)) + const parsed = parseGithubIssueUrl(issueUrl) + if (!parsed) return res.json({ closed: false }) if (!closedCache.has(issueUrl)) - closedCache.set(issueUrl, await probeGithubIssueClosed(issueUrl)) - res.json({ closed: closedCache.get(issueUrl) === true }) + closedCache.set(issueUrl, await probeGithubIssueClosed(parsed)) + res.json({ closed: closedCache.get(issueUrl) }) }) return router diff --git a/src/scripts/test/hub/base_url.mjs b/src/scripts/test/hub/base_url.mjs index 8496bbd23..d89d344d5 100644 --- a/src/scripts/test/hub/base_url.mjs +++ b/src/scripts/test/hub/base_url.mjs @@ -1,17 +1,16 @@ /** - * 测试 hub 根 URL(Deno / Node / 浏览器共用)。 + * 测试 hub 根 URL(Deno / Node 共用;浏览器侧用 `fount.test.hubUrl`)。 * `process.env.FOUNT_TEST_HUB_URL` 或 `globalThis.fount.test.hubUrl`;皆无则空串。 */ +import process from 'node:process' /** * @returns {string} hub base(无尾斜杠);不可用时为空串 */ export function getTestHubBaseUrl() { - const fromEnv = typeof process !== 'undefined' && process.env?.FOUNT_TEST_HUB_URL - ? String(process.env.FOUNT_TEST_HUB_URL).trim() - : '' - const fromPage = globalThis.fount?.test?.hubUrl - ? String(globalThis.fount.test.hubUrl).trim() - : '' - return (fromEnv || fromPage).replace(/\/$/, '') + return String( + process.env.FOUNT_TEST_HUB_URL + || globalThis.fount?.test?.hubUrl + || '', + ).trim().replace(/\/$/, '') } diff --git a/src/scripts/test/hub/clients/github_issue.mjs b/src/scripts/test/hub/clients/github_issue.mjs index 94fc71dc4..6a2c8f5b2 100644 --- a/src/scripts/test/hub/clients/github_issue.mjs +++ b/src/scripts/test/hub/clients/github_issue.mjs @@ -3,8 +3,11 @@ */ import { getTestHubBaseUrl } from '../base_url.mjs' +/** hub 查询有界超时(毫秒)。 */ +const GITHUB_ISSUE_FETCH_TIMEOUT_MS = 10_000 + /** - * issue 是否已关闭(无 hub / 失败 → false)。 + * issue 是否已关闭(无 hub / 失败 / 超时 → false)。 * @param {string} issueUrl GitHub issue URL * @returns {Promise} 已关闭为 true */ @@ -13,7 +16,9 @@ export async function isGithubIssueClosed(issueUrl) { const url = String(issueUrl || '').trim() if (!base || !url) return false try { - const res = await fetch(`${base}/github-issue?url=${encodeURIComponent(url)}`) + const res = await fetch(`${base}/github-issue?url=${encodeURIComponent(url)}`, { + signal: AbortSignal.timeout(GITHUB_ISSUE_FETCH_TIMEOUT_MS), + }) if (!res.ok) return false const data = await res.json() return data?.closed === true diff --git a/src/scripts/test/hub/clients/shared_store.mjs b/src/scripts/test/hub/clients/shared_store.mjs index 5edc23026..39deafc30 100644 --- a/src/scripts/test/hub/clients/shared_store.mjs +++ b/src/scripts/test/hub/clients/shared_store.mjs @@ -3,6 +3,17 @@ */ import { getTestHubBaseUrl } from '../base_url.mjs' +/** + * @param {string} namespace 分区 + * @param {string} key 键 + * @returns {string | null} 请求 URL;无 hub 为 null + */ +function sharedStoreUrl(namespace, key) { + const base = getTestHubBaseUrl() + if (!base) return null + return `${base}/shared-store/${encodeURIComponent(namespace)}/${encodeURIComponent(key)}` +} + /** * 读本次测试运行共享 KV。 * @param {string} namespace 分区(如 `cdn`、`fixture`) @@ -10,11 +21,10 @@ import { getTestHubBaseUrl } from '../base_url.mjs' * @returns {Promise} 值;缺失 / 无 hub 为 undefined */ export async function hubSharedStoreGet(namespace, key) { - const base = getTestHubBaseUrl() - if (!base) return undefined + const url = sharedStoreUrl(namespace, key) + if (!url) return undefined try { - const res = await fetch(`${base}/shared-store/${encodeURIComponent(namespace)}/${encodeURIComponent(key)}`) - if (res.status === 404) return undefined + const res = await fetch(url) if (!res.ok) return undefined return await res.json() } @@ -31,15 +41,15 @@ export async function hubSharedStoreGet(namespace, key) { * @returns {Promise} 是否写入成功 */ export async function hubSharedStoreSet(namespace, key, value) { - const base = getTestHubBaseUrl() - if (!base) return false + const url = sharedStoreUrl(namespace, key) + if (!url) return false try { - const res = await fetch(`${base}/shared-store/${encodeURIComponent(namespace)}/${encodeURIComponent(key)}`, { + const res = await fetch(url, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(value), }) - return res.ok || res.status === 204 + return res.ok } catch { return false @@ -53,13 +63,11 @@ export async function hubSharedStoreSet(namespace, key, value) { * @returns {Promise} 是否删除成功(无 hub 为 false) */ export async function hubSharedStoreDelete(namespace, key) { - const base = getTestHubBaseUrl() - if (!base) return false + const url = sharedStoreUrl(namespace, key) + if (!url) return false try { - const res = await fetch(`${base}/shared-store/${encodeURIComponent(namespace)}/${encodeURIComponent(key)}`, { - method: 'DELETE', - }) - return res.ok || res.status === 204 + const res = await fetch(url, { method: 'DELETE' }) + return res.ok } catch { return false diff --git a/src/scripts/test/hub/index.mjs b/src/scripts/test/hub/index.mjs index 35298177c..c09df687a 100644 --- a/src/scripts/test/hub/index.mjs +++ b/src/scripts/test/hub/index.mjs @@ -11,8 +11,16 @@ import { createSharedStoreRouter } from './apis/shared_store.mjs' /** 测试 hub 固定端口(避开生产 8931 / live 测试口)。 */ export const TEST_HUB_PORT = 8903 +/** + * @param {number} [port=TEST_HUB_PORT] 端口 + * @returns {string} hub base URL(无尾斜杠) + */ +export function testHubUrl(port = TEST_HUB_PORT) { + return `http://127.0.0.1:${port}` +} + /** 默认 base URL(无尾斜杠)。 */ -export const TEST_HUB_DEFAULT_URL = `http://127.0.0.1:${TEST_HUB_PORT}` +export const TEST_HUB_DEFAULT_URL = testHubUrl() /** * 启动测试 hub(仅 `127.0.0.1`)。 @@ -22,18 +30,18 @@ export const TEST_HUB_DEFAULT_URL = `http://127.0.0.1:${TEST_HUB_PORT}` */ export async function startTestHub({ port = TEST_HUB_PORT } = {}) { const app = express() - app.use((_req, res, next) => { + app.use((req, res, next) => { res.setHeader('Access-Control-Allow-Origin', '*') res.setHeader('Access-Control-Allow-Methods', 'GET, PUT, DELETE, OPTIONS') res.setHeader('Access-Control-Allow-Headers', 'Content-Type') - if (_req.method === 'OPTIONS') return res.sendStatus(204) + if (req.method === 'OPTIONS') return res.sendStatus(204) next() }) app.use(express.json({ limit: '4mb' })) app.use(createHealthRouter()) app.use(createGithubIssueRouter()) app.use(createSharedStoreRouter()) - app.use((_req, res) => { + app.use((req, res) => { res.status(404).json({ error: 'not found' }) }) @@ -43,7 +51,7 @@ export async function startTestHub({ port = TEST_HUB_PORT } = {}) { }) return { - url: `http://127.0.0.1:${port}`, + url: testHubUrl(port), /** * @returns {Promise} */ diff --git a/src/scripts/test/manifest.json b/src/scripts/test/manifest.json index b036b9df7..4f9e5abdc 100644 --- a/src/scripts/test/manifest.json +++ b/src/scripts/test/manifest.json @@ -227,7 +227,7 @@ ] }, { - "name": "github_issue", + "name": "test_hub", "run": [ "deno", "test", diff --git a/src/scripts/test/runner/index.mjs b/src/scripts/test/runner/index.mjs index 694e1d9a6..1cd6ef236 100644 --- a/src/scripts/test/runner/index.mjs +++ b/src/scripts/test/runner/index.mjs @@ -38,9 +38,9 @@ import { writeState, writeStateMarkdown, } from '../core/state.mjs' -import { startTestHub } from '../hub/index.mjs' import { auditTriggerCoverage } from '../core/trigger_audit.mjs' import { buildVerdicts } from '../core/verdict.mjs' +import { startTestHub } from '../hub/index.mjs' import { buildReasonsFromPlan } from './continue_reason.mjs' import { PlanRunCoordinator } from './dependency_scheduler.mjs' @@ -783,7 +783,6 @@ export async function runTests(options = {}) { } finally { await hub.close().catch(() => { /* 关口失败不盖过测试退出码 */ }) - if (process.env.FOUNT_TEST_HUB_URL === hub.url) - delete process.env.FOUNT_TEST_HUB_URL + delete process.env.FOUNT_TEST_HUB_URL } } From 913c88aa96ffaa128188dda79e5a086ad85f5b49 Mon Sep 17 00:00:00 2001 From: steve02081504 Date: Sat, 1 Aug 2026 20:40:33 +0800 Subject: [PATCH 06/11] 1 --- .../pages/scripts/components/jsonEditor.mjs | 2 +- src/public/pages/scripts/test/test_watch.mjs | 33 ++++++++++++++----- .../parts/shells/telegrambot/public/index.mjs | 14 ++++---- src/scripts/test/core/github_issue.mjs | 2 +- src/scripts/test/hub/apis/github_issue.mjs | 3 +- src/scripts/test/hub/clients/shared_store.mjs | 25 ++++++++++++-- .../test/selftest/github_issue.test.mjs | 12 ++++--- 7 files changed, 64 insertions(+), 27 deletions(-) diff --git a/src/public/pages/scripts/components/jsonEditor.mjs b/src/public/pages/scripts/components/jsonEditor.mjs index 9becda08e..e2f2d9d0b 100644 --- a/src/public/pages/scripts/components/jsonEditor.mjs +++ b/src/public/pages/scripts/components/jsonEditor.mjs @@ -42,7 +42,7 @@ export function createJsonEditor(jsonEditorContainer, options) { result.updateProps({ ariaLabel: geti18n(ariaLabelKey) }) }) - if (onSave) document.addEventListener('keydown', e => { + if (onSave) jsonEditorContainer.addEventListener('keydown', e => { if (e.ctrlKey && e.key === 's') { e.preventDefault() onSave(result.getJson()) diff --git a/src/public/pages/scripts/test/test_watch.mjs b/src/public/pages/scripts/test/test_watch.mjs index 2750fc606..87cf07b78 100644 --- a/src/public/pages/scripts/test/test_watch.mjs +++ b/src/public/pages/scripts/test/test_watch.mjs @@ -224,6 +224,8 @@ async function runLocaleScriptCheck(locale) { async function checkAriaIgnores() { const nodes = document.querySelectorAll(`[${ARIA_IGNORE}]`) const hub = String(globalThis.fount?.test?.hubUrl || '').replace(/\/$/, '') + /** @type {{ url: string, where: string }[]} */ + const toProbe = [] for (const el of nodes) { const url = (el.getAttribute(ARIA_IGNORE) || '').trim() const where = el.id ? `#${el.id}` : el.className || el.tagName @@ -241,20 +243,35 @@ async function checkAriaIgnores() { console.error(A11Y_PREFIX, 'aria-ignore-bad-url', where, url) continue } - if (!hub) continue + if (hub) toProbe.push({ url, where }) + } + if (!toProbe.length) return + + /** @type {Map} */ + const closedByUrl = new Map() + await Promise.all([...new Set(toProbe.map(item => item.url))].map(async url => { try { const res = await fetch(`${hub}/github-issue?url=${encodeURIComponent(url)}`, { signal: AbortSignal.timeout(10_000), }) - if (!res.ok) continue + if (!res.ok) { + closedByUrl.set(url, false) + return + } const data = await res.json() - if (data?.closed !== true) continue - const key = `aria-ignore-closed\t${url}` - if (printedKeys.has(key)) continue - printedKeys.add(key) - console.error(A11Y_PREFIX, 'aria-ignore-closed', where, url) + closedByUrl.set(url, data?.closed === true) + } + catch { + closedByUrl.set(url, false) } - catch { /* hub 不可达 / 超时:留给 Playwright 收尾再判 */ } + })) + + for (const { url, where } of toProbe) { + if (closedByUrl.get(url) !== true) continue + const key = `aria-ignore-closed\t${url}` + if (printedKeys.has(key)) continue + printedKeys.add(key) + console.error(A11Y_PREFIX, 'aria-ignore-closed', where, url) } } diff --git a/src/public/parts/shells/telegrambot/public/index.mjs b/src/public/parts/shells/telegrambot/public/index.mjs index 27f9c238c..76255f88f 100644 --- a/src/public/parts/shells/telegrambot/public/index.mjs +++ b/src/public/parts/shells/telegrambot/public/index.mjs @@ -227,21 +227,19 @@ function handleToggleToken() { async function handleSaveConfig() { if (!selectedBot) return - let configJson + let config try { - configJson = configEditor.getJson() + config = { + token: tokenInput.value, + char: charSelectDropdown.dataset.value, + config: configEditor.getJson(), + } } catch (err) { showToastI18n('error', 'telegram_bots.alerts.invalidJsonConfig', { error: err.message }) return } - const config = { - token: tokenInput.value, - char: charSelectDropdown.dataset.value, - config: configJson, - } - saveStatusIcon.src = 'https://api.iconify.design/line-md/loading-loop.svg' saveStatusIcon.classList.remove('hidden') saveConfigButton.disabled = true diff --git a/src/scripts/test/core/github_issue.mjs b/src/scripts/test/core/github_issue.mjs index 132f5aa2f..1e12335ca 100644 --- a/src/scripts/test/core/github_issue.mjs +++ b/src/scripts/test/core/github_issue.mjs @@ -8,7 +8,7 @@ * @returns {{ owner: string, repo: string, number: string } | null} 解析结果 */ export function parseGithubIssueUrl(url) { - const match = String(url).match(/^https:\/\/github\.com\/([^/]+)\/([^/]+)\/issues\/(\d+)/) + const match = String(url).match(/^https:\/\/github\.com\/([^/]+)\/([^/]+)\/issues\/(\d+)$/) if (!match) return null return { owner: match[1], repo: match[2], number: match[3] } } diff --git a/src/scripts/test/hub/apis/github_issue.mjs b/src/scripts/test/hub/apis/github_issue.mjs index c818d9938..d92a30e64 100644 --- a/src/scripts/test/hub/apis/github_issue.mjs +++ b/src/scripts/test/hub/apis/github_issue.mjs @@ -23,8 +23,7 @@ async function probeGithubIssueClosed(parsed) { if (result.code !== 0) return false const raw = String(result.stdout || '').trim() if (!raw) return false - const data = JSON.parse(raw) - return data.state === 'CLOSED' + return JSON.parse(raw).state === 'CLOSED' } catch { return false diff --git a/src/scripts/test/hub/clients/shared_store.mjs b/src/scripts/test/hub/clients/shared_store.mjs index 39deafc30..ab53a7605 100644 --- a/src/scripts/test/hub/clients/shared_store.mjs +++ b/src/scripts/test/hub/clients/shared_store.mjs @@ -3,6 +3,9 @@ */ import { getTestHubBaseUrl } from '../base_url.mjs' +/** hub shared-store 有界超时(毫秒)。 */ +const SHARED_STORE_FETCH_TIMEOUT_MS = 10_000 + /** * @param {string} namespace 分区 * @param {string} key 键 @@ -14,6 +17,22 @@ function sharedStoreUrl(namespace, key) { return `${base}/shared-store/${encodeURIComponent(namespace)}/${encodeURIComponent(key)}` } +/** + * @param {string} url 请求地址 + * @param {RequestInit} [init] fetch 选项 + * @returns {Promise} 响应 + */ +async function sharedStoreFetch(url, init = {}) { + const controller = new AbortController() + const timer = setTimeout(() => controller.abort(), SHARED_STORE_FETCH_TIMEOUT_MS) + try { + return await fetch(url, { ...init, signal: controller.signal }) + } + finally { + clearTimeout(timer) + } +} + /** * 读本次测试运行共享 KV。 * @param {string} namespace 分区(如 `cdn`、`fixture`) @@ -24,7 +43,7 @@ export async function hubSharedStoreGet(namespace, key) { const url = sharedStoreUrl(namespace, key) if (!url) return undefined try { - const res = await fetch(url) + const res = await sharedStoreFetch(url) if (!res.ok) return undefined return await res.json() } @@ -44,7 +63,7 @@ export async function hubSharedStoreSet(namespace, key, value) { const url = sharedStoreUrl(namespace, key) if (!url) return false try { - const res = await fetch(url, { + const res = await sharedStoreFetch(url, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(value), @@ -66,7 +85,7 @@ export async function hubSharedStoreDelete(namespace, key) { const url = sharedStoreUrl(namespace, key) if (!url) return false try { - const res = await fetch(url, { method: 'DELETE' }) + const res = await sharedStoreFetch(url, { method: 'DELETE' }) return res.ok } catch { diff --git a/src/scripts/test/selftest/github_issue.test.mjs b/src/scripts/test/selftest/github_issue.test.mjs index e2ddc15c4..ec5045526 100644 --- a/src/scripts/test/selftest/github_issue.test.mjs +++ b/src/scripts/test/selftest/github_issue.test.mjs @@ -11,14 +11,18 @@ Deno.test('parseGithubIssueUrl accepts canonical issue URLs', () => { repo: 'svelte-jsoneditor', number: '584', }) - assertEquals( - parseGithubIssueUrl('https://github.com/josdejong/svelte-jsoneditor/issues/584#issuecomment-1'), - { owner: 'josdejong', repo: 'svelte-jsoneditor', number: '584' }, - ) }) Deno.test('parseGithubIssueUrl rejects non-issue URLs', () => { assertEquals(parseGithubIssueUrl('https://github.com/josdejong/svelte-jsoneditor/pull/580'), null) assertEquals(parseGithubIssueUrl('https://example.com/issues/1'), null) assertEquals(parseGithubIssueUrl(''), null) + assertEquals( + parseGithubIssueUrl('https://github.com/josdejong/svelte-jsoneditor/issues/584#issuecomment-1'), + null, + ) + assertEquals( + parseGithubIssueUrl('https://github.com/josdejong/svelte-jsoneditor/issues/584/'), + null, + ) }) From dc59b845f6c73a900b4faccb3874e947e2d2888c Mon Sep 17 00:00:00 2001 From: steve02081504 Date: Sat, 1 Aug 2026 20:47:21 +0800 Subject: [PATCH 07/11] 1 --- .github/pages/test/frontend/fixtures.mjs | 4 ++-- src/scripts/test/playwright/fixtures.mjs | 4 ++-- src/scripts/test/playwright/github_issue.mjs | 3 +++ src/scripts/test/selftest/github_issue.test.mjs | 1 + src/scripts/test/selftest/test_hub.test.mjs | 4 +++- 5 files changed, 11 insertions(+), 5 deletions(-) diff --git a/.github/pages/test/frontend/fixtures.mjs b/.github/pages/test/frontend/fixtures.mjs index 894f470ec..defd4194d 100644 --- a/.github/pages/test/frontend/fixtures.mjs +++ b/.github/pages/test/frontend/fixtures.mjs @@ -48,13 +48,13 @@ export function createPagesFixtures(options = {}) { globalThis.fount.test.enabled = true }) const hubUrl = (process.env.FOUNT_TEST_HUB_URL || '').trim() - if (hubUrl) { + if (hubUrl) await context.addInitScript(url => { globalThis.fount ??= {} globalThis.fount.test ??= {} globalThis.fount.test.hubUrl = url }, hubUrl) - } + await use(context) await context.close() }, diff --git a/src/scripts/test/playwright/fixtures.mjs b/src/scripts/test/playwright/fixtures.mjs index 55dd7208b..b7d496d9a 100644 --- a/src/scripts/test/playwright/fixtures.mjs +++ b/src/scripts/test/playwright/fixtures.mjs @@ -71,13 +71,13 @@ export function createFountFixtures(options = {}) { globalThis.fount.test.enabled = true }) const hubUrl = (process.env.FOUNT_TEST_HUB_URL || '').trim() - if (hubUrl) { + if (hubUrl) await context.addInitScript(url => { globalThis.fount ??= {} globalThis.fount.test ??= {} globalThis.fount.test.hubUrl = url }, hubUrl) - } + await use(context) await context.close() }, diff --git a/src/scripts/test/playwright/github_issue.mjs b/src/scripts/test/playwright/github_issue.mjs index 39c5e7989..1ffab804e 100644 --- a/src/scripts/test/playwright/github_issue.mjs +++ b/src/scripts/test/playwright/github_issue.mjs @@ -5,6 +5,9 @@ import { parseGithubIssueUrl } from '../core/github_issue.mjs' import { isGithubIssueClosed } from '../hub/clients/github_issue.mjs' +/** + * + */ export { isGithubIssueClosed } /** diff --git a/src/scripts/test/selftest/github_issue.test.mjs b/src/scripts/test/selftest/github_issue.test.mjs index ec5045526..5ce85b1b4 100644 --- a/src/scripts/test/selftest/github_issue.test.mjs +++ b/src/scripts/test/selftest/github_issue.test.mjs @@ -1,6 +1,7 @@ /** * `parseGithubIssueUrl`(纯解析,不打 gh)。 */ +/* global Deno */ import { assertEquals } from 'jsr:@std/assert' import { parseGithubIssueUrl } from '../core/github_issue.mjs' diff --git a/src/scripts/test/selftest/test_hub.test.mjs b/src/scripts/test/selftest/test_hub.test.mjs index 8f2e9cbf7..7abd78e66 100644 --- a/src/scripts/test/selftest/test_hub.test.mjs +++ b/src/scripts/test/selftest/test_hub.test.mjs @@ -1,9 +1,11 @@ /** * 测试 hub(固定生产口 8903;本自测用 18903 避开父进程 hub)。 */ -import { assertEquals } from 'jsr:@std/assert' +/* global Deno */ import process from 'node:process' +import { assertEquals } from 'jsr:@std/assert' + import { getTestHubBaseUrl } from '../hub/base_url.mjs' import { isGithubIssueClosed } from '../hub/clients/github_issue.mjs' import { hubSharedStoreDelete, hubSharedStoreGet, hubSharedStoreSet } from '../hub/clients/shared_store.mjs' From 10ab91bef3b357dc46282ba590776335d0f91b09 Mon Sep 17 00:00:00 2001 From: steve02081504 Date: Sat, 1 Aug 2026 20:53:42 +0800 Subject: [PATCH 08/11] 1 --- src/public/pages/scripts/test/test_watch.mjs | 4 ++-- src/scripts/test/playwright/github_issue.mjs | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/public/pages/scripts/test/test_watch.mjs b/src/public/pages/scripts/test/test_watch.mjs index 87cf07b78..72d4af328 100644 --- a/src/public/pages/scripts/test/test_watch.mjs +++ b/src/public/pages/scripts/test/test_watch.mjs @@ -22,8 +22,8 @@ import axe from 'https://esm.sh/axe-core' */ export const ARIA_IGNORE = 'aria-ignore' -/** GitHub issue URL(路径以 /issues/{n} 结尾) */ -const GITHUB_ISSUE_URL_RE = /^https:\/\/github\.com\/[^/]+\/[^/]+\/issues\/\d+\/?(?:[?#].*)?$/i +/** GitHub issue URL(与 `parseGithubIssueUrl` 一致:路径以 /issues/{n} 结尾) */ +const GITHUB_ISSUE_URL_RE = /^https:\/\/github\.com\/[^/]+\/[^/]+\/issues\/\d+$/ const A11Y_PREFIX = '[test:a11y]' const LOCALE_PREFIX = '[test:locale]' diff --git a/src/scripts/test/playwright/github_issue.mjs b/src/scripts/test/playwright/github_issue.mjs index 1ffab804e..2176f7f9e 100644 --- a/src/scripts/test/playwright/github_issue.mjs +++ b/src/scripts/test/playwright/github_issue.mjs @@ -6,7 +6,7 @@ import { parseGithubIssueUrl } from '../core/github_issue.mjs' import { isGithubIssueClosed } from '../hub/clients/github_issue.mjs' /** - * + * 判断 GitHub issue 是否已关闭。 */ export { isGithubIssueClosed } From 18dabe089f877e6925949cb753e83af721ea8f55 Mon Sep 17 00:00:00 2001 From: steve02081504 Date: Sat, 1 Aug 2026 21:03:45 +0800 Subject: [PATCH 09/11] Update test_watch.mjs --- src/public/pages/scripts/test/test_watch.mjs | 27 ++++++++++---------- 1 file changed, 13 insertions(+), 14 deletions(-) diff --git a/src/public/pages/scripts/test/test_watch.mjs b/src/public/pages/scripts/test/test_watch.mjs index 72d4af328..1a6c55ec6 100644 --- a/src/public/pages/scripts/test/test_watch.mjs +++ b/src/public/pages/scripts/test/test_watch.mjs @@ -224,26 +224,26 @@ async function runLocaleScriptCheck(locale) { async function checkAriaIgnores() { const nodes = document.querySelectorAll(`[${ARIA_IGNORE}]`) const hub = String(globalThis.fount?.test?.hubUrl || '').replace(/\/$/, '') - /** @type {{ url: string, where: string }[]} */ + /** @type {{ url: string, location: string }[]} */ const toProbe = [] - for (const el of nodes) { - const url = (el.getAttribute(ARIA_IGNORE) || '').trim() - const where = el.id ? `#${el.id}` : el.className || el.tagName + for (const element of nodes) { + const url = (element.getAttribute(ARIA_IGNORE) || '').trim() + const location = element.id ? `#${element.id}` : element.className || element.tagName if (!url) { - const key = `aria-ignore-missing\t${where}` + const key = `aria-ignore-missing\t${location}` if (printedKeys.has(key)) continue printedKeys.add(key) - console.error(A11Y_PREFIX, 'aria-ignore-missing-url', where, 'aria-ignore requires a GitHub issue URL') + console.error(A11Y_PREFIX, 'aria-ignore-missing-url', location, 'aria-ignore requires a GitHub issue URL') continue } if (!GITHUB_ISSUE_URL_RE.test(url)) { const key = `aria-ignore-bad-url\t${url}` if (printedKeys.has(key)) continue printedKeys.add(key) - console.error(A11Y_PREFIX, 'aria-ignore-bad-url', where, url) + console.error(A11Y_PREFIX, 'aria-ignore-bad-url', location, url) continue } - if (hub) toProbe.push({ url, where }) + if (hub) toProbe.push({ url, location }) } if (!toProbe.length) return @@ -251,27 +251,26 @@ async function checkAriaIgnores() { const closedByUrl = new Map() await Promise.all([...new Set(toProbe.map(item => item.url))].map(async url => { try { - const res = await fetch(`${hub}/github-issue?url=${encodeURIComponent(url)}`, { + const response = await fetch(`${hub}/github-issue?url=${encodeURIComponent(url)}`, { signal: AbortSignal.timeout(10_000), }) - if (!res.ok) { + if (!response.ok) { closedByUrl.set(url, false) return } - const data = await res.json() - closedByUrl.set(url, data?.closed === true) + closedByUrl.set(url, (await response.json())?.closed === true) } catch { closedByUrl.set(url, false) } })) - for (const { url, where } of toProbe) { + for (const { url, location } of toProbe) { if (closedByUrl.get(url) !== true) continue const key = `aria-ignore-closed\t${url}` if (printedKeys.has(key)) continue printedKeys.add(key) - console.error(A11Y_PREFIX, 'aria-ignore-closed', where, url) + console.error(A11Y_PREFIX, 'aria-ignore-closed', location, url) } } From 07edd6f3e8baf6e1873d142c8247e14d7b8967fb Mon Sep 17 00:00:00 2001 From: steve02081504 Date: Sat, 1 Aug 2026 21:39:20 +0800 Subject: [PATCH 10/11] 1 --- .../pages/scripts/test/github_issue.mjs | 14 ++ src/public/pages/scripts/test/test_watch.mjs | 165 ++++++++++++++---- src/scripts/test/core/github_issue.mjs | 12 +- src/scripts/test/manifest.json | 1 + 4 files changed, 143 insertions(+), 49 deletions(-) create mode 100644 src/public/pages/scripts/test/github_issue.mjs diff --git a/src/public/pages/scripts/test/github_issue.mjs b/src/public/pages/scripts/test/github_issue.mjs new file mode 100644 index 000000000..9a2ae95d1 --- /dev/null +++ b/src/public/pages/scripts/test/github_issue.mjs @@ -0,0 +1,14 @@ +/** + * GitHub issue URL 解析(浏览器 / Deno 共用)。 + */ + +/** + * 解析 `https://github.com/{owner}/{repo}/issues/{n}`。 + * @param {string} url issue URL + * @returns {{ owner: string, repo: string, number: string } | null} 解析结果 + */ +export function parseGithubIssueUrl(url) { + const match = String(url).match(/^https:\/\/github\.com\/([^/]+)\/([^/]+)\/issues\/(\d+)$/) + if (!match) return null + return { owner: match[1], repo: match[2], number: match[3] } +} diff --git a/src/public/pages/scripts/test/test_watch.mjs b/src/public/pages/scripts/test/test_watch.mjs index 1a6c55ec6..7cae2dbb1 100644 --- a/src/public/pages/scripts/test/test_watch.mjs +++ b/src/public/pages/scripts/test/test_watch.mjs @@ -1,8 +1,9 @@ /** - * 测试环境页面监视:import 即启动(无导出)。 + * 测试环境页面监视:import 即启动;导出 `ARIA_IGNORE` 供产品侧标记子树。 * - axe-core 无障碍:真实 DOM 变脏时每 0.5s 扫,静止则停;命中即报 - * - `[aria-ignore="https://github.com/…/issues/n"]`:axe `exclude`;须带 issue URL - * (缺省 / 非法格式在此报;issue 已关闭经 `fount.test.hubUrl` → 测试 hub,或 Playwright 收尾硬失败) + * - `[aria-ignore="https://github.com/…/issues/n"]`:仅合法 issue URL 参与 axe `exclude`; + * 缺省 / 非法格式在此报;关闭态经 `fount.test.hubUrl` → 测试 hub(成功结果与进行中请求按 URL 缓存, + * 失败退避;`kickWatch` / `cycleLocales` 显式刷新),或 Playwright 收尾硬失败 * - 语种轮换:每秒在 zh-CN / ja-JP / en-UK 间切换,并用 `\p{Script=…}` 查错语字符 * (轮换自身的 DOM 写入不计入 a11y dirty,否则扫描定时器永远停不下来) * @@ -16,14 +17,18 @@ */ import axe from 'https://esm.sh/axe-core' +import { parseGithubIssueUrl } from './github_issue.mjs' + /** * 第三方 / 暂不可修子树:axe `exclude`。 * 属性值必须是跟踪上游修复的 GitHub issue URL(`aria-ignore="https://github.com/…/issues/n"`)。 */ export const ARIA_IGNORE = 'aria-ignore' -/** GitHub issue URL(与 `parseGithubIssueUrl` 一致:路径以 /issues/{n} 结尾) */ -const GITHUB_ISSUE_URL_RE = /^https:\/\/github\.com\/[^/]+\/[^/]+\/issues\/\d+$/ +/** hub 查询有界超时(毫秒)。 */ +const GITHUB_ISSUE_FETCH_TIMEOUT_MS = 10_000 +/** hub 失败后的退避(毫秒)。 */ +const GITHUB_ISSUE_PROBE_BACKOFF_MS = 30_000 const A11Y_PREFIX = '[test:a11y]' const LOCALE_PREFIX = '[test:locale]' @@ -71,6 +76,96 @@ let jaForbiddenRe /** @type {Promise | null} */ let jaForbiddenLoading = null +/** @type {Map} */ +const githubIssueClosedCache = new Map() +/** @type {Map>} */ +const githubIssueInflight = new Map() +/** @type {Map} */ +const githubIssueBackoffUntil = new Map() + +/** + * @typedef {{ url: string, location: string, element: Element, parsed: ReturnType }} AriaIgnoreEntry + */ + +/** + * 收集页面 `[aria-ignore]` 节点。 + * @returns {AriaIgnoreEntry[]} 各节点的 URL、位置与解析结果 + */ +function collectAriaIgnoreEntries() { + /** @type {AriaIgnoreEntry[]} */ + const entries = [] + for (const element of document.querySelectorAll(`[${ARIA_IGNORE}]`)) { + const url = (element.getAttribute(ARIA_IGNORE) || '').trim() + const location = element.id ? `#${element.id}` : element.className || element.tagName + entries.push({ + url, + location, + element, + parsed: url ? parseGithubIssueUrl(url) : null, + }) + } + return entries +} + +/** + * 清除 GitHub issue 关闭态探测缓存(供收尾显式刷新)。 + * @returns {void} + */ +function refreshGithubIssueProbes() { + githubIssueClosedCache.clear() + githubIssueBackoffUntil.clear() + githubIssueInflight.clear() +} + +/** + * issue 是否已关闭(成功结果缓存;进行中请求复用;失败退避;无 hub / 超时 → false)。 + * @param {string} url 已解析的 issue URL + * @param {{ refresh?: boolean }} [options] `refresh` 为 true 时跳过缓存并重新探测 + * @returns {Promise} 已关闭为 true + */ +async function probeGithubIssueClosed(url, { refresh = false } = {}) { + const hub = String(globalThis.fount?.test?.hubUrl || '').replace(/\/$/, '') + if (!hub) return false + + if (refresh) { + githubIssueClosedCache.delete(url) + githubIssueBackoffUntil.delete(url) + const inflight = githubIssueInflight.get(url) + if (inflight) await inflight.catch(() => { }) + githubIssueInflight.delete(url) + } + else { + if (githubIssueClosedCache.has(url)) return githubIssueClosedCache.get(url) + if ((githubIssueBackoffUntil.get(url) ?? 0) > Date.now()) return false + const inflight = githubIssueInflight.get(url) + if (inflight) return inflight + } + + const probe = (async () => { + try { + const response = await fetch(`${hub}/github-issue?url=${encodeURIComponent(url)}`, { + signal: AbortSignal.timeout(GITHUB_ISSUE_FETCH_TIMEOUT_MS), + }) + if (!response.ok) { + githubIssueBackoffUntil.set(url, Date.now() + GITHUB_ISSUE_PROBE_BACKOFF_MS) + return false + } + const closed = (await response.json())?.closed === true + githubIssueClosedCache.set(url, closed) + return closed + } + catch { + githubIssueBackoffUntil.set(url, Date.now() + GITHUB_ISSUE_PROBE_BACKOFF_MS) + return false + } + finally { + githubIssueInflight.delete(url) + } + })() + githubIssueInflight.set(url, probe) + return probe +} + /** * Playwright 测体可设 `fount.test.localeHold > 0` 暂停轮换。 * @returns {boolean} 是否暂停语种轮换 @@ -219,16 +314,14 @@ async function runLocaleScriptCheck(locale) { /** * 校验 `[aria-ignore]`:缺 URL / 非法格式立刻报;有 hub 时额外查关闭态。 - * @returns {Promise} + * @param {{ refresh?: boolean, entries?: AriaIgnoreEntry[] }} [options] `refresh` 强制重查关闭态;`entries` 可复用已收集节点 + * @returns {Promise} 完成校验 */ -async function checkAriaIgnores() { - const nodes = document.querySelectorAll(`[${ARIA_IGNORE}]`) +async function checkAriaIgnores({ refresh = false, entries = collectAriaIgnoreEntries() } = {}) { const hub = String(globalThis.fount?.test?.hubUrl || '').replace(/\/$/, '') /** @type {{ url: string, location: string }[]} */ const toProbe = [] - for (const element of nodes) { - const url = (element.getAttribute(ARIA_IGNORE) || '').trim() - const location = element.id ? `#${element.id}` : element.className || element.tagName + for (const { url, location, parsed } of entries) { if (!url) { const key = `aria-ignore-missing\t${location}` if (printedKeys.has(key)) continue @@ -236,7 +329,7 @@ async function checkAriaIgnores() { console.error(A11Y_PREFIX, 'aria-ignore-missing-url', location, 'aria-ignore requires a GitHub issue URL') continue } - if (!GITHUB_ISSUE_URL_RE.test(url)) { + if (!parsed) { const key = `aria-ignore-bad-url\t${url}` if (printedKeys.has(key)) continue printedKeys.add(key) @@ -247,26 +340,12 @@ async function checkAriaIgnores() { } if (!toProbe.length) return - /** @type {Map} */ - const closedByUrl = new Map() - await Promise.all([...new Set(toProbe.map(item => item.url))].map(async url => { - try { - const response = await fetch(`${hub}/github-issue?url=${encodeURIComponent(url)}`, { - signal: AbortSignal.timeout(10_000), - }) - if (!response.ok) { - closedByUrl.set(url, false) - return - } - closedByUrl.set(url, (await response.json())?.closed === true) - } - catch { - closedByUrl.set(url, false) - } - })) + await Promise.all([...new Set(toProbe.map(item => item.url))].map(url => + probeGithubIssueClosed(url, { refresh }), + )) for (const { url, location } of toProbe) { - if (closedByUrl.get(url) !== true) continue + if (githubIssueClosedCache.get(url) !== true) continue const key = `aria-ignore-closed\t${url}` if (printedKeys.has(key)) continue printedKeys.add(key) @@ -275,13 +354,19 @@ async function checkAriaIgnores() { } /** - * @returns {Promise} + * 跑一轮 axe 无障碍扫描。 + * @param {{ refreshGithubIssues?: boolean }} [options] `refreshGithubIssues` 为 true 时重查 issue 关闭态 + * @returns {Promise} 完成扫描 */ -async function runA11y() { +async function runA11y({ refreshGithubIssues = false } = {}) { if (!localeReady) return - await checkAriaIgnores() + const ariaIgnoreEntries = collectAriaIgnoreEntries() + await checkAriaIgnores({ refresh: refreshGithubIssues, entries: ariaIgnoreEntries }) + const axeExclude = ariaIgnoreEntries + .filter(entry => entry.parsed) + .map(entry => entry.element) const results = await axe.run({ - exclude: `[${ARIA_IGNORE}]`, + exclude: axeExclude, }, { resultTypes: ['violations'], iframes: false, @@ -324,10 +409,12 @@ function enqueueWatch(fn) { } /** + * 入队一轮 a11y 扫描。 + * @param {{ refreshGithubIssues?: boolean }} [options] 传给 `runA11y` * @returns {void} */ -function tickA11y() { - void enqueueWatch(() => runA11y()) +function tickA11y(options = {}) { + void enqueueWatch(() => runA11y(options)) } /** @@ -400,7 +487,8 @@ function markDirty() { function kickWatch() { if (!localeReady) return dirty = true - tickA11y() + refreshGithubIssueProbes() + tickA11y({ refreshGithubIssues: true }) watchChain = watchChain.then(async () => { const i18n = await import('../i18n/index.mjs') const locale = i18n.main_locale || i18n.loadPreferredLangs()[0] || 'zh-CN' @@ -438,7 +526,7 @@ async function cycleLocales() { localeIndex = LOCALE_CYCLE.indexOf(locale) await runLocaleScriptCheck(i18n.main_locale || locale) }) - await runA11y() + await runA11y({ refreshGithubIssues: true }) }).catch(() => { }) ensureLocaleTimer() @@ -446,6 +534,7 @@ async function cycleLocales() { globalThis.fount.test.kickWatch = kickWatch globalThis.fount.test.cycleLocales = cycleLocales +globalThis.fount.test.refreshGithubIssueProbes = refreshGithubIssueProbes /** * @returns {void} diff --git a/src/scripts/test/core/github_issue.mjs b/src/scripts/test/core/github_issue.mjs index 1e12335ca..e985002b9 100644 --- a/src/scripts/test/core/github_issue.mjs +++ b/src/scripts/test/core/github_issue.mjs @@ -1,14 +1,4 @@ /** * GitHub issue URL 解析(纯函数,Deno / Node 皆可)。 */ - -/** - * 解析 `https://github.com/{owner}/{repo}/issues/{n}`。 - * @param {string} url issue URL - * @returns {{ owner: string, repo: string, number: string } | null} 解析结果 - */ -export function parseGithubIssueUrl(url) { - const match = String(url).match(/^https:\/\/github\.com\/([^/]+)\/([^/]+)\/issues\/(\d+)$/) - if (!match) return null - return { owner: match[1], repo: match[2], number: match[3] } -} +export { parseGithubIssueUrl } from '../../../public/pages/scripts/test/github_issue.mjs' diff --git a/src/scripts/test/manifest.json b/src/scripts/test/manifest.json index 4f9e5abdc..c5cbd9d34 100644 --- a/src/scripts/test/manifest.json +++ b/src/scripts/test/manifest.json @@ -243,6 +243,7 @@ "src/scripts/test/selftest/github_issue.test.mjs", "src/scripts/test/selftest/test_hub.test.mjs", "src/scripts/test/core/github_issue.mjs", + "src/public/pages/scripts/test/github_issue.mjs", "src/scripts/test/hub/**", "src/scripts/test/playwright/github_issue.mjs" ] From 9b5467f5767cbce4aa07276a27951bf52242de8a Mon Sep 17 00:00:00 2001 From: steve02081504 Date: Sat, 1 Aug 2026 22:03:08 +0800 Subject: [PATCH 11/11] 1 --- src/public/pages/scripts/test/github_issue.mjs | 2 +- src/public/pages/scripts/test/test_watch.mjs | 12 ++++++++++-- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/src/public/pages/scripts/test/github_issue.mjs b/src/public/pages/scripts/test/github_issue.mjs index 9a2ae95d1..e4dc98522 100644 --- a/src/public/pages/scripts/test/github_issue.mjs +++ b/src/public/pages/scripts/test/github_issue.mjs @@ -8,7 +8,7 @@ * @returns {{ owner: string, repo: string, number: string } | null} 解析结果 */ export function parseGithubIssueUrl(url) { - const match = String(url).match(/^https:\/\/github\.com\/([^/]+)\/([^/]+)\/issues\/(\d+)$/) + const match = url.match(/^https:\/\/github\.com\/([^/]+)\/([^/]+)\/issues\/(\d+)$/) if (!match) return null return { owner: match[1], repo: match[2], number: match[3] } } diff --git a/src/public/pages/scripts/test/test_watch.mjs b/src/public/pages/scripts/test/test_watch.mjs index 7cae2dbb1..8aeac070d 100644 --- a/src/public/pages/scripts/test/test_watch.mjs +++ b/src/public/pages/scripts/test/test_watch.mjs @@ -117,6 +117,14 @@ function refreshGithubIssueProbes() { githubIssueInflight.clear() } +/** + * 规范化 `fount.test.hubUrl`(去尾斜杠;未设则为空串)。 + * @returns {string} hub 基址 + */ +function getHubUrl() { + return String(globalThis.fount?.test?.hubUrl || '').replace(/\/$/, '') +} + /** * issue 是否已关闭(成功结果缓存;进行中请求复用;失败退避;无 hub / 超时 → false)。 * @param {string} url 已解析的 issue URL @@ -124,7 +132,7 @@ function refreshGithubIssueProbes() { * @returns {Promise} 已关闭为 true */ async function probeGithubIssueClosed(url, { refresh = false } = {}) { - const hub = String(globalThis.fount?.test?.hubUrl || '').replace(/\/$/, '') + const hub = getHubUrl() if (!hub) return false if (refresh) { @@ -318,7 +326,7 @@ async function runLocaleScriptCheck(locale) { * @returns {Promise} 完成校验 */ async function checkAriaIgnores({ refresh = false, entries = collectAriaIgnoreEntries() } = {}) { - const hub = String(globalThis.fount?.test?.hubUrl || '').replace(/\/$/, '') + const hub = getHubUrl() /** @type {{ url: string, location: string }[]} */ const toProbe = [] for (const { url, location, parsed } of entries) {