dsh-workspace-mover 2.3.0 → 2.4.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +32 -0
- package/README.md +25 -9
- package/README_EN.md +23 -9
- package/client/client.js +80 -15
- package/docs/ARCHITECTURE.md +224 -214
- package/docs/compatibility/dsh-0.2.1-rc.2-2026-10-18.md +37 -0
- package/docs/compatibility/issue-5-desktop-acceptance-2026-10-22.md +106 -0
- package/docs/compatibility/pr-review-2026-10-22.md +56 -0
- package/lib/index.js +4507 -4263
- package/package.json +3 -2
- package/test/client-dom.test.mjs +79 -0
- package/test/e2e-sandbox.test.mjs +2724 -2607
- package/test/host-services.test.mjs +303 -0
- package/test/installed-host.test.mjs +119 -0
- package/test/workspace-tools.test.mjs +977 -0
|
@@ -0,0 +1,977 @@
|
|
|
1
|
+
// Black-box integration + system tests for workspace create / rename:
|
|
2
|
+
// agent tools mover_create_workspace / mover_rename_workspace
|
|
3
|
+
// RPC mover.ws.create / mover.ws.rename (channel /workspace-mover)
|
|
4
|
+
// doctor check id `host-sandbox-policy`
|
|
5
|
+
//
|
|
6
|
+
// Every expected outcome is derived from the feature contract, NOT from lib/index.js.
|
|
7
|
+
// Techniques: Equivalence Partitioning + Boundary Value Analysis on `name` / `directory` /
|
|
8
|
+
// `workspaceId`, a Decision Table for the rename confirmation gate (sandbox mode x approval
|
|
9
|
+
// seam x agent presence x outcome), and State Transition sequences over the registry.
|
|
10
|
+
//
|
|
11
|
+
// The fake registry models the DSH 0.2.1 `ctx.workspaceRegistry` contract on purpose:
|
|
12
|
+
// create(path, title?) canonicalizes through fs.promises.realpath, rejects relative /
|
|
13
|
+
// missing / non-directory paths, and RETURNS THE EXISTING ENTITY (no throw, title untouched)
|
|
14
|
+
// for an already-owned canonical path — so "directory already in use" can only be detected by
|
|
15
|
+
// the plugin, never by a host error. Duplicate titles are allowed by the registry itself, so
|
|
16
|
+
// title uniqueness is entirely the plugin's rule.
|
|
17
|
+
import { test, afterEach } from 'node:test';
|
|
18
|
+
import assert from 'node:assert/strict';
|
|
19
|
+
import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, readdirSync, existsSync, lstatSync, rmSync, realpathSync, symlinkSync } from 'node:fs';
|
|
20
|
+
import { realpath, stat } from 'node:fs/promises';
|
|
21
|
+
import { randomUUID } from 'node:crypto';
|
|
22
|
+
import { tmpdir } from 'node:os';
|
|
23
|
+
import { join, dirname, basename, isAbsolute, parse } from 'node:path';
|
|
24
|
+
import { zstdCompressSync, constants as zlibConstants } from 'node:zlib';
|
|
25
|
+
import { apply, artifactPath, readHeader } from '../lib/index.js';
|
|
26
|
+
|
|
27
|
+
//#region test data
|
|
28
|
+
const TOOL = Object.freeze({
|
|
29
|
+
LIST: 'mover_list_sessions',
|
|
30
|
+
MOVE: 'mover_move_session',
|
|
31
|
+
REPAIR: 'mover_repair_sessions',
|
|
32
|
+
DOCTOR: 'mover_doctor',
|
|
33
|
+
CREATE: 'mover_create_workspace',
|
|
34
|
+
RENAME: 'mover_rename_workspace'
|
|
35
|
+
});
|
|
36
|
+
const RPC = Object.freeze({ CREATE: 'mover.ws.create', RENAME: 'mover.ws.rename', DOCTOR: 'mover.doctor' });
|
|
37
|
+
const RPC_CHANNEL = '/workspace-mover';
|
|
38
|
+
const CODE = Object.freeze({ INVALID: 'invalid-input', NOT_FOUND: 'not-found', CONFLICT: 'conflict' });
|
|
39
|
+
const MODE = Object.freeze({ FULL: 'danger-full-access', WRITE: 'workspace-write', READ: 'read-only' });
|
|
40
|
+
/** Non-mode sandboxPolicy shapes (a plain MODE string means "service present, resolve returns that mode"). */
|
|
41
|
+
const SANDBOX = Object.freeze({ ABSENT: 'absent', NO_RESOLVE: 'no-resolve', THROWS: 'throws', EMPTY_RESULT: 'empty-result' });
|
|
42
|
+
const OUTCOME = Object.freeze({ ALLOWED: 'allowed-once', REJECTED: 'rejected', CANCELLED: 'cancelled', UNAVAILABLE: 'unavailable' });
|
|
43
|
+
/** Non-outcome approval seam shapes (a plain OUTCOME string means "request resolves to that outcome"). */
|
|
44
|
+
const APPROVAL = Object.freeze({ ABSENT: 'absent', NO_REQUEST: 'no-request', THROWS: 'throws' });
|
|
45
|
+
const DOCTOR_CHECK_ID = 'host-sandbox-policy';
|
|
46
|
+
const CHECK_STATE = Object.freeze({ PASS: 'pass', WARN: 'warn' });
|
|
47
|
+
const WS = Object.freeze({ A: { id: 'wid-a', title: 'Alpha' }, B: { id: 'wid-b', title: 'Beta' } });
|
|
48
|
+
const NEW_TITLE = 'Gamma';
|
|
49
|
+
const SECOND_NEW_TITLE = 'Delta';
|
|
50
|
+
const SESSION_ID = 'session-aaa';
|
|
51
|
+
const CALLER_SESSION = { id: 'session-caller' };
|
|
52
|
+
const OTHER_SESSION = { id: 'session-other' };
|
|
53
|
+
const ZSTD_OPTS = { params: { [zlibConstants.ZSTD_c_checksumFlag]: 1 } };
|
|
54
|
+
const ARTIFACT_NAME = /^session(?:\.v[1-9][0-9]*)?\.jsonl\.zstd$/;
|
|
55
|
+
/** Root of the temp volume: '/' on POSIX, e.g. 'C:\' on Windows (a bare '/' is not fully qualified there). */
|
|
56
|
+
const FS_ROOT = parse(tmpdir()).root;
|
|
57
|
+
/**
|
|
58
|
+
* Directory-link types to try, in order. Unprivileged Windows refuses 'dir' symlinks with EPERM, while
|
|
59
|
+
* junctions need no privilege and resolve the same way through native realpath; POSIX ignores the type.
|
|
60
|
+
*/
|
|
61
|
+
const SYMLINK_TYPES = process.platform === 'win32' ? ['dir', 'junction'] : ['dir'];
|
|
62
|
+
const SYMLINK_FALLBACK_CODE = 'EPERM';
|
|
63
|
+
/**
|
|
64
|
+
* Teardown: Windows can transiently refuse to delete fresh temp entries (EBUSY / EPERM / ENOTEMPTY while another
|
|
65
|
+
* process such as antivirus still holds a handle); retry instead of failing an otherwise passing test.
|
|
66
|
+
*/
|
|
67
|
+
const RM_TREE_OPTS = Object.freeze({ recursive: true, force: true, maxRetries: 3 });
|
|
68
|
+
//#endregion
|
|
69
|
+
|
|
70
|
+
//#region fake host
|
|
71
|
+
const hosts = [];
|
|
72
|
+
|
|
73
|
+
afterEach(() => {
|
|
74
|
+
for (const host of hosts.splice(0)) host.dispose();
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
function makeEntity(id, path, title, spies) {
|
|
78
|
+
const now = new Date().toISOString();
|
|
79
|
+
const record = { path, title, sessionIds: [], createdAt: now, updatedAt: now };
|
|
80
|
+
return {
|
|
81
|
+
id,
|
|
82
|
+
get path() { return record.path; },
|
|
83
|
+
get title() { return record.title; },
|
|
84
|
+
get sessionIds() { return [...record.sessionIds]; },
|
|
85
|
+
get record() { return record; },
|
|
86
|
+
async status() {
|
|
87
|
+
try { return (await stat(record.path)).isDirectory() ? 'ok' : 'missing-dir'; } catch { return 'missing-dir'; }
|
|
88
|
+
},
|
|
89
|
+
async setTitle(next) {
|
|
90
|
+
spies.setTitle.push({ id, title: next });
|
|
91
|
+
record.title = next;
|
|
92
|
+
record.updatedAt = new Date().toISOString();
|
|
93
|
+
},
|
|
94
|
+
async mutate(fn) {
|
|
95
|
+
spies.mutate.push(id);
|
|
96
|
+
Object.assign(record, fn(record), { updatedAt: new Date().toISOString() });
|
|
97
|
+
},
|
|
98
|
+
async attachSession(sid) { if (!record.sessionIds.includes(sid)) record.sessionIds.unshift(sid); },
|
|
99
|
+
async detachSession(sid) { record.sessionIds = record.sessionIds.filter((x) => x !== sid); }
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function makeRegistry(spies) {
|
|
104
|
+
const order = [];
|
|
105
|
+
return {
|
|
106
|
+
headers: new Map(),
|
|
107
|
+
sessionPaths: new Map(),
|
|
108
|
+
invalidSessionPaths: new Map(),
|
|
109
|
+
list() { return [...order]; },
|
|
110
|
+
get(id) { return order.find((entity) => entity.id === id); },
|
|
111
|
+
async create(path, title) {
|
|
112
|
+
spies.create.push({ path, title });
|
|
113
|
+
if (typeof path !== 'string' || !isAbsolute(path)) throw new TypeError(`Workspace path is not fully qualified: '${path}'`);
|
|
114
|
+
const canonical = await realpath(path); // ENOENT propagates, like the host
|
|
115
|
+
if (!(await stat(canonical)).isDirectory()) throw new Error(`cannot create a workspace at '${canonical}': path is not a directory`);
|
|
116
|
+
const existing = order.find((entity) => entity.path === canonical);
|
|
117
|
+
if (existing) return existing;
|
|
118
|
+
const entity = makeEntity(randomUUID(), canonical, title ?? basename(canonical), spies);
|
|
119
|
+
order.unshift(entity);
|
|
120
|
+
return entity;
|
|
121
|
+
},
|
|
122
|
+
/** Test-only: append a pre-existing workspace. */
|
|
123
|
+
seed(entity) { order.push(entity); return entity; }
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function makeSandboxPolicy(kind, calls) {
|
|
128
|
+
if (kind === SANDBOX.ABSENT) return undefined;
|
|
129
|
+
if (kind === SANDBOX.NO_RESOLVE) return {};
|
|
130
|
+
return {
|
|
131
|
+
resolve(request) {
|
|
132
|
+
calls.push(request);
|
|
133
|
+
if (kind === SANDBOX.THROWS) throw new Error('simulated sandbox policy failure');
|
|
134
|
+
if (kind === SANDBOX.EMPTY_RESULT) return undefined;
|
|
135
|
+
if (typeof kind === 'function') return { mode: kind(request), root: FS_ROOT };
|
|
136
|
+
return { mode: kind, root: FS_ROOT };
|
|
137
|
+
}
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function makeApproval(kind, calls, probe) {
|
|
142
|
+
if (kind === APPROVAL.ABSENT) return undefined;
|
|
143
|
+
if (kind === APPROVAL.NO_REQUEST) return {};
|
|
144
|
+
return {
|
|
145
|
+
async request(req) {
|
|
146
|
+
calls.push({ req, ...probe() });
|
|
147
|
+
if (kind === APPROVAL.THROWS) throw new Error('simulated approval seam failure');
|
|
148
|
+
return typeof kind === 'function' ? kind(req) : kind;
|
|
149
|
+
}
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function makePersistence(storeRoot) {
|
|
154
|
+
return {
|
|
155
|
+
root: storeRoot,
|
|
156
|
+
compression: 'zstd',
|
|
157
|
+
async list() {
|
|
158
|
+
const headers = [];
|
|
159
|
+
if (!existsSync(storeRoot)) return headers;
|
|
160
|
+
for (const project of readdirSync(storeRoot)) {
|
|
161
|
+
const projectDir = join(storeRoot, project);
|
|
162
|
+
if (!lstatSync(projectDir).isDirectory()) continue;
|
|
163
|
+
for (const sessionDirName of readdirSync(projectDir)) {
|
|
164
|
+
const dir = join(projectDir, sessionDirName);
|
|
165
|
+
const name = readdirSync(dir).filter((n) => ARTIFACT_NAME.test(n)).sort().at(-1);
|
|
166
|
+
if (name) headers.push(readHeader(readFileSync(join(dir, name))));
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
return headers;
|
|
170
|
+
}
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function makeArtifact(header) {
|
|
175
|
+
const head = zstdCompressSync(Buffer.from(JSON.stringify(header) + '\n', 'utf8'), ZSTD_OPTS);
|
|
176
|
+
const event = zstdCompressSync(Buffer.from(JSON.stringify({ seq: 1, text: 'hello' }) + '\n', 'utf8'), ZSTD_OPTS);
|
|
177
|
+
return Buffer.concat([head, event]);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** Recursive, content-bearing listing: proves "no folder or file was created/changed". */
|
|
181
|
+
function snapshotTree(dir) {
|
|
182
|
+
const out = [];
|
|
183
|
+
const walk = (current, rel) => {
|
|
184
|
+
for (const name of readdirSync(current).sort()) {
|
|
185
|
+
const full = join(current, name);
|
|
186
|
+
const relName = rel ? `${rel}/${name}` : name;
|
|
187
|
+
if (lstatSync(full).isDirectory()) {
|
|
188
|
+
out.push(`${relName}/`);
|
|
189
|
+
walk(full, relName);
|
|
190
|
+
} else {
|
|
191
|
+
out.push(`${relName}:${readFileSync(full).toString('base64')}`);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
};
|
|
195
|
+
walk(dir, '');
|
|
196
|
+
return out;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Mount the plugin on a fresh fake host with real temp folders.
|
|
201
|
+
* Layout: <root>/fx/{alpha(=A, has notes.txt + sub/), beta(=B), free, free2, plain.txt},
|
|
202
|
+
* <root>/links (symlinks), <root>/home (DSH_HOME), <root>/store (session persistence).
|
|
203
|
+
* Workspace A owns one real session archive so "sessions untouched" is observable.
|
|
204
|
+
*/
|
|
205
|
+
function mountHost({ sandbox = MODE.WRITE, approval = OUTCOME.ALLOWED, tools = true } = {}) {
|
|
206
|
+
// Native realpath, matching the registry's fs.promises.realpath: the JS realpathSync keeps Windows 8.3
|
|
207
|
+
// short names (C:\Users\RUNNER~1\...) and caller letter case, so every expected path would then differ
|
|
208
|
+
// from the stored canonical one. Children joined below are created with exact case, so they stay canonical.
|
|
209
|
+
const root = realpathSync.native(mkdtempSync(join(tmpdir(), 'wsm-wstools-')));
|
|
210
|
+
const fx = join(root, 'fx');
|
|
211
|
+
const paths = {
|
|
212
|
+
root,
|
|
213
|
+
fx,
|
|
214
|
+
A: join(fx, 'alpha'),
|
|
215
|
+
B: join(fx, 'beta'),
|
|
216
|
+
sub: join(fx, 'alpha', 'sub'),
|
|
217
|
+
free: join(fx, 'free'),
|
|
218
|
+
free2: join(fx, 'free2'),
|
|
219
|
+
file: join(fx, 'plain.txt'),
|
|
220
|
+
links: join(root, 'links'),
|
|
221
|
+
home: join(root, 'home'),
|
|
222
|
+
store: join(root, 'store')
|
|
223
|
+
};
|
|
224
|
+
for (const dir of [paths.A, paths.B, paths.sub, paths.free, paths.free2, paths.links, paths.home, paths.store]) mkdirSync(dir, { recursive: true });
|
|
225
|
+
writeFileSync(join(paths.A, 'notes.txt'), 'alpha notes');
|
|
226
|
+
writeFileSync(paths.file, 'plain file');
|
|
227
|
+
const previousHome = process.env.DSH_HOME;
|
|
228
|
+
process.env.DSH_HOME = paths.home;
|
|
229
|
+
|
|
230
|
+
const spies = { setTitle: [], mutate: [], create: [], approval: [], resolve: [] };
|
|
231
|
+
const registry = makeRegistry(spies);
|
|
232
|
+
const entityA = registry.seed(makeEntity(WS.A.id, paths.A, WS.A.title, spies));
|
|
233
|
+
const entityB = registry.seed(makeEntity(WS.B.id, paths.B, WS.B.title, spies));
|
|
234
|
+
|
|
235
|
+
const sessionArtifact = artifactPath(paths.store, paths.A, SESSION_ID);
|
|
236
|
+
mkdirSync(dirname(sessionArtifact), { recursive: true });
|
|
237
|
+
const sessionHeader = { type: 'session', id: SESSION_ID, cwd: paths.A, title: 'Alpha discussion' };
|
|
238
|
+
writeFileSync(sessionArtifact, makeArtifact(sessionHeader));
|
|
239
|
+
entityA.record.sessionIds.push(SESSION_ID);
|
|
240
|
+
registry.headers.set(SESSION_ID, sessionHeader);
|
|
241
|
+
registry.sessionPaths.set(SESSION_ID, paths.A);
|
|
242
|
+
|
|
243
|
+
const sandboxPolicy = makeSandboxPolicy(sandbox, spies.resolve);
|
|
244
|
+
const registered = [];
|
|
245
|
+
const cleanups = [];
|
|
246
|
+
const handlers = new Map();
|
|
247
|
+
const ctx = {
|
|
248
|
+
workspaceRegistry: registry,
|
|
249
|
+
sessionPersistence: makePersistence(paths.store),
|
|
250
|
+
logger: { info() {}, warn() {}, error() {}, debug() {} },
|
|
251
|
+
get: (key) => (key === 'sandboxPolicy' ? sandboxPolicy : undefined),
|
|
252
|
+
effect(register) {
|
|
253
|
+
const cleanup = register();
|
|
254
|
+
if (typeof cleanup === 'function') cleanups.push(cleanup);
|
|
255
|
+
},
|
|
256
|
+
connection: { rpc: { handle(channel, handler) { handlers.set(channel, handler); return () => {}; } } }
|
|
257
|
+
};
|
|
258
|
+
if (tools) ctx.tools = { register: (def) => { registered.push(def); return () => {}; } };
|
|
259
|
+
const approvalService = makeApproval(approval, spies.approval, () => ({
|
|
260
|
+
setTitleCountAtRequest: spies.setTitle.length,
|
|
261
|
+
titleAtRequest: entityA.title
|
|
262
|
+
}));
|
|
263
|
+
if (approvalService !== undefined) ctx.approval = approvalService;
|
|
264
|
+
|
|
265
|
+
apply(ctx);
|
|
266
|
+
|
|
267
|
+
const host = {
|
|
268
|
+
ctx,
|
|
269
|
+
paths,
|
|
270
|
+
spies,
|
|
271
|
+
registry,
|
|
272
|
+
entityA,
|
|
273
|
+
entityB,
|
|
274
|
+
registered,
|
|
275
|
+
sessionArtifact,
|
|
276
|
+
sessionBytes: readFileSync(sessionArtifact),
|
|
277
|
+
tool(name) {
|
|
278
|
+
const def = registered.find((d) => d.name === name);
|
|
279
|
+
assert.ok(def, `tool ${name} is not registered (registered: ${registered.map((d) => d.name).join(', ')})`);
|
|
280
|
+
return def;
|
|
281
|
+
},
|
|
282
|
+
/** Tool lookup is synchronous so a missing tool fails loudly instead of masquerading as a rejection. */
|
|
283
|
+
runTool(name, args, exec = makeExec()) {
|
|
284
|
+
const def = host.tool(name);
|
|
285
|
+
return Promise.resolve().then(() => def.execute(args, exec));
|
|
286
|
+
},
|
|
287
|
+
async rpc(endpoint, payload) {
|
|
288
|
+
const dispatch = handlers.get(RPC_CHANNEL);
|
|
289
|
+
assert.equal(typeof dispatch, 'function', `RPC channel ${RPC_CHANNEL} not handled`);
|
|
290
|
+
return await dispatch(endpoint, payload ?? {});
|
|
291
|
+
},
|
|
292
|
+
snapshot() {
|
|
293
|
+
return registry.list().map((e) => ({ id: e.id, path: e.path, title: e.title, sessionIds: [...e.sessionIds] }));
|
|
294
|
+
},
|
|
295
|
+
fxTree() { return snapshotTree(fx); },
|
|
296
|
+
dispose() {
|
|
297
|
+
for (const cleanup of cleanups) { try { cleanup(); } catch { /* best effort */ } }
|
|
298
|
+
if (previousHome === undefined) delete process.env.DSH_HOME;
|
|
299
|
+
else process.env.DSH_HOME = previousHome;
|
|
300
|
+
rmSync(root, RM_TREE_OPTS);
|
|
301
|
+
}
|
|
302
|
+
};
|
|
303
|
+
hosts.push(host);
|
|
304
|
+
return host;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
function makeExec({ agent = true } = {}) {
|
|
308
|
+
const signal = new AbortController().signal;
|
|
309
|
+
const callId = `call-${randomUUID()}`;
|
|
310
|
+
return agent ? { agent: { id: 'agent-caller', session: CALLER_SESSION }, signal, callId } : { signal, callId };
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
const outcomeOf = (value) => () => value;
|
|
314
|
+
//#endregion
|
|
315
|
+
|
|
316
|
+
//#region assertions
|
|
317
|
+
async function expectToolCode(promise, code) {
|
|
318
|
+
let caught;
|
|
319
|
+
await assert.rejects(promise, (err) => { caught = err; return true; });
|
|
320
|
+
assert.ok(caught instanceof Error, `expected an Error, got ${String(caught)}`);
|
|
321
|
+
assert.equal(caught.code, code, `error.code (message: ${caught.message})`);
|
|
322
|
+
assert.ok(String(caught.message).startsWith(`[${code}] `), `message should start with "[${code}] ": ${caught.message}`);
|
|
323
|
+
return caught;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
async function expectToolCodeIn(promise, codes) {
|
|
327
|
+
let caught;
|
|
328
|
+
await assert.rejects(promise, (err) => { caught = err; return true; });
|
|
329
|
+
assert.ok(caught instanceof Error, `expected an Error, got ${String(caught)}`);
|
|
330
|
+
assert.ok(codes.includes(caught.code), `error.code ${caught.code} not in ${codes.join('|')} (message: ${caught.message})`);
|
|
331
|
+
return caught;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
function expectRpcCode(res, code) {
|
|
335
|
+
assert.equal(res?.ok, false, `expected failure ${code}, got ${JSON.stringify(res)}`);
|
|
336
|
+
assert.equal(res.error?.code, code, JSON.stringify(res));
|
|
337
|
+
assert.equal(typeof res.error.message, 'string');
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
function expectRpcOk(res) {
|
|
341
|
+
assert.equal(res?.ok, true, `expected ok, got ${JSON.stringify(res)}`);
|
|
342
|
+
return res.value;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
/** Registry, titles, sessions, and fixture folders exactly as before; nothing retitled. */
|
|
346
|
+
function assertNothingChanged(host, before, tree) {
|
|
347
|
+
assert.deepEqual(host.snapshot(), before, 'registry state unchanged');
|
|
348
|
+
assert.deepEqual(host.spies.setTitle, [], 'setTitle never called');
|
|
349
|
+
assert.deepEqual(host.fxTree(), tree, 'no folder or file created/changed on disk');
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
/** Exactly one new workspace with the canonical path and the trimmed title. */
|
|
353
|
+
function assertCreated(host, before, value, { path, title }) {
|
|
354
|
+
const after = host.snapshot();
|
|
355
|
+
assert.equal(after.length, before.length + 1, 'registry gained exactly one workspace');
|
|
356
|
+
const added = after.filter((w) => !before.some((b) => b.id === w.id));
|
|
357
|
+
assert.equal(added.length, 1, 'exactly one new id');
|
|
358
|
+
assert.equal(added[0].path, path, 'stored path is the canonical directory');
|
|
359
|
+
assert.equal(added[0].title, title, 'stored title is the trimmed name');
|
|
360
|
+
for (const old of before) assert.deepEqual(after.find((w) => w.id === old.id), old, `existing workspace ${old.id} untouched`);
|
|
361
|
+
assert.equal(typeof value, 'object');
|
|
362
|
+
assert.equal(value.workspaceId, added[0].id, 'result.workspaceId');
|
|
363
|
+
assert.equal(value.title, title, 'result.title');
|
|
364
|
+
assert.equal(value.path, path, 'result.path');
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
/** Workspace A renamed to `title`; folder, sessions and the other workspace untouched. */
|
|
368
|
+
function assertRenamedA(host, value, tree, title) {
|
|
369
|
+
assert.equal(value.workspaceId, WS.A.id);
|
|
370
|
+
assert.equal(value.path, host.paths.A);
|
|
371
|
+
assert.equal(value.previousTitle, WS.A.title);
|
|
372
|
+
assert.equal(value.title, title);
|
|
373
|
+
assert.equal(value.changed, true);
|
|
374
|
+
assert.equal(host.entityA.title, title, 'registry title changed');
|
|
375
|
+
assert.deepEqual(host.spies.setTitle, [{ id: WS.A.id, title }], 'setTitle(trimmedName) called exactly once');
|
|
376
|
+
assert.equal(host.entityA.path, host.paths.A, 'folder path untouched');
|
|
377
|
+
assert.deepEqual(host.entityA.sessionIds, [SESSION_ID], 'sessions untouched');
|
|
378
|
+
assert.equal(host.entityB.title, WS.B.title, 'other workspace untouched');
|
|
379
|
+
assert.equal(host.registry.list().length, 2, 'no workspace added/removed');
|
|
380
|
+
assert.deepEqual(host.fxTree(), tree, 'folder contents untouched');
|
|
381
|
+
assert.deepEqual(readFileSync(host.sessionArtifact), host.sessionBytes, 'session archive bytes untouched');
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
function assertRenameNotApplied(host, tree) {
|
|
385
|
+
assert.equal(host.entityA.title, WS.A.title, 'title unchanged');
|
|
386
|
+
assert.deepEqual(host.spies.setTitle, [], 'setTitle never called');
|
|
387
|
+
assert.equal(host.entityB.title, WS.B.title);
|
|
388
|
+
assert.deepEqual(host.fxTree(), tree);
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
function trySymlink(t, target, linkPath) {
|
|
392
|
+
let lastErr;
|
|
393
|
+
for (const type of SYMLINK_TYPES) {
|
|
394
|
+
try {
|
|
395
|
+
symlinkSync(target, linkPath, type);
|
|
396
|
+
return true;
|
|
397
|
+
} catch (err) {
|
|
398
|
+
lastErr = err;
|
|
399
|
+
if (err.code !== SYMLINK_FALLBACK_CODE) break;
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
t.skip(`symlinks unavailable here: ${lastErr.code}`);
|
|
403
|
+
return false;
|
|
404
|
+
}
|
|
405
|
+
//#endregion
|
|
406
|
+
|
|
407
|
+
//#region registration (system)
|
|
408
|
+
test('system: apply registers create + rename next to the four existing tools, with JSON-schema required args', () => {
|
|
409
|
+
const host = mountHost();
|
|
410
|
+
const names = host.registered.map((d) => d.name);
|
|
411
|
+
for (const name of Object.values(TOOL)) assert.ok(names.includes(name), `missing ${name} in ${names.join(', ')}`);
|
|
412
|
+
assert.equal(new Set(names).size, names.length, 'tool names are unique');
|
|
413
|
+
const create = host.tool(TOOL.CREATE);
|
|
414
|
+
const rename = host.tool(TOOL.RENAME);
|
|
415
|
+
assert.deepEqual([...create.parameters.required].sort(), ['directory', 'name']);
|
|
416
|
+
assert.deepEqual([...rename.parameters.required].sort(), ['name', 'workspaceId']);
|
|
417
|
+
assert.ok(create.parameters.properties?.name && create.parameters.properties?.directory, 'create schema declares name + directory');
|
|
418
|
+
assert.ok(rename.parameters.properties?.workspaceId && rename.parameters.properties?.name, 'rename schema declares workspaceId + name');
|
|
419
|
+
for (const def of [create, rename]) {
|
|
420
|
+
assert.equal(def.parameters.type, 'object', `${def.name}.parameters.type`);
|
|
421
|
+
assert.equal(typeof def.description, 'string');
|
|
422
|
+
assert.ok(def.description.trim().length > 0, `${def.name}.description non-empty`);
|
|
423
|
+
assert.equal(typeof def.execute, 'function', `${def.name}.execute`);
|
|
424
|
+
assert.equal(typeof def.output?.render, 'function', `${def.name}.output.render`);
|
|
425
|
+
assert.equal(typeof def.output?.schema, 'object', `${def.name}.output.schema`);
|
|
426
|
+
}
|
|
427
|
+
});
|
|
428
|
+
//#endregion
|
|
429
|
+
|
|
430
|
+
//#region mover_create_workspace — name (EP / BVA)
|
|
431
|
+
const INVALID_NAMES = [
|
|
432
|
+
{ label: 'missing', omit: true },
|
|
433
|
+
{ label: 'non-string number', value: 42 },
|
|
434
|
+
{ label: 'null', value: null },
|
|
435
|
+
{ label: 'empty string (length 0)', value: '' },
|
|
436
|
+
{ label: 'single space (trims to length 0)', value: ' ' },
|
|
437
|
+
{ label: 'tabs/newlines only', value: ' \t\n ' }
|
|
438
|
+
];
|
|
439
|
+
for (const c of INVALID_NAMES) {
|
|
440
|
+
test(`create tool EP name ${c.label} -> invalid-input, registry and disk unchanged`, async () => {
|
|
441
|
+
const host = mountHost();
|
|
442
|
+
const before = host.snapshot();
|
|
443
|
+
const tree = host.fxTree();
|
|
444
|
+
const args = { directory: host.paths.free };
|
|
445
|
+
if (!c.omit) args.name = c.value;
|
|
446
|
+
await expectToolCode(host.runTool(TOOL.CREATE, args), CODE.INVALID);
|
|
447
|
+
assertNothingChanged(host, before, tree);
|
|
448
|
+
});
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
const VALID_NAMES = [
|
|
452
|
+
{ label: 'BVA single character', value: 'x', title: 'x' },
|
|
453
|
+
{ label: 'BVA single character padded by spaces', value: ' x ', title: 'x' },
|
|
454
|
+
{ label: 'surrounding whitespace is trimmed, inner kept', value: ' New Workspace\t', title: 'New Workspace' },
|
|
455
|
+
{ label: 'case differs from existing "Alpha" (exact match only)', value: 'alpha', title: 'alpha' },
|
|
456
|
+
{ label: 'prefix of existing "Alpha" is a different name', value: 'Alph', title: 'Alph' }
|
|
457
|
+
];
|
|
458
|
+
for (const c of VALID_NAMES) {
|
|
459
|
+
test(`create tool EP/BVA name ${c.label} -> created with title "${c.title}"`, async () => {
|
|
460
|
+
const host = mountHost();
|
|
461
|
+
const before = host.snapshot();
|
|
462
|
+
const value = await host.runTool(TOOL.CREATE, { name: c.value, directory: host.paths.free });
|
|
463
|
+
assertCreated(host, before, value, { path: host.paths.free, title: c.title });
|
|
464
|
+
});
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
for (const name of [WS.A.title, ` ${WS.B.title} `, `\t${WS.A.title}\n`]) {
|
|
468
|
+
test(`create tool name ${JSON.stringify(name)} equals an existing title after trim -> conflict, registry unchanged`, async () => {
|
|
469
|
+
const host = mountHost();
|
|
470
|
+
const before = host.snapshot();
|
|
471
|
+
const tree = host.fxTree();
|
|
472
|
+
await expectToolCode(host.runTool(TOOL.CREATE, { name, directory: host.paths.free }), CODE.CONFLICT);
|
|
473
|
+
assertNothingChanged(host, before, tree);
|
|
474
|
+
});
|
|
475
|
+
}
|
|
476
|
+
//#endregion
|
|
477
|
+
|
|
478
|
+
//#region mover_create_workspace — directory (EP / BVA)
|
|
479
|
+
const INVALID_DIRECTORIES = [
|
|
480
|
+
{ label: 'relative bare name', dir: () => 'free', code: CODE.INVALID },
|
|
481
|
+
{ label: 'relative ./ path', dir: () => './fx/free', code: CODE.INVALID },
|
|
482
|
+
{ label: 'empty string', dir: () => '', code: CODE.INVALID },
|
|
483
|
+
{ label: 'missing argument', omit: true, code: CODE.INVALID },
|
|
484
|
+
{ label: 'non-string number', dir: () => 42, code: CODE.INVALID },
|
|
485
|
+
{ label: 'absolute path to a regular file', dir: (p) => p.file, code: CODE.INVALID },
|
|
486
|
+
{ label: 'absolute nonexistent leaf', dir: (p) => join(p.fx, 'missing'), code: CODE.NOT_FOUND },
|
|
487
|
+
{ label: 'absolute nonexistent nested path', dir: (p) => join(p.fx, 'missing', 'deeper'), code: CODE.NOT_FOUND }
|
|
488
|
+
];
|
|
489
|
+
for (const c of INVALID_DIRECTORIES) {
|
|
490
|
+
test(`create tool EP directory ${c.label} -> ${c.code}; nothing created on disk`, async () => {
|
|
491
|
+
const host = mountHost();
|
|
492
|
+
const before = host.snapshot();
|
|
493
|
+
const tree = host.fxTree();
|
|
494
|
+
const args = { name: NEW_TITLE };
|
|
495
|
+
if (!c.omit) args.directory = c.dir(host.paths);
|
|
496
|
+
await expectToolCode(host.runTool(TOOL.CREATE, args), c.code);
|
|
497
|
+
assertNothingChanged(host, before, tree);
|
|
498
|
+
assert.equal(existsSync(join(host.paths.fx, 'missing')), false, 'tool never creates the missing folder');
|
|
499
|
+
});
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
const SAME_FOLDER_SPELLINGS = [
|
|
503
|
+
{ label: 'exact canonical path', dir: (p) => p.A },
|
|
504
|
+
{ label: 'trailing slash', dir: (p) => `${p.A}/` },
|
|
505
|
+
{ label: 'trailing /. segment', dir: (p) => `${p.A}/.` },
|
|
506
|
+
{ label: '.. segment through a sibling', dir: (p) => `${p.B}/../alpha` },
|
|
507
|
+
{ label: '.. segment through its own sub-folder', dir: (p) => `${p.sub}/..` },
|
|
508
|
+
{ label: 'symlink to the workspace folder', link: true }
|
|
509
|
+
];
|
|
510
|
+
for (const c of SAME_FOLDER_SPELLINGS) {
|
|
511
|
+
test(`create tool directory already used (${c.label}) -> conflict; no new workspace, existing title untouched`, async (t) => {
|
|
512
|
+
const host = mountHost();
|
|
513
|
+
let directory;
|
|
514
|
+
if (c.link) {
|
|
515
|
+
directory = join(host.paths.links, 'to-alpha');
|
|
516
|
+
if (!trySymlink(t, host.paths.A, directory)) return;
|
|
517
|
+
} else {
|
|
518
|
+
directory = c.dir(host.paths);
|
|
519
|
+
}
|
|
520
|
+
const before = host.snapshot();
|
|
521
|
+
const tree = host.fxTree();
|
|
522
|
+
await expectToolCode(host.runTool(TOOL.CREATE, { name: NEW_TITLE, directory }), CODE.CONFLICT);
|
|
523
|
+
assertNothingChanged(host, before, tree);
|
|
524
|
+
assert.equal(host.entityA.title, WS.A.title, 'owner of the folder keeps its title');
|
|
525
|
+
});
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
// BVA on folder identity: "same folder" is the host registry's canonical identity (native realpath), so the
|
|
529
|
+
// A path with its last segment upper-cased IS folder A on a case-insensitive filesystem (APFS / NTFS defaults)
|
|
530
|
+
// and simply does not exist on a case-sensitive one (Linux), where it is an absolute nonexistent path.
|
|
531
|
+
test('create tool BVA case-variant spelling of an existing workspace folder -> conflict if case-insensitive, not-found if case-sensitive', async (t) => {
|
|
532
|
+
const host = mountHost();
|
|
533
|
+
const { A } = host.paths;
|
|
534
|
+
const directory = join(dirname(A), basename(A).toUpperCase());
|
|
535
|
+
const exists = existsSync(directory);
|
|
536
|
+
if (exists && realpathSync.native(directory) !== realpathSync.native(A)) {
|
|
537
|
+
t.skip('the upper-cased spelling exists but native realpath does not identify it with folder A');
|
|
538
|
+
return;
|
|
539
|
+
}
|
|
540
|
+
const before = host.snapshot();
|
|
541
|
+
const tree = host.fxTree();
|
|
542
|
+
await expectToolCode(host.runTool(TOOL.CREATE, { name: NEW_TITLE, directory }), exists ? CODE.CONFLICT : CODE.NOT_FOUND);
|
|
543
|
+
assertNothingChanged(host, before, tree);
|
|
544
|
+
assert.equal(host.entityA.title, WS.A.title, 'owner of the folder keeps its title');
|
|
545
|
+
});
|
|
546
|
+
|
|
547
|
+
const DIFFERENT_FOLDERS = [
|
|
548
|
+
{ label: 'sub-folder of an existing workspace', dir: (p) => p.sub, canonical: (p) => p.sub },
|
|
549
|
+
{ label: 'parent folder of existing workspaces', dir: (p) => p.fx, canonical: (p) => p.fx },
|
|
550
|
+
{ label: 'unused sibling folder', dir: (p) => p.free, canonical: (p) => p.free },
|
|
551
|
+
{ label: 'unused folder with trailing slash', dir: (p) => `${p.free}/`, canonical: (p) => p.free },
|
|
552
|
+
{ label: 'unused folder via .. segment', dir: (p) => `${p.A}/../free`, canonical: (p) => p.free },
|
|
553
|
+
{ label: 'symlink to an unused folder', link: true, canonical: (p) => p.free }
|
|
554
|
+
];
|
|
555
|
+
for (const c of DIFFERENT_FOLDERS) {
|
|
556
|
+
test(`create tool different folder (${c.label}) -> created at the canonical path`, async (t) => {
|
|
557
|
+
const host = mountHost();
|
|
558
|
+
let directory;
|
|
559
|
+
if (c.link) {
|
|
560
|
+
directory = join(host.paths.links, 'to-free');
|
|
561
|
+
if (!trySymlink(t, host.paths.free, directory)) return;
|
|
562
|
+
} else {
|
|
563
|
+
directory = c.dir(host.paths);
|
|
564
|
+
}
|
|
565
|
+
const before = host.snapshot();
|
|
566
|
+
const tree = host.fxTree();
|
|
567
|
+
const value = await host.runTool(TOOL.CREATE, { name: ` ${NEW_TITLE} `, directory });
|
|
568
|
+
assertCreated(host, before, value, { path: c.canonical(host.paths), title: NEW_TITLE });
|
|
569
|
+
assert.deepEqual(host.fxTree(), tree, 'create never writes into the folder');
|
|
570
|
+
});
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
test('create tool when both name and directory collide -> conflict, registry unchanged', async () => {
|
|
574
|
+
const host = mountHost();
|
|
575
|
+
const before = host.snapshot();
|
|
576
|
+
const tree = host.fxTree();
|
|
577
|
+
await expectToolCode(host.runTool(TOOL.CREATE, { name: WS.B.title, directory: host.paths.A }), CODE.CONFLICT);
|
|
578
|
+
assertNothingChanged(host, before, tree);
|
|
579
|
+
});
|
|
580
|
+
|
|
581
|
+
test('create tool with invalid name AND nonexistent directory is rejected (either input code), registry unchanged', async () => {
|
|
582
|
+
const host = mountHost();
|
|
583
|
+
const before = host.snapshot();
|
|
584
|
+
const tree = host.fxTree();
|
|
585
|
+
await expectToolCodeIn(host.runTool(TOOL.CREATE, { name: ' ', directory: join(host.paths.fx, 'missing') }), [CODE.INVALID, CODE.NOT_FOUND]);
|
|
586
|
+
assertNothingChanged(host, before, tree);
|
|
587
|
+
});
|
|
588
|
+
|
|
589
|
+
// Decision: create is never approval-gated, whatever the sandbox mode, even if the seam would refuse.
|
|
590
|
+
for (const sandbox of [MODE.READ, MODE.WRITE, MODE.FULL, SANDBOX.ABSENT]) {
|
|
591
|
+
test(`create tool never asks for approval (sandbox ${sandbox}, seam would reject)`, async () => {
|
|
592
|
+
const host = mountHost({ sandbox, approval: OUTCOME.REJECTED });
|
|
593
|
+
const before = host.snapshot();
|
|
594
|
+
const value = await host.runTool(TOOL.CREATE, { name: NEW_TITLE, directory: host.paths.free });
|
|
595
|
+
assertCreated(host, before, value, { path: host.paths.free, title: NEW_TITLE });
|
|
596
|
+
assert.equal(host.spies.approval.length, 0, 'approval.request not called');
|
|
597
|
+
});
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
test('state transition (create): S0 -> create -> S1; reused folder / reused name rejected in S1; distinct -> S2', async () => {
|
|
601
|
+
const host = mountHost();
|
|
602
|
+
const s0 = host.snapshot();
|
|
603
|
+
const first = await host.runTool(TOOL.CREATE, { name: NEW_TITLE, directory: host.paths.free });
|
|
604
|
+
assertCreated(host, s0, first, { path: host.paths.free, title: NEW_TITLE });
|
|
605
|
+
const s1 = host.snapshot();
|
|
606
|
+
const tree = host.fxTree();
|
|
607
|
+
|
|
608
|
+
// folder now owned by the freshly created workspace
|
|
609
|
+
await expectToolCode(host.runTool(TOOL.CREATE, { name: SECOND_NEW_TITLE, directory: `${host.paths.free}/` }), CODE.CONFLICT);
|
|
610
|
+
assertNothingChanged(host, s1, tree);
|
|
611
|
+
assert.equal(host.registry.get(first.workspaceId).title, NEW_TITLE, 'new owner keeps its title');
|
|
612
|
+
|
|
613
|
+
// title now taken by the freshly created workspace
|
|
614
|
+
await expectToolCode(host.runTool(TOOL.CREATE, { name: ` ${NEW_TITLE} `, directory: host.paths.free2 }), CODE.CONFLICT);
|
|
615
|
+
assertNothingChanged(host, s1, tree);
|
|
616
|
+
|
|
617
|
+
const second = await host.runTool(TOOL.CREATE, { name: SECOND_NEW_TITLE, directory: host.paths.free2 });
|
|
618
|
+
assertCreated(host, s1, second, { path: host.paths.free2, title: SECOND_NEW_TITLE });
|
|
619
|
+
assert.equal(host.registry.list().length, 4);
|
|
620
|
+
});
|
|
621
|
+
//#endregion
|
|
622
|
+
|
|
623
|
+
//#region mover_rename_workspace — validation before approval (EP / BVA)
|
|
624
|
+
const RENAME_VALIDATION = [
|
|
625
|
+
{ label: 'unknown workspaceId', args: { workspaceId: 'wid-nope', name: NEW_TITLE }, code: CODE.NOT_FOUND },
|
|
626
|
+
{ label: 'empty workspaceId', args: { workspaceId: '', name: NEW_TITLE }, code: CODE.INVALID },
|
|
627
|
+
{ label: 'missing workspaceId', args: { name: NEW_TITLE }, code: CODE.INVALID },
|
|
628
|
+
{ label: 'empty name', args: { workspaceId: WS.A.id, name: '' }, code: CODE.INVALID },
|
|
629
|
+
{ label: 'whitespace-only name', args: { workspaceId: WS.A.id, name: ' \t ' }, code: CODE.INVALID },
|
|
630
|
+
{ label: 'missing name', args: { workspaceId: WS.A.id }, code: CODE.INVALID },
|
|
631
|
+
{ label: 'name equals another workspace title', args: { workspaceId: WS.A.id, name: WS.B.title }, code: CODE.CONFLICT },
|
|
632
|
+
{ label: 'name equals another workspace title after trim', args: { workspaceId: WS.A.id, name: ` ${WS.B.title}\n` }, code: CODE.CONFLICT },
|
|
633
|
+
{ label: 'unknown workspaceId with blank name', args: { workspaceId: 'wid-nope', name: ' ' }, codes: [CODE.NOT_FOUND, CODE.INVALID] },
|
|
634
|
+
{ label: 'non-string workspaceId', args: { workspaceId: 42, name: NEW_TITLE }, codes: [CODE.INVALID, CODE.NOT_FOUND] },
|
|
635
|
+
{ label: 'non-string name', args: { workspaceId: WS.A.id, name: 42 }, codes: [CODE.INVALID] }
|
|
636
|
+
];
|
|
637
|
+
for (const c of RENAME_VALIDATION) {
|
|
638
|
+
test(`rename tool ${c.label} -> ${c.code ?? c.codes.join('|')} before any approval prompt`, async () => {
|
|
639
|
+
const host = mountHost({ sandbox: MODE.WRITE, approval: OUTCOME.ALLOWED });
|
|
640
|
+
const before = host.snapshot();
|
|
641
|
+
const tree = host.fxTree();
|
|
642
|
+
const call = host.runTool(TOOL.RENAME, c.args);
|
|
643
|
+
if (c.code) await expectToolCode(call, c.code);
|
|
644
|
+
else await expectToolCodeIn(call, c.codes);
|
|
645
|
+
assert.equal(host.spies.approval.length, 0, 'approval must not be requested for invalid input');
|
|
646
|
+
assertNothingChanged(host, before, tree);
|
|
647
|
+
});
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
for (const name of [WS.A.title, ` ${WS.A.title} `, `\t${WS.A.title}\n`]) {
|
|
651
|
+
test(`rename tool to current title ${JSON.stringify(name)} -> no-op: changed false, no prompt, setTitle not called`, async () => {
|
|
652
|
+
const host = mountHost({ sandbox: MODE.WRITE, approval: OUTCOME.REJECTED });
|
|
653
|
+
const before = host.snapshot();
|
|
654
|
+
const tree = host.fxTree();
|
|
655
|
+
const value = await host.runTool(TOOL.RENAME, { workspaceId: WS.A.id, name });
|
|
656
|
+
assert.equal(value.changed, false);
|
|
657
|
+
assert.equal(value.workspaceId, WS.A.id);
|
|
658
|
+
assert.equal(host.spies.approval.length, 0, 'no approval for a no-op');
|
|
659
|
+
assertNothingChanged(host, before, tree);
|
|
660
|
+
});
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
// BVA on "exactly equals": a case-only difference is neither a no-op nor a conflict.
|
|
664
|
+
for (const c of [
|
|
665
|
+
{ label: 'own title in another case is a real rename', name: 'alpha' },
|
|
666
|
+
{ label: 'other workspace title in another case is not a conflict', name: 'beta' }
|
|
667
|
+
]) {
|
|
668
|
+
test(`rename tool ${c.label} -> renamed after one approval`, async () => {
|
|
669
|
+
const host = mountHost({ sandbox: MODE.WRITE, approval: OUTCOME.ALLOWED });
|
|
670
|
+
const tree = host.fxTree();
|
|
671
|
+
const value = await host.runTool(TOOL.RENAME, { workspaceId: WS.A.id, name: c.name });
|
|
672
|
+
assert.equal(host.spies.approval.length, 1);
|
|
673
|
+
assertRenamedA(host, value, tree, c.name);
|
|
674
|
+
});
|
|
675
|
+
}
|
|
676
|
+
//#endregion
|
|
677
|
+
|
|
678
|
+
//#region mover_rename_workspace — confirmation decision table
|
|
679
|
+
// Columns: sandbox (service / mode) | approval seam | exec.agent | -> prompts | result.
|
|
680
|
+
// Rename A "Alpha" -> " Gamma " (trimmed "Gamma") in every rule.
|
|
681
|
+
const callerOnlyFullAccess = (req) => (req?.session === CALLER_SESSION ? MODE.FULL : MODE.WRITE);
|
|
682
|
+
const otherOnlyFullAccess = (req) => (req?.session === OTHER_SESSION ? MODE.FULL : MODE.WRITE);
|
|
683
|
+
const RENAME_DECISIONS = [
|
|
684
|
+
{ id: 'D01', sandbox: MODE.FULL, approval: OUTCOME.ALLOWED, prompts: 0, renamed: true, why: 'full access renames without approval' },
|
|
685
|
+
{ id: 'D02', sandbox: MODE.FULL, approval: OUTCOME.REJECTED, prompts: 0, renamed: true, why: 'full access never consults a refusing seam' },
|
|
686
|
+
{ id: 'D03', sandbox: MODE.FULL, approval: APPROVAL.ABSENT, prompts: 0, renamed: true, why: 'full access, no seam' },
|
|
687
|
+
{ id: 'D04', sandbox: callerOnlyFullAccess, approval: OUTCOME.REJECTED, prompts: 0, renamed: true, checkSession: true, why: 'policy resolved for the CALLING session grants full access' },
|
|
688
|
+
{ id: 'D05', sandbox: otherOnlyFullAccess, approval: OUTCOME.ALLOWED, prompts: 1, renamed: true, checkSession: true, why: 'full access of another session does not count' },
|
|
689
|
+
{ id: 'D06', sandbox: MODE.WRITE, approval: OUTCOME.ALLOWED, prompts: 1, renamed: true, why: 'workspace-write + allowed-once' },
|
|
690
|
+
{ id: 'D07', sandbox: MODE.READ, approval: OUTCOME.ALLOWED, prompts: 1, renamed: true, why: 'read-only + allowed-once' },
|
|
691
|
+
{ id: 'D08', sandbox: SANDBOX.ABSENT, approval: OUTCOME.ALLOWED, prompts: 1, renamed: true, why: 'no sandboxPolicy service + allowed-once' },
|
|
692
|
+
{ id: 'D09', sandbox: SANDBOX.THROWS, approval: OUTCOME.ALLOWED, prompts: 1, renamed: true, why: 'resolve throws + allowed-once' },
|
|
693
|
+
{ id: 'D10', sandbox: SANDBOX.NO_RESOLVE, approval: OUTCOME.ALLOWED, prompts: 1, renamed: true, why: 'service without resolve + allowed-once' },
|
|
694
|
+
{ id: 'D11', sandbox: SANDBOX.EMPTY_RESULT, approval: OUTCOME.ALLOWED, prompts: 1, renamed: true, why: 'resolve returns nothing + allowed-once' },
|
|
695
|
+
{ id: 'D12', sandbox: () => 'DANGER-FULL-ACCESS', approval: OUTCOME.ALLOWED, prompts: 1, renamed: true, why: 'BVA: mode must equal "danger-full-access" exactly' },
|
|
696
|
+
{ id: 'D13', sandbox: MODE.WRITE, approval: OUTCOME.REJECTED, prompts: 1, renamed: false, why: 'rejected' },
|
|
697
|
+
{ id: 'D14', sandbox: MODE.WRITE, approval: OUTCOME.CANCELLED, prompts: 1, renamed: false, why: 'cancelled' },
|
|
698
|
+
{ id: 'D15', sandbox: MODE.WRITE, approval: OUTCOME.UNAVAILABLE, prompts: 1, renamed: false, why: 'unavailable' },
|
|
699
|
+
{ id: 'D16', sandbox: MODE.WRITE, approval: outcomeOf('denied'), prompts: 1, renamed: false, why: 'unknown outcome value' },
|
|
700
|
+
{ id: 'D17', sandbox: MODE.WRITE, approval: outcomeOf(undefined), prompts: 1, renamed: false, why: 'undefined outcome' },
|
|
701
|
+
{ id: 'D18', sandbox: MODE.WRITE, approval: outcomeOf('ALLOWED-ONCE'), prompts: 1, renamed: false, why: 'BVA: grant must equal "allowed-once" exactly' },
|
|
702
|
+
{ id: 'D19', sandbox: MODE.WRITE, approval: APPROVAL.THROWS, prompts: 1, renamed: false, why: 'request throws' },
|
|
703
|
+
{ id: 'D20', sandbox: MODE.READ, approval: OUTCOME.REJECTED, prompts: 1, renamed: false, why: 'read-only + rejected' },
|
|
704
|
+
{ id: 'D21', sandbox: SANDBOX.ABSENT, approval: OUTCOME.REJECTED, prompts: 1, renamed: false, why: 'no sandboxPolicy + rejected' },
|
|
705
|
+
{ id: 'D22', sandbox: SANDBOX.THROWS, approval: OUTCOME.CANCELLED, prompts: 1, renamed: false, why: 'resolve throws + cancelled' },
|
|
706
|
+
{ id: 'D23', sandbox: MODE.WRITE, approval: APPROVAL.ABSENT, prompts: 0, renamed: false, why: 'host without approval service refuses' },
|
|
707
|
+
{ id: 'D24', sandbox: MODE.WRITE, approval: APPROVAL.NO_REQUEST, prompts: 0, renamed: false, why: 'approval service without request() refuses' },
|
|
708
|
+
{ id: 'D25', sandbox: MODE.WRITE, approval: OUTCOME.REJECTED, agent: false, prompts: 0, renamed: false, why: 'no exec.agent refuses' },
|
|
709
|
+
{ id: 'D26', sandbox: SANDBOX.ABSENT, approval: APPROVAL.ABSENT, prompts: 0, renamed: false, why: 'neither service present' }
|
|
710
|
+
];
|
|
711
|
+
for (const rule of RENAME_DECISIONS) {
|
|
712
|
+
test(`rename decision ${rule.id}: ${rule.why} -> ${rule.prompts} prompt(s), ${rule.renamed ? 'renamed' : 'refused, title unchanged'}`, async () => {
|
|
713
|
+
const host = mountHost({ sandbox: rule.sandbox, approval: rule.approval });
|
|
714
|
+
const tree = host.fxTree();
|
|
715
|
+
const exec = makeExec({ agent: rule.agent !== false });
|
|
716
|
+
const call = host.runTool(TOOL.RENAME, { workspaceId: WS.A.id, name: ` ${NEW_TITLE} ` }, exec);
|
|
717
|
+
|
|
718
|
+
if (rule.renamed) {
|
|
719
|
+
const value = await call;
|
|
720
|
+
assertRenamedA(host, value, tree, NEW_TITLE);
|
|
721
|
+
} else {
|
|
722
|
+
await assert.rejects(call, (err) => err instanceof Error);
|
|
723
|
+
assertRenameNotApplied(host, tree);
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
assert.equal(host.spies.approval.length, rule.prompts, 'approval.request call count');
|
|
727
|
+
if (rule.prompts === 1) {
|
|
728
|
+
const [{ req, setTitleCountAtRequest, titleAtRequest }] = host.spies.approval;
|
|
729
|
+
assert.equal(req.agent, exec.agent, 'request.agent is exec.agent');
|
|
730
|
+
assert.equal(req.toolName, TOOL.RENAME, 'request.toolName');
|
|
731
|
+
assert.ok(req.signal === exec.signal || req.signal instanceof AbortSignal, 'request.signal forwards the call signal');
|
|
732
|
+
assert.equal(typeof req.reason, 'string');
|
|
733
|
+
assert.ok(req.reason.includes(WS.A.title) && req.reason.includes(NEW_TITLE), `reason names both titles: ${req.reason}`);
|
|
734
|
+
assert.equal(setTitleCountAtRequest, 0, 'approval asked BEFORE renaming');
|
|
735
|
+
assert.equal(titleAtRequest, WS.A.title);
|
|
736
|
+
}
|
|
737
|
+
if (rule.checkSession) {
|
|
738
|
+
assert.ok(host.spies.resolve.some((req) => req?.session === CALLER_SESSION), 'sandboxPolicy.resolve({ session: exec.agent.session })');
|
|
739
|
+
}
|
|
740
|
+
});
|
|
741
|
+
}
|
|
742
|
+
//#endregion
|
|
743
|
+
|
|
744
|
+
//#region mover_rename_workspace — state transitions
|
|
745
|
+
test('state transition (rename): Alpha -> Gamma -> Gamma(no-op) -> Beta(conflict) -> Alpha(freed title) ', async () => {
|
|
746
|
+
const host = mountHost({ sandbox: MODE.WRITE, approval: OUTCOME.ALLOWED });
|
|
747
|
+
|
|
748
|
+
const toGamma = await host.runTool(TOOL.RENAME, { workspaceId: WS.A.id, name: NEW_TITLE });
|
|
749
|
+
assert.equal(toGamma.changed, true);
|
|
750
|
+
assert.equal(host.entityA.title, NEW_TITLE);
|
|
751
|
+
assert.equal(host.spies.approval.length, 1);
|
|
752
|
+
|
|
753
|
+
const noop = await host.runTool(TOOL.RENAME, { workspaceId: WS.A.id, name: ` ${NEW_TITLE} ` });
|
|
754
|
+
assert.equal(noop.changed, false);
|
|
755
|
+
assert.equal(host.spies.approval.length, 1, 'no prompt for the no-op');
|
|
756
|
+
|
|
757
|
+
await expectToolCode(host.runTool(TOOL.RENAME, { workspaceId: WS.A.id, name: WS.B.title }), CODE.CONFLICT);
|
|
758
|
+
assert.equal(host.entityA.title, NEW_TITLE);
|
|
759
|
+
assert.equal(host.spies.approval.length, 1, 'no prompt for the conflict');
|
|
760
|
+
|
|
761
|
+
const back = await host.runTool(TOOL.RENAME, { workspaceId: WS.A.id, name: WS.A.title });
|
|
762
|
+
assert.equal(back.changed, true);
|
|
763
|
+
assert.equal(back.previousTitle, NEW_TITLE);
|
|
764
|
+
assert.equal(back.title, WS.A.title);
|
|
765
|
+
assert.equal(host.entityA.title, WS.A.title);
|
|
766
|
+
assert.equal(host.spies.approval.length, 2);
|
|
767
|
+
assert.deepEqual(host.spies.setTitle.map((c) => c.title), [NEW_TITLE, WS.A.title]);
|
|
768
|
+
|
|
769
|
+
// the title "Gamma" was released and "Beta" is still held
|
|
770
|
+
await expectToolCode(host.runTool(TOOL.RENAME, { workspaceId: WS.A.id, name: WS.B.title }), CODE.CONFLICT);
|
|
771
|
+
const reuse = await host.runTool(TOOL.CREATE, { name: NEW_TITLE, directory: host.paths.free });
|
|
772
|
+
assert.equal(reuse.title, NEW_TITLE, 'freed title can be used by a new workspace');
|
|
773
|
+
});
|
|
774
|
+
|
|
775
|
+
test('state transition (rename): refused attempt keeps state, a later approved retry renames', async () => {
|
|
776
|
+
const outcomes = [OUTCOME.REJECTED, OUTCOME.ALLOWED];
|
|
777
|
+
const host = mountHost({ sandbox: MODE.READ, approval: () => outcomes.shift() });
|
|
778
|
+
const tree = host.fxTree();
|
|
779
|
+
await assert.rejects(host.runTool(TOOL.RENAME, { workspaceId: WS.A.id, name: NEW_TITLE }), (err) => err instanceof Error);
|
|
780
|
+
assertRenameNotApplied(host, tree);
|
|
781
|
+
const value = await host.runTool(TOOL.RENAME, { workspaceId: WS.A.id, name: NEW_TITLE });
|
|
782
|
+
assert.equal(host.spies.approval.length, 2);
|
|
783
|
+
assertRenamedA(host, value, tree, NEW_TITLE);
|
|
784
|
+
});
|
|
785
|
+
//#endregion
|
|
786
|
+
|
|
787
|
+
//#region RPC mover.ws.create / mover.ws.rename (integration)
|
|
788
|
+
const RPC_CREATE_FAILURES = [
|
|
789
|
+
{ label: 'blank name', payload: (p) => ({ name: ' ', directory: p.free }), code: CODE.INVALID },
|
|
790
|
+
{ label: 'missing name', payload: (p) => ({ directory: p.free }), code: CODE.INVALID },
|
|
791
|
+
{ label: 'relative directory', payload: () => ({ name: NEW_TITLE, directory: 'fx/free' }), code: CODE.INVALID },
|
|
792
|
+
{ label: 'nonexistent directory', payload: (p) => ({ name: NEW_TITLE, directory: join(p.fx, 'missing') }), code: CODE.NOT_FOUND },
|
|
793
|
+
{ label: 'regular file', payload: (p) => ({ name: NEW_TITLE, directory: p.file }), code: CODE.INVALID },
|
|
794
|
+
{ label: 'existing title', payload: (p) => ({ name: ` ${WS.A.title} `, directory: p.free }), code: CODE.CONFLICT },
|
|
795
|
+
{ label: 'folder in use (trailing slash)', payload: (p) => ({ name: NEW_TITLE, directory: `${p.B}/` }), code: CODE.CONFLICT },
|
|
796
|
+
{ label: 'folder in use (.. segment)', payload: (p) => ({ name: NEW_TITLE, directory: `${p.A}/../beta` }), code: CODE.CONFLICT }
|
|
797
|
+
];
|
|
798
|
+
for (const c of RPC_CREATE_FAILURES) {
|
|
799
|
+
test(`rpc mover.ws.create ${c.label} -> { ok:false, code:${c.code} }, registry unchanged`, async () => {
|
|
800
|
+
const host = mountHost();
|
|
801
|
+
const before = host.snapshot();
|
|
802
|
+
const tree = host.fxTree();
|
|
803
|
+
expectRpcCode(await host.rpc(RPC.CREATE, c.payload(host.paths)), c.code);
|
|
804
|
+
assertNothingChanged(host, before, tree);
|
|
805
|
+
});
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
test('rpc mover.ws.create via symlink to an existing workspace folder -> conflict', async (t) => {
|
|
809
|
+
const host = mountHost();
|
|
810
|
+
const link = join(host.paths.links, 'to-beta');
|
|
811
|
+
if (!trySymlink(t, host.paths.B, link)) return;
|
|
812
|
+
const before = host.snapshot();
|
|
813
|
+
const tree = host.fxTree();
|
|
814
|
+
expectRpcCode(await host.rpc(RPC.CREATE, { name: NEW_TITLE, directory: link }), CODE.CONFLICT);
|
|
815
|
+
assertNothingChanged(host, before, tree);
|
|
816
|
+
});
|
|
817
|
+
|
|
818
|
+
test('rpc mover.ws.create success -> { ok:true, value:{ workspaceId, title, path } }, no approval', async () => {
|
|
819
|
+
const host = mountHost({ sandbox: MODE.READ, approval: OUTCOME.REJECTED });
|
|
820
|
+
const before = host.snapshot();
|
|
821
|
+
const value = expectRpcOk(await host.rpc(RPC.CREATE, { name: `\t${NEW_TITLE} `, directory: `${host.paths.free}/` }));
|
|
822
|
+
assertCreated(host, before, value, { path: host.paths.free, title: NEW_TITLE });
|
|
823
|
+
assert.equal(host.spies.approval.length, 0);
|
|
824
|
+
});
|
|
825
|
+
|
|
826
|
+
const RPC_RENAME_FAILURES = [
|
|
827
|
+
{ label: 'unknown workspaceId', payload: { workspaceId: 'wid-nope', name: NEW_TITLE }, code: CODE.NOT_FOUND },
|
|
828
|
+
{ label: 'empty workspaceId', payload: { workspaceId: '', name: NEW_TITLE }, code: CODE.INVALID },
|
|
829
|
+
{ label: 'blank name', payload: { workspaceId: WS.A.id, name: ' ' }, code: CODE.INVALID },
|
|
830
|
+
{ label: "another workspace's title", payload: { workspaceId: WS.A.id, name: ` ${WS.B.title} ` }, code: CODE.CONFLICT }
|
|
831
|
+
];
|
|
832
|
+
for (const dryRun of [true, false]) {
|
|
833
|
+
for (const c of RPC_RENAME_FAILURES) {
|
|
834
|
+
test(`rpc mover.ws.rename (dryRun ${dryRun}) ${c.label} -> ${c.code}, nothing written`, async () => {
|
|
835
|
+
const host = mountHost();
|
|
836
|
+
const before = host.snapshot();
|
|
837
|
+
const tree = host.fxTree();
|
|
838
|
+
expectRpcCode(await host.rpc(RPC.RENAME, { ...c.payload, dryRun }), c.code);
|
|
839
|
+
assertNothingChanged(host, before, tree);
|
|
840
|
+
assert.equal(host.spies.approval.length, 0);
|
|
841
|
+
});
|
|
842
|
+
}
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
test('rpc mover.ws.rename dryRun:true returns the plan and writes nothing', async () => {
|
|
846
|
+
const host = mountHost({ sandbox: MODE.READ, approval: OUTCOME.REJECTED });
|
|
847
|
+
const before = host.snapshot();
|
|
848
|
+
const tree = host.fxTree();
|
|
849
|
+
const value = expectRpcOk(await host.rpc(RPC.RENAME, { workspaceId: WS.A.id, name: ` ${NEW_TITLE} `, dryRun: true }));
|
|
850
|
+
assert.equal(value.workspaceId, WS.A.id);
|
|
851
|
+
assert.equal(value.path, host.paths.A);
|
|
852
|
+
assert.equal(value.previousTitle, WS.A.title);
|
|
853
|
+
assert.equal(value.title, NEW_TITLE);
|
|
854
|
+
assert.equal(value.changed, true);
|
|
855
|
+
assert.equal(value.dryRun, true);
|
|
856
|
+
assertNothingChanged(host, before, tree);
|
|
857
|
+
assert.equal(host.spies.approval.length, 0);
|
|
858
|
+
});
|
|
859
|
+
|
|
860
|
+
test('rpc mover.ws.rename dryRun:true to the current title -> changed:false plan', async () => {
|
|
861
|
+
const host = mountHost();
|
|
862
|
+
const before = host.snapshot();
|
|
863
|
+
const tree = host.fxTree();
|
|
864
|
+
const value = expectRpcOk(await host.rpc(RPC.RENAME, { workspaceId: WS.A.id, name: ` ${WS.A.title} `, dryRun: true }));
|
|
865
|
+
assert.equal(value.changed, false);
|
|
866
|
+
assert.equal(value.dryRun, true);
|
|
867
|
+
assertNothingChanged(host, before, tree);
|
|
868
|
+
});
|
|
869
|
+
|
|
870
|
+
for (const dryRun of [undefined, false]) {
|
|
871
|
+
test(`rpc mover.ws.rename dryRun ${dryRun} renames with no approval gate (read-only sandbox, refusing seam)`, async () => {
|
|
872
|
+
const host = mountHost({ sandbox: MODE.READ, approval: OUTCOME.REJECTED });
|
|
873
|
+
const tree = host.fxTree();
|
|
874
|
+
const payload = { workspaceId: WS.A.id, name: ` ${NEW_TITLE} ` };
|
|
875
|
+
if (dryRun !== undefined) payload.dryRun = dryRun;
|
|
876
|
+
const value = expectRpcOk(await host.rpc(RPC.RENAME, payload));
|
|
877
|
+
assertRenamedA(host, value, tree, NEW_TITLE);
|
|
878
|
+
assert.equal(host.spies.approval.length, 0, 'RPC has no approval gate');
|
|
879
|
+
});
|
|
880
|
+
}
|
|
881
|
+
|
|
882
|
+
test('rpc mover.ws.rename to the current title (non-dry) -> changed:false and setTitle not called', async () => {
|
|
883
|
+
const host = mountHost();
|
|
884
|
+
const before = host.snapshot();
|
|
885
|
+
const tree = host.fxTree();
|
|
886
|
+
const value = expectRpcOk(await host.rpc(RPC.RENAME, { workspaceId: WS.A.id, name: WS.A.title }));
|
|
887
|
+
assert.equal(value.changed, false);
|
|
888
|
+
assertNothingChanged(host, before, tree);
|
|
889
|
+
});
|
|
890
|
+
//#endregion
|
|
891
|
+
|
|
892
|
+
//#region doctor host-sandbox-policy
|
|
893
|
+
const DOCTOR_CASES = [
|
|
894
|
+
{ sandbox: MODE.FULL, state: CHECK_STATE.PASS, label: 'resolve present (full access)' },
|
|
895
|
+
{ sandbox: MODE.READ, state: CHECK_STATE.PASS, label: 'resolve present (read-only)' },
|
|
896
|
+
{ sandbox: SANDBOX.THROWS, state: CHECK_STATE.PASS, label: 'resolve present even if it throws when called' },
|
|
897
|
+
{ sandbox: SANDBOX.NO_RESOLVE, state: CHECK_STATE.WARN, label: 'service without resolve' },
|
|
898
|
+
{ sandbox: SANDBOX.ABSENT, state: CHECK_STATE.WARN, label: 'service absent' }
|
|
899
|
+
];
|
|
900
|
+
for (const c of DOCTOR_CASES) {
|
|
901
|
+
test(`doctor ${DOCTOR_CHECK_ID}: ${c.label} -> ${c.state}`, async () => {
|
|
902
|
+
const host = mountHost({ sandbox: c.sandbox });
|
|
903
|
+
const value = expectRpcOk(await host.rpc(RPC.DOCTOR));
|
|
904
|
+
const check = value.checks.find((entry) => entry.id === DOCTOR_CHECK_ID);
|
|
905
|
+
assert.ok(check, `doctor lacks ${DOCTOR_CHECK_ID}: ${value.checks.map((entry) => entry.id).join(', ')}`);
|
|
906
|
+
assert.equal(check.state, c.state);
|
|
907
|
+
});
|
|
908
|
+
}
|
|
909
|
+
//#endregion
|
|
910
|
+
|
|
911
|
+
//#region system: an agent's end-to-end conversation
|
|
912
|
+
test('system: agent lists, creates, renames, re-checks conflicts and runs doctor; folders and sessions untouched', async () => {
|
|
913
|
+
const host = mountHost({ sandbox: MODE.WRITE, approval: OUTCOME.ALLOWED });
|
|
914
|
+
const tree = host.fxTree();
|
|
915
|
+
const exec = makeExec();
|
|
916
|
+
|
|
917
|
+
const listed = await host.runTool(TOOL.LIST, {}, exec);
|
|
918
|
+
assert.ok(listed.workspaces.some((w) => w.workspaceId === WS.A.id));
|
|
919
|
+
assert.ok(listed.workspaces.some((w) => w.workspaceId === WS.B.id));
|
|
920
|
+
|
|
921
|
+
const createArgs = { name: ` ${NEW_TITLE} `, directory: host.paths.free };
|
|
922
|
+
const created = await host.runTool(TOOL.CREATE, createArgs, exec);
|
|
923
|
+
assert.equal(created.title, NEW_TITLE);
|
|
924
|
+
assert.equal(created.path, host.paths.free);
|
|
925
|
+
assert.equal(host.registry.list().length, 3);
|
|
926
|
+
assert.equal(host.spies.approval.length, 0, 'create did not prompt');
|
|
927
|
+
assert.ok(Array.isArray(host.tool(TOOL.CREATE).output.render(createArgs, created)), 'create output renders to content blocks');
|
|
928
|
+
|
|
929
|
+
const relisted = await host.runTool(TOOL.LIST, {}, exec);
|
|
930
|
+
const listedNew = relisted.workspaces.find((w) => w.workspaceId === created.workspaceId);
|
|
931
|
+
assert.ok(listedNew, 'new workspace visible to mover_list_sessions');
|
|
932
|
+
assert.ok(JSON.stringify(listedNew).includes(NEW_TITLE));
|
|
933
|
+
|
|
934
|
+
const renameArgs = { workspaceId: created.workspaceId, name: SECOND_NEW_TITLE };
|
|
935
|
+
const renamed = await host.runTool(TOOL.RENAME, renameArgs, exec);
|
|
936
|
+
assert.equal(renamed.changed, true);
|
|
937
|
+
assert.equal(renamed.previousTitle, NEW_TITLE);
|
|
938
|
+
assert.equal(renamed.title, SECOND_NEW_TITLE);
|
|
939
|
+
assert.equal(renamed.path, host.paths.free);
|
|
940
|
+
assert.equal(host.registry.get(created.workspaceId).title, SECOND_NEW_TITLE);
|
|
941
|
+
assert.equal(host.spies.approval.length, 1, 'rename prompted once');
|
|
942
|
+
assert.ok(host.spies.approval[0].req.reason.includes(NEW_TITLE) && host.spies.approval[0].req.reason.includes(SECOND_NEW_TITLE));
|
|
943
|
+
assert.ok(Array.isArray(host.tool(TOOL.RENAME).output.render(renameArgs, renamed)), 'rename output renders to content blocks');
|
|
944
|
+
|
|
945
|
+
const afterRename = await host.runTool(TOOL.LIST, {}, exec);
|
|
946
|
+
assert.ok(JSON.stringify(afterRename.workspaces.find((w) => w.workspaceId === created.workspaceId)).includes(SECOND_NEW_TITLE));
|
|
947
|
+
|
|
948
|
+
// released vs held titles across both tools
|
|
949
|
+
const recreated = await host.runTool(TOOL.CREATE, { name: NEW_TITLE, directory: host.paths.free2 }, exec);
|
|
950
|
+
assert.equal(recreated.title, NEW_TITLE, 'released title is reusable');
|
|
951
|
+
await expectToolCode(host.runTool(TOOL.CREATE, { name: SECOND_NEW_TITLE, directory: host.paths.sub }, exec), CODE.CONFLICT);
|
|
952
|
+
await expectToolCode(host.runTool(TOOL.RENAME, { workspaceId: WS.A.id, name: SECOND_NEW_TITLE }, exec), CODE.CONFLICT);
|
|
953
|
+
await expectToolCode(host.runTool(TOOL.CREATE, { name: 'Epsilon', directory: `${host.paths.free}/` }, exec), CODE.CONFLICT);
|
|
954
|
+
assert.equal(host.spies.approval.length, 1, 'failed validations never prompted');
|
|
955
|
+
assert.equal(host.registry.list().length, 4);
|
|
956
|
+
|
|
957
|
+
const doctor = await host.runTool(TOOL.DOCTOR, {}, exec);
|
|
958
|
+
assert.ok(JSON.stringify(doctor).includes(DOCTOR_CHECK_ID), 'mover_doctor reports host-sandbox-policy');
|
|
959
|
+
|
|
960
|
+
assert.deepEqual(host.fxTree(), tree, 'no folder or file created/changed under the fixtures');
|
|
961
|
+
assert.deepEqual(readFileSync(host.sessionArtifact), host.sessionBytes, 'session archive untouched');
|
|
962
|
+
assert.equal(readHeader(readFileSync(host.sessionArtifact)).cwd, host.paths.A);
|
|
963
|
+
assert.deepEqual(host.entityA.sessionIds, [SESSION_ID]);
|
|
964
|
+
assert.equal(host.entityA.title, WS.A.title);
|
|
965
|
+
assert.equal(host.entityB.title, WS.B.title);
|
|
966
|
+
});
|
|
967
|
+
|
|
968
|
+
test('system: without dsh-tools the RPC endpoints still create and rename', async () => {
|
|
969
|
+
const host = mountHost({ tools: false });
|
|
970
|
+
assert.equal(host.registered.length, 0);
|
|
971
|
+
const created = expectRpcOk(await host.rpc(RPC.CREATE, { name: NEW_TITLE, directory: host.paths.free }));
|
|
972
|
+
const renamed = expectRpcOk(await host.rpc(RPC.RENAME, { workspaceId: created.workspaceId, name: SECOND_NEW_TITLE }));
|
|
973
|
+
assert.equal(renamed.changed, true);
|
|
974
|
+
assert.equal(host.registry.get(created.workspaceId).title, SECOND_NEW_TITLE);
|
|
975
|
+
assert.equal(host.registry.get(created.workspaceId).path, host.paths.free);
|
|
976
|
+
});
|
|
977
|
+
//#endregion
|