You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@hugegraph.apache.org by ji...@apache.org on 2026/09/04 14:49:35 UTC
(hugegraph-doc) 28/33: fix(ui): harden retry interaction states
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 <ji...@apache.org>
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', () => {