mirror of
https://github.com/nexu-io/open-design.git
synced 2026-06-01 03:14:35 +07:00
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:
parent
c942d99b14
commit
2976c76fc3
20 changed files with 1684 additions and 118 deletions
68
.github/workflows/ci.yml
vendored
68
.github/workflows/ci.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
? {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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()));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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/);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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 }]);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
|
|
|
|||
Loading…
Reference in a new issue