mirror of
https://github.com/nexu-io/open-design.git
synced 2026-10-27 09:51:12 +01:00
* feat(daemon): make OD Next the default route, and keep an opt-out opted out An installation that configured nothing ran the ordinary strategy route, so shipping OD Next in a build never changed how a run behaved until someone opened Settings and asked for it. That made the Labs switch the whole rollout: the strategy could only reach the users who already knew to look for it. `resolveRequestedMode` now resolves an unconfigured installation to `active`. Every other admission condition already defaulted open — assignment bucket, both content flags, the production approval flag — so this last default was the only thing holding the route closed. The one-line change is the small part. Flipping the default inverts which case is load-bearing. While the strategy was opt-in, an installation that lost its saved mode kept the behaviour it already had; now an installation that loses its saved mode is switched back on. So the invariant this has to keep is that an installation which opted out reads `off` through every later release, and that rests on opting out being stored rather than erased: `off` is a value the config carries, `applyConfigValue` drops the key only for a value it cannot store, and `assertWritableControlValues` refuses those on the way in. That last guard is the same code as before but now protects the opposite direction. It used to stop a typo from masquerading as an opt-out; it now stops a typo from revoking one. Its docblock, the field comments on both sides of the contract, the `od strategy` help text, and the prompt-composition note all described the opt-in world and would have read as the reverse of the truth. Tests carry the semantics: - a new assertion pins the opt-out invariant, and checks not just the value read back but that the decision built from it actually evaluates to `off`; - the route test now separates `default` from a saved `off`, which resolve to opposite routes and no longer to the same one; - the app-config acceptance case against a real server is rewritten end to end. It was "nothing configured takes the ordinary route, and the same running daemon takes OD Next once you opt in". It is now the opt-out direction, and proves it from the agent invocation log rather than from the run record: the first run's prompt is the strategy's, the run after `off` is saved is not. Packaged installs have no second control — the packaged child environment allowlist carries no `OD_NEXT_*` key — so the saved mode is the only thing standing between an opted-out user and this default. e2e is deliberately untouched here and follows separately. Its specs mostly never set this mode, so prototype and deck cases now take the OD Next route. Pinning the env var in the harness is not the fix: the env var outranks the saved config by design, which would break the spec asserting that an app-config write takes effect with `app_config` as its source. * test(daemon): stop the panel-grammar spec failing on the clock `thinking-stream-panel-grammar` asserted that the whole SSE body contains neither `Critic` nor `8.1`, on the stated premise that both "只可能来自标记内部". That premise does not hold for the second one. The body also carries the daemon's own `start` envelope, and `toolTokenExpiresAt` is an ISO timestamp: a run whose token expiry lands on a second ending in `8` with milliseconds starting with `1` produces `…:48.161Z`, which contains `8.1`. Roughly one run in a hundred, and it hit on CI — with a thinking stream the log shows was clean, so the spec failed on the clock rather than on a leak. The two attribute-fragment assertions now run against the concatenated `thinking_delta` / `text_delta` text instead of the raw body. That is not a weaker check: persistence consumes exactly those deltas, so it is what the spec's own "SSE clean ≡ database clean" argument was always about, and the envelope fields it drops were never a place a leak could surface. The tag regex stays on the whole body, where an opening `<PANELIST` cannot appear by accident. The two prose assertions move to the same extracted text, which is what keeps the negative assertions honest: an extractor that selected the wrong event type would return an empty string, and every `not.toContain` would pass against it. * feat(daemon): remove the OD Next stop latch A run that hit certain failures wrote a row to `strategy_rollout_controls`. From the next request on, every task on that installation took the legacy route. The row outranked the saved mode, survived restart, and the Labs switch kept reading "on" while the notice underneath said the harness had been paused — with no control to unpause it. The only way back was an operator running `od strategy rollout reset`. The code already argued against this. The docblock over `rolloutStopSignalForBlockedContinuation` says only a failure of the machine contract itself may raise a latch, because an agent turn that mis-populates a machine block "is already fail-closed for that task ... and it must not silently return every later request in the daemon to the legacy path." One signal had already been retired for doing exactly that: `complex_child_unverified` took OD Next away from Codex, Claude and OpenCode because a fourth runtime lacks a child-lifecycle producer. This generalises those narrowings instead of waiting for the next signal to earn its own. It also stopped being survivable. While OD Next was opt-in, the latch could only reach the few installations that had asked for it; with the default flipped it reaches everyone, and the population exposed to an automatic, restart-surviving, UI-unrecoverable disable is now the whole install base. Deleting it changes nothing about how any single failure is handled. All three live trigger points were additive actions taken *after* the existing handling: `native_resume_failed` already blocked the continuation, sent `AGENT_SESSION_RESUME_FAILED` and finished the run failed; the blocked- continuation cases were already set to `outcome: 'blocked'` with their reason codes persisted for attribution. What goes away is only the side effect on every later request. A fourth trigger — the run-duration threshold — never fired at all: `OD_NEXT_STRATEGY_MAX_RUN_DURATION_MS` has no default anywhere, so the comparison ran against NaN. What is removed: the table and its migration, the write path and its telemetry module, the single read in the run route, the reset endpoint, the `od strategy rollout reset` subcommand, the latched lock in the Labs switch and its 19 locale strings, and five contract types. `readOdNextRolloutControlStatus` stays but no longer takes a database — the effective mode is now a pure function of the requested mode and the content flags, so two callers reading it at the same moment cannot disagree. Status is still worth an endpoint because `requestedModeSource` names the authority, and `default` and a saved `off` resolve to opposite routes. Installations currently holding a latch recover on their own: with nothing left to read the row, their next run is admitted normally. No migration needed, and a downgrade is safe — older code recreates the table, reads it empty, and treats the installation as unlatched. Tests carry the invariant rather than a list. The cases that pinned which signals were allowed to latch are replaced by one that pins the shape: the decision is a function of the policy and this run's facts and takes no argument through which an earlier run could reach it, so there is no signal left to enumerate and none can be added by accident. The real-server case now asserts that status still reports the deciding authority while both the reset endpoint and its CLI subcommand are gone. * fix(daemon): never leave the harness switch stuck, whatever the config says Field report from a packaged build of this branch: the Design Harness switch read on, greyed out, with "detected a problem, generation is using the original approach" underneath, and every task on the machine taking the legacy route. Nothing in the UI could clear it. The diagnostics name the cause exactly. One agent turn ended mid-block — `Unclosed <open-design-plan-contract> block`, 2 characters of visible text — and that raised `machine_contract_leak`, whose stop mode is `off`, not `observe`. A truncated response took the whole install off OD Next; the next daemon start logged `requestedMode: 'active'`, `effectiveMode: 'off'`, `od_next_rollout_stop_latched`, which is the screenshot. That is not a rare condition. A stream ending early is the most ordinary failure an agent has, and flipping the default hands it the entire install base. Three changes, one property: the switch a user can see is a switch a user can use. 1. The stop latch is removed (previously PR #7879, folded in here). A build that defaults on and still ships an automatic, restart-surviving, UI-unrecoverable disable is not shippable as two PRs — the window between them is exactly the build that was reported. Everything the latch did on top of the existing per-task handling is gone; the per-task handling is untouched. 2. An unreadable saved config no longer reads as an unconfigured one. The read path kept two states apart where it needed three: no file at all, a readable choice, and a config that exists but cannot be believed — malformed JSON, a non-object body, a mode this build does not recognise. The third used to collapse into the first harmlessly, because unconfigured meant `off` and so did an opt-out. Now unconfigured means `active`, so the same collapse turns "we cannot read your choice" into "you chose OD Next", against the installations most likely to have chosen the opposite. It resolves to `off` instead — the only answer that cannot override a real decision, and the recoverable one, since a user who never opted out sees the switch off and can turn it back on. `assertWritableControlValues` was never enough here: it guards writes through the API and can say nothing about a file that arrived bad, was hand-edited, or was written by another version. 3. A failed status read is retried from the switch instead of being permanent. It rendered the row locked and lifted only on remount, so a transient failure left a control that does nothing. A click on that lock now re-reads. The environment lock stays inert deliberately: the daemon would ignore the write, and pretending otherwise is worse than doing nothing — and no packaged install can reach it, since the packaged child env allowlist carries no `OD_NEXT_*`. After this, the only greyed state a packaged user can reach is the moment before the first read returns. Installations already holding a latch recover on their own: with nothing left to read the row, their next run is admitted normally. No migration, and a downgrade is safe — older code recreates the table, reads it empty, and treats the installation as unlatched. Coverage follows the same split. The config cases run against real files through `readAppConfig` rather than against the policy reader in isolation, because the hole was in the join: truncated JSON, a non-object body, an unrecognised mode, a typo, a non-string — each reads `off`, while a missing file and an explicit `null` still reach the default. One case asserts the whole chain, saving `off` then truncating the file and checking the mode survives, with a fresh installation as the negative control so the guard cannot swallow the rollout it protects. The UI case fails the first status read, clicks, and asserts the row becomes operable. * refactor(daemon): drop the JSDoc left above the removed latch classifier The block described `MACHINE_CONTRACT_BOUNDARY_CODES` and `rolloutStopSignalForBlockedContinuation`, both deleted with the stop latch. Left in place it reads as documentation for the export below it, implying this service still classifies machine-contract failures when it no longer does. * fix(daemon): tell operators why `od strategy rollout reset` is gone The command shipped in 0.21.0 and 0.21.1. Removing it left the subcommand falling through to the generic usage dump, so an operator who scripted it saw a list it was simply missing from and had to guess whether the command was renamed, moved, or broken. It now names itself: the daemon-instance stop latch it cleared no longer exists, so there is nothing to reset, and the message points at the two commands that replaced its use — `od config set odNextStrategyMode off` to opt out, and `od strategy rollout status` to see which authority decides the mode. It still exits non-zero rather than succeeding as a deprecated no-op. The command meant "recover this machine's rollout". Reporting success for a recovery that neither happened nor can happen is the same lie the latch itself told — a control that says it acted while nothing changed — and this release is removing that lie rather than relocating it into the CLI. * fix(daemon): stop a broken config file from opting an installation out The previous commit fixed a real hole — a saved `off` could be silently revoked — but fixed it too widely and in the wrong direction for this rollout. It treated a config file that failed to parse at all as evidence of an opt-out, which it is not. A file that will not parse resets every preference to its default: agent, telemetry, all of it. Singling this one key out to resolve against its default was inconsistent with the rest of the file, and the population it hits is the wrong one — everyone whose config breaks, not everyone who opted out. Since the second group is a subset of the first, the guard opted more installations out of the rollout than it protected, in a change whose entire purpose is opting them in. The guard now covers only what it can actually see: `odNextStrategyMode` present with a value that is not one of the modes — a hand edit, a typo, a mode written by some other version. Something was configured and cannot be read, so it reads `off` rather than becoming "you chose OD Next". A fresh install, a missing file, an unparseable file and a config without the key all reach the default, which is `active`. That keeps both invariants this step owes: the default is on for anyone who has not said otherwise, and a user who did say otherwise keeps their answer. The switch stays operable in every one of these states, so nothing here can leave a user looking at a control that does nothing. * style(daemon): drop the blank line left at EOF by the classifier removal The line separated `resolveAutomaticContinuationEvidence` from the JSDoc and classifier below it. Removing those left the separator as a trailing blank line, which `git diff --check` reports. No logic change. * docs(daemon): stop enumerating which releases shipped `rollout reset` The comment named 0.21.0 and 0.21.1. 0.22.0 shipped today and carries the command too, so the enumeration was already wrong the day it was written -- any list of shipped versions goes stale by construction. Say "every release since 0.21.0" instead, which stays true without maintenance. The point the sentence is making is unchanged, and slightly stronger: the surface has been out for three releases, so an operator who scripted it gets told what happened rather than shown a help list it is missing from. --------- Co-authored-by: Cheems <216108319+hihicheems@users.noreply.github.com>
664 lines
25 KiB
TypeScript
664 lines
25 KiB
TypeScript
// @vitest-environment jsdom
|
|
|
|
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
|
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
import type {
|
|
OdNextRolloutControlStatus,
|
|
OdNextRolloutMode,
|
|
OdNextRolloutModeSource,
|
|
} from '@open-design/contracts';
|
|
|
|
import { LabsSection } from '../../src/components/LabsSection';
|
|
import { I18nProvider } from '../../src/i18n';
|
|
|
|
const track = vi.fn();
|
|
vi.mock('../../src/analytics/provider', () => ({
|
|
useAnalytics: () => ({ track }),
|
|
}));
|
|
|
|
function status(overrides: {
|
|
requestedMode?: OdNextRolloutMode;
|
|
requestedModeSource?: OdNextRolloutModeSource;
|
|
} = {}): OdNextRolloutControlStatus {
|
|
const requestedMode = overrides.requestedMode ?? 'off';
|
|
return {
|
|
strategyId: 'od-next-strategy',
|
|
scope: 'daemon_instance',
|
|
requestedMode,
|
|
requestedModeSource: overrides.requestedModeSource ?? 'default',
|
|
effectiveMode: requestedMode,
|
|
};
|
|
}
|
|
|
|
interface Stub {
|
|
rolloutStatus?: OdNextRolloutControlStatus;
|
|
rolloutFails?: boolean;
|
|
writeFails?: boolean;
|
|
/** Held open to keep a PUT in flight while the section unmounts. */
|
|
writeGate?: Promise<void>;
|
|
}
|
|
|
|
function stubFetch(options: Stub = {}) {
|
|
const writes: unknown[] = [];
|
|
const fetchMock = vi.fn(async (input: string | URL | Request, init?: RequestInit) => {
|
|
const url = typeof input === 'string' ? input : input.toString();
|
|
if (url === '/api/strategies/od-next/rollout') {
|
|
if (options.rolloutFails) return new Response('{}', { status: 500 });
|
|
return new Response(
|
|
JSON.stringify({ status: options.rolloutStatus ?? status() }),
|
|
{ status: 200, headers: { 'content-type': 'application/json' } },
|
|
);
|
|
}
|
|
if (url === '/api/app-config') {
|
|
writes.push(JSON.parse(String(init?.body ?? '{}')));
|
|
if (options.writeGate) await options.writeGate;
|
|
if (options.writeFails) return new Response('{}', { status: 500 });
|
|
return new Response('{"config":{}}', { status: 200 });
|
|
}
|
|
throw new Error(`unexpected fetch: ${url}`);
|
|
});
|
|
vi.stubGlobal('fetch', fetchMock);
|
|
return { writes, fetchMock };
|
|
}
|
|
|
|
/**
|
|
* Stands in for `SettingsDialog`, which owns one shared save indicator for
|
|
* every section. `supersede()` is another section taking it over.
|
|
*/
|
|
function autosaveHost(onAutosaveStatus?: (s: 'saving' | 'saved' | 'error' | 'idle') => void) {
|
|
let epoch = 0;
|
|
return {
|
|
supersede: () => { epoch += 1; },
|
|
controller: {
|
|
claim: () => {
|
|
epoch += 1;
|
|
onAutosaveStatus?.('saving');
|
|
return epoch;
|
|
},
|
|
settle: (claim: number, status: 'saved' | 'error' | 'idle') => {
|
|
if (claim !== epoch) return;
|
|
onAutosaveStatus?.(status);
|
|
},
|
|
},
|
|
};
|
|
}
|
|
|
|
function renderSection(onAutosaveStatus?: (s: 'saving' | 'saved' | 'error' | 'idle') => void) {
|
|
const host = autosaveHost(onAutosaveStatus);
|
|
return {
|
|
...render(
|
|
<I18nProvider initial="en">
|
|
<LabsSection autosave={host.controller} />
|
|
</I18nProvider>,
|
|
),
|
|
host,
|
|
};
|
|
}
|
|
|
|
function switchEl(): HTMLButtonElement {
|
|
return screen.getByTestId('labs-harness-switch') as HTMLButtonElement;
|
|
}
|
|
|
|
describe('LabsSection', () => {
|
|
afterEach(() => {
|
|
cleanup();
|
|
vi.unstubAllGlobals();
|
|
track.mockClear();
|
|
});
|
|
|
|
it('renders the harness row off and operable on a machine that never configured it', async () => {
|
|
stubFetch();
|
|
renderSection();
|
|
await waitFor(() => expect(switchEl().getAttribute('aria-disabled')).toBe('false'));
|
|
expect(switchEl().getAttribute('aria-checked')).toBe('false');
|
|
expect(screen.getByText('Design Harness')).toBeTruthy();
|
|
expect(
|
|
screen.getByText(
|
|
"Your next generation will use OpenDesign's latest strategy, with noticeably more polished results (beta)",
|
|
),
|
|
).toBeTruthy();
|
|
});
|
|
|
|
it('renders on when the installation saved active', async () => {
|
|
stubFetch({ rolloutStatus: status({ requestedMode: 'active', requestedModeSource: 'app_config' }) });
|
|
renderSection();
|
|
await waitFor(() => expect(switchEl().getAttribute('aria-checked')).toBe('true'));
|
|
expect(switchEl().getAttribute('aria-disabled')).toBe('false');
|
|
});
|
|
|
|
it('shows observe as off without rewriting it', async () => {
|
|
const { writes } = stubFetch({
|
|
rolloutStatus: status({ requestedMode: 'observe', requestedModeSource: 'app_config' }),
|
|
});
|
|
renderSection();
|
|
await waitFor(() => expect(switchEl().getAttribute('aria-disabled')).toBe('false'));
|
|
expect(switchEl().getAttribute('aria-checked')).toBe('false');
|
|
expect(writes).toEqual([]);
|
|
});
|
|
|
|
it('writes active on the first turn-on and reports it on the autosave surface', async () => {
|
|
const { writes } = stubFetch();
|
|
const onAutosaveStatus = vi.fn();
|
|
renderSection(onAutosaveStatus);
|
|
await waitFor(() => expect(switchEl().getAttribute('aria-disabled')).toBe('false'));
|
|
|
|
fireEvent.click(switchEl());
|
|
|
|
await waitFor(() => expect(writes).toEqual([{ odNextStrategyMode: 'active' }]));
|
|
expect(switchEl().getAttribute('aria-checked')).toBe('true');
|
|
await waitFor(() => expect(onAutosaveStatus.mock.calls.map((c) => c[0])).toEqual(['saving', 'saved']));
|
|
});
|
|
|
|
it('writes an explicit off rather than clearing the key', async () => {
|
|
const { writes } = stubFetch({
|
|
rolloutStatus: status({ requestedMode: 'active', requestedModeSource: 'app_config' }),
|
|
});
|
|
renderSection();
|
|
await waitFor(() => expect(switchEl().getAttribute('aria-checked')).toBe('true'));
|
|
|
|
fireEvent.click(switchEl());
|
|
|
|
await waitFor(() => expect(writes).toEqual([{ odNextStrategyMode: 'off' }]));
|
|
});
|
|
|
|
it('rolls the switch back and reports an error when the write fails', async () => {
|
|
stubFetch({ writeFails: true });
|
|
const onAutosaveStatus = vi.fn();
|
|
renderSection(onAutosaveStatus);
|
|
await waitFor(() => expect(switchEl().getAttribute('aria-disabled')).toBe('false'));
|
|
|
|
fireEvent.click(switchEl());
|
|
|
|
await waitFor(() => expect(onAutosaveStatus).toHaveBeenCalledWith('error'));
|
|
expect(switchEl().getAttribute('aria-checked')).toBe('false');
|
|
expect(switchEl().getAttribute('aria-disabled')).toBe('false');
|
|
});
|
|
|
|
it('starts one write for a burst of clicks in the same tick', async () => {
|
|
// `busy` is state, so a second click in the same tick still sees the
|
|
// pre-render closure: `busy` false and the old `on`. Without a guard that
|
|
// flips synchronously, each click in the burst starts its own write from a
|
|
// stale baseline.
|
|
const { writes } = stubFetch();
|
|
renderSection();
|
|
await waitFor(() => expect(switchEl().getAttribute('aria-disabled')).toBe('false'));
|
|
|
|
const target = switchEl();
|
|
target.click();
|
|
target.click();
|
|
target.click();
|
|
|
|
await waitFor(() => expect(writes.length).toBeGreaterThan(0));
|
|
expect(writes).toEqual([{ odNextStrategyMode: 'active' }]);
|
|
await waitFor(() => expect(switchEl().getAttribute('aria-checked')).toBe('true'));
|
|
});
|
|
|
|
it('accepts a second toggle once the first write has settled', async () => {
|
|
const { writes } = stubFetch();
|
|
renderSection();
|
|
await waitFor(() => expect(switchEl().getAttribute('aria-disabled')).toBe('false'));
|
|
|
|
fireEvent.click(switchEl());
|
|
await waitFor(() => expect(writes).toEqual([{ odNextStrategyMode: 'active' }]));
|
|
await waitFor(() => expect(switchEl().getAttribute('aria-disabled')).toBe('false'));
|
|
|
|
fireEvent.click(switchEl());
|
|
await waitFor(() => expect(writes).toEqual([
|
|
{ odNextStrategyMode: 'active' },
|
|
{ odNextStrategyMode: 'off' },
|
|
]));
|
|
expect(switchEl().getAttribute('aria-checked')).toBe('false');
|
|
});
|
|
|
|
it('reports the toggle only after the preference is persisted', async () => {
|
|
const { writes } = stubFetch();
|
|
renderSection();
|
|
await waitFor(() => expect(switchEl().getAttribute('aria-disabled')).toBe('false'));
|
|
|
|
fireEvent.click(switchEl());
|
|
|
|
await waitFor(() => expect(writes).toHaveLength(1));
|
|
await waitFor(() => expect(track).toHaveBeenCalledTimes(1));
|
|
expect(track.mock.calls[0]?.[0]).toBe('labs_item_toggled');
|
|
expect(track.mock.calls[0]?.[1]).toEqual({
|
|
item_id: 'design_harness',
|
|
to: 'on',
|
|
source: 'settings',
|
|
});
|
|
});
|
|
|
|
it('reports the opt-out direction on the way back off', async () => {
|
|
stubFetch({ rolloutStatus: status({ requestedMode: 'active', requestedModeSource: 'app_config' }) });
|
|
renderSection();
|
|
await waitFor(() => expect(switchEl().getAttribute('aria-checked')).toBe('true'));
|
|
|
|
fireEvent.click(switchEl());
|
|
|
|
await waitFor(() => expect(track).toHaveBeenCalledTimes(1));
|
|
expect(track.mock.calls[0]?.[1]).toMatchObject({ to: 'off', source: 'settings' });
|
|
});
|
|
|
|
it('reports nothing when the write fails', async () => {
|
|
// The switch rolls back, so the install does not hold the preference the
|
|
// event would have asserted.
|
|
stubFetch({ writeFails: true });
|
|
const onAutosaveStatus = vi.fn();
|
|
renderSection(onAutosaveStatus);
|
|
await waitFor(() => expect(switchEl().getAttribute('aria-disabled')).toBe('false'));
|
|
|
|
fireEvent.click(switchEl());
|
|
|
|
await waitFor(() => expect(onAutosaveStatus).toHaveBeenCalledWith('error'));
|
|
expect(track).not.toHaveBeenCalled();
|
|
});
|
|
|
|
describe('opt-out reason', () => {
|
|
async function optOut() {
|
|
stubFetch({ rolloutStatus: status({ requestedMode: 'active', requestedModeSource: 'app_config' }) });
|
|
renderSection();
|
|
await waitFor(() => expect(switchEl().getAttribute('aria-checked')).toBe('true'));
|
|
fireEvent.click(switchEl());
|
|
await waitFor(() => expect(track).toHaveBeenCalledTimes(1));
|
|
await screen.findByText('Switched back to the previous approach. What did not work?');
|
|
}
|
|
|
|
function reasonEvents() {
|
|
return track.mock.calls.filter((c) => (c[1] as { reason?: unknown }).reason);
|
|
}
|
|
|
|
it('asks only after an opt-out, never after opting in', async () => {
|
|
stubFetch();
|
|
renderSection();
|
|
await waitFor(() => expect(switchEl().getAttribute('aria-disabled')).toBe('false'));
|
|
|
|
fireEvent.click(switchEl());
|
|
|
|
await waitFor(() => expect(track).toHaveBeenCalledTimes(1));
|
|
expect(screen.queryByText('Switched back to the previous approach. What did not work?')).toBeNull();
|
|
});
|
|
|
|
it('reports a chosen reason as a second event, leaving the opt-out count intact', async () => {
|
|
await optOut();
|
|
|
|
fireEvent.click(screen.getByText('Too slow'));
|
|
|
|
await waitFor(() => expect(track).toHaveBeenCalledTimes(2));
|
|
expect(track.mock.calls[0]?.[1]).toEqual({ item_id: 'design_harness', to: 'off', source: 'settings' });
|
|
expect(track.mock.calls[1]?.[1]).toEqual({
|
|
item_id: 'design_harness',
|
|
to: 'off',
|
|
source: 'settings',
|
|
reason: ['too_slow'],
|
|
has_custom_reason: false,
|
|
});
|
|
expect(screen.queryByText('Switched back to the previous approach. What did not work?')).toBeNull();
|
|
});
|
|
|
|
it('records an explicit skip', async () => {
|
|
await optOut();
|
|
|
|
fireEvent.click(screen.getByText('Skip'));
|
|
|
|
await waitFor(() => expect(reasonEvents()).toHaveLength(1));
|
|
expect(reasonEvents()[0]?.[1]).toMatchObject({ reason: ['skipped'], has_custom_reason: false });
|
|
});
|
|
|
|
it('carries the free text when the user picks other', async () => {
|
|
await optOut();
|
|
|
|
fireEvent.click(screen.getByText('Other'));
|
|
const input = screen.getByLabelText('What specifically did not work?') as HTMLInputElement;
|
|
fireEvent.change(input, { target: { value: ' layout drifts on long decks ' } });
|
|
fireEvent.click(screen.getByText('Submit'));
|
|
|
|
await waitFor(() => expect(reasonEvents()).toHaveLength(1));
|
|
expect(reasonEvents()[0]?.[1]).toMatchObject({
|
|
reason: ['other'],
|
|
has_custom_reason: true,
|
|
custom_reason: 'layout drifts on long decks',
|
|
});
|
|
});
|
|
|
|
it('keeps submit unavailable until the free text has content', async () => {
|
|
await optOut();
|
|
|
|
fireEvent.click(screen.getByText('Other'));
|
|
const submit = screen.getByText('Submit') as HTMLButtonElement;
|
|
expect(submit.disabled).toBe(true);
|
|
|
|
fireEvent.change(screen.getByLabelText('What specifically did not work?'), {
|
|
target: { value: ' ' },
|
|
});
|
|
expect(submit.disabled).toBe(true);
|
|
|
|
fireEvent.change(screen.getByLabelText('What specifically did not work?'), {
|
|
target: { value: 'x' },
|
|
});
|
|
expect(submit.disabled).toBe(false);
|
|
});
|
|
|
|
it('records a skip when the question times out unanswered', async () => {
|
|
vi.useFakeTimers({ shouldAdvanceTime: true });
|
|
try {
|
|
await optOut();
|
|
expect(reasonEvents()).toHaveLength(0);
|
|
|
|
await vi.advanceTimersByTimeAsync(120_000);
|
|
|
|
await waitFor(() => expect(reasonEvents()).toHaveLength(1));
|
|
expect(reasonEvents()[0]?.[1]).toMatchObject({ reason: ['skipped'] });
|
|
} finally {
|
|
vi.useRealTimers();
|
|
}
|
|
});
|
|
|
|
it('stops the clock once the user starts writing a custom reason', async () => {
|
|
vi.useFakeTimers({ shouldAdvanceTime: true });
|
|
try {
|
|
await optOut();
|
|
fireEvent.click(screen.getByText('Other'));
|
|
|
|
await vi.advanceTimersByTimeAsync(300_000);
|
|
|
|
// Taking the panel away mid-sentence would discard what they typed.
|
|
expect(reasonEvents()).toHaveLength(0);
|
|
expect(screen.getByLabelText('What specifically did not work?')).toBeTruthy();
|
|
} finally {
|
|
vi.useRealTimers();
|
|
}
|
|
});
|
|
|
|
it('retracts the question when the user turns the switch back on', async () => {
|
|
// A fumbled off/on used to leave the panel asking about a switch that was
|
|
// already back on, and reported two opt-outs against a single reason row
|
|
// once the stale question finally settled.
|
|
await optOut();
|
|
|
|
fireEvent.click(switchEl());
|
|
|
|
await waitFor(() => expect(switchEl().getAttribute('aria-checked')).toBe('true'));
|
|
await waitFor(() =>
|
|
expect(screen.queryByText('Switched back to the previous approach. What did not work?')).toBeNull());
|
|
|
|
const offs = track.mock.calls.filter((c) => (c[1] as { to?: string }).to === 'off');
|
|
const ons = track.mock.calls.filter((c) => (c[1] as { to?: string }).to === 'on');
|
|
// One reported opt-out, one reason row for it, one opt-in.
|
|
expect(offs).toHaveLength(2);
|
|
expect(offs.filter((c) => (c[1] as { reason?: unknown }).reason)).toHaveLength(1);
|
|
expect(ons).toHaveLength(1);
|
|
});
|
|
|
|
it('records a skip when the user leaves the page with the question open', async () => {
|
|
// Otherwise every abandoned question is a silent gap, and the share of
|
|
// people who declined to answer reads lower than it is.
|
|
await optOut();
|
|
|
|
cleanup();
|
|
|
|
await waitFor(() => expect(reasonEvents()).toHaveLength(1));
|
|
expect(reasonEvents()[0]?.[1]).toMatchObject({ reason: ['skipped'] });
|
|
});
|
|
|
|
it('answers exactly once even when two paths out race', async () => {
|
|
await optOut();
|
|
|
|
fireEvent.click(screen.getByText('Too slow'));
|
|
cleanup();
|
|
|
|
await waitFor(() => expect(reasonEvents()).toHaveLength(1));
|
|
expect(reasonEvents()[0]?.[1]).toMatchObject({ reason: ['too_slow'] });
|
|
});
|
|
});
|
|
|
|
it('clears the saved confirmation instead of leaving it up', async () => {
|
|
// The dialog's autosave pill is shared and has no timer of its own, so the
|
|
// confirmation used to sit there for the rest of the session and follow the
|
|
// user into every other settings section.
|
|
vi.useFakeTimers({ shouldAdvanceTime: true });
|
|
try {
|
|
stubFetch();
|
|
const onAutosaveStatus = vi.fn();
|
|
renderSection(onAutosaveStatus);
|
|
await waitFor(() => expect(switchEl().getAttribute('aria-disabled')).toBe('false'));
|
|
|
|
fireEvent.click(switchEl());
|
|
await waitFor(() => expect(onAutosaveStatus).toHaveBeenCalledWith('saved'));
|
|
expect(onAutosaveStatus).not.toHaveBeenCalledWith('idle');
|
|
|
|
await vi.advanceTimersByTimeAsync(3_000);
|
|
|
|
await waitFor(() => expect(onAutosaveStatus).toHaveBeenCalledWith('idle'));
|
|
} finally {
|
|
vi.useRealTimers();
|
|
}
|
|
});
|
|
|
|
it('takes the saved confirmation down when the section is left early', async () => {
|
|
stubFetch();
|
|
const onAutosaveStatus = vi.fn();
|
|
renderSection(onAutosaveStatus);
|
|
await waitFor(() => expect(switchEl().getAttribute('aria-disabled')).toBe('false'));
|
|
|
|
fireEvent.click(switchEl());
|
|
await waitFor(() => expect(onAutosaveStatus).toHaveBeenCalledWith('saved'));
|
|
|
|
cleanup();
|
|
|
|
expect(onAutosaveStatus).toHaveBeenCalledWith('idle');
|
|
});
|
|
|
|
// OPEND-2365 (P2). Leaving Labs mid-write is the ordinary case — the user
|
|
// flips the switch and immediately clicks another settings section. The
|
|
// write still lands, so the installation holds the preference the event
|
|
// asserts; a `mounted` guard that also swallows the report turns every one
|
|
// of those into a silently missing data point.
|
|
describe('left while the write is still in flight', () => {
|
|
function heldWrite() {
|
|
let release = () => {};
|
|
const gate = new Promise<void>((resolve) => {
|
|
release = () => resolve();
|
|
});
|
|
return { gate, release };
|
|
}
|
|
|
|
it('still reports the toggle once the successful write lands', async () => {
|
|
const { gate, release } = heldWrite();
|
|
const { writes } = stubFetch({ writeGate: gate });
|
|
renderSection();
|
|
await waitFor(() => expect(switchEl().getAttribute('aria-disabled')).toBe('false'));
|
|
|
|
fireEvent.click(switchEl());
|
|
await waitFor(() => expect(writes).toHaveLength(1));
|
|
cleanup();
|
|
release();
|
|
|
|
await waitFor(() => expect(track).toHaveBeenCalledTimes(1));
|
|
expect(track.mock.calls[0]?.[0]).toBe('labs_item_toggled');
|
|
expect(track.mock.calls[0]?.[1]).toEqual({
|
|
item_id: 'design_harness',
|
|
to: 'on',
|
|
source: 'settings',
|
|
});
|
|
});
|
|
|
|
it('settles the dialog autosave pill instead of leaving it on saving', async () => {
|
|
vi.useFakeTimers({ shouldAdvanceTime: true });
|
|
try {
|
|
const { gate, release } = heldWrite();
|
|
const { writes } = stubFetch({ writeGate: gate });
|
|
const onAutosaveStatus = vi.fn();
|
|
renderSection(onAutosaveStatus);
|
|
await waitFor(() => expect(switchEl().getAttribute('aria-disabled')).toBe('false'));
|
|
|
|
fireEvent.click(switchEl());
|
|
await waitFor(() => expect(writes).toHaveLength(1));
|
|
expect(onAutosaveStatus).toHaveBeenCalledWith('saving');
|
|
cleanup();
|
|
release();
|
|
|
|
await waitFor(() => expect(onAutosaveStatus).toHaveBeenCalledWith('saved'));
|
|
await vi.advanceTimersByTimeAsync(3_000);
|
|
await waitFor(() => expect(onAutosaveStatus).toHaveBeenCalledWith('idle'));
|
|
} finally {
|
|
vi.useRealTimers();
|
|
}
|
|
});
|
|
|
|
it('reports the failed write as an error rather than stranding the pill', async () => {
|
|
const { gate, release } = heldWrite();
|
|
const { writes } = stubFetch({ writeGate: gate, writeFails: true });
|
|
const onAutosaveStatus = vi.fn();
|
|
renderSection(onAutosaveStatus);
|
|
await waitFor(() => expect(switchEl().getAttribute('aria-disabled')).toBe('false'));
|
|
|
|
fireEvent.click(switchEl());
|
|
await waitFor(() => expect(writes).toHaveLength(1));
|
|
cleanup();
|
|
release();
|
|
|
|
await waitFor(() => expect(onAutosaveStatus).toHaveBeenCalledWith('error'));
|
|
expect(track).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('cannot take the shared save indicator back from a newer section', async () => {
|
|
// The indicator belongs to the dialog, not to this section. A Labs write
|
|
// that lands after the user has moved on and started a newer save must
|
|
// not relabel that save's outcome — nor force it to idle three seconds
|
|
// later, which is worse than a stuck pill.
|
|
const { gate, release } = heldWrite();
|
|
const { writes } = stubFetch({ writeGate: gate });
|
|
const onAutosaveStatus = vi.fn();
|
|
const { host } = renderSection(onAutosaveStatus);
|
|
await waitFor(() => expect(switchEl().getAttribute('aria-disabled')).toBe('false'));
|
|
|
|
fireEvent.click(switchEl());
|
|
await waitFor(() => expect(writes).toHaveLength(1));
|
|
cleanup();
|
|
// Another Settings section takes the indicator for a newer edit.
|
|
host.supersede();
|
|
onAutosaveStatus.mockClear();
|
|
release();
|
|
|
|
// The toggle still reports — the preference did land.
|
|
await waitFor(() => expect(track).toHaveBeenCalledTimes(1));
|
|
expect(onAutosaveStatus).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('keeps an opt-out paired with exactly one reason row', async () => {
|
|
// The reason panel cannot be shown to a section that is gone, and the
|
|
// unmount settler has already run by the time the write lands. Without
|
|
// an immediate skip the opt-out would be counted with no reason row at
|
|
// all, which is the one thing the pairing invariant exists to prevent.
|
|
const { gate, release } = heldWrite();
|
|
const { writes } = stubFetch({
|
|
rolloutStatus: status({ requestedMode: 'active', requestedModeSource: 'app_config' }),
|
|
writeGate: gate,
|
|
});
|
|
renderSection();
|
|
await waitFor(() => expect(switchEl().getAttribute('aria-checked')).toBe('true'));
|
|
|
|
fireEvent.click(switchEl());
|
|
await waitFor(() => expect(writes).toHaveLength(1));
|
|
cleanup();
|
|
release();
|
|
|
|
await waitFor(() => expect(track).toHaveBeenCalledTimes(2));
|
|
expect(track.mock.calls[0]?.[1]).toEqual({
|
|
item_id: 'design_harness',
|
|
to: 'off',
|
|
source: 'settings',
|
|
});
|
|
expect(track.mock.calls[1]?.[1]).toMatchObject({
|
|
item_id: 'design_harness',
|
|
to: 'off',
|
|
source: 'settings',
|
|
reason: ['skipped'],
|
|
has_custom_reason: false,
|
|
});
|
|
});
|
|
});
|
|
|
|
it('locks the switch and explains when an environment variable owns the mode', async () => {
|
|
stubFetch({
|
|
rolloutStatus: status({ requestedMode: 'active', requestedModeSource: 'env' }),
|
|
});
|
|
renderSection();
|
|
await waitFor(() => expect(switchEl().getAttribute('aria-disabled')).toBe('true'));
|
|
expect(switchEl().getAttribute('aria-checked')).toBe('true');
|
|
expect(
|
|
screen.getByText('An environment variable is controlling this setting, so it cannot be changed here.'),
|
|
).toBeTruthy();
|
|
});
|
|
|
|
it('keeps the page usable when the daemon cannot be reached', async () => {
|
|
const { writes } = stubFetch({ rolloutFails: true });
|
|
renderSection();
|
|
await waitFor(() => expect(switchEl().getAttribute('aria-disabled')).toBe('true'));
|
|
expect(screen.getByText('Design Harness')).toBeTruthy();
|
|
expect(
|
|
screen.getByText('Could not read this setting. Check that the local daemon is running.'),
|
|
).toBeTruthy();
|
|
|
|
fireEvent.click(switchEl());
|
|
expect(writes).toEqual([]);
|
|
});
|
|
|
|
it('reveals the explanation on hover and on keyboard focus, and never toggles from it', async () => {
|
|
const { writes } = stubFetch();
|
|
renderSection();
|
|
await waitFor(() => expect(switchEl().getAttribute('aria-disabled')).toBe('false'));
|
|
|
|
const trigger = screen.getByLabelText('About Design Harness');
|
|
expect(screen.queryByRole('tooltip')).toBeNull();
|
|
|
|
fireEvent.mouseEnter(trigger);
|
|
expect(screen.getByRole('tooltip').textContent).toContain('agent harness');
|
|
fireEvent.mouseLeave(trigger);
|
|
expect(screen.queryByRole('tooltip')).toBeNull();
|
|
|
|
fireEvent.focus(trigger);
|
|
expect(screen.getByRole('tooltip').textContent).toContain('Hyperframes');
|
|
fireEvent.blur(trigger);
|
|
expect(screen.queryByRole('tooltip')).toBeNull();
|
|
|
|
fireEvent.click(trigger);
|
|
expect(writes).toEqual([]);
|
|
expect(switchEl().getAttribute('aria-checked')).toBe('false');
|
|
});
|
|
|
|
it('lets a failed status read be retried from the switch itself', async () => {
|
|
// The state this guards against: the read fails once, the row renders
|
|
// locked, and nothing the user can do inside Settings clears it. That is
|
|
// only survivable while some other control exists, and for a packaged
|
|
// install this switch is the only one.
|
|
let attempt = 0;
|
|
const status = {
|
|
strategyId: 'od-next-strategy' as const,
|
|
scope: 'daemon_instance' as const,
|
|
requestedMode: 'active' as const,
|
|
requestedModeSource: 'default' as const,
|
|
effectiveMode: 'active' as const,
|
|
};
|
|
vi.stubGlobal('fetch', vi.fn(async (input: RequestInfo | URL) => {
|
|
if (String(input).includes('/api/strategies/od-next/rollout')) {
|
|
attempt += 1;
|
|
if (attempt === 1) return new Response('nope', { status: 500 });
|
|
return new Response(JSON.stringify({ status }), {
|
|
status: 200,
|
|
headers: { 'content-type': 'application/json' },
|
|
});
|
|
}
|
|
return new Response('{}', { status: 200 });
|
|
}));
|
|
|
|
render(<I18nProvider><LabsSection /></I18nProvider>);
|
|
const control = await screen.findByRole('switch');
|
|
await waitFor(() => expect(control).toHaveAttribute('aria-disabled', 'true'));
|
|
|
|
fireEvent.click(control);
|
|
|
|
await waitFor(() => expect(control).toHaveAttribute('aria-disabled', 'false'));
|
|
expect(attempt).toBe(2);
|
|
});
|
|
});
|