test: expand Memory and Routines coverage (#1521)

* test: expand settings and packaged coverage

* test: extend memory settings coverage

* test: cover routine settings failure states

* test: cover routine operation failures

* test: fix daemon test typing on CI

* test: decouple packaged smoke from orbit bug

* test: avoid live memory LLM calls in route tests

* test: fix daemon fetch typing in CI

* fix: restore preview comment and inspect toggles

* test: align manual edit flow with current inspector UX

* test: align comment attachment flow with current preview comments UI

* fix: probe resolved Codex launch path during detection

* fix: remove duplicate board activation helper after rebase

* test: update ghost cli detection mock

* test: align FileViewer toolbar expectation

* ci: move full app tests to extended lane

* ci: run app tests by changed scope

* ci: cover shared app inputs in test scopes

* ci: avoid setup-node cache in windows packaged smoke

* test: align extended settings and manual edit flows
This commit is contained in:
shangxinyu1 2026-05-14 14:48:40 +08:00 committed by GitHub
parent c942d99b14
commit 2976c76fc3
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
20 changed files with 1684 additions and 118 deletions

View file

@ -26,10 +26,14 @@ concurrency:
jobs:
packaged_changes:
name: Detect packaged smoke changes
name: Detect PR change scopes
runs-on: ubuntu-latest
outputs:
required: ${{ steps.detect.outputs.required }}
daemon_tests_required: ${{ steps.detect.outputs.daemon_tests_required }}
web_tests_required: ${{ steps.detect.outputs.web_tests_required }}
tools_dev_tests_required: ${{ steps.detect.outputs.tools_dev_tests_required }}
tools_pack_tests_required: ${{ steps.detect.outputs.tools_pack_tests_required }}
steps:
- name: Checkout
@ -37,12 +41,16 @@ jobs:
with:
fetch-depth: 0
- name: Detect desktop/sidecar/packaging changes
- name: Detect desktop, packaging, and app test scopes
id: detect
shell: bash
run: |
set -euo pipefail
required=false
daemon_tests_required=false
web_tests_required=false
tools_dev_tests_required=false
tools_pack_tests_required=false
if [ "${{ github.event_name }}" = "pull_request" ]; then
git diff --name-only "${{ github.event.pull_request.base.sha }}" "${{ github.event.pull_request.head.sha }}" > "$RUNNER_TEMP/changed-files.txt"
patterns=(
@ -62,20 +70,53 @@ jobs:
required=true
fi
done
if [[ "$file" == "apps/daemon/"* || "$file" == "packages/contracts/"* || "$file" == "packages/platform/"* || "$file" == "packages/sidecar/"* || "$file" == "packages/sidecar-proto/"* ]]; then
daemon_tests_required=true
fi
if [[ "$file" == "apps/web/"* || "$file" == "packages/contracts/"* || "$file" == "packages/platform/"* || "$file" == "packages/sidecar/"* || "$file" == "packages/sidecar-proto/"* ]]; then
web_tests_required=true
fi
if [[ "$file" == "scripts/"* || "$file" == "assets/"* || "$file" == "skills/"* || "$file" == "prompt-templates/"* || "$file" == "design-systems/"* || "$file" == "design-templates/"* || "$file" == "craft/"* ]]; then
daemon_tests_required=true
web_tests_required=true
fi
if [[ "$file" == "tools/dev/"* || "$file" == "packages/platform/"* || "$file" == "packages/sidecar/"* || "$file" == "packages/sidecar-proto/"* ]]; then
tools_dev_tests_required=true
fi
if [[ "$file" == "tools/pack/"* || "$file" == "apps/packaged/"* || "$file" == "apps/desktop/"* || "$file" == "packages/platform/"* || "$file" == "packages/sidecar/"* || "$file" == "packages/sidecar-proto/"* ]]; then
tools_pack_tests_required=true
fi
if [[ "$file" == "e2e/specs/mac.spec.ts" || "$file" == "e2e/specs/win.spec.ts" || "$file" == "package.json" || "$file" == "pnpm-lock.yaml" || "$file" == "pnpm-workspace.yaml" || "$file" == ".github/workflows/ci.yml" || "$file" == ".github/workflows/release-beta.yml" ]]; then
required=true
daemon_tests_required=true
web_tests_required=true
tools_dev_tests_required=true
tools_pack_tests_required=true
fi
if [ "$required" = "true" ]; then
if [ "$required" = "true" ] \
&& [ "$daemon_tests_required" = "true" ] \
&& [ "$web_tests_required" = "true" ] \
&& [ "$tools_dev_tests_required" = "true" ] \
&& [ "$tools_pack_tests_required" = "true" ]; then
break
fi
done < "$RUNNER_TEMP/changed-files.txt"
else
required=true
daemon_tests_required=true
web_tests_required=true
tools_dev_tests_required=true
tools_pack_tests_required=true
fi
echo "required=$required" >> "$GITHUB_OUTPUT"
echo "daemon_tests_required=$daemon_tests_required" >> "$GITHUB_OUTPUT"
echo "web_tests_required=$web_tests_required" >> "$GITHUB_OUTPUT"
echo "tools_dev_tests_required=$tools_dev_tests_required" >> "$GITHUB_OUTPUT"
echo "tools_pack_tests_required=$tools_pack_tests_required" >> "$GITHUB_OUTPUT"
validate:
name: Validate workspace
needs: [packaged_changes]
runs-on: ubuntu-latest
timeout-minutes: 45
@ -137,12 +178,25 @@ jobs:
pnpm --filter @open-design/sidecar test
pnpm --filter @open-design/sidecar-proto test
- name: App workspace tests
- name: App workspace smoke tests
if: ${{ needs.packaged_changes.outputs.tools_dev_tests_required == 'true' || needs.packaged_changes.outputs.tools_pack_tests_required == 'true' }}
run: |
if [ "${{ needs.packaged_changes.outputs.tools_dev_tests_required }}" = "true" ]; then
pnpm --filter @open-design/tools-dev test
fi
if [ "${{ needs.packaged_changes.outputs.tools_pack_tests_required }}" = "true" ]; then
pnpm --filter @open-design/tools-pack test
fi
- name: App workspace daemon tests
if: ${{ needs.packaged_changes.outputs.daemon_tests_required == 'true' }}
run: |
pnpm --filter @open-design/daemon test
- name: App workspace web tests
if: ${{ needs.packaged_changes.outputs.web_tests_required == 'true' }}
run: |
pnpm --filter @open-design/web test
pnpm --filter @open-design/tools-dev test
pnpm --filter @open-design/tools-pack test
- name: E2E vitest
run: pnpm --filter @open-design/e2e test
@ -247,8 +301,6 @@ jobs:
uses: actions/setup-node@v6
with:
node-version: 24
cache: pnpm
cache-dependency-path: pnpm-lock.yaml
- name: Compute Windows tools-pack cache key
id: win_tools_pack_cache_key

View file

@ -1,7 +1,7 @@
import { execAgentFile } from './invocation.js';
import { AGENT_DEFS } from './registry.js';
import { DEFAULT_MODEL_OPTION, rememberLiveModels } from './models.js';
import { resolveAgentExecutable } from './executables.js';
import { applyAgentLaunchEnv, resolveAgentLaunch } from './launch.js';
import { spawnEnvForAgent } from './env.js';
import { probeAgentAuthStatus } from './auth.js';
import { agentCapabilities } from './capabilities.js';
@ -118,27 +118,30 @@ async function probe(
def: RuntimeAgentDef,
configuredEnv: Record<string, string> = {},
): Promise<DetectedAgent> {
// Resolution returns whichever path the rest of the daemon will spawn
// (configured override wins, PATH fallback otherwise). Detection must
// probe THAT path and report `available` accordingly, so the Settings
// UI never advertises an executable that `resolveAgentBin` won't pick
// at run time. Surfacing a different PATH candidate as `available: true`
// while a stale configured override survives in chat/run resolution
// breaks the invariant flagged on PR #1301 review and would only swap
// the ghost in Settings for a ghost in chat (Siri-Ray, #1301 round 3).
const resolved = resolveAgentExecutable(def, configuredEnv);
if (!resolved) {
// Detection must probe the exact path the runtime will spawn, not just the
// PATH-visible shim. This is load-bearing for Codex under nvm/fnm/mise:
// the discovered `codex` entry is often a `#!/usr/bin/env node` wrapper
// that is not invocable from a GUI-launched app's stripped PATH, while the
// launch resolver can still upgrade it to the packaged native Codex binary.
// If detection probes the shim but chat/run spawns the native binary, the
// UI incorrectly reports "not installed" until the user pins CODEX_BIN by
// hand even though the real launch path is healthy.
const launch = resolveAgentLaunch(def, configuredEnv);
if (!launch.selectedPath || !launch.launchPath) {
return unavailableAgent(def);
}
const probeEnv = spawnEnvForAgent(
def.id,
{
...process.env,
...(def.env || {}),
},
configuredEnv,
const probeEnv = applyAgentLaunchEnv(
spawnEnvForAgent(
def.id,
{
...process.env,
...(def.env || {}),
},
configuredEnv,
),
launch,
);
const outcome = await probeVersionAtPath(def, resolved, probeEnv);
const outcome = await probeVersionAtPath(def, launch.launchPath, probeEnv);
if (outcome.kind === 'not-invocable') {
return unavailableAgent(def);
}
@ -147,7 +150,7 @@ async function probe(
if (def.helpArgs && def.capabilityFlags) {
const caps: RuntimeCapabilityMap = {};
try {
const { stdout } = await execAgentFile(resolved, def.helpArgs, {
const { stdout } = await execAgentFile(launch.launchPath, def.helpArgs, {
env: probeEnv,
timeout: 5000,
maxBuffer: 4 * 1024 * 1024,
@ -161,13 +164,13 @@ async function probe(
}
agentCapabilities.set(def.id, caps);
}
const models = await fetchModels(def, resolved, probeEnv);
const auth = await probeAgentAuthStatus(def.id, resolved, probeEnv);
const models = await fetchModels(def, launch.launchPath, probeEnv);
const auth = await probeAgentAuthStatus(def.id, launch.launchPath, probeEnv);
return {
...stripFns(def),
models,
available: true,
path: resolved,
path: launch.selectedPath,
version: outcome.version,
...(auth
? {

View file

@ -154,4 +154,138 @@ describe('PATCH /api/memory/config apiKey three-state handling', () => {
expect(extraction?.provider).toBe('anthropic');
expect(extraction?.apiKey ?? '').toBe('');
});
it('clears the extraction override when the patch sends extraction: null', async () => {
await writeMemoryConfig(dataDir, {
extraction: {
provider: 'openai',
model: 'gpt-4o-mini',
apiKey: 'sk-stored-secret',
baseUrl: 'https://api.openai.com',
},
});
const res = await patchConfig({
extraction: null,
});
expect(res.status).toBe(200);
const extraction = await readStoredExtraction();
expect(extraction).toBeNull();
});
it('preserves the stored azure apiVersion when the patch omits the field', async () => {
await writeMemoryConfig(dataDir, {
extraction: {
provider: 'azure',
model: 'gpt-4.1-mini',
apiKey: 'azure-secret',
baseUrl: 'https://example.openai.azure.com',
apiVersion: '2025-01-01-preview',
},
});
const res = await patchConfig({
extraction: {
provider: 'azure',
model: 'gpt-4.1-mini',
baseUrl: 'https://example.openai.azure.com',
},
});
expect(res.status).toBe(200);
const extraction = await readStoredExtraction();
expect(extraction?.provider).toBe('azure');
expect(extraction?.apiVersion).toBe('2025-01-01-preview');
});
it('clears the stored azure apiVersion when the patch sends an explicit empty string', async () => {
await writeMemoryConfig(dataDir, {
extraction: {
provider: 'azure',
model: 'gpt-4.1-mini',
apiKey: 'azure-secret',
baseUrl: 'https://example.openai.azure.com',
apiVersion: '2025-01-01-preview',
},
});
const res = await patchConfig({
extraction: {
provider: 'azure',
model: 'gpt-4.1-mini',
baseUrl: 'https://example.openai.azure.com',
apiVersion: '',
},
});
expect(res.status).toBe(200);
const extraction = await readStoredExtraction();
expect(extraction?.provider).toBe('azure');
expect(extraction?.apiVersion ?? '').toBe('');
});
it('updates the enabled flag independently of extraction settings', async () => {
await writeMemoryConfig(dataDir, {
enabled: true,
extraction: {
provider: 'openai',
model: 'gpt-4o-mini',
apiKey: 'sk-stored-secret',
baseUrl: 'https://api.openai.com',
},
});
const res = await patchConfig({ enabled: false });
expect(res.status).toBe(200);
const json = await res.json() as {
enabled: boolean;
extraction: { provider: string; apiKeyConfigured: boolean } | null;
};
expect(json.enabled).toBe(false);
expect(json.extraction).toMatchObject({
provider: 'openai',
apiKeyConfigured: true,
});
const extraction = await readStoredExtraction();
expect(extraction?.provider).toBe('openai');
});
it('returns a masked extraction config without leaking the apiKey on GET /api/memory', async () => {
await writeMemoryConfig(dataDir, {
extraction: {
provider: 'azure',
model: 'gpt-4.1-mini',
apiKey: 'azure-secret-1234',
baseUrl: 'https://example.openai.azure.com',
apiVersion: '2025-01-01-preview',
},
});
const res = await fetch(`${baseUrl}/api/memory`);
expect(res.status).toBe(200);
const json = await res.json() as {
extraction: {
provider: string;
model: string;
baseUrl: string;
apiVersion: string;
apiKeyTail: string;
apiKeyConfigured: boolean;
apiKey?: string;
} | null;
};
expect(json.extraction).toMatchObject({
provider: 'azure',
model: 'gpt-4.1-mini',
baseUrl: 'https://example.openai.azure.com',
apiVersion: '2025-01-01-preview',
apiKeyTail: '1234',
apiKeyConfigured: true,
});
expect(json.extraction && 'apiKey' in json.extraction).toBe(false);
});
});

View file

@ -23,6 +23,12 @@ const dataDir = process.env.OD_DATA_DIR as string;
let baseUrl: string;
let server: http.Server;
const originalFetch = globalThis.fetch;
interface SseEvent {
event: string;
data: unknown;
}
async function closeServer(nextServer: http.Server | undefined): Promise<void> {
if (!nextServer) return;
@ -36,15 +42,81 @@ beforeAll(async () => {
})) as StartedServer;
baseUrl = started.url;
server = started.server;
globalThis.fetch = async (
input: Parameters<typeof fetch>[0],
init?: Parameters<typeof fetch>[1],
) => {
const url = typeof input === 'string'
? input
: input instanceof URL
? input.toString()
: input.url;
if (url.startsWith(baseUrl)) return originalFetch(input, init);
return new Response(
JSON.stringify({
choices: [{ message: { content: '[]' } }],
}),
{
status: 200,
headers: { 'content-type': 'application/json' },
},
);
};
});
afterAll(() => closeServer(server));
afterAll(async () => {
globalThis.fetch = originalFetch;
await closeServer(server);
});
beforeEach(async () => {
await fsp.rm(memoryDir(dataDir), { recursive: true, force: true });
__resetExtractionsForTests();
});
async function readNextSseEvent(
reader: ReadableStreamDefaultReader<Uint8Array>,
decoder: InstanceType<typeof TextDecoder>,
state: { buffer: string },
): Promise<SseEvent> {
while (true) {
const boundaryIndex = state.buffer.indexOf('\n\n');
if (boundaryIndex !== -1) {
const rawEvent = state.buffer.slice(0, boundaryIndex);
state.buffer = state.buffer.slice(boundaryIndex + 2);
const eventLine = rawEvent
.split('\n')
.find((line) => line.startsWith('event: '));
const dataLine = rawEvent
.split('\n')
.find((line) => line.startsWith('data: '));
if (!eventLine || !dataLine) continue;
return {
event: eventLine.slice('event: '.length),
data: JSON.parse(dataLine.slice('data: '.length)),
};
}
const chunk = await reader.read();
if (chunk.done) {
throw new Error('memory SSE stream ended before the next event arrived');
}
state.buffer += decoder.decode(chunk.value, { stream: true });
}
}
async function readSseEventByType(
reader: ReadableStreamDefaultReader<Uint8Array>,
decoder: InstanceType<typeof TextDecoder>,
state: { buffer: string },
eventType: string,
): Promise<SseEvent> {
while (true) {
const event = await readNextSseEvent(reader, decoder, state);
if (event.event === eventType) return event;
}
}
describe('memory routes', () => {
it('lists the default memory state when the store is empty', async () => {
const res = await fetch(`${baseUrl}/api/memory`);
@ -118,6 +190,23 @@ describe('memory routes', () => {
expect(listJson.entries).toEqual([]);
});
it('rejects invalid memory entry payloads during creation', async () => {
const res = await fetch(`${baseUrl}/api/memory`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
name: '',
description: 'Missing required values',
type: 'unknown',
body: '- Invalid entry',
}),
});
expect(res.status).toBe(400);
const json = await res.json() as { error: string };
expect(json.error).toContain('memory entry requires');
});
it('saves the memory index and returns it from the list payload', async () => {
const nextIndex = '# Memory\n\n- user_ui_preferences.md\n';
const putRes = await fetch(`${baseUrl}/api/memory/index`, {
@ -227,6 +316,30 @@ describe('memory routes', () => {
]);
});
it('reports attemptedLLM for post-turn extraction requests without triggering a real provider call', async () => {
const res = await fetch(`${baseUrl}/api/memory/extract`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
userMessage: 'Remember that I prefer dark mode for demos.',
assistantMessage: 'I will keep future demos darker and quieter.',
chatProvider: {
provider: 'openai',
apiKey: 'sk-test',
model: 'gpt-5-mini',
},
}),
});
expect(res.status).toBe(200);
const json = await res.json() as {
changed: Array<unknown>;
attemptedLLM: boolean;
};
expect(json.attemptedLLM).toBe(true);
expect(json.changed).toEqual([]);
});
it('returns the composed system prompt body from indexed memory entries', async () => {
await fetch(`${baseUrl}/api/memory`, {
method: 'POST',
@ -257,4 +370,82 @@ describe('memory routes', () => {
expect(json.body).toContain('### Project');
expect(json.body).toContain('**Project goal** — Ship a cleaner onboarding flow');
});
it('streams memory change events over SSE when entries are created', async () => {
const response = await fetch(`${baseUrl}/api/memory/events`);
expect(response.status).toBe(200);
expect(response.body).toBeTruthy();
const reader = response.body!.getReader();
const decoder = new TextDecoder();
const state = { buffer: '' };
try {
const connected = await readNextSseEvent(reader, decoder, state);
expect(connected.event).toBe('connected');
const createRes = await fetch(`${baseUrl}/api/memory`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
name: 'Shipping priority',
description: 'Protect onboarding polish in examples',
type: 'project',
body: '- Keep onboarding examples polished',
}),
});
expect(createRes.status).toBe(200);
const change = await readSseEventByType(reader, decoder, state, 'change');
expect(change.event).toBe('change');
expect(change.data).toMatchObject({
kind: 'upsert',
id: 'project_shipping_priority',
name: 'Shipping priority',
description: 'Protect onboarding polish in examples',
type: 'project',
source: 'manual',
});
} finally {
await reader.cancel();
}
});
it('streams extraction events over SSE when the extraction buffer changes', async () => {
const response = await fetch(`${baseUrl}/api/memory/events`);
expect(response.status).toBe(200);
expect(response.body).toBeTruthy();
const reader = response.body!.getReader();
const decoder = new TextDecoder();
const state = { buffer: '' };
try {
const connected = await readNextSseEvent(reader, decoder, state);
expect(connected.event).toBe('connected');
recordHeuristic({
userMessage: 'Remember that I prefer editorial chart labels.',
writtenCount: 1,
writtenIds: ['feedback_editorial_chart_labels'],
});
const extraction = await readNextSseEvent(reader, decoder, state);
expect(extraction.event).toBe('extraction');
expect(extraction.data).toMatchObject({
kind: 'heuristic',
phase: 'success',
writtenCount: 1,
writtenIds: ['feedback_editorial_chart_labels'],
});
} finally {
await reader.cancel();
}
});
it('returns 404 when reading a missing memory entry', async () => {
const res = await fetch(`${baseUrl}/api/memory/user_missing_note`);
expect(res.status).toBe(404);
const json = await res.json() as { error: string };
expect(json.error).toBe('memory not found');
});
});

View file

@ -179,6 +179,39 @@ describe('routine routes', () => {
}
});
it('rejects patching to a missing reuse-mode target project', async () => {
const { app } = buildApp();
const { server, port } = await listen(app);
try {
const createRes = await fetch(`http://127.0.0.1:${port}/api/routines`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
name: 'Daily digest',
prompt: 'Summarize activity.',
schedule: { kind: 'daily', time: '09:00', timezone: 'UTC' },
target: { mode: 'create_each_run' },
enabled: true,
}),
});
const created = await createRes.json() as { routine: { id: string } };
const patchRes = await fetch(`http://127.0.0.1:${port}/api/routines/${created.routine.id}`, {
method: 'PATCH',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
target: { mode: 'reuse', projectId: 'missing-project' },
}),
});
expect(patchRes.status).toBe(400);
const json = await patchRes.json() as { error: string };
expect(json.error).toContain('target project missing-project not found');
} finally {
await new Promise<void>((resolve) => server.close(() => resolve()));
}
});
it('runs a routine now and exposes its run history', async () => {
const { app, runNow } = buildApp();
const { server, port } = await listen(app);
@ -222,6 +255,100 @@ describe('routine routes', () => {
}
});
it('maps the latest persisted run into the routine contract', async () => {
const { app, db } = buildApp();
const { server, port } = await listen(app);
try {
const createRes = await fetch(`http://127.0.0.1:${port}/api/routines`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
name: 'Daily digest',
prompt: 'Summarize activity.',
schedule: { kind: 'daily', time: '09:00', timezone: 'UTC' },
target: { mode: 'create_each_run' },
enabled: true,
}),
});
const created = await createRes.json() as { routine: { id: string } };
insertRoutineRun(db, {
id: 'run-failed-1',
routineId: created.routine.id,
trigger: 'manual',
status: 'failed',
projectId: 'proj-failed',
conversationId: 'conv-failed',
agentRunId: 'agent-run-failed',
startedAt: Date.now() - 1000,
completedAt: Date.now(),
summary: 'Connector auth failed',
error: 'provider rejected credentials',
});
const getRes = await fetch(`http://127.0.0.1:${port}/api/routines/${created.routine.id}`);
expect(getRes.status).toBe(200);
const json = await getRes.json() as {
routine: {
lastRun: {
runId: string;
status: string;
trigger: string;
projectId: string;
conversationId: string;
agentRunId: string;
summary: string;
completedAt: number;
} | null;
};
};
expect(json.routine.lastRun).toMatchObject({
runId: 'run-failed-1',
status: 'failed',
trigger: 'manual',
projectId: 'proj-failed',
conversationId: 'conv-failed',
agentRunId: 'agent-run-failed',
summary: 'Connector auth failed',
});
expect(json.routine.lastRun?.completedAt).toBeTypeOf('number');
} finally {
await new Promise<void>((resolve) => server.close(() => resolve()));
}
});
it('returns 500 when running a routine now throws', async () => {
const { app, runNow } = buildApp();
runNow.mockImplementationOnce(async () => {
throw new Error('agent unavailable');
});
const { server, port } = await listen(app);
try {
const createRes = await fetch(`http://127.0.0.1:${port}/api/routines`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
name: 'Daily digest',
prompt: 'Summarize activity.',
schedule: { kind: 'daily', time: '09:00', timezone: 'UTC' },
target: { mode: 'create_each_run' },
enabled: true,
}),
});
const created = await createRes.json() as { routine: { id: string } };
const runRes = await fetch(`http://127.0.0.1:${port}/api/routines/${created.routine.id}/run`, {
method: 'POST',
});
expect(runRes.status).toBe(500);
const json = await runRes.json() as { error: string };
expect(json.error).toContain('agent unavailable');
} finally {
await new Promise<void>((resolve) => server.close(() => resolve()));
}
});
it('rejects reuse-mode creation when the target project does not exist', async () => {
const { app } = buildApp();
const { server, port } = await listen(app);
@ -251,6 +378,30 @@ describe('routine routes', () => {
}
});
it('rejects unsupported target modes during creation', async () => {
const { app } = buildApp();
const { server, port } = await listen(app);
try {
const res = await fetch(`http://127.0.0.1:${port}/api/routines`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
name: 'Weird target digest',
prompt: 'Summarize activity.',
schedule: { kind: 'daily', time: '09:00', timezone: 'UTC' },
target: { mode: 'teleport' },
enabled: true,
}),
});
expect(res.status).toBe(400);
const json = await res.json() as { error: string };
expect(json.error).toContain('Unsupported routine target mode');
} finally {
await new Promise<void>((resolve) => server.close(() => resolve()));
}
});
it('deletes a routine and unschedules it', async () => {
const { app, unschedule } = buildApp();
const { server, port } = await listen(app);
@ -317,4 +468,90 @@ describe('routine routes', () => {
await new Promise<void>((resolve) => server.close(() => resolve()));
}
});
it('rejects invalid timezone values during creation', async () => {
const { app } = buildApp();
const { server, port } = await listen(app);
try {
const res = await fetch(`http://127.0.0.1:${port}/api/routines`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
name: 'Bad timezone digest',
prompt: 'Summarize activity.',
schedule: { kind: 'daily', time: '09:00', timezone: 'Mars/Olympus' },
target: { mode: 'create_each_run' },
enabled: true,
}),
});
expect(res.status).toBe(400);
const json = await res.json() as { error: string };
expect(json.error).toContain('Invalid timezone');
} finally {
await new Promise<void>((resolve) => server.close(() => resolve()));
}
});
it('rejects invalid weekly weekday values during creation', async () => {
const { app } = buildApp();
const { server, port } = await listen(app);
try {
const res = await fetch(`http://127.0.0.1:${port}/api/routines`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
name: 'Bad weekday digest',
prompt: 'Summarize activity.',
schedule: {
kind: 'weekly',
weekday: 8,
time: '09:00',
timezone: 'UTC',
},
target: { mode: 'create_each_run' },
enabled: true,
}),
});
expect(res.status).toBe(400);
const json = await res.json() as { error: string };
expect(json.error).toContain('weekly.weekday');
} finally {
await new Promise<void>((resolve) => server.close(() => resolve()));
}
});
it('rejects invalid schedule input during routine patch updates', async () => {
const { app } = buildApp();
const { server, port } = await listen(app);
try {
const createRes = await fetch(`http://127.0.0.1:${port}/api/routines`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
name: 'Daily digest',
prompt: 'Summarize activity.',
schedule: { kind: 'daily', time: '09:00', timezone: 'UTC' },
target: { mode: 'create_each_run' },
enabled: true,
}),
});
const created = await createRes.json() as { routine: { id: string } };
const patchRes = await fetch(`http://127.0.0.1:${port}/api/routines/${created.routine.id}`, {
method: 'PATCH',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
schedule: { kind: 'daily', time: '25:99', timezone: 'UTC' },
}),
});
expect(patchRes.status).toBe(400);
const json = await patchRes.json() as { error: string };
expect(json.error).toContain('Invalid time');
} finally {
await new Promise<void>((resolve) => server.close(() => resolve()));
}
});
});

View file

@ -1,6 +1,10 @@
import { describe, expect, it } from 'vitest';
import { nextRunAtForSchedule } from '../src/routines.js';
import {
nextRunAtForSchedule,
validateSchedule,
validateTarget,
} from '../src/routines.js';
function partsIn(timezone: string, at: Date): Record<string, string> {
const dtf = new Intl.DateTimeFormat('en-US', {
@ -114,4 +118,80 @@ describe('nextRunAtForSchedule DST handling', () => {
expect(parts.hour).toBe('02');
expect(parts.minute).toBe('30');
});
it('returns the next hourly slot strictly after now', () => {
const now = new Date('2026-05-13T10:45:30Z');
const next = nextRunAtForSchedule({ kind: 'hourly', minute: 15 }, now);
expect(next).not.toBeNull();
if (!next) return;
expect(next.toISOString()).toBe('2026-05-13T11:15:00.000Z');
});
it('returns the next weekday occurrence for weekday schedules', () => {
const now = new Date('2026-05-16T00:00:00Z'); // Saturday
const next = nextRunAtForSchedule(
{ kind: 'weekdays', time: '09:00', timezone: 'UTC' },
now,
);
expect(next).not.toBeNull();
if (!next) return;
const parts = partsIn('UTC', next);
expect(parts.year).toBe('2026');
expect(parts.month).toBe('05');
expect(parts.day).toBe('18');
expect(parts.hour).toBe('09');
expect(parts.minute).toBe('00');
});
it('returns the next requested weekday for weekly schedules', () => {
const now = new Date('2026-05-13T10:00:00Z'); // Wednesday
const next = nextRunAtForSchedule(
{ kind: 'weekly', weekday: 5, time: '08:30', timezone: 'UTC' },
now,
);
expect(next).not.toBeNull();
if (!next) return;
const parts = partsIn('UTC', next);
expect(parts.year).toBe('2026');
expect(parts.month).toBe('05');
expect(parts.day).toBe('15');
expect(parts.hour).toBe('08');
expect(parts.minute).toBe('30');
});
});
describe('routine validation', () => {
it('accepts valid schedule and target shapes', () => {
expect(() =>
validateSchedule({ kind: 'weekly', weekday: 1, time: '09:00', timezone: 'UTC' }),
).not.toThrow();
expect(() => validateTarget({ mode: 'create_each_run' })).not.toThrow();
expect(() => validateTarget({ mode: 'reuse', projectId: 'proj-1' })).not.toThrow();
});
it('rejects invalid wall times and timezones', () => {
expect(() =>
validateSchedule({ kind: 'daily', time: '25:00', timezone: 'UTC' }),
).toThrow(/Invalid time/);
expect(() =>
validateSchedule({ kind: 'daily', time: '09:00', timezone: 'Mars\/Olympus' }),
).toThrow(/Invalid timezone/);
});
it('rejects invalid weekday and unsupported target mode', () => {
expect(() =>
validateSchedule({ kind: 'weekly', weekday: 9 as 0, time: '09:00', timezone: 'UTC' }),
).toThrow(/weekly\.weekday/);
expect(() =>
validateTarget({ mode: 'teleport' } as unknown as Parameters<typeof validateTarget>[0]),
).toThrow(/Unsupported routine target mode/);
});
it('rejects reuse targets without a project id', () => {
expect(() =>
validateTarget({ mode: 'reuse', projectId: '' }),
).toThrow(/projectId/);
});
});

View file

@ -1,3 +1,4 @@
import { symlinkSync } from 'node:fs';
import { test } from 'vitest';
import { homedir } from 'node:os';
import {
@ -5,6 +6,8 @@ import {
} from './helpers/test-helpers.js';
import { isCursorAuthFailureText } from '../../src/runtimes/auth.js';
const fsTest = process.platform === 'win32' ? test.skip : test;
// Issue #398: Claude Code prefers ANTHROPIC_API_KEY over `claude login`
// credentials, silently billing API usage. Strip it for the claude
// adapter so the user's subscription wins.
@ -197,6 +200,64 @@ test('detectAgents includes sanitized install and docs metadata from split runti
}
});
fsTest('detectAgents marks Codex available when nvm exposes a node shim but launch resolution upgrades it to the native binary', async () => {
const home = mkdtempSync(join(tmpdir(), 'od-detect-codex-nvm-native-'));
try {
return await withEnvSnapshot(['HOME', 'PATH', 'OD_AGENT_HOME'], async () => {
const wrapperBinDir = join(home, '.nvm', 'versions', 'node', '24.14.1', 'bin');
const wrapperPkgDir = join(home, '.nvm', 'versions', 'node', '24.14.1', 'lib', 'node_modules', '@openai', 'codex');
const wrapperRealPath = join(wrapperPkgDir, 'bin', 'codex.js');
const wrapperLinkPath = join(wrapperBinDir, 'codex');
const nativePkgDir = join(
wrapperPkgDir,
'node_modules',
'@openai',
`codex-${process.platform}-${process.arch}`,
);
const nativeTargetTriple = codexNativeTargetTriple();
const nativePathDir = join(nativePkgDir, 'vendor', nativeTargetTriple, 'path');
const nativeBin = join(nativePkgDir, 'vendor', nativeTargetTriple, 'codex', 'codex');
mkdirSync(join(wrapperPkgDir, 'bin'), { recursive: true });
mkdirSync(wrapperBinDir, { recursive: true });
mkdirSync(join(nativePkgDir, 'vendor', nativeTargetTriple, 'codex'), { recursive: true });
mkdirSync(nativePathDir, { recursive: true });
writeFileSync(
wrapperRealPath,
'#!/usr/bin/env node\nconsole.log("wrapper should not be probed");\n',
);
writeFileSync(nativeBin, '#!/bin/sh\necho "codex 9.9.9"\n');
chmodSync(wrapperRealPath, 0o755);
chmodSync(nativeBin, 0o755);
symlinkSync(wrapperRealPath, wrapperLinkPath);
process.env.HOME = home;
process.env.PATH = '/usr/bin:/bin';
process.env.OD_AGENT_HOME = home;
const agents = await detectAgents();
const codexAgent = agents.find((agent) => agent.id === 'codex');
assert.ok(codexAgent);
assert.equal(codexAgent.available, true);
assert.equal(codexAgent.path, wrapperLinkPath);
assert.equal(codexAgent.version, 'codex 9.9.9');
});
} finally {
rmSync(home, { recursive: true, force: true });
}
});
function codexNativeTargetTriple(): string {
if (process.platform === 'darwin' && process.arch === 'arm64') return 'aarch64-apple-darwin';
if (process.platform === 'darwin' && process.arch === 'x64') return 'x86_64-apple-darwin';
if (process.platform === 'linux' && process.arch === 'arm64') return 'aarch64-unknown-linux-musl';
if (process.platform === 'linux' && process.arch === 'x64') return 'x86_64-unknown-linux-musl';
if (process.platform === 'win32' && process.arch === 'arm64') return 'aarch64-pc-windows-msvc';
if (process.platform === 'win32' && process.arch === 'x64') return 'x86_64-pc-windows-msvc';
return `${process.platform}-${process.arch}`;
}
test('resolveAgentExecutable ignores relative CODEX_BIN overrides', () => {
const dir = mkdtempSync(join(tmpdir(), 'od-codex-bin-rel-'));
const oldCwd = process.cwd();

View file

@ -22,39 +22,51 @@
* so adapters whose `--version` flag is unsupported are not
* regressed.
*
* Detection always probes the same path `resolveAgentExecutable`
* picks for chat/run resolution, so a stale configured override that
* shadows a working PATH binary is reported as unavailable rather
* than swapped for the PATH candidate; advertising a different path
* would break the invariant that Settings and the chat spawn path
* agree on what the agent runs (PR #1301 review, Siri-Ray).
* Detection always probes the same launch path chat/run resolution
* picks, so a stale configured override that shadows a working PATH
* binary is reported as unavailable rather than swapped for the PATH
* candidate; advertising a different path would break the invariant
* that Settings and the chat spawn path agree on what the agent runs
* (PR #1301 review, Siri-Ray).
*/
import { beforeEach, describe, expect, it, vi } from 'vitest';
const execAgentFileMock = vi.fn();
const resolveAgentExecutableMock = vi.fn();
const resolveAgentLaunchMock = vi.fn();
vi.mock('../../src/runtimes/invocation.js', () => ({
execAgentFile: (...args: unknown[]) =>
(execAgentFileMock as unknown as (...args: unknown[]) => unknown)(...args),
}));
vi.mock('../../src/runtimes/executables.js', async (importOriginal) => {
const actual = await importOriginal<typeof import('../../src/runtimes/executables.js')>();
vi.mock('../../src/runtimes/launch.js', async (importOriginal) => {
const actual = await importOriginal<typeof import('../../src/runtimes/launch.js')>();
return {
...actual,
resolveAgentExecutable: (
...args: Parameters<typeof actual.resolveAgentExecutable>
resolveAgentLaunch: (
...args: Parameters<typeof actual.resolveAgentLaunch>
) =>
(
resolveAgentExecutableMock as unknown as (
...a: Parameters<typeof actual.resolveAgentExecutable>
) => ReturnType<typeof actual.resolveAgentExecutable>
resolveAgentLaunchMock as unknown as (
...a: Parameters<typeof actual.resolveAgentLaunch>
) => ReturnType<typeof actual.resolveAgentLaunch>
)(...args),
};
});
function fakeCodexLaunch() {
return {
configuredOverridePath: null,
pathResolvedPath: '/fake/bin/codex',
selectedPath: '/fake/bin/codex',
launchPath: '/fake/bin/codex',
launchKind: 'selected' as const,
childPathPrepend: ['/fake/bin'],
diagnostic: null,
};
}
function spawnError(code: 'ENOENT' | 'EACCES' | 'ENOTDIR' | 'ETIMEDOUT'): NodeJS.ErrnoException {
const error = new Error(`spawn failed (${code})`) as NodeJS.ErrnoException;
error.code = code;
@ -74,10 +86,10 @@ function exitCodeError(code: number): NodeJS.ErrnoException {
describe('probe (issue #658) — ghost CLI after the binary is uninstalled', () => {
beforeEach(() => {
execAgentFileMock.mockReset();
resolveAgentExecutableMock.mockReset();
resolveAgentLaunchMock.mockReset();
// Default: pretend every agent definition resolves to a fake bin so
// we exercise the spawn path uniformly.
resolveAgentExecutableMock.mockImplementation(() => '/fake/bin/codex');
resolveAgentLaunchMock.mockImplementation(fakeCodexLaunch);
});
for (const failingCode of ['ENOENT', 'EACCES', 'ENOTDIR'] as const) {
@ -156,25 +168,29 @@ describe('probe (issue #658) — ghost CLI after the binary is uninstalled', ()
// to fall back to a PATH candidate when the configured override
// failed to spawn, but that broke the invariant that detection and
// chat-run resolution agree on the executable. resolveAgentBin
// still resolves via resolveAgentExecutable (configured override
// still resolves via resolveAgentLaunch (configured override
// wins when present and executable), so if detection adopted a
// different PATH binary, Settings would show "available at
// /usr/local/bin/codex" while every actual run would spawn the
// stale /stale/custom/codex and fail. The fix is to keep detection
// honest: probe whichever path resolveAgentExecutable picks, and
// honest: probe whichever path resolveAgentLaunch picks, and
// report exactly that path's availability. The Settings repair
// flow (PR #1205) needs to derive its adopt-or-clear affordance
// from the resolution diagnostic — not from `available`.
const {
resolveAgentExecutable: realResolveAgentExecutable,
resolveAgentLaunch: realResolveAgentLaunch,
} = await vi.importActual<typeof import('../../src/runtimes/launch.js')>(
'../../src/runtimes/launch.js',
);
const {
inspectAgentExecutableResolution,
} = await vi.importActual<typeof import('../../src/runtimes/executables.js')>(
'../../src/runtimes/executables.js',
);
// Drive the resolver through its real path so a future refactor
// that diverges resolution from detection trips this assertion.
resolveAgentExecutableMock.mockImplementation(
(def, env) => realResolveAgentExecutable(def, env),
resolveAgentLaunchMock.mockImplementation(
(def, env) => realResolveAgentLaunch(def, env),
);
// Force a stale configured override + a working PATH candidate.
execAgentFileMock.mockImplementation((cmd: string) => {
@ -194,9 +210,9 @@ describe('probe (issue #658) — ghost CLI after the binary is uninstalled', ()
expect(codex).toBeDefined();
// Detection must report unavailable rather than swap to a hypothetical
// PATH candidate, because resolveAgentExecutable (which chat-run
// PATH candidate, because resolveAgentLaunch (which chat-run
// resolution uses) will pick whatever the same call returns.
const resolvedForRun = realResolveAgentExecutable(
const resolvedForRun = realResolveAgentLaunch(
// re-run AGENT_DEFS's codex entry through the real resolver to
// get the executable resolveAgentBin would pick at chat time.
// The detection side already validated this path.
@ -204,11 +220,11 @@ describe('probe (issue #658) — ghost CLI after the binary is uninstalled', ()
{ id: 'codex', bin: 'codex' } as any,
configuredEnv.codex,
);
if (resolvedForRun) {
if (resolvedForRun.selectedPath && resolvedForRun.launchPath) {
// If the resolver found a working PATH binary, detection must
// have reported available=true with the SAME path.
expect(codex?.available).toBe(true);
expect(codex?.path).toBe(resolvedForRun);
expect(codex?.path).toBe(resolvedForRun.selectedPath);
} else {
// Otherwise detection must report unavailable rather than invent
// a different path.

View file

@ -5007,6 +5007,12 @@ function HtmlViewer({
setZoom((z) => Math.max(25, Math.min(200, z + delta)));
}
function activateBoard(nextTool?: BoardTool) {
setMode('preview');
setBoardMode(true);
if (nextTool) setBoardTool(nextTool);
}
function clearBoardComposer() {
setActiveCommentTarget(null);
setHoveredCommentTarget(null);
@ -5015,12 +5021,6 @@ function HtmlViewer({
setStrokePoints([]);
}
function activateBoard(tool: BoardTool) {
setBoardTool(tool);
setDrawOverlayOpen(false);
setBoardMode(true);
}
function queueCurrentDraft() {
const note = commentDraft.trim();
if (!note) return;
@ -5308,6 +5308,78 @@ function HtmlViewer({
/>
</>
) : null}
<button
type="button"
className={`viewer-action viewer-comment-toggle${boardMode ? ' active' : ''}`}
data-testid="board-mode-toggle"
title={t('fileViewer.comment')}
aria-pressed={boardMode}
onClick={() => {
if (boardMode) {
setBoardMode(false);
clearBoardComposer();
return;
}
setManualEditMode(false);
setInspectMode(false);
setDrawOverlayOpen(false);
activateBoard(boardTool);
}}
>
<Icon name="comment" size={13} />
<span>{t('fileViewer.comment')}</span>
</button>
{boardMode ? (
<>
<button
className={`viewer-action${boardTool === 'inspect' ? ' active' : ''}`}
type="button"
data-testid="comment-mode-toggle"
title="Pick one element"
aria-label="Picker"
aria-pressed={boardTool === 'inspect'}
onClick={() => activateBoard('inspect')}
>
<Icon name="edit" size={13} />
<span>Picker</span>
</button>
<button
className={`viewer-action${boardTool === 'pod' ? ' active' : ''}`}
type="button"
title="Draw a pod selection"
aria-label="Pods"
aria-pressed={boardTool === 'pod'}
onClick={() => activateBoard('pod')}
>
<Icon name="draw" size={13} />
<span>Pods</span>
</button>
</>
) : null}
<button
className={`viewer-action${inspectMode ? ' active' : ''}`}
type="button"
data-testid="inspect-mode-toggle"
title="Inspect"
aria-pressed={inspectMode}
onClick={() => {
setInspectMode((v) => {
const next = !v;
if (next) {
setBoardMode(false);
clearBoardComposer();
setManualEditMode(false);
setDrawOverlayOpen(false);
setOpenHintBox(true);
setMode('preview');
}
return next;
});
}}
>
<Icon name="tweaks" size={13} />
<span>Inspect</span>
</button>
<button
className={`viewer-action${manualEditMode ? ' active' : ''}`}
type="button"

View file

@ -1001,7 +1001,7 @@ describe('FileViewer tweaks toolbar', () => {
});
}
it('renders the toolbar Draw entry and no legacy picker/pod toggle', () => {
it('renders the toolbar Draw entry alongside restored Comment and Inspect entries', () => {
render(
<FileViewer projectId="project-1" projectKind="prototype" file={htmlPreviewFile()}
liveHtml='<html><body><main data-od-id="hero">Hero</main></body></html>'
@ -1009,13 +1009,12 @@ describe('FileViewer tweaks toolbar', () => {
);
expect(screen.getByTestId('palette-tweaks-toggle')).toBeTruthy();
expect(screen.getByTestId('board-mode-toggle')).toBeTruthy();
expect(screen.getByTestId('inspect-mode-toggle')).toBeTruthy();
expect(screen.getByTestId('draw-overlay-toggle')).toBeTruthy();
expect(screen.queryByPlaceholderText('Type anywhere to add a note')).toBeNull();
expect(screen.queryByTestId('board-mode-toggle')).toBeNull();
expect(screen.queryByTestId('comment-mode-toggle')).toBeNull();
expect(screen.queryByRole('button', { name: 'Pods' })).toBeNull();
expect(screen.queryByTestId('inspect-mode-toggle')).toBeNull();
expect(screen.queryByRole('button', { name: 'Inspect' })).toBeNull();
fireEvent.click(screen.getByTestId('draw-overlay-toggle'));
expect(screen.getByPlaceholderText('Type anywhere to add a note')).toBeTruthy();

View file

@ -459,6 +459,104 @@ describe('MemorySection', () => {
expect(deletedUrls).toEqual(['/api/memory/user_ui_preferences']);
});
it('keeps the editor open when saving a memory entry fails', async () => {
globalThis.EventSource = StubEventSource as unknown as typeof EventSource;
globalThis.fetch = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
const url = input.toString();
if (url === '/api/memory' && (!init || init.method === undefined)) {
return new Response(JSON.stringify({
enabled: true,
rootDir: '/tmp/memory',
index: '# Memory\n',
entries: [],
extraction: null,
}), { status: 200, headers: { 'content-type': 'application/json' } });
}
if (url === '/api/memory/extractions') {
return new Response(JSON.stringify({ extractions: [] }), {
status: 200,
headers: { 'content-type': 'application/json' },
});
}
if (url === '/api/memory' && init?.method === 'POST') {
return new Response(JSON.stringify({ error: 'write failed' }), {
status: 500,
headers: { 'content-type': 'application/json' },
});
}
return new Response(JSON.stringify({}), { status: 404 });
}) as typeof fetch;
renderMemorySection();
fireEvent.click(await screen.findByRole('button', { name: 'New memory' }));
fireEvent.change(screen.getByPlaceholderText('e.g. UI preferences'), {
target: { value: 'UI preferences' },
});
fireEvent.change(screen.getByPlaceholderText('One sentence — what is this memory about?'), {
target: { value: 'Persistent UI rendering preferences' },
});
fireEvent.change(
screen.getByPlaceholderText(/- Rule one[\s\S]*When to apply: optional scope/),
{
target: { value: '- Prefer dark mode\n- Prefer generous spacing' },
},
);
fireEvent.click(screen.getByRole('button', { name: 'Create' }));
await waitFor(() => {
expect(screen.getByDisplayValue('UI preferences')).toBeTruthy();
});
expect(screen.queryByText('✓ Memory created')).toBeNull();
expect(screen.queryByText('UI preferences')).toBeTruthy();
});
it('keeps unsaved index edits when saving the index fails', async () => {
globalThis.EventSource = StubEventSource as unknown as typeof EventSource;
globalThis.fetch = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
const url = input.toString();
if (url === '/api/memory' && (!init || init.method === undefined)) {
return new Response(JSON.stringify({
enabled: true,
rootDir: '/tmp/memory',
index: '# Memory\n\n- Existing bullet\n',
entries: [],
extraction: null,
}), { status: 200, headers: { 'content-type': 'application/json' } });
}
if (url === '/api/memory/extractions') {
return new Response(JSON.stringify({ extractions: [] }), {
status: 200,
headers: { 'content-type': 'application/json' },
});
}
if (url === '/api/memory/index' && init?.method === 'PUT') {
return new Response(JSON.stringify({ error: 'disk full' }), {
status: 500,
headers: { 'content-type': 'application/json' },
});
}
return new Response(JSON.stringify({}), { status: 404 });
}) as typeof fetch;
renderMemorySection();
fireEvent.click(await screen.findByText('MEMORY.md (index)'));
const indexArea = screen.getByRole('textbox') as HTMLTextAreaElement;
fireEvent.change(indexArea, {
target: { value: '# Memory\n\n- Existing bullet\n- New bullet\n' },
});
fireEvent.click(screen.getByRole('button', { name: 'Save index' }));
await waitFor(() => {
expect(screen.getByText(/Unsaved changes/i)).toBeTruthy();
});
expect((screen.getByRole('textbox') as HTMLTextAreaElement).value).toContain('- New bullet');
expect(screen.queryByText('✓ Index saved')).toBeNull();
});
it('deletes a single extraction row without clearing the whole history', async () => {
globalThis.EventSource = StubEventSource as unknown as typeof EventSource;
const deletedUrls: string[] = [];
@ -590,4 +688,119 @@ describe('MemorySection', () => {
expect(screen.getByText('Project brief')).toBeTruthy();
});
});
it('renders failed extraction rows with the error details', async () => {
globalThis.EventSource = StubEventSource as unknown as typeof EventSource;
globalThis.fetch = vi.fn(async (input: RequestInfo | URL) => {
const url = input.toString();
if (url === '/api/memory') {
return new Response(JSON.stringify({
enabled: true,
rootDir: '/tmp/memory',
index: '# Memory\n',
entries: [],
extraction: null,
}), { status: 200, headers: { 'content-type': 'application/json' } });
}
if (url === '/api/memory/extractions') {
return new Response(JSON.stringify({
extractions: [
{
id: 'ex-failed',
phase: 'failed',
kind: 'llm',
startedAt: Date.now(),
finishedAt: Date.now() + 2500,
userMessagePreview: 'Remember my dashboard preference',
error: 'provider returned 429 quota exceeded',
},
],
}), {
status: 200,
headers: { 'content-type': 'application/json' },
});
}
return new Response(JSON.stringify({}), { status: 404 });
}) as typeof fetch;
renderMemorySection();
fireEvent.click(await screen.findByText('Extraction history'));
expect(await screen.findByText('Remember my dashboard preference')).toBeTruthy();
expect(screen.getByText('provider returned 429 quota exceeded')).toBeTruthy();
expect(screen.getByText('Failed')).toBeTruthy();
});
it('renders the disabled banner when memory starts disabled', async () => {
globalThis.EventSource = StubEventSource as unknown as typeof EventSource;
globalThis.fetch = vi.fn(async (input: RequestInfo | URL) => {
const url = input.toString();
if (url === '/api/memory') {
return new Response(JSON.stringify({
enabled: false,
rootDir: '/tmp/memory',
index: '# Memory\n',
entries: [],
extraction: null,
}), { status: 200, headers: { 'content-type': 'application/json' } });
}
if (url === '/api/memory/extractions') {
return new Response(JSON.stringify({ extractions: [] }), {
status: 200,
headers: { 'content-type': 'application/json' },
});
}
return new Response(JSON.stringify({}), { status: 404 });
}) as typeof fetch;
renderMemorySection();
const banner = await screen.findByRole('status');
expect(banner.textContent).toContain('Memory is currently OFF.');
});
it('toggles memory injection off and persists the PATCH payload', async () => {
globalThis.EventSource = StubEventSource as unknown as typeof EventSource;
const patchBodies: unknown[] = [];
globalThis.fetch = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
const url = input.toString();
if (url === '/api/memory' && (!init || init.method === undefined)) {
return new Response(JSON.stringify({
enabled: true,
rootDir: '/tmp/memory',
index: '# Memory\n',
entries: [],
extraction: null,
}), { status: 200, headers: { 'content-type': 'application/json' } });
}
if (url === '/api/memory/extractions') {
return new Response(JSON.stringify({ extractions: [] }), {
status: 200,
headers: { 'content-type': 'application/json' },
});
}
if (url === '/api/memory/config' && init?.method === 'PATCH') {
patchBodies.push(JSON.parse(String(init.body)));
return new Response(JSON.stringify({ enabled: false, extraction: null }), {
status: 200,
headers: { 'content-type': 'application/json' },
});
}
return new Response(JSON.stringify({}), { status: 404 });
}) as typeof fetch;
renderMemorySection();
const toggle = await screen.findByRole('checkbox', { name: 'Enable memory injection' }) as HTMLInputElement;
fireEvent.click(toggle);
await waitFor(() => {
expect(screen.getByRole('status').textContent).toContain('Memory is currently OFF.');
});
expect(patchBodies).toEqual([{ enabled: false }]);
});
});

View file

@ -470,4 +470,261 @@ describe('RoutinesSection', () => {
expect(await screen.findByText('No runs yet.')).toBeTruthy();
});
it('falls back to the empty history state when loading run history fails', async () => {
const routines = [{
id: 'routine-1',
name: 'Morning briefing',
prompt: 'Morning summary',
schedule: { kind: 'daily', time: '09:00', timezone: 'UTC' },
target: { mode: 'create_each_run' },
enabled: true,
nextRunAt: Date.now() + 3600_000,
lastRun: null,
createdAt: Date.now(),
updatedAt: Date.now(),
}];
globalThis.fetch = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
const url = input.toString();
if (url === '/api/routines' && (!init || init.method === undefined)) {
return new Response(JSON.stringify({ routines }), {
status: 200,
headers: { 'content-type': 'application/json' },
});
}
if (url === '/api/projects' && (!init || init.method === undefined)) {
return new Response(JSON.stringify({ projects: [] }), {
status: 200,
headers: { 'content-type': 'application/json' },
});
}
if (url === '/api/routines/routine-1/runs?limit=10') {
return new Response(JSON.stringify({ error: 'history unavailable' }), {
status: 500,
headers: { 'content-type': 'application/json' },
});
}
return new Response(JSON.stringify({}), { status: 404 });
}) as typeof fetch;
render(<RoutinesSection />);
const row = (await screen.findByText('Morning briefing')).closest('li')!;
fireEvent.click(within(row).getByRole('button', { name: 'History' }));
expect(await screen.findByText('No runs yet.')).toBeTruthy();
});
it('shows an error alert when the initial routines load fails', async () => {
globalThis.fetch = vi.fn(async (input: RequestInfo | URL) => {
const url = input.toString();
if (url === '/api/routines') {
return new Response(JSON.stringify({ error: 'boom' }), {
status: 500,
headers: { 'content-type': 'application/json' },
});
}
if (url === '/api/projects') {
return new Response(JSON.stringify({ projects: [] }), {
status: 200,
headers: { 'content-type': 'application/json' },
});
}
return new Response(JSON.stringify({}), { status: 404 });
}) as typeof fetch;
render(<RoutinesSection />);
expect(await screen.findByRole('alert')).toBeTruthy();
expect(screen.getByRole('alert').textContent).toContain('routines: 500');
});
it('shows an error alert when creating a routine fails', async () => {
globalThis.fetch = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
const url = input.toString();
if (url === '/api/routines' && (!init || init.method === undefined)) {
return new Response(JSON.stringify({ routines: [] }), {
status: 200,
headers: { 'content-type': 'application/json' },
});
}
if (url === '/api/projects' && (!init || init.method === undefined)) {
return new Response(JSON.stringify({ projects: [] }), {
status: 200,
headers: { 'content-type': 'application/json' },
});
}
if (url === '/api/routines' && init?.method === 'POST') {
return new Response(JSON.stringify({ error: 'provider unavailable' }), {
status: 500,
headers: { 'content-type': 'application/json' },
});
}
return new Response(JSON.stringify({}), { status: 404 });
}) as typeof fetch;
render(<RoutinesSection />);
fireEvent.click(await screen.findByRole('button', { name: 'New routine' }));
fireEvent.change(screen.getByLabelText('Name'), {
target: { value: 'Weekly digest' },
});
fireEvent.change(screen.getByLabelText('Prompt'), {
target: { value: 'Summarize GitHub and design activity.' },
});
fireEvent.click(screen.getByRole('button', { name: 'Create' }));
expect((await screen.findByRole('alert')).textContent).toContain('provider unavailable');
expect(screen.getByDisplayValue('Weekly digest')).toBeTruthy();
});
it('shows an error alert when running a routine now fails', async () => {
const routines: Routine[] = [{
id: 'routine-1',
name: 'Morning briefing',
prompt: 'Morning summary',
schedule: { kind: 'daily', time: '09:00', timezone: 'UTC' },
target: { mode: 'create_each_run' },
skillId: null,
agentId: null,
enabled: true,
nextRunAt: Date.now() + 3600_000,
lastRun: null,
createdAt: Date.now(),
updatedAt: Date.now(),
}];
globalThis.fetch = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
const url = input.toString();
if (url === '/api/routines' && (!init || init.method === undefined)) {
return new Response(JSON.stringify({ routines }), {
status: 200,
headers: { 'content-type': 'application/json' },
});
}
if (url === '/api/projects' && (!init || init.method === undefined)) {
return new Response(JSON.stringify({ projects: [] }), {
status: 200,
headers: { 'content-type': 'application/json' },
});
}
if (url === '/api/routines/routine-1/run' && init?.method === 'POST') {
return new Response(JSON.stringify({ error: 'agent unavailable' }), {
status: 500,
headers: { 'content-type': 'application/json' },
});
}
return new Response(JSON.stringify({}), { status: 404 });
}) as typeof fetch;
render(<RoutinesSection />);
const row = await screen.findByText('Morning briefing');
const card = row.closest('li')!;
fireEvent.click(within(card).getByRole('button', { name: 'Run now' }));
expect((await screen.findByRole('alert')).textContent).toContain('agent unavailable');
expect(within(card).queryByRole('button', { name: 'Hide history' })).toBeNull();
});
it('shows an error alert when pausing a routine fails and keeps the current action', async () => {
const routines: Routine[] = [{
id: 'routine-1',
name: 'Morning briefing',
prompt: 'Morning summary',
schedule: { kind: 'daily', time: '09:00', timezone: 'UTC' },
target: { mode: 'create_each_run' },
skillId: null,
agentId: null,
enabled: true,
nextRunAt: Date.now() + 3600_000,
lastRun: null,
createdAt: Date.now(),
updatedAt: Date.now(),
}];
globalThis.fetch = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
const url = input.toString();
if (url === '/api/routines' && (!init || init.method === undefined)) {
return new Response(JSON.stringify({ routines }), {
status: 200,
headers: { 'content-type': 'application/json' },
});
}
if (url === '/api/projects' && (!init || init.method === undefined)) {
return new Response(JSON.stringify({ projects: [] }), {
status: 200,
headers: { 'content-type': 'application/json' },
});
}
if (url === '/api/routines/routine-1' && init?.method === 'PATCH') {
return new Response(JSON.stringify({ error: 'scheduler unavailable' }), {
status: 500,
headers: { 'content-type': 'application/json' },
});
}
return new Response(JSON.stringify({}), { status: 404 });
}) as typeof fetch;
render(<RoutinesSection />);
const row = await screen.findByText('Morning briefing');
const card = row.closest('li')!;
fireEvent.click(within(card).getByRole('button', { name: 'Pause' }));
expect((await screen.findByRole('alert')).textContent).toContain('scheduler unavailable');
expect(within(card).getByRole('button', { name: 'Pause' })).toBeTruthy();
expect(within(card).queryByRole('button', { name: 'Resume' })).toBeNull();
});
it('shows an error alert when deleting a routine fails', async () => {
const routines: Routine[] = [{
id: 'routine-1',
name: 'Morning briefing',
prompt: 'Morning summary',
schedule: { kind: 'daily', time: '09:00', timezone: 'UTC' },
target: { mode: 'create_each_run' },
skillId: null,
agentId: null,
enabled: true,
nextRunAt: Date.now() + 3600_000,
lastRun: null,
createdAt: Date.now(),
updatedAt: Date.now(),
}];
window.confirm = vi.fn(() => true);
globalThis.fetch = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
const url = input.toString();
if (url === '/api/routines' && (!init || init.method === undefined)) {
return new Response(JSON.stringify({ routines }), {
status: 200,
headers: { 'content-type': 'application/json' },
});
}
if (url === '/api/projects' && (!init || init.method === undefined)) {
return new Response(JSON.stringify({ projects: [] }), {
status: 200,
headers: { 'content-type': 'application/json' },
});
}
if (url === '/api/routines/routine-1' && init?.method === 'DELETE') {
return new Response(JSON.stringify({ error: 'delete failed upstream' }), {
status: 500,
headers: { 'content-type': 'application/json' },
});
}
return new Response(JSON.stringify({}), { status: 404 });
}) as typeof fetch;
render(<RoutinesSection />);
const row = (await screen.findByText('Morning briefing')).closest('li')!;
fireEvent.click(within(row).getByRole('button', { name: 'Delete' }));
expect(await screen.findByRole('alert')).toBeTruthy();
expect(screen.getByRole('alert').textContent).toContain('delete failed upstream');
expect(screen.getByText('Morning briefing')).toBeTruthy();
});
});

View file

@ -125,6 +125,48 @@ test('manual edit inspector previews and persists page and selected element styl
await expect(page.getByRole('menuitem', { name: /Export as PDF/ })).toBeVisible();
});
test('manual edit mode preserves preview actions after style edits', async ({ page }) => {
await routeMockAgents(page);
const projectId = await createEmptyProject(page, 'Manual edit smoke');
await seedHtmlArtifact(page, projectId, 'manual-edit.html', manualEditHtml());
await page.goto(`/projects/${projectId}/files/manual-edit.html`);
await openDesignFile(page, 'manual-edit.html');
await expect(page.getByTestId('artifact-preview-frame')).toBeVisible();
const frame = page.frameLocator('[data-testid="artifact-preview-frame"]');
await expect(frame.getByRole('heading', { name: 'Original Hero' })).toBeVisible();
await page.getByTestId('manual-edit-mode-toggle').click();
const fontSizeInput = await selectStyleRowInput(page, frame, '[data-od-id="hero-title"]', 'TYPOGRAPHY', 'Size');
await fontSizeInput.fill('48');
await expectFileSource(page, projectId, 'manual-edit.html', ['font-size: 48px']);
await page.getByTestId('manual-edit-mode-toggle').click();
await expect(frame.getByRole('heading', { name: 'Original Hero' })).toBeVisible();
await page.getByTestId('board-mode-toggle').click();
await expect(page.getByTestId('comment-mode-toggle')).toBeVisible();
await frame.getByRole('heading', { name: 'Original Hero' }).click();
await expect(page.getByTestId('comment-popover')).toBeVisible();
await page.getByRole('button', { name: /^Share$/ }).click();
await expect(page.getByRole('menuitem', { name: /Export as PDF/ })).toBeVisible();
});
async function selectStyleRowInput(
page: Page,
frame: ReturnType<Page['frameLocator']>,
selector: string,
section: string,
label: string,
) {
await frame.locator(selector).click();
await expect(page.locator('.manual-edit-modal')).toContainText('TYPOGRAPHY');
const row = inspectorSection(page, section).locator('.cc-row').filter({ hasText: label }).locator('input');
await expect(row).toBeVisible();
return row;
}
test('manual edit mode keeps deck navigation available for deck-shaped HTML', async ({ page }) => {
await routeMockAgents(page);
const projectId = await createEmptyProject(page, 'Manual edit deck smoke');

View file

@ -625,13 +625,13 @@ async function runCommentAttachmentFlow(
await frame.locator('[data-od-id="hero-title"]').click();
await expect(page.getByTestId('comment-popover')).toBeVisible();
await page.getByTestId('comment-popover-input').fill('Make the headline more specific.');
await page.getByTestId('comment-popover').getByRole('button', { name: 'Save comment' }).click();
await page.getByTestId('comment-popover-save').click();
await expect(page.getByTestId('comment-saved-marker-hero-title')).toBeVisible();
await expect(page.getByTestId('staged-comment-attachments')).toHaveCount(0);
await expect(page.getByTestId('chat-composer-input')).toHaveValue('');
await expect(page.getByTestId('chat-send')).toBeDisabled();
await page.getByTestId('comment-popover').getByRole('button', { name: 'Close' }).click();
await expect(page.getByTestId('comment-popover')).toHaveCount(0);
await frame.locator('[data-od-id="hero-copy"]').hover();
await expect(page.getByTestId('comment-target-overlay')).toBeVisible();
@ -640,50 +640,27 @@ async function runCommentAttachmentFlow(
await page.getByTestId('comment-saved-marker-hero-title').getByRole('button').click();
await expect(page.getByTestId('comment-popover')).toBeVisible();
await expect(page.getByTestId('comment-popover-input')).toHaveValue('Make the headline more specific.');
await page.getByTestId('comment-popover').getByRole('button', { name: 'Close' }).click();
await page.locator('.comment-popover-close').click();
await page.getByRole('tab', { name: 'Comments' }).click();
await expect(page.getByTestId('comments-panel')).toBeVisible();
await expect(page.getByTestId('comments-panel').getByRole('heading', { name: 'Saved comments' })).toBeVisible();
await page.getByTestId('comments-panel')
.locator('[data-testid="comment-card-hero-title"]')
.getByRole('button', { name: 'Add' })
const sidePanel = page.getByTestId('comment-side-panel');
await expect(sidePanel).toBeVisible();
await expect(sidePanel).toContainText('Make the headline more specific.');
await sidePanel.getByTestId('comment-side-item').filter({ hasText: 'Make the headline more specific.' })
.getByRole('button', { name: 'Select' })
.click();
await page.getByRole('tab', { name: 'Chat' }).click();
await expect(page.getByTestId('staged-comment-attachments')).toBeVisible();
await expect(page.getByTestId('staged-comment-attachments')).toContainText('hero-title');
await expect(page.getByTestId('staged-comment-attachments')).toContainText('Make the headline more specific.');
await page.getByRole('tab', { name: 'Comments' }).click();
await expect(page.getByTestId('comments-panel').getByRole('heading', { name: 'Attached to chat' })).toBeVisible();
await page.getByTestId('comments-panel')
.locator('[data-testid="comment-card-hero-title"]')
.getByRole('button', { name: 'Remove' })
.click();
await page.getByRole('tab', { name: 'Chat' }).click();
await expect(page.getByTestId('staged-comment-attachments')).toHaveCount(0);
await expect(page.getByTestId('chat-send')).toBeDisabled();
await page.getByRole('tab', { name: 'Comments' }).click();
await page.getByTestId('comments-panel')
.locator('[data-testid="comment-card-hero-title"]')
.getByRole('button', { name: 'Add' })
.click();
await page.getByRole('tab', { name: 'Chat' }).click();
await expect(page.getByTestId('staged-comment-attachments')).toContainText('hero-title');
await expect(page.getByTestId('comment-side-send-claude')).toBeVisible();
const runRequest = page.waitForRequest(
isCreateRunRequest,
);
await page.getByTestId('chat-send').click();
await page.getByTestId('comment-side-send-claude').click();
const request = await runRequest;
const body = request.postDataJSON() as {
message?: string;
commentAttachments?: Array<{ elementId?: string; comment?: string; filePath?: string }>;
};
expect(body.message).toMatch(/\n\n## user\n$/);
expect(body.message).not.toContain('Apply selected preview comments');
expect(body.message ?? '').not.toContain('Apply selected preview comments');
expect(body.commentAttachments).toEqual([
expect.objectContaining({
elementId: 'hero-title',

View file

@ -19,7 +19,7 @@ async function openExecutionSettings(
});
await page.goto('/');
await page.getByTitle('Configure execution mode').click();
await page.getByRole('button', { name: /Configure execution mode|配置执行模式/i }).click();
await expect(page.getByRole('dialog')).toBeVisible();
}
@ -57,7 +57,7 @@ async function openExecutionSettingsWithAgents(
});
await page.goto('/');
await page.getByTitle('Configure execution mode').click();
await page.getByRole('button', { name: /Configure execution mode|配置执行模式/i }).click();
await expect(page.getByRole('dialog')).toBeVisible();
}
@ -185,7 +185,7 @@ test('BYOK quick fill provider updates fields and saved settings persist after c
apiProviderBaseUrl: 'https://api.deepseek.com',
});
await page.getByTitle('Configure execution mode').click();
await page.getByRole('button', { name: /Configure execution mode|配置执行模式/i }).click();
await expect(page.getByRole('dialog')).toBeVisible();
const reopenedDialog = page.getByRole('dialog');
await expect(reopenedDialog.getByRole('tab', { name: 'OpenAI', exact: true })).toHaveAttribute('aria-selected', 'true');
@ -342,7 +342,7 @@ test('saving Local CLI updates the entry status pill with the selected agent', a
const dialog = page.getByRole('dialog');
await dialog.getByRole('tab', { name: /Local CLI.*1 installed/i }).click();
await dialog.getByRole('tab', { name: /Local CLI|本机 CLI/i }).click();
await dialog.getByRole('button', { name: /Codex CLI/i }).click();
await expect.poll(async () => readSavedConfig(page)).toMatchObject({
mode: 'daemon',
@ -351,8 +351,8 @@ test('saving Local CLI updates the entry status pill with the selected agent', a
await dialog.getByRole('button', { name: 'Close', exact: true }).click();
await expect(page.getByRole('dialog')).toHaveCount(0);
const executionPill = page.getByTitle('Configure execution mode');
await expect(executionPill).toContainText('Local CLI');
const executionPill = page.getByRole('button', { name: /Configure execution mode|配置执行模式/i });
await expect(executionPill).toContainText(/Local CLI|本机 CLI/i);
await expect(executionPill).toContainText('Codex CLI');
await expect(executionPill).toContainText('0.80.0');
});

View file

@ -175,11 +175,11 @@ async function openConnectorsSettings(
});
await page.goto('/');
await page.getByTitle('Configure execution mode').click();
await page.getByRole('button', { name: /Configure execution mode|配置执行模式/i }).click();
const dialog = page.getByRole('dialog');
await expect(dialog).toBeVisible();
await dialog.getByRole('button', { name: /^Connectors\b/ }).click();
await dialog.getByRole('button', { name: /Connectors|连接器/i }).click();
await expect(dialog.getByTestId('connector-grid-wrap')).toBeVisible();
await expect(connectorCard(dialog, 'github')).toBeVisible();
return dialog;

View file

@ -198,11 +198,11 @@ async function openConnectorsSettings(
});
await page.goto('/');
await page.getByTitle('Configure execution mode').click();
await page.getByRole('button', { name: /Configure execution mode|配置执行模式/i }).click();
const dialog = page.getByRole('dialog');
await expect(dialog).toBeVisible();
await dialog.getByRole('button', { name: /^Connectors\b/ }).click();
await dialog.getByRole('button', { name: /Connectors|连接器/i }).click();
await expect(dialog.getByTestId('connector-grid-wrap')).toBeVisible();
await expect(connectorCard(dialog, 'github')).toBeVisible();
return dialog;
@ -301,7 +301,7 @@ test('clears pending authorization when OAuth launch is blocked after redirect_r
await expect(githubCard.getByRole('button', { name: 'Cancel' })).toBeVisible();
await page.reload();
await page.getByTitle('Configure execution mode').click();
await page.getByRole('button', { name: /Configure execution mode|配置执行模式/i }).click();
const reloadedDialog = page.getByRole('dialog');
await expect(reloadedDialog).toBeVisible();

View file

@ -136,9 +136,11 @@ async function openLocalCliSettings(
});
await page.goto('/');
await page
.getByRole('button', { name: /Configure execution mode|配置执行模式/i })
.click();
const settingsButton = page.locator(
'button[aria-label="Configure execution mode"], button[aria-label="配置执行模式"], button[title="Configure execution mode"], button[title="配置执行模式"]',
).first();
await expect(settingsButton).toBeVisible();
await settingsButton.click();
const dialog = page.getByRole('dialog');
await expect(dialog).toBeVisible();

View file

@ -55,7 +55,11 @@ async function seedSettingsBase(page: Page) {
async function openSettings(page: Page) {
await page.goto('/');
await page.getByTitle('Configure execution mode').click();
const settingsButton = page.locator(
'button[aria-label="Configure execution mode"], button[aria-label="配置执行模式"], button[title="Configure execution mode"], button[title="配置执行模式"]',
).first();
await expect(settingsButton).toBeVisible();
await settingsButton.click();
const dialog = page.getByRole('dialog');
await expect(dialog).toBeVisible();
return dialog;
@ -232,6 +236,69 @@ test.describe('Settings Memory and Routines flows', () => {
await expect(reopened.locator('.memory-disabled-banner')).toBeVisible();
});
test('keeps the memory editor open when creating a memory entry fails', async ({ page }) => {
await seedSettingsBase(page);
await page.route('**/api/memory', async (route) => {
const method = route.request().method();
if (method === 'GET') {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
enabled: true,
rootDir: '/tmp/memory',
index: '# Memory\n',
entries: [],
extraction: null,
}),
});
return;
}
if (method === 'POST') {
await route.fulfill({
status: 500,
contentType: 'application/json',
body: JSON.stringify({ error: 'provider unavailable' }),
});
return;
}
await route.fulfill({ status: 404, body: '{}' });
});
await page.route('**/api/memory/extractions', async (route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ extractions: [] }),
});
});
await page.route('**/api/memory/events', async (route) => {
await route.fulfill({
status: 200,
contentType: 'text/event-stream',
body: '',
});
});
const dialog = await openMemorySettings(page);
await dialog.getByRole('button', { name: 'New memory' }).click();
await dialog.getByPlaceholder('e.g. UI preferences').fill('UI preferences');
await dialog.getByPlaceholder('One sentence — what is this memory about?').fill(
'Persistent rendering preferences',
);
await dialog
.getByPlaceholder(/- Rule one[\s\S]*When to apply: optional scope/)
.fill('- Prefer dark mode');
await dialog.getByRole('button', { name: 'Create' }).click();
await expect(dialog.getByPlaceholder('e.g. UI preferences')).toHaveValue('UI preferences');
await expect(dialog.locator('.memory-flash-pill')).toHaveCount(0);
await expect(dialog.getByText('No memory yet.')).toBeVisible();
});
test('creates a routine and loads its history after Run now', async ({ page }) => {
await seedSettingsBase(page);
@ -350,4 +417,154 @@ test.describe('Settings Memory and Routines flows', () => {
await expect(dialog.getByText('manual')).toBeVisible();
await expect(dialog.getByRole('button', { name: 'Open project' })).toBeVisible();
});
test('falls back to the empty history state when loading routine history fails', async ({ page }) => {
await seedSettingsBase(page);
const projects = [{ id: 'proj-1', name: 'Routine Test Project' }];
const routines = [
{
id: 'routine-1',
name: 'Weekly digest',
prompt: 'Summarize GitHub and design activity.',
schedule: { kind: 'weekly', weekday: 3, time: '09:00', timezone: 'UTC' },
target: { mode: 'reuse', projectId: 'proj-1' },
enabled: true,
nextRunAt: Date.now() + 3600_000,
lastRun: null,
createdAt: Date.now(),
updatedAt: Date.now(),
},
];
await page.route('**/api/projects', async (route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ projects }),
});
});
await page.route('**/api/routines', async (route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ routines }),
});
});
await page.route('**/api/routines/routine-1/runs?limit=10', async (route) => {
await route.fulfill({
status: 500,
contentType: 'application/json',
body: JSON.stringify({ error: 'history unavailable' }),
});
});
const dialog = await openRoutinesSettings(page);
const row = dialog.locator('.routines-item', { hasText: 'Weekly digest' }).first();
await expect(row).toBeVisible();
await row.getByRole('button', { name: 'History' }).click();
await expect(dialog.getByText('No runs yet.')).toBeVisible();
});
test('keeps the routine form open when creating a routine fails', async ({ page }) => {
await seedSettingsBase(page);
const projects = [{ id: 'proj-1', name: 'Routine Test Project' }];
await page.route('**/api/projects', async (route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ projects }),
});
});
await page.route('**/api/routines', async (route) => {
const method = route.request().method();
if (method === 'GET') {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ routines: [] }),
});
return;
}
if (method === 'POST') {
await route.fulfill({
status: 500,
contentType: 'application/json',
body: JSON.stringify({ error: 'provider unavailable' }),
});
return;
}
await route.fulfill({ status: 404, body: '{}' });
});
const dialog = await openRoutinesSettings(page);
await dialog.getByRole('button', { name: 'New routine' }).click();
await dialog.getByLabel('Name').fill('Weekly digest');
await dialog.getByLabel('Prompt').fill('Summarize GitHub and design activity.');
await dialog.getByRole('tab', { name: 'Weekly' }).click();
await dialog.getByRole('button', { name: 'Wed' }).click();
await dialog.getByText('Reuse an existing project', { exact: true }).click();
await dialog.getByRole('combobox').nth(1).selectOption('proj-1');
await dialog.getByRole('button', { name: 'Create' }).click();
await expect(dialog.getByLabel('Name')).toHaveValue('Weekly digest');
await expect(dialog.getByLabel('Prompt')).toHaveValue('Summarize GitHub and design activity.');
await expect(dialog.getByText('No routines yet.')).toBeVisible();
});
test('keeps routine history collapsed when Run now fails', async ({ page }) => {
await seedSettingsBase(page);
const routines = [
{
id: 'routine-1',
name: 'Weekly digest',
prompt: 'Summarize GitHub and design activity.',
schedule: { kind: 'weekly', weekday: 3, time: '09:00', timezone: 'UTC' },
target: { mode: 'create_each_run' },
enabled: true,
nextRunAt: Date.now() + 3600_000,
lastRun: null,
createdAt: Date.now(),
updatedAt: Date.now(),
},
];
await page.route('**/api/projects', async (route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ projects: [] }),
});
});
await page.route('**/api/routines', async (route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ routines }),
});
});
await page.route('**/api/routines/routine-1/run', async (route) => {
await route.fulfill({
status: 500,
contentType: 'application/json',
body: JSON.stringify({ error: 'agent unavailable' }),
});
});
const dialog = await openRoutinesSettings(page);
const row = dialog.locator('.routines-item', { hasText: 'Weekly digest' }).first();
await expect(row).toBeVisible();
await row.getByRole('button', { name: 'Run now' }).click();
await expect(row.getByRole('button', { name: 'History' })).toBeVisible();
await expect(row.getByRole('button', { name: 'Hide history' })).toHaveCount(0);
});
});

View file

@ -18,6 +18,12 @@ describe("copyBundledResourceTrees", () => {
"image",
"sample.json",
);
const designTemplatePath = join(
workspaceRoot,
"design-templates",
"orbit-general",
"SKILL.md",
);
const communityPetPath = join(
workspaceRoot,
"assets",
@ -31,7 +37,7 @@ describe("copyBundledResourceTrees", () => {
// `design-templates/` tree that copyBundledResourceTrees now also
// bundles. Create it in the fixture so the recursive copy does not
// fail with ENOENT before reaching the prompt-templates assertion.
await mkdir(join(workspaceRoot, "design-templates", "sample"), {
await mkdir(join(workspaceRoot, "design-templates", "orbit-general"), {
recursive: true,
});
await mkdir(join(workspaceRoot, "design-systems", "sample"), {
@ -46,6 +52,7 @@ describe("copyBundledResourceTrees", () => {
recursive: true,
});
await writeFile(promptTemplatePath, "{\"id\":\"sample\"}\n", "utf8");
await writeFile(designTemplatePath, "# Orbit General\n", "utf8");
await writeFile(communityPetPath, "{\"name\":\"sample\"}\n", "utf8");
await copyBundledResourceTrees({ workspaceRoot, resourceRoot });
@ -56,6 +63,12 @@ describe("copyBundledResourceTrees", () => {
"utf8",
),
).resolves.toBe("{\"id\":\"sample\"}\n");
await expect(
readFile(
join(resourceRoot, "design-templates", "orbit-general", "SKILL.md"),
"utf8",
),
).resolves.toBe("# Orbit General\n");
await expect(
readFile(
join(resourceRoot, "community-pets", "sample", "pet.json"),