mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-07 16:09:43 +02:00
chore: version packages
Gate the idle-alert acknowledgement to human selections: the boot restore, a solo window opening its target, and the post-close fallback no longer spend a yellow tab alert. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,132 @@
|
||||
/**
|
||||
* @fileoverview A pending IDLE tab alert is spent by a HUMAN opening a session,
|
||||
* never by the app putting one on screen.
|
||||
*
|
||||
* `selectSession()` acknowledges the session's idle approval item server-side
|
||||
* (`markIdleAlertSeen` → `POST /api/approvals/session/:id/viewed`), which is
|
||||
* what makes "I checked it" survive a reload and reach the user's other
|
||||
* devices. Three call sites are the APP choosing a session rather than the
|
||||
* user: the boot restore, a solo (popped-out) window opening its target, and
|
||||
* the fallback after the active session is deleted. Those pass `auto: true`
|
||||
* and must not spend the alert, or a yellow tab would clear itself every time
|
||||
* the page loaded and the user would never see it.
|
||||
*
|
||||
* The gate defaults to user-initiated on purpose: an untagged call site fails
|
||||
* toward acknowledging (today's behavior) rather than toward an alert nothing
|
||||
* can clear. This suite pins both halves, the runtime gate through the real
|
||||
* `selectSession`, and the three tagged call sites as a source guard.
|
||||
*
|
||||
* Loaded via `vm` with a stubbed context (no jsdom), like input-send-order.test.ts.
|
||||
* Port: N/A.
|
||||
*/
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { performance } from 'node:perf_hooks';
|
||||
import { resolve } from 'node:path';
|
||||
import vm from 'node:vm';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const APP_PATH = resolve(import.meta.dirname, '../src/web/public/app.js');
|
||||
const APP_SOURCE = readFileSync(APP_PATH, 'utf8');
|
||||
|
||||
function loadCodemanAppClass() {
|
||||
const constants = readFileSync(resolve(import.meta.dirname, '../src/web/public/constants.js'), 'utf8');
|
||||
const context = vm.createContext({
|
||||
console: { ...console, log: vi.fn(), warn: vi.fn(), error: vi.fn() },
|
||||
performance,
|
||||
setInterval: vi.fn(),
|
||||
clearInterval: vi.fn(),
|
||||
setTimeout,
|
||||
clearTimeout,
|
||||
requestAnimationFrame: vi.fn(),
|
||||
HTMLCanvasElement: class HTMLCanvasElement {},
|
||||
WebSocket: { OPEN: 1 },
|
||||
fetch: vi.fn(),
|
||||
document: { addEventListener: vi.fn(), getElementById: () => null, querySelector: () => null },
|
||||
localStorage: { length: 0, key: vi.fn(), getItem: vi.fn(), setItem: vi.fn(), removeItem: vi.fn() },
|
||||
window: { addEventListener: vi.fn(), removeEventListener: vi.fn() },
|
||||
MobileDetection: { isTouchDevice: () => false },
|
||||
});
|
||||
vm.runInContext(`${constants}\n${APP_SOURCE}\nglobalThis.__CodemanApp = CodemanApp;`, context);
|
||||
return (context as { __CodemanApp: new () => unknown }).__CodemanApp;
|
||||
}
|
||||
|
||||
const CodemanApp = loadCodemanAppClass();
|
||||
const SID = 'session-with-a-yellow-tab';
|
||||
|
||||
/**
|
||||
* Minimal instance: enough surface for selectSession to reach the
|
||||
* acknowledgement line. Everything after it is DOM work that throws in this
|
||||
* context, which is why callers swallow the rejection.
|
||||
*/
|
||||
function makeApp(activeSessionId: string | null) {
|
||||
const app = Object.create((CodemanApp as { prototype: object }).prototype) as Record<string, unknown>;
|
||||
app.markIdleAlertSeen = vi.fn();
|
||||
app.pendingHooks = new Map([[SID, new Set(['idle_prompt'])]]);
|
||||
app.activeSessionId = activeSessionId;
|
||||
app.detachedSessions = new Set();
|
||||
app.isSoloWindow = false;
|
||||
app._selectGeneration = 0;
|
||||
app._shouldFocusTerminalForTabSwitch = () => false;
|
||||
app._setTerminalLoadState = vi.fn();
|
||||
app._clearTerminalLoadState = vi.fn();
|
||||
app._cleanupPreviousSession = vi.fn();
|
||||
app._renderHistoryTruncationBanner = vi.fn();
|
||||
app._updateSseSubscription = vi.fn();
|
||||
app.hideWelcome = vi.fn();
|
||||
app.sessions = new Map([[SID, { id: SID, name: 'w1' }]]);
|
||||
return app as Record<string, unknown> & {
|
||||
selectSession: (id: string, opts?: Record<string, unknown>) => Promise<void>;
|
||||
markIdleAlertSeen: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
}
|
||||
|
||||
describe('selectSession acknowledgement gate', () => {
|
||||
describe('switching to a session (the main path)', () => {
|
||||
it('a user-initiated selection spends the idle alert', async () => {
|
||||
const app = makeApp(null);
|
||||
await app.selectSession(SID).catch(() => {});
|
||||
expect(app.markIdleAlertSeen).toHaveBeenCalledWith(SID);
|
||||
});
|
||||
|
||||
it('an `auto` selection leaves it armed', async () => {
|
||||
const app = makeApp(null);
|
||||
await app.selectSession(SID, { auto: true }).catch(() => {});
|
||||
expect(app.markIdleAlertSeen).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('re-selecting the session already on screen (the early return)', () => {
|
||||
it('a tap on the active tab spends the idle alert', async () => {
|
||||
const app = makeApp(SID);
|
||||
await app.selectSession(SID);
|
||||
expect(app.markIdleAlertSeen).toHaveBeenCalledWith(SID);
|
||||
});
|
||||
|
||||
it('an `auto` re-select leaves it armed', async () => {
|
||||
const app = makeApp(SID);
|
||||
await app.selectSession(SID, { auto: true });
|
||||
expect(app.markIdleAlertSeen).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('the call sites the app drives itself', () => {
|
||||
// Source guard: these three are the reason the flag exists. If a refactor
|
||||
// moves or reformats them, fail loudly rather than silently going back to
|
||||
// "every page load clears the user's yellow tab".
|
||||
it.each([
|
||||
['boot restore, stored session', 'this.selectSession(restoreId, { auto: true });'],
|
||||
['boot restore, first tab fallback', 'this.selectSession(this.sessionOrder[0], { auto: true });'],
|
||||
['solo window opening its target', 'this.selectSession(this.soloSessionId, { auto: true });'],
|
||||
['fallback after the active session is removed', 'this.selectSession(nextSessionId, { auto: true });'],
|
||||
])('%s passes auto: true', (_label, call) => {
|
||||
expect(APP_SOURCE).toContain(call);
|
||||
});
|
||||
|
||||
it('keyboard tab switching stays user-initiated', () => {
|
||||
// Alt+1..9 and Alt+[/] are a human asking for that tab, so they keep
|
||||
// acknowledging; only app-chosen selections are tagged.
|
||||
expect(APP_SOURCE).toContain('this.selectSession(live[idx]);');
|
||||
expect(APP_SOURCE).toContain('this.selectSession(this.sessionOrder[nextIndex]);');
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user