依 R5 五輪決策把 visionA-local 從「Wails 內嵌 Next.js」重構為「Wails
本機伺服器控制台 + 瀏覽器 Web UI」模式(類比 Docker Desktop / Ollama)。
程式碼變動
- M8-1 砍 yt-dlp 全套(後端 resolver / URL handler / 前端 URL tab /
Makefile vendor / installer / bootstrap / CI workflow,-555 行)
- M8-2 砍 Mock 模式全套(driver/mock、mock_camera、Settings runtimeMode、
VISIONA_MOCK 環境變數,-528 行)
- M8-3 ffmpeg 從 GPL 切換到 LGPL 混合方案:Windows/Linux 用 BtbN 現成
LGPL binary,macOS 自 build minimal decoder-only 進 git
(vendor/ffmpeg/macos/ffmpeg 5.7MB + ffprobe 5.6MB,比 GPL 版省 85% 空間)
- M8-4 Wails Server Controller:state machine、log ring buffer 2000 行、
preferences.json atomic write、boot-id、Gin SkipPaths、shutdown 7+1 秒、
notify_*.go 三平台 OS 通知、watchServer 改 Error state 不 os.Exit
- M8-4b 啟動階段管線 R5-E:6 階段進度 event、20s soft / 60s hard timeout、
stage 5/6 skip 規則、sentinel file、RestartStartupSequence 5 步驟
- M8-5 Wails 控制台 vanilla HTML/JS/CSS(9 檔 ~2012 行)取代 M7-B splash:
state 視覺、log panel、startup progress panel、Stage 6 manual CTA
pulse、shutdown modal、Settings、Dark Mode、i18n 中英雙語
- M8-6 上傳影片副檔名擴充(mp4/avi/mov/mpeg/mpg)
- M8-7 Web UI Server Offline Overlay(role=alertdialog + focus trap +
wsEverConnected 容錯 + Page Visibility)
- M8-8 CORS middleware(127.0.0.1/localhost only + suffix attack 防護)+
ws/origin.go 獨立 WebSocket CheckOrigin 避 package cycle
- MAJ-4 server:shutdown-imminent WebSocket broadcast 機制
(/ws/system endpoint + notifyShutdownImminent helper)
- M8-9 Boot-ID + 瀏覽器 tab 自動重連(sessionStorage loop guard)
品質
- ~105+ 新 unit test + race detector (-count=2) 全綠
- 10 個 milestone 全部通過 Reviewer 審查
- 三方 v2 + v2.1 文件(PRD / Design Spec / TDD)+ 交叉互審紀錄
收錄在 .autoflow/
交付前待處理(M8-10)
- 重跑 make payload-macos 把舊 GPL 77MB binary 換成新 LGPL
- 三平台 end-to-end build 驗證
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
126 lines
4.2 KiB
TypeScript
126 lines
4.2 KiB
TypeScript
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
|
||
import { useSystemStore } from '@/stores/system-store';
|
||
|
||
/**
|
||
* M8-9:use-shutdown-watcher 的 boot-id reload 行為測試。
|
||
*
|
||
* 透過 retryServerHealth(匯出可測)間接覆蓋 handleBootIdCheck 路徑:
|
||
* - 首次 fetch → setBootId,markOnline
|
||
* - 相同 bootId → markOnline,不 reload
|
||
* - 不同 bootId → 觸發 window.location.reload()
|
||
* - 已觸發過 reload(sessionStorage guard)→ 同一 bootId 不再 reload
|
||
*/
|
||
|
||
vi.mock('@/lib/api', () => ({
|
||
getRelayHeaders: () => ({}),
|
||
ensureRelayToken: vi.fn().mockResolvedValue(undefined),
|
||
}));
|
||
|
||
vi.mock('@/lib/constants', () => ({
|
||
getApiBaseUrl: () => 'http://localhost:3000/api',
|
||
getWsBaseUrl: () => 'ws://localhost:3000',
|
||
getRelayToken: () => 'test-token',
|
||
}));
|
||
|
||
import { retryServerHealth } from '@/hooks/use-shutdown-watcher';
|
||
|
||
const RELOAD_LOOP_GUARD_KEY = 'visiona-reload-loop-guard';
|
||
|
||
function mockFetchBootId(bootId: string) {
|
||
return vi.fn().mockResolvedValue({
|
||
ok: true,
|
||
json: async () => ({
|
||
success: true,
|
||
data: { bootId, startedAt: Date.now() },
|
||
}),
|
||
});
|
||
}
|
||
|
||
describe('use-shutdown-watcher — boot-id reload (M8-9)', () => {
|
||
const reloadSpy = vi.fn();
|
||
|
||
beforeEach(() => {
|
||
useSystemStore.setState({
|
||
serverOnline: true,
|
||
forcedOffline: false,
|
||
offlineReason: null,
|
||
bootId: null,
|
||
consecutiveFailures: 0,
|
||
});
|
||
window.sessionStorage.clear();
|
||
reloadSpy.mockClear();
|
||
|
||
// 替換 window.location.reload 為 spy(jsdom 的 location 非 configurable,
|
||
// 用 defineProperty 套一層替代物件)。
|
||
Object.defineProperty(window, 'location', {
|
||
configurable: true,
|
||
value: { ...window.location, reload: reloadSpy },
|
||
});
|
||
});
|
||
|
||
afterEach(() => {
|
||
vi.restoreAllMocks();
|
||
});
|
||
|
||
it('first fetch should persist bootId and mark online without reload', async () => {
|
||
const fetchMock = mockFetchBootId('boot-1');
|
||
vi.stubGlobal('fetch', fetchMock);
|
||
|
||
const ok = await retryServerHealth();
|
||
expect(ok).toBe(true);
|
||
expect(useSystemStore.getState().bootId).toBe('boot-1');
|
||
expect(useSystemStore.getState().serverOnline).toBe(true);
|
||
expect(reloadSpy).not.toHaveBeenCalled();
|
||
});
|
||
|
||
it('second fetch with same bootId should just mark online', async () => {
|
||
useSystemStore.setState({ bootId: 'boot-1' });
|
||
const fetchMock = mockFetchBootId('boot-1');
|
||
vi.stubGlobal('fetch', fetchMock);
|
||
|
||
const ok = await retryServerHealth();
|
||
expect(ok).toBe(true);
|
||
expect(useSystemStore.getState().bootId).toBe('boot-1');
|
||
expect(reloadSpy).not.toHaveBeenCalled();
|
||
});
|
||
|
||
it('fetch with different bootId should trigger window.location.reload', async () => {
|
||
useSystemStore.setState({ bootId: 'boot-1' });
|
||
const fetchMock = mockFetchBootId('boot-2');
|
||
vi.stubGlobal('fetch', fetchMock);
|
||
|
||
const ok = await retryServerHealth();
|
||
expect(ok).toBe(true);
|
||
expect(reloadSpy).toHaveBeenCalledTimes(1);
|
||
// mismatch 不該更新 store 的 bootId(等 reload 後重走 first 路徑)
|
||
expect(useSystemStore.getState().bootId).toBe('boot-1');
|
||
// guard 應被設為新的 bootId
|
||
expect(window.sessionStorage.getItem(RELOAD_LOOP_GUARD_KEY)).toBe('boot-2');
|
||
});
|
||
|
||
it('should not reload twice for the same new bootId (loop guard)', async () => {
|
||
useSystemStore.setState({ bootId: 'boot-1' });
|
||
window.sessionStorage.setItem(RELOAD_LOOP_GUARD_KEY, 'boot-2');
|
||
const fetchMock = mockFetchBootId('boot-2');
|
||
vi.stubGlobal('fetch', fetchMock);
|
||
|
||
const ok = await retryServerHealth();
|
||
expect(ok).toBe(true);
|
||
expect(reloadSpy).not.toHaveBeenCalled();
|
||
// guard 命中時應該把 store 對齊新 bootId 並 markOnline,避免卡在假離線
|
||
expect(useSystemStore.getState().bootId).toBe('boot-2');
|
||
expect(useSystemStore.getState().serverOnline).toBe(true);
|
||
});
|
||
|
||
it('fetch failure should record failure and not reload', async () => {
|
||
useSystemStore.setState({ bootId: 'boot-1' });
|
||
const fetchMock = vi.fn().mockResolvedValue({ ok: false });
|
||
vi.stubGlobal('fetch', fetchMock);
|
||
|
||
const ok = await retryServerHealth();
|
||
expect(ok).toBe(false);
|
||
expect(reloadSpy).not.toHaveBeenCalled();
|
||
expect(useSystemStore.getState().consecutiveFailures).toBe(1);
|
||
});
|
||
});
|