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', () => {

Reply via email to