This is an automated email from the ASF dual-hosted git repository. imbajin pushed a commit to branch feat/oink-core-platform in repository https://gitbox.apache.org/repos/asf/hugegraph-doc.git
commit ffc41adde3d918386d94ac43dc05212a10618b6a Author: dark <[email protected]> AuthorDate: Fri Sep 4 21:53:23 2026 +0800 fix(ui): harden retry interaction states - keep native search retry DOM stable and keyboard usable - reset timed-out Kapa scripts and ignore late callbacks - source Kapa primary colors from the site theme token - limit backlinks to five before native expansion - cover pending, failure, and six-link fixtures --- assets/js/hugegraph-shell.js | 6 +- assets/js/kapa-adapter.js | 108 +++++++++++++++------ layouts/_partials/backlinks.html | 50 ++++++++++ layouts/_partials/hooks/body-end.html | 2 + tests/e2e/ai.spec.js | 51 +++++++++- tests/e2e/package.json | 2 +- tests/e2e/platform.spec.js | 38 ++++++++ tests/ui-ai/backlinks-render.test.cjs | 54 +++++++++++ tests/ui-ai/fixtures/backlinks.yaml | 5 + .../fixtures/backlinks/content/en/docs/_index.md | 3 + .../fixtures/backlinks/content/en/docs/source-1.md | 5 + .../fixtures/backlinks/content/en/docs/source-2.md | 5 + .../fixtures/backlinks/content/en/docs/source-3.md | 5 + .../fixtures/backlinks/content/en/docs/source-4.md | 5 + .../fixtures/backlinks/content/en/docs/source-5.md | 5 + .../fixtures/backlinks/content/en/docs/source-6.md | 5 + .../fixtures/backlinks/content/en/docs/target.md | 6 ++ tests/ui-ai/kapa-adapter.test.cjs | 90 ++++++++++++++++- tests/ui-ai/ui-contract.test.cjs | 18 ++++ 19 files changed, 424 insertions(+), 39 deletions(-) diff --git a/assets/js/hugegraph-shell.js b/assets/js/hugegraph-shell.js index df56062da..a54a9dad8 100644 --- a/assets/js/hugegraph-shell.js +++ b/assets/js/hugegraph-shell.js @@ -171,7 +171,6 @@ function sync() { scheduled = false; var existing = list.querySelector('[data-hg-search-retry]'); - if (existing) existing.remove(); var failure = root.dataset.tdTIndexUnavailable || ''; var failed = failure && @@ -182,7 +181,9 @@ return node.textContent.trim() === failure; }, )); - if (!failed) return; + if (failed && existing) return existing; + if (existing) existing.remove(); + if (!failed) return null; var notice = documentObject.createElement('div'); notice.className = 'hg-search-retry'; @@ -204,6 +205,7 @@ notice.appendChild(text); notice.appendChild(button); list.appendChild(notice); + return notice; } function schedule() { diff --git a/assets/js/kapa-adapter.js b/assets/js/kapa-adapter.js index 1102b99f6..690d012e7 100644 --- a/assets/js/kapa-adapter.js +++ b/assets/js/kapa-adapter.js @@ -32,16 +32,26 @@ throw new Error('Kapa API is unavailable'); } - function preinitialize(windowObject) { - if (windowObject.Kapa) return; + function preinitialize(windowObject, force) { + if (!force && windowObject.Kapa) return windowObject.Kapa; + if ( + force && + windowObject.Kapa && + windowObject.Kapa.hgKapaPreinitialized && + Array.isArray(windowObject.Kapa.q) + ) { + windowObject.Kapa.q.length = 0; + } var queue = function () { queue.c(arguments); }; queue.q = []; + queue.hgKapaPreinitialized = true; queue.c = function (args) { queue.q.push(args); }; windowObject.Kapa = queue; + return queue; } function scriptAttributes(config) { @@ -50,7 +60,7 @@ 'data-source-group-ids-include': config.sourceGroupId, 'data-language': config.locale, 'data-project-name': 'Apache HugeGraph', - 'data-project-color': '#532fc9', + 'data-project-color': config.themeColor, 'data-project-color-dark': '#9f83ff', 'data-surface-color': '#ffffff', 'data-surface-elevated-color': '#f6f4fb', @@ -58,7 +68,7 @@ 'data-text-color': '#24212d', 'data-text-muted-color': '#686275', 'data-border-color': '#d9d4e4', - 'data-anchor-color': '#532fc9', + 'data-anchor-color': config.themeColor, 'data-surface-color-dark': '#17151d', 'data-surface-elevated-color-dark': '#221f2b', 'data-surface-hover-color-dark': '#302b3d', @@ -90,6 +100,8 @@ var attempt = 0; var timer = 0; var lastTrigger = null; + var activeScript = null; + var activeQueue = null; var status = documentObject.querySelector('[data-hg-ai-status]'); function renderState(next, message) { @@ -116,9 +128,35 @@ }); } + function discardAttempt(serial) { + if ( + activeScript && + activeScript.dataset.hgKapaAttempt === String(serial) + ) { + activeScript.remove(); + activeScript = null; + } + if ( + activeQueue && + activeQueue.hgKapaPreinitialized && + Array.isArray(activeQueue.q) + ) { + activeQueue.q.length = 0; + if (windowObject.Kapa === activeQueue) { + try { + delete windowObject.Kapa; + } catch (_) { + windowObject.Kapa = undefined; + } + } + } + activeQueue = null; + } + function fail(serial) { if (serial !== attempt || state !== 'loading') return; windowObject.clearTimeout(timer); + discardAttempt(serial); renderState('error', config.labels.error); } @@ -129,35 +167,46 @@ openWidget(query, submit); } - function ensureScript(serial, query, submit) { - var script = documentObject.querySelector( - 'script[data-hg-kapa-widget]', - ); - if (!script) { - preinitialize(windowObject); - script = documentObject.createElement('script'); - script.async = true; - script.src = BUNDLE_URL; - script.dataset.hgKapaWidget = ''; - var attrs = scriptAttributes(config); - Object.keys(attrs).forEach(function (name) { - script.setAttribute(name, attrs[name]); - }); - script.addEventListener('error', function () { - script.remove(); - fail(serial); - }, { once: true }); - documentObject.head.appendChild(script); + function ensureScript(serial, query, submit, retrying) { + var loaded = false; + var rendered = false; + activeQueue = preinitialize(windowObject, retrying); + if (retrying) { + invokeKapa(windowObject, 'onModalClose', restoreFocus); + } + var script = documentObject.createElement('script'); + activeScript = script; + script.async = true; + script.src = + BUNDLE_URL + (retrying ? '?hg-retry=' + encodeURIComponent(serial) : ''); + script.dataset.hgKapaWidget = ''; + script.dataset.hgKapaAttempt = String(serial); + var attrs = scriptAttributes(config); + Object.keys(attrs).forEach(function (name) { + script.setAttribute(name, attrs[name]); + }); + function finish() { + if (loaded && rendered) ready(serial, query, submit); } + script.addEventListener('load', function () { + loaded = true; + finish(); + }, { once: true }); + script.addEventListener('error', function () { + fail(serial); + }, { once: true }); try { invokeKapa(windowObject, 'render', { onRender: function () { - ready(serial, query, submit); + rendered = true; + finish(); }, }); } catch (_) { fail(serial); + return; } + documentObject.head.appendChild(script); } function activate(query, submit, trigger) { @@ -168,20 +217,23 @@ openWidget(query, Boolean(submit && query)); return; } + var retrying = state === 'error'; var serial = ++attempt; renderState('loading', ''); timer = windowObject.setTimeout(function () { fail(serial); }, TIMEOUT_MS); - ensureScript(serial, query, Boolean(submit && query)); + ensureScript(serial, query, Boolean(submit && query), retrying); } - preinitialize(windowObject); - invokeKapa(windowObject, 'onModalClose', function () { + function restoreFocus() { if (lastTrigger && typeof lastTrigger.focus === 'function') { lastTrigger.focus(); } - }); + } + + activeQueue = preinitialize(windowObject); + invokeKapa(windowObject, 'onModalClose', restoreFocus); return { activate: activate, diff --git a/layouts/_partials/backlinks.html b/layouts/_partials/backlinks.html new file mode 100644 index 000000000..4c0917334 --- /dev/null +++ b/layouts/_partials/backlinks.html @@ -0,0 +1,50 @@ +{{- /* Keep the right rail compact: five backlinks remain immediately visible + and any additional sources use a native, keyboard-accessible disclosure. + Input: dict "page" . "sources" (from backlinks-sources.html). */ -}} +{{- $p := .page -}} +{{- $sources := .sources -}} +{{- with $sources -}} +{{- $shown := first 5 . -}} +{{- $rest := after 5 . -}} +<div class="td-shell-aside-group td-shell-backlinks"> + <button type="button" class="td-shell-tree__row td-shell-aside-group__head" + data-td-shell-tree-toggle data-td-shell-aside-keep-open + aria-expanded="true" aria-controls="td-shell-backlinks-children" + aria-label="{{ T "ui_sidebar_collapse_section" }}: {{ T "ui_backlinks" }}" + data-td-label-expand="{{ T "ui_sidebar_expand_section" }}: {{ T "ui_backlinks" }}" + data-td-label-collapse="{{ T "ui_sidebar_collapse_section" }}: {{ T "ui_backlinks" }}"> + <span class="td-shell-aside-group__icon" aria-hidden="true"> + {{- partialCached "shell/icon.html" "link" "link" -}} + </span> + <span class="td-shell-aside-group__title">{{ T "ui_backlinks" }}</span> + <span class="td-shell-tree__chevron" aria-hidden="true"> + {{- partialCached "shell/icon.html" "chevron-down" "chevron-down" -}} + </span> + </button> + <div id="td-shell-backlinks-children" class="td-shell-tree__children td-is-open"> + <div class="td-shell-tree__children-inner"> + <ul class="td-shell-backlinks__list"> + {{- range $shown }} + <li class="td-shell-backlinks__item"><a href="{{ .RelPermalink }}" + {{- with .Description | plainify | strings.TrimSpace }} title="{{ . }}"{{ end }}> + {{- .LinkTitle | default .Title -}} + </a></li> + {{- end }} + </ul> + {{- with $rest }} + <details class="td-shell-backlinks__more"> + <summary>{{ T "ui_backlinks_more" (len .) }}</summary> + <ul class="td-shell-backlinks__list"> + {{- range . }} + <li class="td-shell-backlinks__item"><a href="{{ .RelPermalink }}" + {{- with .Description | plainify | strings.TrimSpace }} title="{{ . }}"{{ end }}> + {{- .LinkTitle | default .Title -}} + </a></li> + {{- end }} + </ul> + </details> + {{- end }} + </div> + </div> +</div> +{{- end -}} diff --git a/layouts/_partials/hooks/body-end.html b/layouts/_partials/hooks/body-end.html index 7866c8f25..063272377 100644 --- a/layouts/_partials/hooks/body-end.html +++ b/layouts/_partials/hooks/body-end.html @@ -12,6 +12,7 @@ {{- if $ai.enabled -}} {{- $lang := .Site.Language.Lang -}} {{- $sourceGroup := index $ai.sourceGroups $lang -}} + {{- $themeColor := index .Site.Params.ui "theme_color" -}} {{- $historical := ne (.Site.Params.version | default "latest") "latest" -}} {{- $labels := cond (eq $lang "cn") (dict "ask" "询问 AI" "description" "由 Kapa 提供;仅发送你的问题。" "latest" "回答基于 latest 文档" "retry" "重试" "error" "AI 暂时不可用,本地搜索不受影响。") @@ -21,6 +22,7 @@ "websiteId" $ai.websiteID "sourceGroupId" $sourceGroup "locale" (cond (eq $lang "cn") "zh" "en") + "themeColor" $themeColor "historical" $historical "labels" $labels -}} diff --git a/tests/e2e/ai.spec.js b/tests/e2e/ai.spec.js index 0d7357db1..0dbf2afc0 100644 --- a/tests/e2e/ai.spec.js +++ b/tests/e2e/ai.spec.js @@ -4,7 +4,7 @@ const AI_ORIGIN = "http://127.0.0.1:4174"; const mockBundle = ` (function () { var queued = window.Kapa && window.Kapa.q ? window.Kapa.q.slice() : []; - window.__kapaCalls = []; + window.__kapaCalls = window.__kapaCalls || []; window.Kapa = function (method, value) { window.__kapaCalls.push([method, value]); if (method === 'render' && value && value.onRender) value.onRender(); @@ -22,7 +22,7 @@ for (const [locale, route, source, language] of [ ]) { test(`AI tail is click-gated and locale-bound for ${locale}`, async ({ page }) => { const requests = []; - await page.route("https://widget.kapa.ai/kapa-widget.bundle.js", async (route) => { + await page.route("https://widget.kapa.ai/kapa-widget.bundle.js*", async (route) => { requests.push(route.request().url()); await route.fulfill({ status: 200, contentType: "text/javascript", body: mockBundle }); }); @@ -61,7 +61,7 @@ for (const [locale, route, source, language] of [ test("AI 500 remains non-blocking and retry issues one fresh request", async ({ page }) => { let attempts = 0; - await page.route("https://widget.kapa.ai/kapa-widget.bundle.js", async (route) => { + await page.route("https://widget.kapa.ai/kapa-widget.bundle.js*", async (route) => { attempts += 1; if (attempts === 1) await route.fulfill({ status: 500, body: "failed" }); else await route.fulfill({ status: 200, contentType: "text/javascript", body: mockBundle }); @@ -75,3 +75,48 @@ test("AI 500 remains non-blocking and retry issues one fresh request", async ({ await expect.poll(() => attempts).toBe(2); await expect(launcher).toHaveAttribute("data-hg-ai-state", "ready"); }); + +test("AI pending timeout discards stale state and retry waits for a fresh bundle", async ({ + page +}) => { + let attempts = 0; + let releaseStale; + const staleGate = new Promise((resolve) => { releaseStale = resolve; }); + await page.route("https://widget.kapa.ai/kapa-widget.bundle.js*", async (route) => { + attempts += 1; + if (attempts === 1) { + await staleGate; + } + await route.fulfill({ + status: 200, + contentType: "text/javascript", + body: mockBundle + }); + }); + await page.goto(AI_ORIGIN + "/docs/"); + const launcher = page.locator(".hg-ask-ai-launcher"); + await launcher.click(); + await expect.poll(() => attempts).toBe(1); + await expect(launcher).toHaveAttribute("data-hg-ai-state", "error", { + timeout: 7_000 + }); + await launcher.click(); + await expect.poll(() => attempts).toBe(2); + await expect(launcher).toHaveAttribute("data-hg-ai-state", "ready"); + expect( + await page.locator("script[data-hg-kapa-widget]").getAttribute("src") + ).toContain("?hg-retry=2"); + expect( + await page.evaluate(() => + (window.__kapaCalls || []).filter(([method]) => method === "open").length + ) + ).toBe(1); + + releaseStale(); + await page.waitForTimeout(250); + expect( + await page.evaluate(() => + (window.__kapaCalls || []).filter(([method]) => method === "open").length + ) + ).toBe(1); +}); diff --git a/tests/e2e/package.json b/tests/e2e/package.json index 232afcefa..36ef845b8 100644 --- a/tests/e2e/package.json +++ b/tests/e2e/package.json @@ -6,7 +6,7 @@ }, "scripts": { "test": "playwright test", - "test:ci": "node --test workflow-contract.test.cjs && playwright test versioning.spec.js search-ranking.spec.js platform.spec.js ai.spec.js accessibility.spec.js --reporter=line,html", + "test:ci": "node --test ../ui-ai/*.test.cjs && node --test workflow-contract.test.cjs && playwright test versioning.spec.js search-ranking.spec.js platform.spec.js ai.spec.js accessibility.spec.js --reporter=line,html", "test:visual": "playwright test visual.spec.js --reporter=line" }, "devDependencies": { diff --git a/tests/e2e/platform.spec.js b/tests/e2e/platform.spec.js index 6937190ec..9a012e21b 100644 --- a/tests/e2e/platform.spec.js +++ b/tests/e2e/platform.spec.js @@ -57,6 +57,44 @@ test("disabled AI emits no UI or Kapa request", async ({ page }) => { await expect(page.locator("[data-hg-ask-ai]")).toHaveCount(0); }); +test("search index failure keeps one stable, focusable retry control", async ({ + page +}) => { + let attempts = 0; + await page.route("**/offline-search-index.en.*.json", async (route) => { + attempts += 1; + if (attempts === 1) await route.abort("failed"); + else await route.continue(); + }); + await page.goto("/docs/"); + await page.locator("[data-td-shell-search-open]").first().click(); + await page.locator(".td-shell-search__input").fill("server"); + const retry = page.locator("[data-hg-search-retry]"); + await expect(retry).toHaveCount(1); + const mutations = await retry.evaluate((node) => { + window.__hgRetryNode = node; + window.__hgRetryMutations = 0; + new MutationObserver(() => { window.__hgRetryMutations += 1; }) + .observe(node.parentNode, { childList: true, subtree: true }); + return window.__hgRetryMutations; + }); + expect(mutations).toBe(0); + await page.waitForTimeout(250); + expect(await page.evaluate(() => window.__hgRetryMutations)).toBe(0); + expect( + await retry.evaluate((node) => node === window.__hgRetryNode) + ).toBe(true); + + const button = retry.locator("button"); + await button.focus(); + await expect(button).toBeFocused(); + await button.click(); + await expect.poll(() => attempts).toBe(2); + await expect(page.locator('[role="option"]').first()).toBeVisible(); + await expect(page.locator(".td-shell-search__input")).toBeFocused(); + await expect(retry).toHaveCount(0); +}); + test("Community grid and HTML/Print/Markdown profiles stay in parity", async ({ page, request diff --git a/tests/ui-ai/backlinks-render.test.cjs b/tests/ui-ai/backlinks-render.test.cjs new file mode 100644 index 000000000..4cf6add55 --- /dev/null +++ b/tests/ui-ai/backlinks-render.test.cjs @@ -0,0 +1,54 @@ +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); +const { spawnSync } = require('node:child_process'); +const test = require('node:test'); + +const root = path.resolve(__dirname, '../..'); + +test('six backlink fixture renders five rows and one expandable row', () => { + const destination = fs.mkdtempSync( + path.join(os.tmpdir(), 'hg-backlinks-fixture-'), + ); + try { + const result = spawnSync( + 'hugo', + [ + '--config', + 'hugo.yaml,tests/ui-ai/fixtures/backlinks.yaml', + '--destination', + destination, + '--quiet', + ], + { + cwd: root, + encoding: 'utf8', + env: { + ...process.env, + HUGO_CACHEDIR: path.join(destination, '.hugo-cache'), + }, + }, + ); + assert.equal(result.status, 0, result.stderr || result.stdout); + const html = fs.readFileSync( + path.join(destination, 'docs/target/index.html'), + 'utf8', + ); + const block = html.match( + /<div class="td-shell-aside-group td-shell-backlinks">([\s\S]*?)<\/div>\s*<\/div>\s*<\/div>/, + ); + assert.ok(block, 'expected rendered backlinks group'); + const beforeDetails = block[1].split( + '<details class="td-shell-backlinks__more">', + )[0]; + assert.equal( + (beforeDetails.match(/td-shell-backlinks__item/g) || []).length, + 5, + ); + assert.match(block[1], /<details class="td-shell-backlinks__more">/); + assert.match(block[1], /Source 6/); + } finally { + fs.rmSync(destination, { recursive: true, force: true }); + } +}); diff --git a/tests/ui-ai/fixtures/backlinks.yaml b/tests/ui-ai/fixtures/backlinks.yaml new file mode 100644 index 000000000..8e3ed1efb --- /dev/null +++ b/tests/ui-ai/fixtures/backlinks.yaml @@ -0,0 +1,5 @@ +enableGitInfo: false +disableLanguages: [cn] +languages: + en: + contentDir: tests/ui-ai/fixtures/backlinks/content/en diff --git a/tests/ui-ai/fixtures/backlinks/content/en/docs/_index.md b/tests/ui-ai/fixtures/backlinks/content/en/docs/_index.md new file mode 100644 index 000000000..77c4cd3c4 --- /dev/null +++ b/tests/ui-ai/fixtures/backlinks/content/en/docs/_index.md @@ -0,0 +1,3 @@ +--- +title: Fixture docs +--- diff --git a/tests/ui-ai/fixtures/backlinks/content/en/docs/source-1.md b/tests/ui-ai/fixtures/backlinks/content/en/docs/source-1.md new file mode 100644 index 000000000..4aee9b5aa --- /dev/null +++ b/tests/ui-ai/fixtures/backlinks/content/en/docs/source-1.md @@ -0,0 +1,5 @@ +--- +title: Source 1 +--- + +[Target](/docs/target/) diff --git a/tests/ui-ai/fixtures/backlinks/content/en/docs/source-2.md b/tests/ui-ai/fixtures/backlinks/content/en/docs/source-2.md new file mode 100644 index 000000000..ad1dcef4d --- /dev/null +++ b/tests/ui-ai/fixtures/backlinks/content/en/docs/source-2.md @@ -0,0 +1,5 @@ +--- +title: Source 2 +--- + +[Target](/docs/target/) diff --git a/tests/ui-ai/fixtures/backlinks/content/en/docs/source-3.md b/tests/ui-ai/fixtures/backlinks/content/en/docs/source-3.md new file mode 100644 index 000000000..513529f01 --- /dev/null +++ b/tests/ui-ai/fixtures/backlinks/content/en/docs/source-3.md @@ -0,0 +1,5 @@ +--- +title: Source 3 +--- + +[Target](/docs/target/) diff --git a/tests/ui-ai/fixtures/backlinks/content/en/docs/source-4.md b/tests/ui-ai/fixtures/backlinks/content/en/docs/source-4.md new file mode 100644 index 000000000..cae32ee7d --- /dev/null +++ b/tests/ui-ai/fixtures/backlinks/content/en/docs/source-4.md @@ -0,0 +1,5 @@ +--- +title: Source 4 +--- + +[Target](/docs/target/) diff --git a/tests/ui-ai/fixtures/backlinks/content/en/docs/source-5.md b/tests/ui-ai/fixtures/backlinks/content/en/docs/source-5.md new file mode 100644 index 000000000..be8bd888e --- /dev/null +++ b/tests/ui-ai/fixtures/backlinks/content/en/docs/source-5.md @@ -0,0 +1,5 @@ +--- +title: Source 5 +--- + +[Target](/docs/target/) diff --git a/tests/ui-ai/fixtures/backlinks/content/en/docs/source-6.md b/tests/ui-ai/fixtures/backlinks/content/en/docs/source-6.md new file mode 100644 index 000000000..0c912a9a7 --- /dev/null +++ b/tests/ui-ai/fixtures/backlinks/content/en/docs/source-6.md @@ -0,0 +1,5 @@ +--- +title: Source 6 +--- + +[Target](/docs/target/) diff --git a/tests/ui-ai/fixtures/backlinks/content/en/docs/target.md b/tests/ui-ai/fixtures/backlinks/content/en/docs/target.md new file mode 100644 index 000000000..9414ace95 --- /dev/null +++ b/tests/ui-ai/fixtures/backlinks/content/en/docs/target.md @@ -0,0 +1,6 @@ +--- +title: Backlink target +description: Receives six fixture backlinks. +--- + +Fixture target. diff --git a/tests/ui-ai/kapa-adapter.test.cjs b/tests/ui-ai/kapa-adapter.test.cjs index f11f5b6db..8f1127e98 100644 --- a/tests/ui-ai/kapa-adapter.test.cjs +++ b/tests/ui-ai/kapa-adapter.test.cjs @@ -6,8 +6,9 @@ const adapter = require('../../assets/js/kapa-adapter.js'); function harness() { const calls = []; const timers = new Map(); + const scripts = []; let nextTimer = 1; - let onRender = null; + let renderCallbacks = []; const trigger = { dataset: {}, disabled: false, @@ -20,22 +21,49 @@ function harness() { textContent: '', classList: { toggle() {} }, }; - const script = {}; const documentObject = { activeElement: trigger, querySelector(selector) { if (selector === '[data-hg-ai-status]') return status; - if (selector === 'script[data-hg-kapa-widget]') return script; + if (selector === 'script[data-hg-kapa-widget]') { + return scripts.find((script) => !script.removed) || null; + } return null; }, querySelectorAll(selector) { return selector === '[data-hg-ask-ai]' ? [trigger] : []; }, + createElement(name) { + assert.equal(name, 'script'); + const listeners = new Map(); + const script = { + dataset: {}, + attrs: {}, + addEventListener(name, callback) { listeners.set(name, callback); }, + setAttribute(name, value) { this.attrs[name] = value; }, + remove() { this.removed = true; }, + fire(name) { + const callback = listeners.get(name); + if (callback) callback(); + }, + }; + scripts.push(script); + return script; + }, + head: { + appendChild(script) { script.appended = true; }, + }, }; const windowObject = { + setKapaImplementation() { + this.Kapa = function (method, value) { + calls.push([method, value]); + if (method === 'render') renderCallbacks.push(value.onRender); + }; + }, Kapa(method, value) { calls.push([method, value]); - if (method === 'render') onRender = value.onRender; + if (method === 'render') renderCallbacks.push(value.onRender); }, setTimeout(callback) { const id = nextTimer++; @@ -48,14 +76,25 @@ function harness() { websiteId: 'website', sourceGroupId: 'source-en', locale: 'en', + themeColor: '#123456', labels: { error: 'unavailable' }, }; return { calls, config, documentObject, - fireRender() { onRender(); }, + fireRender(index = renderCallbacks.length - 1) { renderCallbacks[index](); }, fireTimeout() { Array.from(timers.values()).forEach((callback) => callback()); }, + installBundle() { + const queued = + windowObject.Kapa && Array.isArray(windowObject.Kapa.q) + ? windowObject.Kapa.q.slice() + : []; + windowObject.setKapaImplementation(); + queued.forEach((args) => windowObject.Kapa(...Array.from(args))); + }, + renderCallbacks, + scripts, trigger, windowObject, }; @@ -70,6 +109,7 @@ test('uses one fixed bundle and explicit privacy-safe widget settings', () => { websiteId: 'website', sourceGroupId: 'source-cn', locale: 'zh', + themeColor: '#123456', }); assert.equal(attrs['data-render-on-load'], 'false'); assert.equal(attrs['data-launcher-button-hidden'], 'true'); @@ -80,6 +120,8 @@ test('uses one fixed bundle and explicit privacy-safe widget settings', () => { assert.equal(attrs['data-user-analytics-fingerprint-enabled'], 'false'); assert.equal(attrs['data-bot-protection-mechanism'], 'hcaptcha'); assert.equal(attrs['data-source-group-ids-include'], 'source-cn'); + assert.equal(attrs['data-project-color'], '#123456'); + assert.equal(attrs['data-anchor-color'], '#123456'); }); test('sends only the trimmed query after explicit activation and render', () => { @@ -95,6 +137,7 @@ test('sends only the trimmed query after explicit activation and render', () => assert.equal(controller.getState(), 'loading'); assert.deepEqual(h.calls.map(([name]) => name), ['onModalClose', 'render']); + h.scripts[0].fire('load'); h.fireRender(); assert.equal(controller.getState(), 'ready'); assert.deepEqual(h.calls.slice(-2), [ @@ -124,6 +167,7 @@ test('ignores duplicate activation and never opens after a late render', () => { h.fireTimeout(); assert.equal(controller.getState(), 'error'); + assert.equal(h.scripts[0].removed, true); h.fireRender(); assert.equal( h.calls.filter(([name]) => name === 'open').length, @@ -139,9 +183,45 @@ test('launcher opens a blank session without auto-submit', () => { h.config, ); controller.activate('', false, h.trigger); + h.scripts[0].fire('load'); h.fireRender(); assert.deepEqual(h.calls.at(-1), [ 'open', { mode: 'ai', query: '', submit: false }, ]); }); + +test('a pending timeout retries with a fresh script and ignores the late attempt', () => { + const h = harness(); + const controller = adapter.createController( + h.windowObject, + h.documentObject, + h.config, + ); + controller.activate('first', true, h.trigger); + assert.equal(h.scripts.length, 1); + const staleRender = h.renderCallbacks[0]; + + h.fireTimeout(); + assert.equal(controller.getState(), 'error'); + assert.equal(h.scripts[0].removed, true); + + controller.activate('second', true, h.trigger); + assert.equal(h.scripts.length, 2); + assert.match(h.scripts[1].src, /\?hg-retry=2$/); + staleRender(); + assert.equal( + h.calls.filter(([name]) => name === 'open').length, + 0, + 'a late callback from the timed-out script must stay inert', + ); + + h.installBundle(); + h.scripts[1].fire('load'); + h.fireRender(); + assert.equal(controller.getState(), 'ready'); + assert.deepEqual(h.calls.at(-1), [ + 'open', + { mode: 'ai', query: 'second', submit: true }, + ]); +}); diff --git a/tests/ui-ai/ui-contract.test.cjs b/tests/ui-ai/ui-contract.test.cjs index fc65cba5b..8aa35f0d7 100644 --- a/tests/ui-ai/ui-contract.test.cjs +++ b/tests/ui-ai/ui-contract.test.cjs @@ -30,10 +30,15 @@ test('Kapa active resource is dynamic, exact-hosted, and never wildcarded', () = test('theme color and social fallback have one configuration authority', () => { const config = read('hugo.yaml'); const css = read('assets/scss/_styles_project.scss'); + const hook = read('layouts/_partials/hooks/body-end.html'); + const adapter = read('assets/js/kapa-adapter.js'); assert.match(config, /theme_color: '#532fc9'/); assert.match(config, /images: \[\/img\/social\/hugegraph-default\.png\]/); assert.equal(css.includes('$hg-navbar-purple'), false); assert.equal(css.includes('#532fc9'), false); + assert.match(hook, /"themeColor"\s+\$themeColor/); + assert.equal(adapter.includes("'data-project-color': '#532fc9'"), false); + assert.match(adapter, /'data-project-color': config\.themeColor/); }); test('documentation menu has five groups and no duplicate version panel', () => { @@ -53,9 +58,22 @@ test('documentation menu has five groups and no duplicate version panel', () => test('backlinks are limited to latest documentation', () => { const partial = read('layouts/_partials/backlinks-sources.html'); + const renderer = read('layouts/_partials/backlinks.html'); assert.match(partial, /Params\.version/); assert.match(partial, /"latest"/); assert.match(partial, /\.Section "docs"/); + assert.match(renderer, /\$shown := first 5/); + assert.match(renderer, /\$rest := after 5/); + assert.match(renderer, /<details class="td-shell-backlinks__more">/); +}); + +test('search retry reconciliation keeps an existing failure control stable', () => { + const source = read('assets/js/hugegraph-shell.js'); + assert.match(source, /if \(failed && existing\) return existing/); + assert.equal( + source.includes("if (existing) existing.remove();\n var failure"), + false, + ); }); test('image zoom is limited to docs and blog', () => {
