Skip to content

Commit 615c58c

Browse files
committed
test(apple-runner): pin the backup path a half-finished restore may name
1 parent e732fe6 commit 615c58c

1 file changed

Lines changed: 70 additions & 0 deletions

File tree

‎packages/platform-apple/src/runner/__tests__/runner-device-set.test.ts‎

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -404,6 +404,76 @@ test('the host’s device set is back before the lock is', async () => {
404404
});
405405
});
406406

407+
test('a restore that half-finished names the backup it was refused, not an older leftover', async () => {
408+
await withTempDir('device-set-half-restored-legacy-', async (root) => {
409+
const paths = makeRedirectPaths(root);
410+
// The default backup path, so the older version's leftover prefix is the one a real host sees.
411+
const backupPath = `${paths.xctestDeviceSetPath}.agent-device-backup`;
412+
const legacyBackupPath = path.join(
413+
path.dirname(backupPath),
414+
'.agent-device-xctestdevices-backup-1600000000000',
415+
);
416+
fs.mkdirSync(paths.requestedSetPath, { recursive: true });
417+
fs.mkdirSync(backupPath, { recursive: true });
418+
fs.writeFileSync(path.join(backupPath, 'host-device.txt'), 'the host owns this');
419+
fs.mkdirSync(legacyBackupPath, { recursive: true });
420+
fs.writeFileSync(path.join(legacyBackupPath, 'stale-device.txt'), 'an older interruption');
421+
fs.symlinkSync(paths.requestedSetPath, paths.xctestDeviceSetPath, 'dir');
422+
423+
// An interrupted build left the symlink, this run's backup holds the host's set, and an older
424+
// version's leftover sits beside it. The first restore is refused after it took the symlink down, so
425+
// the hand-back is the one that puts the host's set back — and it is refused while deleting the
426+
// leftover. The report must name what is still renamed aside, which by then is nothing: sending the
427+
// reader to the older leftover would have them copy a stale set over the one now in place.
428+
const realRename = fs.renameSync;
429+
let restoreAttempts = 0;
430+
const renameSpy = vi.spyOn(fs, 'renameSync').mockImplementation(((from, to) => {
431+
if (String(to) === paths.xctestDeviceSetPath) {
432+
restoreAttempts += 1;
433+
if (restoreAttempts === 1) {
434+
throw Object.assign(new Error('EACCES: permission denied'), { code: 'EACCES' });
435+
}
436+
}
437+
return realRename(from, to);
438+
}) as typeof fs.renameSync);
439+
const realRm = fs.rmSync;
440+
const rmSpy = vi.spyOn(fs, 'rmSync').mockImplementation(((target, options) => {
441+
if (String(target) === legacyBackupPath) {
442+
throw Object.assign(new Error('EPERM: operation not permitted'), { code: 'EPERM' });
443+
}
444+
return realRm(target as Parameters<typeof fs.rmSync>[0], options);
445+
}) as typeof fs.rmSync);
446+
447+
try {
448+
await assert.rejects(
449+
() =>
450+
acquireXcodebuildSimulatorSetRedirect(makeScopedSimulator(paths), redirectOptions(paths)),
451+
(error: unknown) => {
452+
assert.ok(error instanceof AppError);
453+
assert.equal(error.message, 'Failed to redirect XCTest device set path');
454+
assert.match(String(error.details?.error), /EACCES/);
455+
assert.match(String(error.details?.restoreError), /EPERM/);
456+
assert.equal(error.details?.hint, undefined);
457+
return true;
458+
},
459+
);
460+
assert.equal(fs.existsSync(legacyBackupPath), true, 'the leftover stays where it is');
461+
assert.equal(
462+
fs.readFileSync(path.join(paths.xctestDeviceSetPath, 'host-device.txt'), 'utf8'),
463+
'the host owns this',
464+
);
465+
assert.equal(
466+
fs.existsSync(paths.lockDirPath),
467+
false,
468+
'the lock must not outlive the failure',
469+
);
470+
} finally {
471+
rmSpy.mockRestore();
472+
renameSpy.mockRestore();
473+
}
474+
});
475+
});
476+
407477
test('a redirect that failed before it moved anything names no backup that is not there', async () => {
408478
await withTempDir('device-set-failed-before-rename-', async (root) => {
409479
const paths = makeRedirectPaths(root);

0 commit comments

Comments
 (0)