Skip to content

Commit 2af7ea1

Browse files
committed
add harness checks for first workdir write + open-folder action
1 parent 984c9ec commit 2af7ea1

File tree

11 files changed

+333
-5
lines changed

11 files changed

+333
-5
lines changed

AGENTS.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,8 @@ mise run test-delete-tasks
8787
mise run test-auth-list / test-auth-set-key / test-auth-delete / test-auth-import-pi
8888
mise run test-send-login # trigger /login UI flow
8989
mise run test-open-preview <task> <path> # open file preview
90+
mise run test-write-working-file <path> [content] # write file via runtime /mnt/workdir path
91+
mise run test-open-working-folder <task> # open task working folder via Finder action path
9092
mise run test-check-permissions # verify screenshot capture works
9193
```
9294

README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,8 @@ mise run test-auth-set-key <provider> <key>
5656
mise run test-auth-delete <provider>
5757
mise run test-auth-import-pi
5858
mise run test-open-preview <task-id> <relative-path>
59+
mise run test-write-working-file <relative-path> [content]
60+
mise run test-open-working-folder <task-id>
5961
mise run test-dump-state
6062
mise run test-screenshot name
6163
mise run test-check-permissions # quick preflight for screenshot visibility

TODO.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -29,8 +29,8 @@
2929
- [x] **Fix opener permission path** — added `opener:allow-open-path` capability so `Open in Finder` is authorized.
3030
- [x] **Fix right-panel error isolation** — Working-folder action errors are now scoped to the Working folder card.
3131
- [x] **Fix first `/mnt/workdir` write reliability (race mitigation)** — task-bound folder changes now mark `taskSwitching` before validation, and prompt send is blocked until runtime is ready.
32-
- [ ] **Add harness regression for working-folder writes** — set folder → write file immediately → assert host path has file.
33-
- [ ] **Add harness check for open-folder action** — validate Working-folder header icon opens Finder path successfully.
32+
- [x] **Add harness regression for working-folder writes** — set folder → write file immediately → assert host path has file.
33+
- [x] **Add harness check for open-folder action** — validate Working-folder header icon opens Finder path successfully.
3434
- [ ] **Inject minimal FS runtime hint into prompts** — include working-folder host path + `/mnt/workdir` alias + scratchpad path, and refresh when folder is bound later (not just at startup).
3535
- [x] **Fix dev cwd chip staleness on task reopen** — reopen now validates persisted working folder before runtime prep, then refreshes on `task_ready`, so cwd settles to `/mnt/workdir...` instead of sticking at `/mnt/taskstate/.../outputs`.
3636
- [ ] **Delete remaining slop** — review docs for stale references to v1, v2 flags, sync protocol, smoke suites
@@ -84,6 +84,6 @@
8484

8585
## Testing
8686

87-
- Harness primitives: `test-start`, `test-prompt`, `test-screenshot`, `test-set-folder`, `test-set-task`, `test-create-task`, `test-delete-tasks`, `test-dump-state`, `test-stop`, `test-open-preview`, `test-auth-*`, `test-send-login`, `test-check-permissions`
87+
- Harness primitives: `test-start`, `test-prompt`, `test-screenshot`, `test-set-folder`, `test-set-task`, `test-create-task`, `test-delete-tasks`, `test-dump-state`, `test-stop`, `test-open-preview`, `test-write-working-file`, `test-open-working-folder`, `test-auth-*`, `test-send-login`, `test-check-permissions`
8888
- Scope enforcement: `scripts/harness/path-i-lite-negative.sh`
8989
- Rule: primitives only, no monolithic E2E scripts

docs/task-artifact-contract.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -61,8 +61,8 @@ Last updated: 2026-02-06
6161
- [ ] Add harness evidence for contract:
6262
- [x] folder-change rejection on existing task
6363
- [x] scratchpad aggregation (`outputs` + `uploads`)
64-
- [ ] opener action permission success (`Open in Finder` works in packaged UI)
65-
- [ ] first-write working-folder reliability (`/mnt/workdir` write appears on host without retry)
64+
- [x] opener action permission success (`Open in Finder` works in packaged UI)
65+
- [x] first-write working-folder reliability (`/mnt/workdir` write appears on host without retry)
6666
- [ ] uploads write-denied behavior
6767

6868
## Notes

docs/testing-strategy.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ Tasks: `test-create-task`, `test-delete-tasks`, `test-set-task`
2222
Folders: `test-set-folder` (one-time bind; existing bound task rejects changes)
2323
Auth: `test-auth-list`, `test-auth-set-key`, `test-auth-delete`, `test-auth-import-pi`, `test-send-login`
2424
Preview: `test-open-preview`
25+
Working-folder helpers: `test-write-working-file`, `test-open-working-folder`
2526
Preflight: `test-check-permissions` (screenshot permission)
2627

2728
Screenshot checks require Screen Recording permission. Blank/black captures fail.
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
#!/usr/bin/env bash
2+
#MISE description="Open a task's working folder via the Finder action path (test harness)"
3+
set -euo pipefail
4+
5+
TASK_ID="${1:-}"
6+
7+
if [[ -z "$TASK_ID" ]]; then
8+
echo "Usage: mise run test-open-working-folder <task-id>"
9+
exit 1
10+
fi
11+
12+
if ! nc -z localhost 19385 2>/dev/null; then
13+
echo "Test server not running. Run: mise run test-start"
14+
exit 1
15+
fi
16+
17+
RESULT=$(python - "$TASK_ID" <<'PY' | nc -w 2 localhost 19385
18+
import json
19+
import sys
20+
21+
payload = {
22+
"cmd": "open_working_folder",
23+
"taskId": sys.argv[1],
24+
}
25+
print(json.dumps(payload))
26+
PY
27+
)
28+
29+
if [[ "$RESULT" == ERR:* ]]; then
30+
echo "$RESULT"
31+
exit 1
32+
fi
33+
34+
echo "$RESULT"

mise-tasks/test-write-working-file

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
#!/usr/bin/env bash
2+
#MISE description="Write a file into /mnt/workdir via runtime (test harness)"
3+
set -euo pipefail
4+
5+
RELATIVE_PATH="${1:-}"
6+
CONTENT="${2:-}"
7+
8+
if [[ -z "$RELATIVE_PATH" ]]; then
9+
echo "Usage: mise run test-write-working-file <relative-path> [content]"
10+
exit 1
11+
fi
12+
13+
if ! nc -z localhost 19385 2>/dev/null; then
14+
echo "Test server not running. Run: mise run test-start"
15+
exit 1
16+
fi
17+
18+
RESULT=$(python - "$RELATIVE_PATH" "$CONTENT" <<'PY' | nc -w 2 localhost 19385
19+
import json
20+
import sys
21+
22+
payload = {
23+
"cmd": "write_working_file",
24+
"relativePath": sys.argv[1],
25+
"content": sys.argv[2],
26+
}
27+
print(json.dumps(payload))
28+
PY
29+
)
30+
31+
if [[ "$RESULT" == ERR:* ]]; then
32+
echo "$RESULT"
33+
exit 1
34+
fi
35+
36+
echo "$RESULT"

src-tauri/src/lib.rs

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -963,6 +963,8 @@ fn sanitize_test_server_value(value: &serde_json::Value) -> serde_json::Value {
963963
/// - `{"cmd":"artifact_list","taskId":"..."}` - returns scratchpad artifact list JSON (`outputs` + `uploads`)
964964
/// - `{"cmd":"artifact_read","taskId":"...","source":"outputs|uploads","relativePath":"..."}` - returns artifact file content JSON
965965
/// - `{"cmd":"open_preview","taskId":"...","relativePath":"..."}` - opens preview pane in UI
966+
/// - `{"cmd":"write_working_file","relativePath":"...","content":"..."}` - writes a file via runtime `system_bash` after folder bind settles
967+
/// - `{"cmd":"open_working_folder","taskId":"..."}` - opens a task working folder via the same `open_path_in_finder` path as the UI action
966968
/// - `{"cmd":"rpc",...}` - sends raw RPC to VM
967969
#[cfg(debug_assertions)]
968970
#[allow(clippy::too_many_lines)]
@@ -1236,6 +1238,70 @@ fn start_test_server(app_handle: tauri::AppHandle) {
12361238
let _ = app.emit("test_open_preview", payload);
12371239
let _ = stream.write_all(b"OK\n");
12381240
}
1241+
"write_working_file" => {
1242+
let relative_path = json.get("relativePath").and_then(|v| v.as_str()).unwrap_or("");
1243+
let content = json.get("content").and_then(|v| v.as_str()).unwrap_or("");
1244+
1245+
if relative_path.trim().is_empty() {
1246+
let _ = stream.write_all(b"ERR: relativePath is required\n");
1247+
continue;
1248+
}
1249+
1250+
eprintln!("[test-server] emitting test_write_working_file: {relative_path}");
1251+
let payload = serde_json::json!({
1252+
"relativePath": relative_path,
1253+
"content": content,
1254+
});
1255+
let _ = app.emit("test_write_working_file", payload);
1256+
let _ = stream.write_all(b"OK\n");
1257+
}
1258+
"open_working_folder" => {
1259+
let task_id = json.get("taskId").and_then(|v| v.as_str()).unwrap_or("").trim();
1260+
1261+
if task_id.is_empty() {
1262+
let _ = stream.write_all(b"ERR: taskId is required\n");
1263+
continue;
1264+
}
1265+
1266+
if !is_valid_task_id(task_id) {
1267+
let _ = stream.write_all(b"ERR: Invalid task id\n");
1268+
continue;
1269+
}
1270+
1271+
let tasks_path = match tasks_dir(&app) {
1272+
Ok(path) => path,
1273+
Err(error) => {
1274+
let _ = stream.write_all(format!("ERR: {error}\n").as_bytes());
1275+
continue;
1276+
}
1277+
};
1278+
1279+
let task = match task_store::load_task(&tasks_path, task_id) {
1280+
Ok(Some(task)) => task,
1281+
Ok(None) => {
1282+
let _ = stream.write_all(b"ERR: Task not found\n");
1283+
continue;
1284+
}
1285+
Err(error) => {
1286+
let _ = stream.write_all(format!("ERR: {error}\n").as_bytes());
1287+
continue;
1288+
}
1289+
};
1290+
1291+
let Some(folder) = task.working_folder else {
1292+
let _ = stream.write_all(b"ERR: Task has no working folder\n");
1293+
continue;
1294+
};
1295+
1296+
match open_path_in_finder(folder) {
1297+
Ok(()) => {
1298+
let _ = stream.write_all(b"OK\n");
1299+
}
1300+
Err(error) => {
1301+
let _ = stream.write_all(format!("ERR: {error}\n").as_bytes());
1302+
}
1303+
}
1304+
}
12391305
_ => {
12401306
// Direct RPC send (bypass UI)
12411307
let state: tauri::State<vm::VmState> = app.state();

src/lib/__tests__/integration/harness.ts

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -458,6 +458,29 @@ export class IntegrationHarness {
458458
}
459459
}
460460

461+
async writeWorkingFile(relativePath: string, content: string): Promise<void> {
462+
const response = await this.sendCommand({
463+
cmd: "write_working_file",
464+
relativePath,
465+
content,
466+
});
467+
468+
if (!isOkResponse(response)) {
469+
throw new Error(`write_working_file failed: ${response}`);
470+
}
471+
}
472+
473+
async openWorkingFolder(taskId: string): Promise<void> {
474+
const response = await this.sendCommand({
475+
cmd: "open_working_folder",
476+
taskId,
477+
});
478+
479+
if (!isOkResponse(response)) {
480+
throw new Error(`open_working_folder failed: ${response}`);
481+
}
482+
}
483+
461484
async previewList(taskId: string): Promise<PreviewListResponse> {
462485
return await this.sendJson<PreviewListResponse>({ cmd: "preview_list", taskId });
463486
}
@@ -516,6 +539,27 @@ export class IntegrationHarness {
516539
);
517540
}
518541

542+
async waitForHostFile(filePath: string, expectedContent: string, timeoutMs = 60_000): Promise<string> {
543+
return await waitFor(
544+
async () => {
545+
if (!existsSync(filePath)) {
546+
return null;
547+
}
548+
549+
try {
550+
const content = readFileSync(filePath, "utf8");
551+
return content === expectedContent ? content : null;
552+
} catch {
553+
return null;
554+
}
555+
},
556+
timeoutMs,
557+
250,
558+
`host file ${filePath}`,
559+
async () => this.timeoutDiagnostics(`host file ${filePath}`),
560+
);
561+
}
562+
519563
async listTasks(): Promise<TaskSummary[]> {
520564
return await this.sendJson<TaskSummary[]>({ cmd: "task_list" });
521565
}

src/lib/__tests__/integration/runtime-steady-state.integration.test.ts

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
/* @vitest-environment node */
22

3+
import { mkdirSync, rmSync } from "node:fs";
34
import path from "node:path";
45
import { afterAll, beforeAll, describe, expect, it } from "vitest";
56
import { IntegrationHarness } from "./harness";
@@ -8,15 +9,18 @@ describe.sequential("runtime steady-state contracts", () => {
89
const harness = new IntegrationHarness();
910
const prefix = `regression-steady-${Date.now()}`;
1011
const workingFolder = path.resolve(process.cwd());
12+
const firstWriteFolder = path.resolve(process.cwd(), "tmp/integration/first-write");
1113

1214
beforeAll(async () => {
15+
mkdirSync(firstWriteFolder, { recursive: true });
1316
await harness.start();
1417
await harness.deleteTasksByPrefix(prefix);
1518
}, 360_000);
1619

1720
afterAll(async () => {
1821
await harness.deleteTasksByPrefix(prefix).catch(() => undefined);
1922
await harness.stop();
23+
rmSync(firstWriteFolder, { recursive: true, force: true });
2024
}, 120_000);
2125

2226
async function createAndSelectTask(suffix: string) {
@@ -174,6 +178,63 @@ describe.sequential("runtime steady-state contracts", () => {
174178
expect(settledAfterBind.runtimeDebug.mismatchVisible).toBe(false);
175179
}, 240_000);
176180

181+
it("writes to the host working folder when queued immediately after first bind", async () => {
182+
const task = await createAndSelectTask("first-write");
183+
const fileName = `first-write-${Date.now()}.txt`;
184+
const fileContent = `first-write-token-${Date.now()}`;
185+
const hostPath = path.join(firstWriteFolder, fileName);
186+
187+
await harness.setFolder(firstWriteFolder);
188+
await harness.writeWorkingFile(fileName, fileContent);
189+
await harness.waitForHostFile(hostPath, fileContent, 120_000);
190+
191+
const settled = await harness.waitForSnapshot((snapshot) => {
192+
if (snapshot.task.currentTaskId !== task.id) {
193+
return null;
194+
}
195+
196+
if (!snapshot.runtime.rpcConnected || snapshot.runtime.taskSwitching) {
197+
return null;
198+
}
199+
200+
if (snapshot.task.currentWorkingFolder !== firstWriteFolder) {
201+
return null;
202+
}
203+
204+
if (!snapshot.runtimeDebug.currentCwd?.startsWith("/mnt/workdir")) {
205+
return null;
206+
}
207+
208+
return snapshot;
209+
}, 150_000);
210+
211+
expect(settled.runtimeDebug.currentCwd?.startsWith("/mnt/workdir")).toBe(true);
212+
}, 240_000);
213+
214+
it("opens task working folder through the Finder action path", async () => {
215+
const task = await createAndSelectTask("open-working-folder");
216+
217+
await harness.setFolder(workingFolder);
218+
219+
await harness.waitForSnapshot((snapshot) => {
220+
if (snapshot.task.currentTaskId !== task.id) {
221+
return null;
222+
}
223+
224+
if (!snapshot.runtime.rpcConnected || snapshot.runtime.taskSwitching) {
225+
return null;
226+
}
227+
228+
if (snapshot.task.currentWorkingFolder !== workingFolder) {
229+
return null;
230+
}
231+
232+
return snapshot;
233+
}, 150_000);
234+
235+
await harness.openWorkingFolder(task.id);
236+
}, 240_000);
237+
177238
it("keeps model picker state truthful (empty/error/real)", async () => {
178239
const task = await createAndSelectTask("models");
179240

0 commit comments

Comments
 (0)