Browse Source

fix(auto-update): clarify failed install state (#292)

* fix(auto-update): clarify failed install state

- #289 已兼容 OpenCode packages 缓存模型,但安装失败分支仍提示重启即可生效。
- 该提示与实际日志 `update not installed` 不一致,容易误导用户。

- 将 auto-update 安装失败时的 toast 改为明确失败,不再提示重启即可生效。
- 为更新流程补充 local dev、prepare 失败、install 成功、install 失败四类主路径测试。
- 将异步等待改为基于调用次数轮询,降低测试对定时器层级的耦合。

- bun test src/hooks/auto-update-checker
- bun run typecheck
- bun run build

- 仅调整 auto-update 的失败提示和测试覆盖,不改变成功安装路径。

* test(auto-update): relax async wait timeout

## 背景
- Greptile 在 PR 292 中指出 `waitForCalls()` 的 200ms 超时在繁忙 CI 上可能偶发 flaky。

## 变更
- 将 `waitForCalls()` 的等待窗口从 200ms 调整到 1000ms。
- 仅影响测试稳定性,不改变运行时代码和断言逻辑。

## 验证
- bun test src/hooks/auto-update-checker
- bun run typecheck
- bun run build
huilang021x 1 day ago
parent
commit
80464fcc34
2 changed files with 239 additions and 14 deletions
  1. 237 12
      src/hooks/auto-update-checker/index.test.ts
  2. 2 2
      src/hooks/auto-update-checker/index.ts

+ 237 - 12
src/hooks/auto-update-checker/index.test.ts

@@ -1,33 +1,258 @@
-import { describe, expect, mock, test } from 'bun:test';
+import {
+  afterEach,
+  beforeEach,
+  describe,
+  expect,
+  mock,
+  spyOn,
+  test,
+} from 'bun:test';
 
-// Mock logger to avoid noise
-mock.module('../../utils/logger', () => ({
-  log: mock(() => {}),
-}));
+const logMock = mock(() => {});
 
-mock.module('./checker', () => ({
+const checkerMocks = {
   extractChannel: mock(() => 'latest'),
   findPluginEntry: mock(() => null),
   getCachedVersion: mock(() => null),
   getLatestVersion: mock(async () => null),
   getLocalDevVersion: mock(() => null),
   getCurrentRuntimePackageJsonPath: mock(() => null),
-}));
+};
 
-mock.module('./cache', () => ({
+const cacheMocks = {
   preparePackageUpdate: mock(() => '/tmp/opencode'),
   resolveInstallContext: mock(() => ({ installDir: '/tmp/opencode' })),
+};
+
+mock.module('../../utils/logger', () => ({
+  log: logMock,
 }));
 
-// Cache buster for dynamic imports
+mock.module('./checker', () => checkerMocks);
+
+mock.module('./cache', () => cacheMocks);
+
 let importCounter = 0;
+let bunSpawnSpy: ReturnType<typeof spyOn> | undefined;
+
+function createCtx() {
+  const showToast = mock(() => Promise.resolve(undefined));
+
+  return {
+    ctx: {
+      directory: '/test',
+      client: {
+        tui: {
+          showToast,
+        },
+      },
+    },
+    showToast,
+  };
+}
+
+async function waitForCalls(
+  fn: { mock: { calls: unknown[] } },
+  minCalls = 1,
+): Promise<void> {
+  const deadline = Date.now() + 1000;
+
+  while (fn.mock.calls.length < minCalls) {
+    if (Date.now() > deadline) {
+      throw new Error('Timed out waiting for async hook work');
+    }
+
+    await new Promise((resolve) => setTimeout(resolve, 0));
+  }
+}
 
 describe('auto-update-checker/index', () => {
-  test('uses OpenCode cache dir for auto-update installs', async () => {
+  beforeEach(() => {
+    logMock.mockClear();
+
+    checkerMocks.extractChannel.mockReset();
+    checkerMocks.extractChannel.mockImplementation(() => 'latest');
+    checkerMocks.findPluginEntry.mockReset();
+    checkerMocks.findPluginEntry.mockImplementation(() => null);
+    checkerMocks.getCachedVersion.mockReset();
+    checkerMocks.getCachedVersion.mockImplementation(() => null);
+    checkerMocks.getLatestVersion.mockReset();
+    checkerMocks.getLatestVersion.mockImplementation(async () => null);
+    checkerMocks.getLocalDevVersion.mockReset();
+    checkerMocks.getLocalDevVersion.mockImplementation(() => null);
+
+    cacheMocks.preparePackageUpdate.mockReset();
+    cacheMocks.preparePackageUpdate.mockImplementation(() => '/tmp/opencode');
+    cacheMocks.resolveInstallContext.mockReset();
+    cacheMocks.resolveInstallContext.mockImplementation(() => ({
+      installDir: '/tmp/opencode',
+    }));
+  });
+
+  afterEach(() => {
+    bunSpawnSpy?.mockRestore();
+    bunSpawnSpy = undefined;
+  });
+
+  test('uses resolved install root for auto-update installs', async () => {
     const { getAutoUpdateInstallDir } = await import(
       `./index?test=${importCounter++}`
     );
-    // The actual cache dir depends on the platform, but it should be a string
-    expect(typeof getAutoUpdateInstallDir()).toBe('string');
+
+    expect(getAutoUpdateInstallDir()).toBe('/tmp/opencode');
+  });
+
+  test('shows development toast and skips background update for local dev installs', async () => {
+    checkerMocks.getLocalDevVersion.mockImplementation(() => '0.9.11-dev');
+
+    const { createAutoUpdateCheckerHook } = await import(
+      `./index?test=${importCounter++}`
+    );
+    const { ctx, showToast } = createCtx();
+
+    const hook = createAutoUpdateCheckerHook(ctx as never);
+    hook.event({ event: { type: 'session.created', properties: {} } });
+    await waitForCalls(showToast);
+
+    expect(showToast).toHaveBeenCalledWith({
+      body: {
+        title: 'OMO-Slim 0.9.11-dev (dev)',
+        message: 'Running in local development mode.',
+        variant: 'info',
+        duration: 3000,
+      },
+    });
+    expect(checkerMocks.findPluginEntry).not.toHaveBeenCalled();
+    expect(checkerMocks.getLatestVersion).not.toHaveBeenCalled();
+  });
+
+  test('shows success toast after updating the active install root', async () => {
+    checkerMocks.findPluginEntry.mockImplementation(() => ({
+      pinnedVersion: null,
+      isPinned: false,
+    }));
+    checkerMocks.getCachedVersion.mockImplementation(() => '0.9.1');
+    checkerMocks.getLatestVersion.mockImplementation(async () => '0.9.11');
+
+    bunSpawnSpy = spyOn(Bun, 'spawn').mockImplementation(
+      () =>
+        ({
+          exited: Promise.resolve(0),
+          exitCode: 0,
+          kill: mock(() => {}),
+        }) as never,
+    );
+
+    const { createAutoUpdateCheckerHook } = await import(
+      `./index?test=${importCounter++}`
+    );
+    const { ctx, showToast } = createCtx();
+
+    const hook = createAutoUpdateCheckerHook(ctx as never, {
+      showStartupToast: false,
+    });
+    hook.event({ event: { type: 'session.created', properties: {} } });
+    await waitForCalls(showToast);
+
+    expect(cacheMocks.preparePackageUpdate).toHaveBeenCalledWith(
+      '0.9.11',
+      'oh-my-opencode-slim',
+    );
+    expect(bunSpawnSpy).toHaveBeenCalledWith(
+      ['bun', 'install'],
+      expect.objectContaining({ cwd: '/tmp/opencode' }),
+    );
+    expect(showToast).toHaveBeenCalledWith({
+      body: {
+        title: 'OMO-Slim Updated!',
+        message: 'v0.9.1 → v0.9.11\nRestart OpenCode to apply.',
+        variant: 'success',
+        duration: 8000,
+      },
+    });
+  });
+
+  test('shows prepare failure toast and skips installation when active install cannot be resolved', async () => {
+    checkerMocks.findPluginEntry.mockImplementation(() => ({
+      pinnedVersion: null,
+      isPinned: false,
+    }));
+    checkerMocks.getCachedVersion.mockImplementation(() => '0.9.1');
+    checkerMocks.getLatestVersion.mockImplementation(async () => '0.9.11');
+    cacheMocks.preparePackageUpdate.mockImplementation(() => null);
+
+    bunSpawnSpy = spyOn(Bun, 'spawn').mockImplementation(
+      () =>
+        ({
+          exited: Promise.resolve(0),
+          exitCode: 0,
+          kill: mock(() => {}),
+        }) as never,
+    );
+
+    const { createAutoUpdateCheckerHook } = await import(
+      `./index?test=${importCounter++}`
+    );
+    const { ctx, showToast } = createCtx();
+
+    const hook = createAutoUpdateCheckerHook(ctx as never, {
+      showStartupToast: false,
+    });
+    hook.event({ event: { type: 'session.created', properties: {} } });
+    await waitForCalls(showToast);
+
+    expect(bunSpawnSpy).not.toHaveBeenCalled();
+    expect(showToast).toHaveBeenCalledWith({
+      body: {
+        title: 'OMO-Slim 0.9.11',
+        message:
+          'v0.9.11 available. Auto-update could not prepare the active install.',
+        variant: 'info',
+        duration: 8000,
+      },
+    });
+  });
+
+  test('shows install failure toast without telling users to restart', async () => {
+    checkerMocks.findPluginEntry.mockImplementation(() => ({
+      pinnedVersion: null,
+      isPinned: false,
+    }));
+    checkerMocks.getCachedVersion.mockImplementation(() => '0.9.1');
+    checkerMocks.getLatestVersion.mockImplementation(async () => '0.9.11');
+
+    bunSpawnSpy = spyOn(Bun, 'spawn').mockImplementation(
+      () =>
+        ({
+          exited: Promise.resolve(1),
+          exitCode: 1,
+          kill: mock(() => {}),
+        }) as never,
+    );
+
+    const { createAutoUpdateCheckerHook } = await import(
+      `./index?test=${importCounter++}`
+    );
+    const { ctx, showToast } = createCtx();
+
+    const hook = createAutoUpdateCheckerHook(ctx as never, {
+      showStartupToast: false,
+    });
+    hook.event({ event: { type: 'session.created', properties: {} } });
+    await waitForCalls(showToast);
+
+    expect(bunSpawnSpy).toHaveBeenCalledWith(
+      ['bun', 'install'],
+      expect.objectContaining({ cwd: '/tmp/opencode' }),
+    );
+    expect(showToast).toHaveBeenCalledWith({
+      body: {
+        title: 'OMO-Slim 0.9.11',
+        message:
+          'v0.9.11 available, but auto-update failed to install it. Check logs or retry manually.',
+        variant: 'error',
+        duration: 8000,
+      },
+    });
   });
 });

+ 2 - 2
src/hooks/auto-update-checker/index.ts

@@ -170,8 +170,8 @@ async function runBackgroundUpdateCheck(
     showToast(
       ctx,
       `OMO-Slim ${latestVersion}`,
-      `v${latestVersion} available. Restart to apply.`,
-      'info',
+      `v${latestVersion} available, but auto-update failed to install it. Check logs or retry manually.`,
+      'error',
       8000,
     );
     log('[auto-update-checker] bun install failed; update not installed');