Skip to content

feat(web-viewer): 引入 vitest 測試骨架 + 修 8 個健壯性風險 (harden-web-viewer-test-resilience) - #146

Merged
monkey1sai merged 5 commits into
mainfrom
codex/openspec/harden-web-viewer-test-resilience
Jun 2, 2026
Merged

monkey1sai merged 5 commits into
mainfrom
codex/openspec/harden-web-viewer-test-resilience

Conversation

@monkey1sai

@monkey1sai monkey1sai commented Jun 2, 2026 •

Copy link
Copy Markdown
Owner

為什麼

web-viewer-sample 目前零 test framework(只有 4 個自製 .mjs source-level smoke),且散落多個「demo 當下才爆」的脆弱點:無上限 poll、spectator 永遠 incomplete、CSP 下 GFN ReferenceError 炸整頁、env 繞過 coordinator boundary、私有 SDK hack、unmount 後 timer 仍 setState。本 PR 引入 vitest 純函式單測骨架(devDep)並修掉 8 個健壯性風險,把「demo 當下才爆」變「build 時就有 test / 防禦擋住」。

改了什麼

範圍邊界

  • 純前端,只動 web-viewer-sample;不改 bim-review-coordinator / bim-streaming-server / 任何對外 contract(coordinator REST / Socket.IO / DataChannel JSON / callback payload)。
  • 新 dependency 只進 devDependencies(vitest / jsdom),不新增 production dependency;不升級 @nvidia/omniverse-webrtc-streaming-library(terminate(false) 在已釘的 ^5.6.0 即可用)。
  • docs(openspec): 封存 worker 原始 IFC 檔名追蹤 #18 只修 env boundary;BimControlClient → ReviewMetadataClient 純改名 defer 為 optional follow-up。

驗證

OpenSpec

  • change-id: harden-web-viewer-test-resilience
  • spec delta: session-first-review-viewer MODIFIED 3 requirements,新增 spectator-binding / coordinator-boundary / poll-bounded / stream-teardown / sdk-load-failure / spectator-trusts-primary scenarios。

已知風險 / 後續

Summary by CodeRabbit

  • New Features

    • Implemented bounded retry logic for session readiness polling with timeout error feedback
    • Added conditional lazy-loading for external streaming SDK with graceful failure handling
  • Bug Fixes

    • Improved streaming lifecycle management and deterministic reconnect behavior after disconnections
    • Enhanced spectator viewer binding selection with explicit primary kit instance identification
    • Fixed timer race conditions in polling mechanisms with proper cleanup on unmount
  • Tests

    • Established comprehensive test infrastructure using Vitest and jsdom for unit testing
    • Added test coverage for polling, environment configuration, and streaming utility functions

monkey1sai and others added 2 commits June 2, 2026 10:45
引入 vitest 純函式單測骨架(devDep) + 修 8 個 web-viewer 健壯性風險
(#17 #27 #8 #28 #15 #16 #18 #32);spec delta 於 session-first-review-viewer
MODIFIED 3 requirements,新增 spectator-binding / coordinator-boundary /
poll-bounded / stream-teardown / sdk-load-failure / spectator-trusts-primary
scenarios。baseline:verify 10 passed + test:session-first passed。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- #17 vitest + jsdom(devDep) + 抽 windowHelpers/pollHelpers/envHelpers 純函式 + colocated test(21 passed)
- #27 _pollForKitReady 存 _pollForKitReadyId + componentWillUnmount clear(防 unmounted setState)
- #8 AppStream 兩處私有 _stream hack 換公開 AppStreamer.terminate(false)
- #28 pollForSessionReady MAX_POLL_RETRIES=36 上限 + 逾時 reset + catch reschedule(不再靜默死)
- #15 selectSpectatorBinding 用 viewport_sharing.primary_kit_instance_id 顯式挑選(取代 port 隱性)
- #16 spectator 分支 stageLoadStatus: 'matched'(信任 primary serving stage)
- #18 env.ts 移除 VITE_BIM_CONTROL_API_BASE override(resolveBimControlBase 恆等 coordinator)
- #32 index.html 刪無條件 GFN script → AppStream source==='gfn' 動態載入 + onerror 回饋

verify-session-first 既有斷言因 #17 抽函式改查 Window.tsx+windowHelpers 聯集。
驗證:build + vitest 21 + struct-log 10 + session-first + root pytest 65 全綠。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings June 2, 2026 03:05
@coderabbitai

coderabbitai Bot commented Jun 2, 2026 •

Copy link
Copy Markdown
Contributor

Review Change Stack

Warning

Review limit reached

@monkey1sai, we couldn't start this review because you've reached your PR review rate limit.

More reviews will be available in 28 minutes and 34 seconds. Learn how PR review limits work.

Your organization has run out of usage credits. Purchase more in the billing tab.

⌛ How to resolve this issue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans include higher PR review limits than trial, open-source, and free plans. In all cases, reviews become available again over time. During sustained high-volume PR review activity, CodeRabbit may temporarily slow when the next review becomes available.

Please see our Fair Usage Limits Policy for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 620413ba-2265-46b9-96b9-3db9ee9923ec

📥 Commits

Reviewing files that changed from the base of the PR and between 64d8ea6 and 4117ce2.

📒 Files selected for processing (9)
  • openspec/changes/harden-web-viewer-test-resilience/design.md
  • openspec/changes/harden-web-viewer-test-resilience/specs/session-first-review-viewer/spec.md
  • web-viewer-sample/scripts/verify-session-first-contract.mjs
  • web-viewer-sample/src/AppStream.tsx
  • web-viewer-sample/src/Window.tsx
  • web-viewer-sample/src/config/envHelpers.test.ts
  • web-viewer-sample/src/utils/windowHelpers.test.ts
  • web-viewer-sample/src/utils/windowHelpers.ts
  • web-viewer-sample/vitest.config.ts
📝 Walkthrough

Walkthrough

This PR hardens the web-viewer-sample viewer against timing races, unbounded retries, and coordinator boundary bypasses. It introduces Vitest testing infrastructure, extracts pure helper functions, bounds polling with explicit retry limits and timeouts, lazy-loads external streaming SDKs, fixes spectator endpoint selection using coordinator-provided kit instance identity, enforces coordinator-trusted API bases, and hardens contract verification assertions. A complete OpenSpec specification and task checklist are included.

Web Viewer Test Resilience Hardening

Layer / File(s) Summary
Vitest test infrastructure setup
web-viewer-sample/vitest.config.ts, web-viewer-sample/tsconfig.json, web-viewer-sample/package.json
Vitest 1.x with jsdom environment is configured via vitest.config.ts; TypeScript globals are enabled; npm verify script extended to run tests between build and struct-log; jsdom and vitest added to devDependencies.
Pure function helpers extracted from Window
web-viewer-sample/src/utils/windowHelpers.ts, web-viewer-sample/src/utils/windowHelpers.test.ts, web-viewer-sample/src/Window.tsx
Stream endpoint types, lifecycle blocking detection, lifecycle status text mapping, and spectator binding selection are extracted to a dedicated module with comprehensive test coverage including edge cases for nullish ports and fallback behavior.
Bounded polling and retry control
web-viewer-sample/src/App.tsx, web-viewer-sample/src/utils/pollHelpers.ts, web-viewer-sample/src/utils/pollHelpers.test.ts, web-viewer-sample/src/Window.tsx
App.tsx bounds session polling with MAX_POLL_RETRIES and uses shouldRetryPoll to decide continuation; resets state with timeout messaging when retries exhausted. Window.tsx tracks Kit readiness polling timeout ID, clears on unmount, and prevents race conditions. pollHelpers provides the centralized retry decision with tests.
Stream initialization and GFN SDK lazy loading
web-viewer-sample/src/AppStream.tsx, web-viewer-sample/index.html
AppStream conditionally lazy-loads NVIDIA GFN script only when source is 'gfn'; uses public terminate(false) API instead of private _stream manipulation. GFN script tag removed from HTML; external SDK failures no longer crash the page.
Spectator endpoint selection and stage readiness
web-viewer-sample/src/Window.tsx, web-viewer-sample/src/utils/triReady.test.ts
Spectator binding selection uses explicit viewport_sharing.primary_kit_instance_id with selectSpectatorBinding helper and port-diff fallback; spectator stage immediately marked 'matched' on stream start. triReady tests validate readiness computation for spectator/primary lifecycle combinations, model URLs, and semantic fidelity.
Coordinator boundary trust and API base resolution
web-viewer-sample/src/config/envHelpers.ts, web-viewer-sample/src/config/envHelpers.test.ts, web-viewer-sample/src/config/env.ts
New resolveBimControlBase helper removes VITE_BIM_CONTROL_API_BASE override allowing client-side bypass; resolution always follows coordinator base via query parameter or environment source. Tests verify fallback behavior.
Session-first contract verification hardening
web-viewer-sample/scripts/verify-session-first-contract.mjs
Verification script combines Window.tsx and windowHelpers.ts as unified "Window logic" source; adds cross-file assertions validating Kit polling handle name, spectator matched stage logic, AppStream terminate(false) usage (no private _stream), MAX_POLL_RETRIES definition, and absence of unconditional GFN inline script.
OpenSpec specification and task documentation
openspec/changes/harden-web-viewer-test-resilience/*
Design.md documents Vitest strategy and configuration decisions; proposal.md outlines problem statement and detailed change items; spec.md specifies hardening requirements for session bootstrapping, lifecycle transitions, endpoint selection, and readiness determination; tasks.md provides numbered implementation checklist with verification and rollout steps.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

🐰 Bounce with joy, the tests now hold tight,
No more races when timers take flight!
Spectator endpoints dance in the light,
And coordinator boundaries guard what is right.
From chaos to rigor, we hardened the night! ✨

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 16.67% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The pull request title clearly describes the main change: introducing vitest testing infrastructure and fixing 8 robustness issues in the web-viewer component.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch codex/openspec/harden-web-viewer-test-resilience

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR strengthens web-viewer-sample by introducing a Vitest-based unit test skeleton (pure-function tests) and hardening several demo-time failure modes (bounded polling, safer teardown, spectator readiness fixes, coordinator-boundary enforcement, and conditional GFN SDK loading). It also adds an OpenSpec change artifact documenting the updated viewer requirements and scenarios.

Changes:

  • Add Vitest + JSDOM test harness and colocated unit tests for extracted pure helpers.
  • Harden viewer/runtime behaviors: bounded polling, timer cleanup on unmount, safer spectator binding/ready-state handling, and public SDK teardown API usage.
  • Remove unconditional GFN CDN injection and load the SDK dynamically only for source === 'gfn', plus tighten env boundary rules.

Reviewed changes

Copilot reviewed 19 out of 19 changed files in this pull request and generated 6 comments.

Show a summary per file
File Description
web-viewer-sample/vitest.config.ts Adds Vitest configuration (jsdom, globals, test include glob).
web-viewer-sample/tsconfig.json Adds Vitest globals typing for tests.
web-viewer-sample/src/Window.tsx Imports extracted helpers; clears Kit-ready poll timer on unmount; improves spectator binding selection + spectator stage status.
web-viewer-sample/src/utils/windowHelpers.ts New pure helper module for lifecycle/endpoint comparison + spectator binding selection.
web-viewer-sample/src/utils/windowHelpers.test.ts Unit tests for lifecycle blocking, transport equality, spectator binding selection.
web-viewer-sample/src/utils/pollHelpers.ts New pure helper for bounded poll retry decision.
web-viewer-sample/src/utils/pollHelpers.test.ts Unit tests for bounded poll retry helper.
web-viewer-sample/src/config/envHelpers.ts New helper to enforce coordinator boundary for bim-control base resolution.
web-viewer-sample/src/config/envHelpers.test.ts Unit tests for env base resolution behavior.
web-viewer-sample/src/config/env.ts Removes bim-control env override; makes bimControlApiBase resolve from coordinator base only.
web-viewer-sample/src/AppStream.tsx Uses AppStreamer.terminate(false); dynamically loads GFN SDK only for gfn source.
web-viewer-sample/src/App.tsx Bounds session readiness polling with retry count + max retries and timeout UI/reset behavior.
web-viewer-sample/scripts/verify-session-first-contract.mjs Updates source-level contract checks to include extracted helpers + new resilience assertions.
web-viewer-sample/package.json Adds vitest/jsdom devDeps; adds test/test:watch; includes tests in verify.
web-viewer-sample/index.html Removes unconditional GFN SDK script tag.
openspec/changes/harden-web-viewer-test-resilience/tasks.md Adds implementation task checklist for the change.
openspec/changes/harden-web-viewer-test-resilience/proposal.md Adds rationale and scope for the change.
openspec/changes/harden-web-viewer-test-resilience/design.md Documents design decisions/trade-offs/verification plan.
openspec/changes/harden-web-viewer-test-resilience/specs/session-first-review-viewer/spec.md Spec delta: adds/updates requirements and scenarios around boundary, polling, teardown, spectator behavior, SDK load failure.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +153 to +157
else {
console.error(`Session ${sessionId} did not become ready after ${MAX_POLL_RETRIES} retries.`);
this.setState({ connectionText: "等待 streaming session 就緒逾時,請重試。" })
this._resetState()
}
Comment on lines +163 to +166
else {
this.setState({ connectionText: "等待 streaming session 就緒逾時,請重試。" })
this._resetState()
}
Comment on lines 72 to +76
if (StreamConfig.source === 'gfn') {
streamSource = StreamType.GFN;
streamConfig = {
//@ts-ignore
GFN : GFN,
catalogClientId : StreamConfig.gfn.catalogClientId,
clientId : StreamConfig.gfn.clientId,
cmsId : StreamConfig.gfn.cmsId,
onUpdate : (message: StreamEvent) => this._onUpdate(message),
onStart : (message: StreamEvent) => this._onStart(message),
onCustomEvent : (message: any) => this._onCustomEvent(message)
}
const existing = document.getElementById('gfn-client-sdk-script');
if (existing) {
this._initStream();
} else {
Comment on lines +98 to 109
if (StreamConfig.source === 'gfn') {
streamSource = StreamType.GFN;
streamConfig = {
//@ts-ignore
GFN : GFN,
catalogClientId : StreamConfig.gfn.catalogClientId,
clientId : StreamConfig.gfn.clientId,
cmsId : StreamConfig.gfn.cmsId,
onUpdate : (message: StreamEvent) => this._onUpdate(message),
onStart : (message: StreamEvent) => this._onStart(message),
onCustomEvent : (message: any) => this._onCustomEvent(message)
}
Comment on lines +82 to +86
if (primaryKitInstanceId) {
return bindings.find((binding) => binding.kit_instance_id !== primaryKitInstanceId) || null;
}
return bindings.find((binding) => !sameStreamTransportEndpoint(binding.stream_config, primaryWebrtc)) || null;
}
Comment thread web-viewer-sample/vitest.config.ts Outdated
Comment on lines +1 to +9
import { defineConfig } from 'vitest/config'

export default defineConfig({
test: {
environment: 'jsdom',
globals: true,
include: ['src/**/*.{test,spec}.{ts,tsx}'],
},
})

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: a3ffab2ca1

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

const script = document.createElement('script');
script.id = 'gfn-client-sdk-script';
script.src = 'https://sdk.nvidia.com/gfn/client-sdk/1.x/gfn-client-sdk.js';
script.onload = () => this._initStream();

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Guard the lazy GFN load after unmount

When StreamConfig.source === 'gfn' and this component unmounts before the CDN script finishes loading, this onload handler still calls _initStream() on the stale instance. Because componentWillUnmount() has already terminated the streamer, the late handler can start a hidden AppStreamer connection that races the current viewer/reconnect flow; clear or guard the handler on unmount before connecting.

Useful? React with 👍 / 👎.

Comment on lines 210 to 212
static stop() {
AppStreamer.stop();
(AppStreamer as any)._stream = null; // Accessing a private member
AppStreamer.terminate(false);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Await stream termination before remounting

In the reconnect path I inspected (Window.tsx calls AppStream.stop() and immediately increments streamMountKey), this new stop() implementation starts AppStreamer.terminate(false) but does not return or await its promise. The SDK only clears its singleton stream after that promise resolves, so a remounted AppStream can call connect() while termination is still in progress and get stuck in the same busy/disconnecting state the reconnect button is meant to recover from.

Useful? React with 👍 / 👎.

Comment thread web-viewer-sample/src/AppStream.tsx Outdated
Comment on lines +73 to +75
const existing = document.getElementById('gfn-client-sdk-script');
if (existing) {
this._initStream();

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Wait for an existing GFN script to finish loading

If a GFN reconnect/remount happens after the first mount appended gfn-client-sdk-script but before that script has loaded, this branch sees the element and immediately calls _initStream(). At that point the GFN global is still undefined, so _initStream() throws instead of waiting for the pending script's load/error event; track the loaded state or attach another listener before initializing.

Useful? React with 👍 / 👎.

Comment thread web-viewer-sample/src/Window.tsx Outdated
showUI: true,
isLoading: false,
loadingText: "旁觀串流已連線",
stageLoadStatus: 'matched',

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Only mark spectator stage matched when coordinator says ready

For any URL opened with streamRole=spectator/view_only, a successful WebRTC start now marks stageLoadStatus as matched without checking latestStreamConfig.viewport_sharing?.spectator_ready or any stage-load evidence. When the coordinator has not marked the spectator Kit ready (or spectator mode fell back to the primary/only binding), this makes Runtime ready report yes even though the primary stage may not be serving the expected artifact yet.

Useful? React with 👍 / 👎.

@github-actions

github-actions Bot commented Jun 2, 2026

Copy link
Copy Markdown
Contributor

PR Review Agent Summary

Field Value
Status warning
Risk medium
PR 146
Head codex/openspec/harden-web-viewer-test-resilience / a3ffab2ca11e254d9d8b66d4c47854399c1e7d19
Base main / fb4396efbb9a0ce137c7618d426894703ffd471f

Blockers

  • None

Warnings

  • [medium] GitNexus detect changes did not pass: warning.

Validation Commands

  • openspec validate harden-web-viewer-test-resilience
  • npm run verify

Checks

  • passed openspec validate harden-web-viewer-test-resilience (openspec)
  • passed web-viewer-sample verify (web-viewer-sample)

Human Review Notes

  • OpenSpec changes detected: harden-web-viewer-test-resilience
  • Optional AI adapter is not required by policy and was skipped.

- #27(P2): _pollForKitReady 進入點改 _clearPollForKitReady(),閉合 in-mount 重入孤兒並行 chain(原僅 id=null 不 clearTimeout)
- #28(P2): _resetState 參數化 connectionText,逾時訊息不再被 _resetState 的 connectionText:'' 覆寫
- #32(P2): GFN script 命中既有但全域 GFN 未就緒時補掛 load/error,避免 remount-during-load 仍 ReferenceError(用 //@ts-ignore 不引入 as any)
- design.md #8: 修正捏造的 SDK 引用(實裝 5.17.0/L71/terminate(terminateApp?) 無 _force)
- 補 triReady.test.ts(11 test,含 #16 spectator started+matched→yes);Decision 7 誠實揭露 structLog.test.ts defer

驗證:build + vitest 32 passed(21→32) + struct-log 10 + session-first 全綠。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@github-actions

github-actions Bot commented Jun 2, 2026

Copy link
Copy Markdown
Contributor

PR Review Agent Summary

Field Value
Status warning
Risk medium
PR 146
Head codex/openspec/harden-web-viewer-test-resilience / 64d8ea6f6f7e78a8dcd9eaa9b2d51dc129a5b2dc
Base main / fb4396efbb9a0ce137c7618d426894703ffd471f

Blockers

  • None

Warnings

  • [medium] GitNexus detect changes did not pass: warning.

Validation Commands

  • openspec validate harden-web-viewer-test-resilience
  • npm run verify

Checks

  • passed openspec validate harden-web-viewer-test-resilience (openspec)
  • passed web-viewer-sample verify (web-viewer-sample)

Human Review Notes

  • OpenSpec changes detected: harden-web-viewer-test-resilience
  • Optional AI adapter is not required by policy and was skipped.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🧹 Nitpick comments (4)
web-viewer-sample/src/App.tsx (1)

149-152: 💤 Low value

Untracked setTimeout poll chain has no cleanup.

pollForSessionReady schedules retries with setTimeout but never stores the handle, so a pending poll can fire after teardown and call setState. App is the root component (low unmount likelihood), but this mirrors the timer race fixed for _pollForKitReady (#27) in Window.tsx; consider tracking and clearing the handle for parity and to guard against future remounts.

Also applies to: 159-161

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@web-viewer-sample/src/App.tsx` around lines 149 - 152, The retry setTimeout
in pollForSessionReady (and its twin at lines ~159-161) is untracked and can
fire after unmount causing setState; modify pollForSessionReady to store the
timeout handle (e.g., this.sessionPollTimeout or an array this.pollTimeouts)
when calling setTimeout, and add cleanup logic that clears those handles in
componentWillUnmount (or useEffect cleanup) so pending timers are cleared;
mirror the approach used for _pollForKitReady to locate where to add the handle
storage and clear calls.
web-viewer-sample/src/Window.tsx (1)

982-992: 💤 Low value

Consider adding a retry limit to prevent unbounded polling.

The _pollForKitReady method polls indefinitely until Kit responds or the component unmounts. While timer cleanup on unmount (line 312) prevents memory leaks, if Kit never responds due to a permanent failure, the viewer will continue polling until user action.

Consider adding a retry counter and fallback similar to the MAX_POLL_RETRIES pattern used elsewhere in the codebase to surface a user-visible error after a reasonable timeout.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@web-viewer-sample/src/Window.tsx` around lines 982 - 992, Add a retry limit
to the indefinite poll in _pollForKitReady: introduce a MAX_POLL_RETRIES
constant and a counter field (e.g., _pollForKitReadyAttempts) incremented each
time _pollForKitReady runs; if attempts exceed MAX_POLL_RETRIES call
_clearPollForKitReady(), stop scheduling further timeouts, and set a
user-visible fallback state (e.g., setState({ isKitReady: false, kitError: true
}) or call an existing error handler). Reset the attempts counter when polling
starts successfully (when isKitReady becomes true) and clear it in
componentWillUnmount or inside _clearPollForKitReady so cleanup is consistent;
update any places that start polling (e.g., _onStreamStarted) to reset the
counter before calling _pollForKitReady.
web-viewer-sample/src/AppStream.tsx (1)

73-87: ⚖️ Poor tradeoff

Potential missed error on script remount.

If the GFN SDK script has already failed to load before componentDidMount runs (e.g., on component remount after a prior failure), the script element exists but GFN is undefined, and the error event won't re-fire. The added error listener at line 82-85 won't catch the already-completed failure.

Consider checking the script element's error state or the global GFN presence with a timeout fallback to detect stale failures:

const existing = document.getElementById('gfn-client-sdk-script');
if (existing) {
  if (typeof GFN !== 'undefined') {
    this._initStream();
  } else {
    const timeout = setTimeout(() => {
      console.error('GFN SDK did not load within expected time');
      this.props.onStreamFailed();
    }, 5000);
    existing.addEventListener('load', () => {
      clearTimeout(timeout);
      this._initStream();
    });
    existing.addEventListener('error', () => {
      clearTimeout(timeout);
      console.error('Failed to load GFN client SDK script');
      this.props.onStreamFailed();
    });
  }
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@web-viewer-sample/src/AppStream.tsx` around lines 73 - 87, When the script
element 'gfn-client-sdk-script' exists but GFN is undefined, add a timeout
fallback and/or check the script element's ready state so a prior failed load is
detected: if typeof GFN === 'undefined' and existing.readyState indicates
completed/error or a timeout (e.g., 5s) elapses, call
this.props.onStreamFailed(), otherwise attach load and error listeners that
clear the timeout and either call this._initStream() on load or call
this.props.onStreamFailed() on error; ensure the timeout is cleared in both
listeners to avoid stray failure callbacks.
web-viewer-sample/src/utils/windowHelpers.ts (1)

77-86: 💤 Low value

Edge case: Multiple bindings with same kit_instance_id but different transports.

If primaryKitInstanceId is provided but all bindings share the same kit_instance_id value (with different transports), this function returns null, and the caller falls back to primaryBinding. This means spectator and primary would connect to the same endpoint.

However, this scenario would indicate a coordinator configuration error, since properly deployed Kit instances should have unique IDs when primary_kit_instance_id is provided. The current implementation correctly prioritizes explicit ID-based selection over transport inference.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@web-viewer-sample/src/utils/windowHelpers.ts` around lines 77 - 86, The
selectSpectatorBinding function currently returns null when primaryKitInstanceId
is provided but no binding has a different kit_instance_id, causing the caller
to reuse the primary endpoint; change selectSpectatorBinding to first try
id-based selection (bindings.find(binding => binding.kit_instance_id !==
primaryKitInstanceId)) and if that returns null, fall back to transport-based
selection by returning bindings.find(binding =>
!sameStreamTransportEndpoint(binding.stream_config, primaryWebrtc)) || null;
update the function body (selectSpectatorBinding and its use of
sameStreamTransportEndpoint and primaryWebrtc) so a spectator with a different
transport is chosen even when kit_instance_ids are all the same.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@web-viewer-sample/src/config/envHelpers.test.ts`:
- Around line 3-11: Add a test to cover the null case for resolveBimControlBase:
when queryCoordinatorApiBase is null the function should fall back to the
envCoordinatorApiBase; update the describe block to include an it test that
calls resolveBimControlBase(null, "e") and asserts the result equals "e" so the
string|null path is exercised (reference resolveBimControlBase in
envHelpers.test.ts).

---

Nitpick comments:
In `@web-viewer-sample/src/App.tsx`:
- Around line 149-152: The retry setTimeout in pollForSessionReady (and its twin
at lines ~159-161) is untracked and can fire after unmount causing setState;
modify pollForSessionReady to store the timeout handle (e.g.,
this.sessionPollTimeout or an array this.pollTimeouts) when calling setTimeout,
and add cleanup logic that clears those handles in componentWillUnmount (or
useEffect cleanup) so pending timers are cleared; mirror the approach used for
_pollForKitReady to locate where to add the handle storage and clear calls.

In `@web-viewer-sample/src/AppStream.tsx`:
- Around line 73-87: When the script element 'gfn-client-sdk-script' exists but
GFN is undefined, add a timeout fallback and/or check the script element's ready
state so a prior failed load is detected: if typeof GFN === 'undefined' and
existing.readyState indicates completed/error or a timeout (e.g., 5s) elapses,
call this.props.onStreamFailed(), otherwise attach load and error listeners that
clear the timeout and either call this._initStream() on load or call
this.props.onStreamFailed() on error; ensure the timeout is cleared in both
listeners to avoid stray failure callbacks.

In `@web-viewer-sample/src/utils/windowHelpers.ts`:
- Around line 77-86: The selectSpectatorBinding function currently returns null
when primaryKitInstanceId is provided but no binding has a different
kit_instance_id, causing the caller to reuse the primary endpoint; change
selectSpectatorBinding to first try id-based selection (bindings.find(binding =>
binding.kit_instance_id !== primaryKitInstanceId)) and if that returns null,
fall back to transport-based selection by returning bindings.find(binding =>
!sameStreamTransportEndpoint(binding.stream_config, primaryWebrtc)) || null;
update the function body (selectSpectatorBinding and its use of
sameStreamTransportEndpoint and primaryWebrtc) so a spectator with a different
transport is chosen even when kit_instance_ids are all the same.

In `@web-viewer-sample/src/Window.tsx`:
- Around line 982-992: Add a retry limit to the indefinite poll in
_pollForKitReady: introduce a MAX_POLL_RETRIES constant and a counter field
(e.g., _pollForKitReadyAttempts) incremented each time _pollForKitReady runs; if
attempts exceed MAX_POLL_RETRIES call _clearPollForKitReady(), stop scheduling
further timeouts, and set a user-visible fallback state (e.g., setState({
isKitReady: false, kitError: true }) or call an existing error handler). Reset
the attempts counter when polling starts successfully (when isKitReady becomes
true) and clear it in componentWillUnmount or inside _clearPollForKitReady so
cleanup is consistent; update any places that start polling (e.g.,
_onStreamStarted) to reset the counter before calling _pollForKitReady.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 0e9da65c-8099-4df9-945a-947b4c68bc79

📥 Commits

Reviewing files that changed from the base of the PR and between fb4396e and 64d8ea6.

📒 Files selected for processing (20)
  • openspec/changes/harden-web-viewer-test-resilience/design.md
  • openspec/changes/harden-web-viewer-test-resilience/proposal.md
  • openspec/changes/harden-web-viewer-test-resilience/specs/session-first-review-viewer/spec.md
  • openspec/changes/harden-web-viewer-test-resilience/tasks.md
  • web-viewer-sample/index.html
  • web-viewer-sample/package.json
  • web-viewer-sample/scripts/verify-session-first-contract.mjs
  • web-viewer-sample/src/App.tsx
  • web-viewer-sample/src/AppStream.tsx
  • web-viewer-sample/src/Window.tsx
  • web-viewer-sample/src/config/env.ts
  • web-viewer-sample/src/config/envHelpers.test.ts
  • web-viewer-sample/src/config/envHelpers.ts
  • web-viewer-sample/src/utils/pollHelpers.test.ts
  • web-viewer-sample/src/utils/pollHelpers.ts
  • web-viewer-sample/src/utils/triReady.test.ts
  • web-viewer-sample/src/utils/windowHelpers.test.ts
  • web-viewer-sample/src/utils/windowHelpers.ts
  • web-viewer-sample/tsconfig.json
  • web-viewer-sample/vitest.config.ts
💤 Files with no reviewable changes (1)
  • web-viewer-sample/index.html

Comment on lines +153 to +156
else {
console.error(`Session ${sessionId} did not become ready after ${MAX_POLL_RETRIES} retries.`);
this._resetState("等待 streaming session 就緒逾時,請重試。")
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Look for connectionText rendering across the app and the forms
rg -nP --type=tsx -C3 'connectionText'
fd -e tsx -i 'forms' --exec rg -nP -C3 'connectionText' {}

Repository: monkey1sai/AI-BIM-governance

Length of output: 100


🏁 Script executed:

#!/bin/bash
set -euo pipefail
# Find relevant code locations in App.tsx
rg -n "connectionText" web-viewer-sample/src/App.tsx
rg -n "_resetState" web-viewer-sample/src/App.tsx
rg -n "Forms\.IDLE|currentForm|enum Forms|AppOnly|Stream" web-viewer-sample/src/App.tsx

# Show specific nearby regions for the lines referenced in the review comment
sed -n '80,140p' web-viewer-sample/src/App.tsx
sed -n '160,220p' web-viewer-sample/src/App.tsx
sed -n '260,340p' web-viewer-sample/src/App.tsx

Repository: monkey1sai/AI-BIM-governance

Length of output: 15667


🏁 Script executed:

#!/bin/bash
set -euo pipefail
# Ensure file exists
ls -la web-viewer-sample/src/App.tsx

# Find relevant code locations in App.tsx
rg -n "connectionText" web-viewer-sample/src/App.tsx
rg -n "_resetState" web-viewer-sample/src/App.tsx
rg -n "Forms\.IDLE|currentForm|AppOnly|Stream" web-viewer-sample/src/App.tsx

# Show specific nearby regions for the lines referenced in the review comment
sed -n '90,140p' web-viewer-sample/src/App.tsx
sed -n '150,210p' web-viewer-sample/src/App.tsx
sed -n '270,320p' web-viewer-sample/src/App.tsx

Repository: monkey1sai/AI-BIM-governance

Length of output: 14074


🏁 Script executed:

#!/bin/bash
set -euo pipefail
ls -la web-viewer-sample/src/App.tsx
rg -n "connectionText" web-viewer-sample/src/App.tsx
rg -n "_resetState" web-viewer-sample/src/App.tsx
rg -n "Forms\.IDLE|currentForm|AppOnly|Stream" web-viewer-sample/src/App.tsx
sed -n '90,140p' web-viewer-sample/src/App.tsx
sed -n '150,210p' web-viewer-sample/src/App.tsx
sed -n '270,320p' web-viewer-sample/src/App.tsx

Repository: monkey1sai/AI-BIM-governance

Length of output: 14074


🏁 Script executed:

#!/bin/bash
set -euo pipefail
ls -la web-viewer-sample/src/App.tsx
rg -n "connectionText" web-viewer-sample/src/App.tsx
rg -n "_resetState" web-viewer-sample/src/App.tsx
rg -n "Forms\.IDLE|currentForm|AppOnly|Stream" web-viewer-sample/src/App.tsx
sed -n '90,140p' web-viewer-sample/src/App.tsx
sed -n '150,210p' web-viewer-sample/src/App.tsx
sed -n '270,320p' web-viewer-sample/src/App.tsx

Repository: monkey1sai/AI-BIM-governance

Length of output: 14074


🏁 Script executed:

#!/bin/bash
set -euo pipefail
ls -la web-viewer-sample/src/App.tsx

rg -n "connectionText" web-viewer-sample/src/App.tsx
rg -n "_resetState" web-viewer-sample/src/App.tsx
rg -n "Forms\.IDLE|currentForm|AppOnly|Stream" web-viewer-sample/src/App.tsx

sed -n '90,140p' web-viewer-sample/src/App.tsx
sed -n '150,210p' web-viewer-sample/src/App.tsx
sed -n '270,320p' web-viewer-sample/src/App.tsx

Repository: monkey1sai/AI-BIM-governance

Length of output: 14074


🏁 Script executed:

#!/bin/bash
set -euo pipefail
rg -n "connectionText" web-viewer-sample/src

Repository: monkey1sai/AI-BIM-governance

Length of output: 738


Fix hidden timeout feedback in pollForSessionReady (App.tsx).

pollForSessionReady calls this._resetState("等待 streaming session 就緒逾時,請重試。") on timeout, but _resetState(...) switches currentForm to Forms.Stream (local) / Forms.AppOnly (non-local). connectionText is only rendered when currentForm === Forms.IDLE, so the timeout message won’t be visible to the user. Also, the retry loop uses setTimeout without canceling when _resetState runs, so it can keep updating state after the UI has moved on.

Comment thread web-viewer-sample/src/config/envHelpers.test.ts
- #32(A): _initStream 改 globalThis.GFN 讀取,缺失走 onStreamFailed,避免 onload 後 GFN 未建立仍裸變數 ReferenceError(Copilot+Codex)
- #15(B): selectSpectatorBinding 先驗 primaryKitInstanceId 在 bindings 內才用 id 挑,否則退 port-diff,避免 coordinator 資料不一致時誤選 primary(Copilot)
- #16(D): spectator 僅在 viewport_sharing.spectator_ready 時 stageLoadStatus='matched',否則 pending(Codex);連帶 verify 斷言 + spec scenario 精確化
- envHelpers.test 補 null case(CodeRabbit);vitest.config 補分號對齊 .prettierrc semi(Copilot)

驗證:build + vitest 34 passed(32→34) + struct-log 10 + session-first 全綠。
defer(PR 揭露):reconnect await terminate、GFN unmount guard(pre-existing async / gfn 窄窗競態)。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@monkey1sai

Copy link
Copy Markdown
Owner Author

Review 回應(opus 對抗式 + Copilot / Codex / CodeRabbit)

兩輪 review fix 已 push(64d8ea6 opus 自審 / c3dfd04 外部 reviewer):

已修(真問題 / proposal 目標缺口)

Defer(pre-existing / 跨輪追蹤,本 PR 不擴大 scope)

驗證:web-viewer build + vitest 34 + struct-log 10 + test:session-first;root pytest 65 passed;GitNexus impact 全 LOW。

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: c3dfd04151

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +153 to +155
else {
console.error(`Session ${sessionId} did not become ready after ${MAX_POLL_RETRIES} retries.`);
this._resetState("等待 streaming session 就緒逾時,請重試。")

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Tear down timed-out streaming sessions before resetting

When a newly created stream returns 202 and stays non-ready through MAX_POLL_RETRIES, this path resets the UI state and clears sessionId without calling destroyStreamingSession for the already-created session. If the backend finishes provisioning after this timeout, the viewer has hidden the End Stream control and lost the id needed to release the Kit/GPU session, so the resource can keep running until an operator cleans it up.

Useful? React with 👍 / 👎.

Comment on lines +957 to +958
loadingText: spectatorReady ? "旁觀串流已連線" : "旁觀串流已連線,等待 primary stage 就緒",
stageLoadStatus: spectatorReady ? 'matched' : 'pending',

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Refresh spectator readiness before staying pending

When a spectator connects before viewport_sharing.spectator_ready is true, this branch leaves stageLoadStatus at pending and returns without scheduling any stream-config refresh. I checked the Socket.IO onEvent path in this file; it only appends/logs events, so if the coordinator later flips spectator_ready after the primary stage loads, this viewer keeps reporting Runtime ready as incomplete until a full reload/manual bootstrap even though the coordinator is now ready.

Useful? React with 👍 / 👎.

coordinatorApiBase: queryCoordinatorApiBase || envCoordinatorApiBase,
coordinatorSocketUrl: queryCoordinatorSocketUrl || queryCoordinatorApiBase || import.meta.env.VITE_COORDINATOR_SOCKET_URL || envCoordinatorApiBase,
bimControlApiBase: import.meta.env.VITE_BIM_CONTROL_API_BASE || queryCoordinatorApiBase || envCoordinatorApiBase,
bimControlApiBase: resolveBimControlBase(queryCoordinatorApiBase, envCoordinatorApiBase),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve review-request endpoints when rebasing metadata calls

With this base now forced to the coordinator, BimControlClient.getReviewSessionRequest() and .getArtifacts() call /api/review-session-requests/... and /api/model-versions/.../artifacts on the coordinator whenever the viewer is opened with reviewRequestId or falls back to metadata loading. I searched the coordinator routes and found only /api/review-sessions and /api/external/ifc-ready families, not these BimControlClient paths, so those launches now 404 before they can create/bind the review session unless equivalent coordinator endpoints are added or the call sites are removed.

Useful? React with 👍 / 👎.

@github-actions

github-actions Bot commented Jun 2, 2026

Copy link
Copy Markdown
Contributor

PR Review Agent Summary

Field Value
Status warning
Risk medium
PR 146
Head codex/openspec/harden-web-viewer-test-resilience / c3dfd04151c66e4ae51041d965d043f75e1e0170
Base main / fb4396efbb9a0ce137c7618d426894703ffd471f

Blockers

  • None

Warnings

  • [medium] GitNexus detect changes did not pass: warning.

Validation Commands

  • openspec validate harden-web-viewer-test-resilience
  • npm run verify

Checks

  • passed openspec validate harden-web-viewer-test-resilience (openspec)
  • passed web-viewer-sample verify (web-viewer-sample)

Human Review Notes

  • OpenSpec changes detected: harden-web-viewer-test-resilience
  • Optional AI adapter is not required by policy and was skipped.

reviewRequestId/BimControlClient endpoint gap(既有,非 #18 引入)、#28 session
teardown、#16 spectator readiness refresh、reconnect await terminate 等 P2
列為 follow-up(pre-existing / 跨 repo / 超 spec scope),本 PR 不擴大 scope。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@monkey1sai

Copy link
Copy Markdown
Owner Author

回應 Codex 第二輪 P2(App.tsx L155 / Window.tsx L958 / env.ts L77)

三項已查證,均為 enhancement / 既有 gap,非本 PR 引入的 regression,列為 follow-up(已寫入 design.md → ## Deferred follow-ups):

兩輪已修所有真 bug / proposal 目標缺口;CI 全綠、mergeable CLEAN。

@github-actions

github-actions Bot commented Jun 2, 2026

Copy link
Copy Markdown
Contributor

PR Review Agent Summary

Field Value
Status warning
Risk medium
PR 146
Head codex/openspec/harden-web-viewer-test-resilience / 4117ce2090ac40bb2c858e2aefd5ccdc2dd49446
Base main / fb4396efbb9a0ce137c7618d426894703ffd471f

Blockers

  • None

Warnings

  • [medium] GitNexus detect changes did not pass: warning.

Validation Commands

  • openspec validate harden-web-viewer-test-resilience
  • npm run verify

Checks

  • passed openspec validate harden-web-viewer-test-resilience (openspec)
  • passed web-viewer-sample verify (web-viewer-sample)

Human Review Notes

  • OpenSpec changes detected: harden-web-viewer-test-resilience
  • Optional AI adapter is not required by policy and was skipped.

@monkey1sai
monkey1sai merged commit 73749c7 into main Jun 2, 2026
2 checks passed
@monkey1sai
monkey1sai deleted the codex/openspec/harden-web-viewer-test-resilience branch June 2, 2026 04:00
monkey1sai added a commit that referenced this pull request Jun 2, 2026
CH-4(web-viewer vitest 骨架 + 8 健壯性風險)archive:session-first-review-viewer
MODIFIED 3 requirements 合入 specs;roadmap §1.6 加對齊紀錄(雙層多輪 review +
defer follow-up 揭露)。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants