mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-11-10 09:19:33 +01:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7602b6db21 |
No files matched your search
@@ -62,6 +62,7 @@ export const BROWSER_TEST_GLOBS = [
|
||||
'test/phone-press-feedback.browser.test.ts',
|
||||
'test/desktop-connect-page.browser.test.ts', // no server: the page is served by request interception
|
||||
'test/local-model-ui.browser.test.ts',
|
||||
'test/home-breakpoint-swap.browser.test.ts',
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
@@ -1053,16 +1053,9 @@ class LocalModelServiceImpl implements LocalModelService {
|
||||
return progress ? { ...next, progress } : next;
|
||||
}
|
||||
|
||||
/**
|
||||
* The stored state as a view or a verb must see it: settled, unless a verb is just starting a
|
||||
* job. A job writes its last state BEFORE it lets go of `this.job`, so when the job ended while
|
||||
* this read was out, the read may hold the `installing` from before that write; it is read
|
||||
* again rather than settled, or it would show as an interrupted (`paused`) job for a tick.
|
||||
*/
|
||||
/** The stored state as a view or a verb must see it: settled, unless a verb is just starting a job. */
|
||||
private async currentState(): Promise<LocalModelStateFile> {
|
||||
const jobAtRead = this.job;
|
||||
let state = await this.deps.readState();
|
||||
if (jobAtRead && !this.job) state = await this.deps.readState();
|
||||
const state = await this.deps.readState();
|
||||
return this.jobStarting ? state : this.settled(state);
|
||||
}
|
||||
|
||||
|
||||
@@ -1232,6 +1232,11 @@ class CodemanApp {
|
||||
// and then applies the sidebar variant on top — do not call both.
|
||||
this.applySessionListLayout();
|
||||
this.applyTabOrientation();
|
||||
// The home screens' breakpoint listeners, once, before any home surface is
|
||||
// shown: a fold or unfold must swap the surface whichever width the page
|
||||
// loaded at (#702). Both self-gate on a home screen being up.
|
||||
this._wireMobileOverviewBreakpoint?.();
|
||||
this._wireHomeSessionsBreakpoint?.();
|
||||
this.initTabRailResize?.();
|
||||
this.applyMonitorVisibility();
|
||||
this.applyLineageLineSettings?.();
|
||||
|
||||
@@ -126,20 +126,31 @@ Object.assign(CodemanApp.prototype, {
|
||||
this._stopHomeSessionsClock();
|
||||
},
|
||||
|
||||
/** Re-render only when showing (called from the tab renderer's tail). */
|
||||
/**
|
||||
* Re-render only when showing (called from the tab renderer's tail). Asked
|
||||
* again when the debounce fires: `renderHomeSessions()` unhides the column,
|
||||
* so a render queued just before a fold to phone width hid it (#702) would
|
||||
* otherwise bring it back behind the phone overview.
|
||||
*/
|
||||
_refreshHomeSessionsIfVisible() {
|
||||
if (!this.isHomeSessionsVisible()) return;
|
||||
this._debouncedCall('homeSessions', () => this.renderHomeSessions(), 150);
|
||||
this._debouncedCall(
|
||||
'homeSessions',
|
||||
() => {
|
||||
if (this.isHomeSessionsVisible() && this.shouldShowHomeSessions()) this.renderHomeSessions();
|
||||
},
|
||||
150
|
||||
);
|
||||
},
|
||||
|
||||
/**
|
||||
* One delegated click listener for every row, plus a width listener so
|
||||
* resizing the window while on the home screen adds or drops the column
|
||||
* instead of leaving it overlapping the content it was sized to clear.
|
||||
* One delegated click listener for every row. The width listener lives in
|
||||
* `_wireHomeSessionsBreakpoint()`, installed at startup.
|
||||
*/
|
||||
_wireHomeSessions(el) {
|
||||
if (this._homeSessionsWired) return;
|
||||
this._homeSessionsWired = true;
|
||||
this._wireHomeSessionsBreakpoint();
|
||||
|
||||
el.addEventListener('click', (event) => {
|
||||
const target = event.target?.closest?.('[data-hs-action]');
|
||||
@@ -150,20 +161,29 @@ Object.assign(CodemanApp.prototype, {
|
||||
void this.openWebview?.(target.dataset.hsWebview);
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
if (window.matchMedia) {
|
||||
const mq = window.matchMedia(`(min-width: ${HOME_SESSIONS_MIN_WIDTH}px)`);
|
||||
const onChange = () => {
|
||||
// Only relevant while the welcome screen is up; entering a session
|
||||
// re-decides through hideWelcome()/showWelcome() anyway.
|
||||
if (this.activeSessionId) return;
|
||||
const overlay = document.getElementById('welcomeOverlay');
|
||||
if (!overlay || !overlay.classList.contains('visible')) return;
|
||||
this.showHomeSessions();
|
||||
};
|
||||
if (mq.addEventListener) mq.addEventListener('change', onChange);
|
||||
else if (mq.addListener) mq.addListener(onChange);
|
||||
}
|
||||
/**
|
||||
* Add or drop the column when the window crosses its width gate, so it never
|
||||
* overlaps the content it was sized to clear. Installed ONCE at startup
|
||||
* (`init()` in app.js) like the phone overview's breakpoint listener, so a
|
||||
* page that loads on a phone and is unfolded to desktop width gets it too
|
||||
* (#702). Idempotent.
|
||||
*/
|
||||
_wireHomeSessionsBreakpoint() {
|
||||
if (this._homeSessionsBreakpointWired || !window.matchMedia) return;
|
||||
this._homeSessionsBreakpointWired = true;
|
||||
const mq = window.matchMedia(`(min-width: ${HOME_SESSIONS_MIN_WIDTH}px)`);
|
||||
const onChange = () => {
|
||||
// Only relevant while the welcome screen is up; entering a session
|
||||
// re-decides through hideWelcome()/showWelcome() anyway.
|
||||
if (this.activeSessionId) return;
|
||||
const overlay = document.getElementById('welcomeOverlay');
|
||||
if (!overlay || !overlay.classList.contains('visible')) return;
|
||||
this.showHomeSessions();
|
||||
};
|
||||
if (mq.addEventListener) mq.addEventListener('change', onChange);
|
||||
else if (mq.addListener) mq.addListener(onChange);
|
||||
},
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
@@ -385,13 +385,13 @@ Object.assign(CodemanApp.prototype, {
|
||||
},
|
||||
|
||||
/**
|
||||
* One delegated click listener for every row, plus a breakpoint listener so
|
||||
* rotating or unfolding while on the home screen swaps to the right surface
|
||||
* instead of stranding a phone layout on a tablet-width viewport.
|
||||
* One delegated click listener for every row. The breakpoint listener lives
|
||||
* in `_wireMobileOverviewBreakpoint()`, installed at startup.
|
||||
*/
|
||||
_wireMobileOverview(el) {
|
||||
if (this._mobileOverviewWired) return;
|
||||
this._mobileOverviewWired = true;
|
||||
this._wireMobileOverviewBreakpoint();
|
||||
|
||||
el.addEventListener('click', (event) => {
|
||||
const target = event.target && event.target.closest && event.target.closest('[data-mo-action]');
|
||||
@@ -426,18 +426,34 @@ Object.assign(CodemanApp.prototype, {
|
||||
this.showWebviewModal();
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
if (window.matchMedia) {
|
||||
const mq = window.matchMedia(MOBILE_OVERVIEW_PHONE_QUERY);
|
||||
const onChange = () => {
|
||||
// Only relevant while a home surface is up; entering a session re-decides
|
||||
// through hideWelcome()/showWelcome() anyway.
|
||||
if (this.activeSessionId) return;
|
||||
if (typeof this.showWelcome === 'function') this.showWelcome();
|
||||
};
|
||||
if (mq.addEventListener) mq.addEventListener('change', onChange);
|
||||
else if (mq.addListener) mq.addListener(onChange);
|
||||
}
|
||||
/**
|
||||
* Swap the home surface when the width crosses the phone breakpoint (rotate,
|
||||
* fold, unfold), so a phone layout is never stranded on a tablet-width
|
||||
* viewport and the welcome screen never on a phone-width one.
|
||||
*
|
||||
* Installed ONCE at startup (`init()` in app.js), not on the first show: a
|
||||
* page that loads wide never shows the phone overview, so a listener wired
|
||||
* from there was missing on exactly the fold that needed it (#702). Idempotent,
|
||||
* so the call from `_wireMobileOverview()` adds nothing after startup.
|
||||
*/
|
||||
_wireMobileOverviewBreakpoint() {
|
||||
if (this._mobileOverviewBreakpointWired || !window.matchMedia) return;
|
||||
this._mobileOverviewBreakpointWired = true;
|
||||
const mq = window.matchMedia(MOBILE_OVERVIEW_PHONE_QUERY);
|
||||
const onChange = () => {
|
||||
// Only relevant while a home surface is up; entering a session re-decides
|
||||
// through hideWelcome()/showWelcome() anyway, and before the first
|
||||
// showWelcome() there is nothing on screen to swap.
|
||||
if (this.activeSessionId) return;
|
||||
const overlay = document.getElementById('welcomeOverlay');
|
||||
const welcomeUp = !!overlay && overlay.classList.contains('visible');
|
||||
if (!welcomeUp && !this.isMobileOverviewVisible()) return;
|
||||
if (typeof this.showWelcome === 'function') this.showWelcome();
|
||||
};
|
||||
if (mq.addEventListener) mq.addEventListener('change', onChange);
|
||||
else if (mq.addListener) mq.addListener(onChange);
|
||||
},
|
||||
|
||||
_toggleMobileOverviewRunMenu() {
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
// Port: none (the real home-screen modules in a vm context with a fake
|
||||
// matchMedia; no browser, no server).
|
||||
//
|
||||
// #702: folding a phone on the home screen must swap the home surface even when
|
||||
// the page loaded wide. The phone overview's breakpoint listener used to be
|
||||
// wired from its first show, which a wide-loaded page never reaches. Both home
|
||||
// screens' listeners are now installed once at startup and self-gate on a home
|
||||
// screen being up. The real resize walk lives in
|
||||
// test/home-breakpoint-swap.browser.test.ts (browser suite, not in the gate);
|
||||
// this file keeps the wiring itself under `npm test`.
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
import vm from 'node:vm';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
const PUBLIC = resolve(import.meta.dirname, '../src/web/public');
|
||||
|
||||
type Listener = () => void;
|
||||
|
||||
function loadHomeApp(innerWidth = 1440) {
|
||||
const listeners = new Map<string, Listener[]>();
|
||||
const elements: Record<string, any> = {
|
||||
welcomeOverlay: { classList: new Set<string>(), hidden: false },
|
||||
mobileOverview: { classList: new Set<string>(), hidden: true },
|
||||
homeSessions: { hidden: true },
|
||||
};
|
||||
for (const el of Object.values(elements)) {
|
||||
if (el.classList) {
|
||||
const set = el.classList as Set<string>;
|
||||
el.classList = {
|
||||
contains: (c: string) => set.has(c),
|
||||
add: (c: string) => set.add(c),
|
||||
remove: (c: string) => set.delete(c),
|
||||
};
|
||||
}
|
||||
}
|
||||
const timers: Array<() => void> = [];
|
||||
const window: any = {
|
||||
innerWidth,
|
||||
matchMedia: (query: string) => ({
|
||||
matches: false,
|
||||
addEventListener: (_type: string, fn: Listener) => {
|
||||
listeners.set(query, [...(listeners.get(query) || []), fn]);
|
||||
},
|
||||
}),
|
||||
};
|
||||
const CodemanApp = function CodemanApp(this: any) {};
|
||||
const context = vm.createContext({
|
||||
CodemanApp,
|
||||
console,
|
||||
window,
|
||||
document: {
|
||||
documentElement: { getAttribute: () => null },
|
||||
getElementById: (id: string) => elements[id] || null,
|
||||
createElement: () => ({ dataset: {}, style: {}, appendChild() {}, setAttribute() {} }),
|
||||
},
|
||||
MobileDetection: { getDeviceType: () => (window.innerWidth < 600 ? 'mobile' : 'desktop') },
|
||||
});
|
||||
for (const file of ['constants.js', 'mobile-overview.js', 'home-sessions.js']) {
|
||||
vm.runInContext(readFileSync(resolve(PUBLIC, file), 'utf8'), context, { filename: file });
|
||||
}
|
||||
const app = new (CodemanApp as any)();
|
||||
app.loadAppSettingsFromStorage = () => ({});
|
||||
app.activeSessionId = null;
|
||||
app.showWelcomeCalls = 0;
|
||||
app.showWelcome = () => app.showWelcomeCalls++;
|
||||
app.renderHomeSessionsCalls = 0;
|
||||
app.renderHomeSessions = () => {
|
||||
app.renderHomeSessionsCalls++;
|
||||
elements.homeSessions.hidden = false;
|
||||
};
|
||||
app._debouncedCall = (_key: string, fn: () => void) => timers.push(fn);
|
||||
const fire = (query: string) => (listeners.get(query) || []).forEach((fn) => fn());
|
||||
const flushTimers = () => timers.splice(0).forEach((fn) => fn());
|
||||
return { app, window, elements, listeners, fire, flushTimers };
|
||||
}
|
||||
|
||||
const PHONE_QUERY = '(max-width: 599px)';
|
||||
const RAIL_QUERY = '(min-width: 1180px)';
|
||||
|
||||
describe('home-screen breakpoint listeners (#702)', () => {
|
||||
it('installs each listener once, with nothing shown yet', () => {
|
||||
const { app, listeners } = loadHomeApp();
|
||||
app._wireMobileOverviewBreakpoint();
|
||||
app._wireHomeSessionsBreakpoint();
|
||||
// The lazy wiring from a first show must not add a second copy.
|
||||
app._wireMobileOverviewBreakpoint();
|
||||
app._wireHomeSessionsBreakpoint();
|
||||
expect(listeners.get(PHONE_QUERY)).toHaveLength(1);
|
||||
expect(listeners.get(RAIL_QUERY)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('a fold on a wide-loaded welcome screen re-decides the surface', () => {
|
||||
const { app, window, elements, fire } = loadHomeApp(1440);
|
||||
app._wireMobileOverviewBreakpoint();
|
||||
elements.welcomeOverlay.classList.add('visible');
|
||||
window.innerWidth = 390;
|
||||
fire(PHONE_QUERY);
|
||||
expect(app.showWelcomeCalls).toBe(1);
|
||||
});
|
||||
|
||||
it('an unfold on the phone overview re-decides the surface', () => {
|
||||
const { app, window, elements, fire } = loadHomeApp(390);
|
||||
app._wireMobileOverviewBreakpoint();
|
||||
elements.mobileOverview.classList.add('visible');
|
||||
window.innerWidth = 820;
|
||||
fire(PHONE_QUERY);
|
||||
expect(app.showWelcomeCalls).toBe(1);
|
||||
});
|
||||
|
||||
it('stays out of the way inside a session and before any home screen is up', () => {
|
||||
const { app, elements, fire } = loadHomeApp();
|
||||
app._wireMobileOverviewBreakpoint();
|
||||
fire(PHONE_QUERY); // page still loading: no home surface yet
|
||||
elements.welcomeOverlay.classList.add('visible');
|
||||
app.activeSessionId = 'abc';
|
||||
fire(PHONE_QUERY);
|
||||
expect(app.showWelcomeCalls).toBe(0);
|
||||
});
|
||||
|
||||
it('a rail render queued before a fold does not bring the hidden rail back', () => {
|
||||
const { app, window, elements, flushTimers } = loadHomeApp(1440);
|
||||
elements.welcomeOverlay.classList.add('visible');
|
||||
elements.homeSessions.hidden = false;
|
||||
app._refreshHomeSessionsIfVisible(); // queued while the rail is still up
|
||||
window.innerWidth = 390; // the fold
|
||||
elements.homeSessions.hidden = true; // showWelcome()'s phone path hid it
|
||||
flushTimers();
|
||||
expect(app.renderHomeSessionsCalls).toBe(0);
|
||||
expect(elements.homeSessions.hidden).toBe(true);
|
||||
});
|
||||
|
||||
it('is wired from init(), not from the first show', () => {
|
||||
const appJs = readFileSync(resolve(PUBLIC, 'app.js'), 'utf8');
|
||||
const start = appJs.indexOf('\n init() {');
|
||||
const end = appJs.indexOf('\n }\n', start);
|
||||
const init = appJs.slice(start, end);
|
||||
expect(init).toContain('this._wireMobileOverviewBreakpoint?.()');
|
||||
expect(init).toContain('this._wireHomeSessionsBreakpoint?.()');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,151 @@
|
||||
/**
|
||||
* @fileoverview Folding or unfolding a phone on the home screen swaps the home
|
||||
* surface in place, whichever width the page loaded at (#702).
|
||||
*
|
||||
* Both breakpoint listeners (the phone overview's `(max-width: 599px)` and the
|
||||
* desktop rail's `(min-width: 1180px)`) used to be wired from the first show of
|
||||
* their own surface. A page that loaded wide never showed the phone overview,
|
||||
* so folding it to phone width left the welcome screen up until something
|
||||
* called `showWelcome()` again. They are now installed once at startup.
|
||||
*
|
||||
* Real server, real Chromium, one page per direction, resized across 599/600
|
||||
* and 767/768 in both directions with no navigation, plus a height-only resize
|
||||
* (the on-screen keyboard) that must not re-decide the surface.
|
||||
*/
|
||||
import { mkdtempSync, rmSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
import { chromium, type Browser, type BrowserContext, type Page } from 'playwright';
|
||||
import { WebServer } from '../src/web/server.js';
|
||||
|
||||
type Surface = { welcome: boolean; overview: boolean; rail: boolean };
|
||||
|
||||
describe('home surface follows a fold or unfold without a reload', () => {
|
||||
let server: WebServer;
|
||||
let browser: Browser;
|
||||
let workDir: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
server = new WebServer(0, false, true);
|
||||
await server.start();
|
||||
// One session, so the desktop rail has a row to show (it hides when empty).
|
||||
workDir = mkdtempSync(join(tmpdir(), 'home-breakpoint-'));
|
||||
const res = await fetch(`http://localhost:${server.boundPort}/api/sessions`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ workingDir: workDir }),
|
||||
});
|
||||
expect(res.ok).toBe(true);
|
||||
browser = await chromium.launch({ headless: true });
|
||||
}, 90000);
|
||||
|
||||
afterAll(async () => {
|
||||
if (browser) await browser.close();
|
||||
if (server) await server.stop();
|
||||
if (workDir) rmSync(workDir, { recursive: true, force: true });
|
||||
}, 60000);
|
||||
|
||||
async function open(width: number, height: number): Promise<[BrowserContext, Page]> {
|
||||
const context = await browser.newContext({ viewport: { width, height }, deviceScaleFactor: 1 });
|
||||
const page = await context.newPage();
|
||||
await page.goto(`http://localhost:${server.boundPort}`, { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForFunction(() => (window as any).app?.terminal, null, { timeout: 30000 });
|
||||
// The load selects the one session; Home is where a fold has to swap surfaces.
|
||||
await page.waitForFunction(() => (window as any).app?.sessions?.size > 0, null, { timeout: 30000 });
|
||||
await page.evaluate(() => (window as any).app.goHome());
|
||||
await page.waitForFunction(
|
||||
() =>
|
||||
document.getElementById('welcomeOverlay')?.classList.contains('visible') ||
|
||||
document.getElementById('mobileOverview')?.classList.contains('visible'),
|
||||
null,
|
||||
{ timeout: 30000 }
|
||||
);
|
||||
return [context, page];
|
||||
}
|
||||
|
||||
const surface = (page: Page): Promise<Surface> =>
|
||||
page.evaluate(() => {
|
||||
const overview = document.getElementById('mobileOverview')!;
|
||||
const rail = document.getElementById('homeSessions')!;
|
||||
return {
|
||||
welcome: document.getElementById('welcomeOverlay')!.classList.contains('visible'),
|
||||
overview: !overview.hidden && overview.classList.contains('visible'),
|
||||
rail: !rail.hidden,
|
||||
};
|
||||
});
|
||||
|
||||
/** Resize, then wait until the surface settles on what that width should show. */
|
||||
async function resizeTo(page: Page, width: number, height: number, expected: Surface) {
|
||||
await page.setViewportSize({ width, height });
|
||||
await expect
|
||||
.poll(() => surface(page), { timeout: 5000, message: `surface at ${width}x${height}` })
|
||||
.toEqual(expected);
|
||||
}
|
||||
|
||||
const PHONE: Surface = { welcome: false, overview: true, rail: false };
|
||||
const TABLET: Surface = { welcome: true, overview: false, rail: false };
|
||||
const DESKTOP: Surface = { welcome: true, overview: false, rail: true };
|
||||
|
||||
it('loaded wide: folding to phone width shows the phone overview, unfolding brings the welcome back', async () => {
|
||||
const [context, page] = await open(1440, 900);
|
||||
try {
|
||||
expect(await surface(page)).toEqual(DESKTOP);
|
||||
await resizeTo(page, 768, 900, TABLET);
|
||||
await resizeTo(page, 767, 900, TABLET);
|
||||
await resizeTo(page, 600, 900, TABLET);
|
||||
await resizeTo(page, 599, 900, PHONE);
|
||||
await resizeTo(page, 390, 844, PHONE);
|
||||
await resizeTo(page, 600, 900, TABLET);
|
||||
await resizeTo(page, 767, 900, TABLET);
|
||||
await resizeTo(page, 768, 900, TABLET);
|
||||
await resizeTo(page, 1440, 900, DESKTOP);
|
||||
// A straight fold, the way a real device changes posture, in one step.
|
||||
await resizeTo(page, 390, 844, PHONE);
|
||||
} finally {
|
||||
await context.close();
|
||||
}
|
||||
}, 60000);
|
||||
|
||||
it('loaded narrow: unfolding to desktop width shows the welcome and the rail, folding brings the overview back', async () => {
|
||||
const [context, page] = await open(390, 844);
|
||||
try {
|
||||
expect(await surface(page)).toEqual(PHONE);
|
||||
await resizeTo(page, 599, 844, PHONE);
|
||||
await resizeTo(page, 600, 844, TABLET);
|
||||
await resizeTo(page, 767, 844, TABLET);
|
||||
await resizeTo(page, 768, 844, TABLET);
|
||||
await resizeTo(page, 1440, 900, DESKTOP);
|
||||
await resizeTo(page, 768, 900, TABLET);
|
||||
await resizeTo(page, 767, 900, TABLET);
|
||||
await resizeTo(page, 600, 900, TABLET);
|
||||
await resizeTo(page, 599, 900, PHONE);
|
||||
} finally {
|
||||
await context.close();
|
||||
}
|
||||
}, 60000);
|
||||
|
||||
it('a height-only resize (the on-screen keyboard) does not re-decide the home surface', async () => {
|
||||
const [context, page] = await open(1440, 900);
|
||||
try {
|
||||
await resizeTo(page, 390, 844, PHONE);
|
||||
await page.evaluate(() => {
|
||||
const app = (window as any).app;
|
||||
app.__showWelcomeCalls = 0;
|
||||
const original = app.showWelcome;
|
||||
app.showWelcome = function (...args: unknown[]) {
|
||||
app.__showWelcomeCalls++;
|
||||
return original.apply(this, args);
|
||||
};
|
||||
});
|
||||
await page.setViewportSize({ width: 390, height: 480 });
|
||||
await page.waitForTimeout(400);
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await page.waitForTimeout(400);
|
||||
expect(await surface(page)).toEqual(PHONE);
|
||||
expect(await page.evaluate(() => (window as any).app.__showWelcomeCalls)).toBe(0);
|
||||
} finally {
|
||||
await context.close();
|
||||
}
|
||||
}, 60000);
|
||||
});
|
||||
@@ -1938,57 +1938,6 @@ describe('view()', () => {
|
||||
await h.service.cancel();
|
||||
});
|
||||
|
||||
it('a view whose read began while the job ran shows how the job ended, never paused', async () => {
|
||||
// The window polls every second, so a read of the state file can be under way while the
|
||||
// job writes its last state and lets go. That read returns the old `installing`, and
|
||||
// settling it with no job in this process read it as an interrupted job: the window (and
|
||||
// the e2e test, under CI load) saw `paused` with Resume for one tick instead of `failed`.
|
||||
const slow = {
|
||||
read: undefined as undefined | (() => Promise<LocalModelStateFile>),
|
||||
hold: undefined as undefined | Promise<void>,
|
||||
entered: undefined as undefined | (() => void),
|
||||
};
|
||||
const h = setup({
|
||||
overrides: {
|
||||
readState: async () => {
|
||||
const snapshot = await slow.read!();
|
||||
const hold = slow.hold;
|
||||
slow.hold = undefined;
|
||||
if (hold) {
|
||||
slow.entered?.();
|
||||
await hold;
|
||||
}
|
||||
return snapshot;
|
||||
},
|
||||
},
|
||||
});
|
||||
slow.read = h.store.readState;
|
||||
let failVerify!: () => void;
|
||||
const verifying = new Promise<void>((resolve) => (failVerify = resolve));
|
||||
h.deps.verifyModel.mockImplementationOnce(async () => {
|
||||
await verifying;
|
||||
return { ok: false, code: 'no-tool-call' } as never;
|
||||
});
|
||||
|
||||
await h.service.install(REQUEST, ADMIN);
|
||||
await vi.waitFor(() => expect(h.deps.verifyModel).toHaveBeenCalled());
|
||||
|
||||
// A view starts reading while the job is still in the verify step...
|
||||
let releaseRead!: () => void;
|
||||
slow.hold = new Promise<void>((resolve) => (releaseRead = resolve));
|
||||
const entered = new Promise<void>((resolve) => (slow.entered = resolve));
|
||||
const viewing = h.service.view();
|
||||
await entered;
|
||||
// ...the job fails, writes it and ends while that read is still out...
|
||||
failVerify();
|
||||
await h.service.idle();
|
||||
expect(h.store.get().state).toBe('failed');
|
||||
// ...and only then does the read come back, with the file as it was before.
|
||||
releaseRead();
|
||||
const view = await viewing;
|
||||
expect(view).toMatchObject({ state: 'failed', step: 'verify', error: { code: 'no-tool-call' } });
|
||||
});
|
||||
|
||||
it('names the model to try next only when the catalogue still has it', async () => {
|
||||
const state: LocalModelStateFile = {
|
||||
...jobAt('verify', 'paused'),
|
||||
|
||||
Reference in new issue
Block a user