pi-odd-worktree 1.0.0 → 1.1.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.
@@ -0,0 +1,495 @@
1
+ 'use strict';
2
+ // warp-empty-session T1/T2 (S1-S5): a session with no conversation does not
3
+ // "move" — it changes PATH silently.
4
+ //
5
+ // S1: an empty session warps silently — no `worktree-warp` message, no
6
+ // transition card, no "moved:/to:" wording in the returned text.
7
+ // S2: warping back after writing nothing is the same case, in both directions
8
+ // and any number of times.
9
+ // S3: no in-place path change exists in the host, so the equivalent is a fresh
10
+ // session at the target cwd through the create/switch machinery — never
11
+ // `forkFrom` (the host refuses a conversation-less source).
12
+ // S4: branching semantics stay untouched for a session WITH conversation; an
13
+ // empty source RESUMES a destination that already holds a conversation.
14
+ // S5: empty means "the active branch carries no `message` entry with role user
15
+ // or assistant once the warp bookkeeping is excluded".
16
+ //
17
+ // Everything is injected: the SessionManager statics (`forkFrom`/`create`/
18
+ // `open`), the command context, and the OSC stream. The repositories and the
19
+ // session files live in throwaway directories under the system temp dir; no
20
+ // live host, no network, and no real Pi session is touched.
21
+
22
+ const { test, describe, before, after } = require('node:test');
23
+ const assert = require('node:assert/strict');
24
+ const fs = require('node:fs');
25
+ const os = require('node:os');
26
+ const path = require('node:path');
27
+ const { pathToFileURL } = require('node:url');
28
+ const { spawnSync } = require('node:child_process');
29
+ const { LIB_DIR, stubbedSource } = require('./fixtures/load-extension.js');
30
+ const warpSessionsLib = require('../lib/worktree-warp-sessions.js');
31
+
32
+ const STAMP = '2026-01-01T00:00:00.000Z';
33
+ const savedEnv = { ...process.env };
34
+ const roots = [];
35
+
36
+ function track(dir) {
37
+ roots.push(dir);
38
+ return dir;
39
+ }
40
+
41
+ after(() => {
42
+ for (const key of Object.keys(process.env)) {
43
+ if (!(key in savedEnv)) delete process.env[key];
44
+ }
45
+ for (const [key, value] of Object.entries(savedEnv)) process.env[key] = value;
46
+ for (const dir of roots) {
47
+ try { fs.rmSync(dir, { recursive: true, force: true }); } catch { /* best effort */ }
48
+ }
49
+ });
50
+
51
+ function runGit(cwd, args) {
52
+ const out = spawnSync('git', args, { cwd, encoding: 'utf8' });
53
+ if (out.status !== 0) throw new Error(`git ${args.join(' ')} failed: ${out.stderr}`);
54
+ return out.stdout.trim();
55
+ }
56
+
57
+ let mod;
58
+
59
+ before(async () => {
60
+ const dir = track(fs.mkdtempSync(path.join(os.tmpdir(), 'odd-empty-ext-')));
61
+ const copy = path.join(dir, 'odd-worktree-tools.mjs');
62
+ fs.writeFileSync(copy, stubbedSource());
63
+ process.env.GENTLE_WORKTREE_LIB_DIR = LIB_DIR;
64
+ process.env.HERDR_BIN_PATH = path.join(dir, 'no-such-herdr-binary');
65
+ process.env.GENTLE_WORKTREE_THREAD = 'thread-1';
66
+ mod = await import(pathToFileURL(copy).href);
67
+ mod.default({
68
+ registerCommand() {},
69
+ registerTool() {},
70
+ registerMessageRenderer() {},
71
+ on() { return () => {}; },
72
+ });
73
+ });
74
+
75
+ // ---------------------------------------------------------------------------
76
+ // Entry fixtures: the shapes the host persists, chained so the active branch
77
+ // is the whole list (each entry's parentId is the previous id).
78
+ // ---------------------------------------------------------------------------
79
+
80
+ const message = (role, text) => (id, parentId) => ({
81
+ type: 'message', id, parentId, timestamp: STAMP, message: { role, content: [{ type: 'text', text }] },
82
+ });
83
+ const userSays = (text) => message('user', text);
84
+ const assistantSays = (text) => message('assistant', text);
85
+ const toolResult = (text) => message('toolResult', text);
86
+ // The four warp bookkeeping shapes (lib/worktree-warp-sessions.js): none of
87
+ // them is conversation.
88
+ const registration = () => (id, parentId) => ({
89
+ type: 'custom', id, parentId, timestamp: STAMP,
90
+ customType: 'gentle-pi.session-worktree/v1', data: { sessionId: 'thread-1', cwd: '/' },
91
+ });
92
+ const rename = (name) => (id, parentId) => ({ type: 'session_info', id, parentId, timestamp: STAMP, name });
93
+ const transition = (from, to) => (id, parentId) => ({
94
+ type: 'custom', id, parentId, timestamp: STAMP, customType: 'worktree-warp', data: { from, to },
95
+ });
96
+ const warpNotice = (text) => (id, parentId) => ({
97
+ type: 'custom_message', id, parentId, timestamp: STAMP, customType: 'worktree-warp', content: text, display: true,
98
+ });
99
+
100
+ function chain(builders) {
101
+ const entries = [];
102
+ let parentId = null;
103
+ builders.forEach((build, index) => {
104
+ const entry = build(`e${index + 1}`, parentId);
105
+ entries.push(entry);
106
+ parentId = entry.id;
107
+ });
108
+ return entries;
109
+ }
110
+
111
+ function readJsonl(file) {
112
+ const raw = fs.readFileSync(file, 'utf8');
113
+ return raw.split('\n').filter((line) => line.length > 0).map((line) => JSON.parse(line));
114
+ }
115
+
116
+ function writeJsonl(file, lines) {
117
+ fs.writeFileSync(file, `${lines.map((line) => JSON.stringify(line)).join('\n')}\n`);
118
+ }
119
+
120
+ function sessionHeader(name, cwd) {
121
+ return { type: 'session', version: 3, id: `id-${name}`, timestamp: STAMP, cwd };
122
+ }
123
+
124
+ // ---------------------------------------------------------------------------
125
+ // The injected host: a repository pair, a session directory, the
126
+ // SessionManager double and the command context.
127
+ // ---------------------------------------------------------------------------
128
+
129
+ function makeRepoPair() {
130
+ const parent = track(fs.mkdtempSync(path.join(os.tmpdir(), 'odd-empty-repo-')));
131
+ const repo = path.join(parent, 'project');
132
+ fs.mkdirSync(repo);
133
+ runGit(repo, ['init', '-b', 'main']);
134
+ runGit(repo, ['config', 'user.email', 'test@example.test']);
135
+ runGit(repo, ['config', 'user.name', 'Test']);
136
+ fs.writeFileSync(path.join(repo, 'README.md'), '# fixture\n');
137
+ runGit(repo, ['add', 'README.md']);
138
+ runGit(repo, ['commit', '-m', 'init']);
139
+ const worktree = path.join(parent, 'project-worktrees', 'feat-login');
140
+ runGit(repo, ['worktree', 'add', worktree, '-b', 'feat/login']);
141
+ return {
142
+ repo: fs.realpathSync(repo),
143
+ target: fs.realpathSync(worktree),
144
+ targetName: 'feat-login',
145
+ };
146
+ }
147
+
148
+ function makeWorld() {
149
+ const sessionDir = track(fs.mkdtempSync(path.join(os.tmpdir(), 'odd-empty-sessions-')));
150
+ const calls = {
151
+ forkFrom: [], create: [], open: [], switches: [], messages: [],
152
+ statuses: [], customEntries: [], renames: [],
153
+ };
154
+ const osc = [];
155
+ let created = 0;
156
+
157
+ class FakeSession {
158
+ constructor({ file = null, cwd = null, header = null, entries = [], name = '' }) {
159
+ this.file = file;
160
+ this.cwd = cwd;
161
+ this.header = header;
162
+ this.entries = entries.slice();
163
+ this.name = name;
164
+ const last = this.entries[this.entries.length - 1];
165
+ this.leafId = last ? last.id : null;
166
+ }
167
+
168
+ getSessionFile() { return this.file; }
169
+ getSessionDir() { return sessionDir; }
170
+ getSessionId() { return 'thread-1'; }
171
+ getSessionName() { return this.name; }
172
+ getHeader() { return this.header; }
173
+ getEntries() { return this.entries.slice(); }
174
+ getLeafId() { return this.leafId; }
175
+ branch(id) { this.leafId = id; }
176
+ resetLeaf() { this.leafId = null; }
177
+
178
+ appendCustomEntry(customType, data) {
179
+ calls.customEntries.push({ file: this.file, customType, data });
180
+ this.push({ type: 'custom', customType, data });
181
+ }
182
+
183
+ appendSessionInfo(name) {
184
+ calls.renames.push({ file: this.file, name });
185
+ this.push({ type: 'session_info', name });
186
+ }
187
+
188
+ push(partial) {
189
+ const entry = { ...partial, id: `${this.file || 'memory'}#${this.entries.length + 1}`, parentId: this.leafId, timestamp: STAMP };
190
+ this.entries.push(entry);
191
+ this.leafId = entry.id;
192
+ }
193
+ }
194
+
195
+ // The host allocates a session file for the target cwd and writes nothing to
196
+ // it yet: the file only appears once the manager flushes an assistant turn.
197
+ FakeSession.create = (cwd, dir) => {
198
+ calls.create.push({ cwd, sessionDir: dir === undefined ? null : dir });
199
+ created += 1;
200
+ const header = sessionHeader(`created-${created}`, cwd);
201
+ return new FakeSession({ file: path.join(dir || sessionDir, `created-${created}.jsonl`), cwd, header });
202
+ };
203
+
204
+ FakeSession.open = (file, dir) => {
205
+ calls.open.push({ file, sessionDir: dir === undefined ? null : dir });
206
+ return load(file);
207
+ };
208
+
209
+ // forkFrom copies the source entries VERBATIM under a new header pointing at
210
+ // the source, exactly like the installed SessionManager.
211
+ FakeSession.forkFrom = (sourcePath, targetCwd, dir) => {
212
+ calls.forkFrom.push({ sourcePath, targetCwd, sessionDir: dir === undefined ? null : dir });
213
+ created += 1;
214
+ const file = path.join(dir || sessionDir, `fork-${created}.jsonl`);
215
+ const lines = readJsonl(sourcePath);
216
+ const header = { ...sessionHeader(`fork-${created}`, targetCwd), parentSession: sourcePath };
217
+ const entries = lines.filter((entry) => entry.type !== 'session');
218
+ writeJsonl(file, [header, ...entries]);
219
+ return new FakeSession({ file, cwd: targetCwd, header, entries });
220
+ };
221
+
222
+ function load(file) {
223
+ if (!file || !fs.existsSync(file)) return new FakeSession({ file });
224
+ const lines = readJsonl(file);
225
+ const header = lines.length > 0 && lines[0].type === 'session' ? lines[0] : null;
226
+ return new FakeSession({
227
+ file,
228
+ cwd: header ? header.cwd : null,
229
+ header,
230
+ entries: lines.filter((entry) => entry.type !== 'session'),
231
+ });
232
+ }
233
+
234
+ function current(file) {
235
+ const manager = load(file);
236
+ manager.name = 'Test session';
237
+ return manager;
238
+ }
239
+
240
+ // A session Pi has not written to disk yet: no file, only memory.
241
+ function bare(entries = []) {
242
+ const manager = new FakeSession({ entries });
243
+ manager.name = 'Test session';
244
+ return manager;
245
+ }
246
+
247
+ function ctx({ cwd, manager }) {
248
+ return {
249
+ cwd,
250
+ hasUI: false,
251
+ mode: 'tui',
252
+ sessionManager: manager,
253
+ ui: { notify: () => {}, setStatus: (key, text) => calls.statuses.push([key, text]) },
254
+ waitForIdle: async () => {},
255
+ switchSession: async (file, options) => {
256
+ calls.switches.push({ file, withSession: !!(options && options.withSession) });
257
+ if (options && typeof options.withSession === 'function') {
258
+ await options.withSession({
259
+ ui: { setStatus: (key, text) => calls.statuses.push([key, text]) },
260
+ sendMessage: (textOrMessage, sendOptions) => {
261
+ calls.messages.push({ message: textOrMessage, options: sendOptions });
262
+ },
263
+ });
264
+ }
265
+ return undefined;
266
+ },
267
+ };
268
+ }
269
+
270
+ function writeSession(name, cwd, builders) {
271
+ const file = path.join(sessionDir, `${name}.jsonl`);
272
+ writeJsonl(file, [sessionHeader(name, cwd), ...chain(builders)]);
273
+ return file;
274
+ }
275
+
276
+ function sessionFiles() {
277
+ return fs.readdirSync(sessionDir).filter((entry) => entry.endsWith('.jsonl')).sort();
278
+ }
279
+
280
+ return {
281
+ sessionDir, calls, osc, SM: FakeSession,
282
+ stream: { isTTY: true, write: (chunk) => osc.push(chunk) },
283
+ ctx, current, bare, load, writeSession, sessionFiles,
284
+ };
285
+ }
286
+
287
+ function warpOptions(world) {
288
+ return { sessionManagerClass: world.SM, oscStream: world.stream };
289
+ }
290
+
291
+ function worktreeWarpMessages(world) {
292
+ return world.calls.messages.filter(({ message }) => message && message.customType === 'worktree-warp');
293
+ }
294
+
295
+ // ---------------------------------------------------------------------------
296
+ // S5: the predicate itself.
297
+ // ---------------------------------------------------------------------------
298
+
299
+ describe('hasConversation (S5)', () => {
300
+ test('a session that holds only warp bookkeeping carries no conversation', () => {
301
+ const entries = chain([registration(), rename('New session'), transition('/a', '/b'), warpNotice('warped')]);
302
+ assert.equal(warpSessionsLib.hasConversation(entries), false);
303
+ assert.equal(warpSessionsLib.hasConversation([]), false);
304
+ assert.equal(warpSessionsLib.hasConversation(undefined), false);
305
+ });
306
+
307
+ test('a user or assistant message is conversation, a tool result alone is not', () => {
308
+ assert.equal(warpSessionsLib.hasConversation(chain([registration(), userSays('hi')])), true);
309
+ assert.equal(warpSessionsLib.hasConversation(chain([assistantSays('hello')])), true);
310
+ assert.equal(warpSessionsLib.hasConversation(chain([registration(), toolResult('ok')])), false);
311
+ });
312
+
313
+ test('only the ACTIVE branch decides: an abandoned leg never makes it non-empty', () => {
314
+ const abandoned = { type: 'message', id: 'leg-1', parentId: 'e1', timestamp: STAMP, message: { role: 'user', content: [] } };
315
+ const entries = [{ ...chain([registration()])[0] }, abandoned];
316
+ // The abandoned entry IS the last entry, so it is also the leaf; point the
317
+ // leaf at the bookkeeping entry instead to model a rewind onto the root leg.
318
+ assert.equal(warpSessionsLib.hasConversation(entries, 'e1'), false);
319
+ assert.equal(warpSessionsLib.hasConversation(entries), true);
320
+ });
321
+ });
322
+
323
+ // ---------------------------------------------------------------------------
324
+ // S1/S3: an empty session changes path, silently.
325
+ // ---------------------------------------------------------------------------
326
+
327
+ describe('an empty session warps silently', () => {
328
+ test('no fork, no card, no transition entry, and it continues at the target cwd', async () => {
329
+ const { repo, target, targetName } = makeRepoPair();
330
+ const world = makeWorld();
331
+ const source = world.writeSession('empty-source', repo, [registration(), rename('New session')]);
332
+
333
+ const out = await mod.executeWorktreeWarp(targetName, world.ctx({ cwd: repo, manager: world.current(source) }), warpOptions(world));
334
+
335
+ assert.notEqual(out.isError, true, out.text);
336
+ assert.deepEqual(world.calls.forkFrom, [], 'an empty session never forks');
337
+ assert.deepEqual(worktreeWarpMessages(world), [], 'no worktree-warp card is sent');
338
+ assert.deepEqual(world.calls.messages, [], 'nothing at all is sent to the empty session');
339
+ assert.deepEqual(world.calls.customEntries, [], 'no transition entry is appended');
340
+
341
+ assert.equal(world.calls.switches.length, 1);
342
+ const switchedFile = world.calls.switches[0].file;
343
+ const header = readJsonl(switchedFile)[0];
344
+ assert.equal(header.cwd, target, 'the session continues in the target checkout');
345
+ assert.equal(world.calls.switches[0].withSession, true);
346
+
347
+ // Where the session IS still travels: the status line and the OSC-7 hint.
348
+ assert.equal(world.calls.statuses.length > 0, true);
349
+ assert.equal(world.calls.statuses[world.calls.statuses.length - 1][0], 'worktree');
350
+ assert.equal(world.calls.statuses[world.calls.statuses.length - 1][1].endsWith(`(${target})`), true);
351
+ assert.deepEqual(world.osc, [`\x1b]7;${pathToFileURL(target).href}\x1b\\`]);
352
+
353
+ // The returned text names the position, never a move.
354
+ assert.doesNotMatch(out.text, /moved/i);
355
+ assert.doesNotMatch(out.text, /(^|\n)to: /);
356
+ assert.doesNotMatch(out.text, /\bfrom: /);
357
+ assert.match(out.text, /empty session/);
358
+
359
+ // Recording is unchanged: both sides are recorded, so a later warp finds
360
+ // the destination session again.
361
+ assert.equal(warpSessionsLib.getWarpSession(repo, target).sessionFile, switchedFile);
362
+ assert.equal(warpSessionsLib.getWarpSession(repo, repo).sessionFile, source);
363
+ });
364
+
365
+ test('a brand-new session with no file and no entries changes path instead of refusing', async () => {
366
+ const { repo, target, targetName } = makeRepoPair();
367
+ const world = makeWorld();
368
+
369
+ const out = await mod.executeWorktreeWarp(targetName, world.ctx({ cwd: repo, manager: world.bare() }), warpOptions(world));
370
+
371
+ assert.notEqual(out.isError, true, out.text);
372
+ assert.deepEqual(world.calls.forkFrom, []);
373
+ assert.deepEqual(world.calls.messages, []);
374
+ assert.equal(world.calls.switches.length, 1);
375
+ assert.equal(readJsonl(world.calls.switches[0].file)[0].cwd, target);
376
+ assert.equal(warpSessionsLib.getWarpSession(repo, target).sessionFile, world.calls.switches[0].file);
377
+ });
378
+ });
379
+
380
+ // ---------------------------------------------------------------------------
381
+ // S2: warping back, and repeating, keeps it empty.
382
+ // ---------------------------------------------------------------------------
383
+
384
+ describe('warping back from an empty session is the same case', () => {
385
+ test('a round trip warps silently both ways and accumulates nothing', async () => {
386
+ const { repo, target, targetName } = makeRepoPair();
387
+ const world = makeWorld();
388
+ const source = world.writeSession('empty-source', repo, [registration()]);
389
+
390
+ let cwd = repo;
391
+ let file = source;
392
+ const rounds = [
393
+ { name: targetName, expect: target },
394
+ { name: 'principal', expect: repo },
395
+ { name: targetName, expect: target },
396
+ { name: 'principal', expect: repo },
397
+ ];
398
+ for (const round of rounds) {
399
+ const out = await mod.executeWorktreeWarp(round.name, world.ctx({ cwd, manager: world.current(file) }), warpOptions(world));
400
+ assert.notEqual(out.isError, true, out.text);
401
+ assert.match(out.text, /empty session/);
402
+ assert.doesNotMatch(out.text, /moved/i);
403
+ const switched = world.calls.switches[world.calls.switches.length - 1].file;
404
+ assert.equal(readJsonl(switched)[0].cwd, round.expect);
405
+ cwd = round.expect;
406
+ file = switched;
407
+ // Exactly one session file per checkout: the empty warp never allocates a
408
+ // second one for the same destination.
409
+ assert.deepEqual(world.sessionFiles(), ['created-1.jsonl', 'empty-source.jsonl']);
410
+ }
411
+
412
+ assert.deepEqual(world.calls.forkFrom, [], 'no round of the round trip forks');
413
+ assert.deepEqual(world.calls.messages, [], 'no round of the round trip sends a card');
414
+ assert.deepEqual(world.calls.customEntries, [], 'no round of the round trip appends a transition');
415
+ });
416
+ });
417
+
418
+ // ---------------------------------------------------------------------------
419
+ // S4: an empty source resumes a destination that holds a conversation.
420
+ // ---------------------------------------------------------------------------
421
+
422
+ describe('an empty source and a destination with conversation', () => {
423
+ test('resumes the recorded destination session, with no fork and no card', async () => {
424
+ const { repo, target, targetName } = makeRepoPair();
425
+ const world = makeWorld();
426
+ const source = world.writeSession('empty-source', repo, [registration()]);
427
+ const destination = world.writeSession('written-earlier', target, [userSays('do the thing'), assistantSays('done')]);
428
+ warpSessionsLib.recordWarpSession(repo, target, {
429
+ sessionFile: destination, tipId: 'e2', count: 2,
430
+ });
431
+
432
+ const out = await mod.executeWorktreeWarp(targetName, world.ctx({ cwd: repo, manager: world.current(source) }), warpOptions(world));
433
+
434
+ assert.notEqual(out.isError, true, out.text);
435
+ assert.deepEqual(world.calls.forkFrom, [], 'a conversation is resumed, never forked over');
436
+ assert.deepEqual(world.calls.messages, [], 'nothing was carried from the source, so nothing is announced');
437
+ assert.deepEqual(world.calls.customEntries, []);
438
+ assert.equal(world.calls.switches.length, 1);
439
+ assert.equal(world.calls.switches[0].file, destination);
440
+ assert.match(out.text, /empty session/);
441
+ assert.doesNotMatch(out.text, /moved/i);
442
+ });
443
+ });
444
+
445
+ // ---------------------------------------------------------------------------
446
+ // PRESERVE: a session WITH conversation keeps today's behaviour.
447
+ // ---------------------------------------------------------------------------
448
+
449
+ describe('a session with conversation keeps today behaviour', () => {
450
+ test('it forks once and sends exactly one Warp card', async () => {
451
+ const { repo, target, targetName } = makeRepoPair();
452
+ const world = makeWorld();
453
+ const source = world.writeSession('with-conversation', repo, [registration(), userSays('hi'), assistantSays('hello')]);
454
+
455
+ const out = await mod.executeWorktreeWarp(targetName, world.ctx({ cwd: repo, manager: world.current(source) }), warpOptions(world));
456
+
457
+ assert.notEqual(out.isError, true, out.text);
458
+ assert.equal(world.calls.forkFrom.length, 1);
459
+ assert.equal(world.calls.forkFrom[0].sourcePath, source);
460
+ assert.equal(world.calls.forkFrom[0].targetCwd, target);
461
+ assert.equal(world.calls.customEntries.length, 1);
462
+ assert.equal(world.calls.customEntries[0].customType, 'worktree-warp');
463
+ const cards = worktreeWarpMessages(world);
464
+ assert.equal(cards.length, 1, 'exactly one card');
465
+ assert.equal(cards[0].message.details.to, target);
466
+ assert.equal(cards[0].message.details.fromName, 'principal');
467
+ assert.equal(cards[0].options.triggerTurn, false);
468
+ assert.match(cards[0].message.content, /Worktree warp: now in /);
469
+ assert.equal(world.calls.switches.length, 1);
470
+ assert.equal(readJsonl(world.calls.switches[0].file)[0].cwd, target);
471
+ // The returned text is still the two-line compact that names the move.
472
+ assert.match(out.text, /^moved: /);
473
+ assert.match(out.text, /\nto: /);
474
+ });
475
+
476
+ test('a destination at the same point is still resumed silently', async () => {
477
+ const { repo, target, targetName } = makeRepoPair();
478
+ const world = makeWorld();
479
+ const source = world.writeSession('with-conversation', repo, [userSays('hi'), assistantSays('hello')]);
480
+ // Same filtered point: the recorded destination holds the same entries.
481
+ const destination = path.join(world.sessionDir, 'same-point.jsonl');
482
+ writeJsonl(destination, [sessionHeader('same-point', target), ...readJsonl(source).filter((entry) => entry.type !== 'session')]);
483
+ warpSessionsLib.recordWarpSession(repo, target, { sessionFile: destination, tipId: 'e2', count: 2 });
484
+
485
+ const out = await mod.executeWorktreeWarp(targetName, world.ctx({ cwd: repo, manager: world.current(source) }), warpOptions(world));
486
+
487
+ assert.notEqual(out.isError, true, out.text);
488
+ assert.deepEqual(world.calls.forkFrom, []);
489
+ assert.deepEqual(world.calls.messages, [], 'a resume re-enters silently');
490
+ assert.deepEqual(world.calls.customEntries, []);
491
+ assert.equal(world.calls.switches.length, 1);
492
+ assert.equal(world.calls.switches[0].file, destination);
493
+ assert.match(out.text, /^moved: /);
494
+ });
495
+ });