Skip to content

Commit e16bb4a

Browse files
committed
test: cover release refusal branches, text rendering, and orphan warnings
Changed-line coverage on the stale-release slice was 69.33% against the 70% gate: the refusal-reason branches, the text-mode outcome rendering with the live-owner hint, and the daemon stop orphaned-claim warning had no tests. Cover them directly; the misnamed-claim-file refusal is also pinned.
1 parent da8c9fe commit e16bb4a

3 files changed

Lines changed: 129 additions & 0 deletions

File tree

‎src/__tests__/cli-device-release.test.ts‎

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -128,3 +128,39 @@ test('device status --stale offers the exact release command for provably dead o
128128
fs.rmSync(claimsDir, { recursive: true, force: true });
129129
}
130130
});
131+
132+
test('device release --stale renders per-claim outcomes with a live-owner hint in text mode', async () => {
133+
const claimsDir = mkdtempForTestSync('agent-device-cli-release-');
134+
const stateDir = mkdtempForTestSync('agent-device-cli-release-state-');
135+
const owner = readCurrentOwnerIdentity();
136+
try {
137+
writeClaim(claimsDir, {
138+
deviceKey: 'local:android:none:emulator-5554',
139+
id: 'emulator-5554',
140+
name: 'Dead Pixel',
141+
session: 'dead-session',
142+
ownerPid: 999_999_999,
143+
ownerStartTime: 'old-start-time',
144+
stateDir,
145+
});
146+
writeClaim(claimsDir, {
147+
deviceKey: 'local:android:none:emulator-5556',
148+
id: 'emulator-5556',
149+
name: 'Live Pixel',
150+
session: 'live-session',
151+
ownerPid: owner.pid,
152+
ownerStartTime: owner.startTime,
153+
stateDir: process.cwd(),
154+
});
155+
156+
const result = await runCliCapture(['device', 'release', '--stale'], {
157+
env: { AGENT_DEVICE_CLAIMS_DIR: claimsDir },
158+
});
159+
assert.match(result.stdout, /released android Dead Pixel session=dead-session/);
160+
assert.match(result.stdout, /refused android Live Pixel session=live-session[^\n]*live-owner/);
161+
assert.match(result.stdout, /agent-device daemon stop --state-dir/);
162+
} finally {
163+
fs.rmSync(claimsDir, { recursive: true, force: true });
164+
fs.rmSync(stateDir, { recursive: true, force: true });
165+
}
166+
});

‎src/cli/commands/__tests__/daemon.test.ts‎

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -139,3 +139,40 @@ test('reports graceful provider cleanup as unknown when the shutdown report is u
139139
fs.rmSync(stateDir, { recursive: true, force: true });
140140
}
141141
});
142+
143+
test('warns in text output when a graceful stop leaves an orphaned claim', async () => {
144+
const stateDir = mkdtempForTestSync('agent-device-daemon-command-');
145+
mocks.readDaemonStopIdentity.mockReturnValue({ pid: 123, processStartTime: 'start-time' });
146+
mocks.stopDaemon.mockResolvedValue(GRACEFUL_RESULT);
147+
const claim = {
148+
deviceKey: 'local:android:none:emulator-5554',
149+
session: 'default',
150+
platform: 'android',
151+
deviceId: 'emulator-5554',
152+
};
153+
mocks.readDaemonShutdownReport.mockReturnValue({
154+
providerReleases: { released: [], pending: [] },
155+
claims: { released: [], orphaned: [claim], superseded: [] },
156+
});
157+
158+
try {
159+
await daemonCommand({
160+
positionals: ['stop'],
161+
flags: { clean: false, help: false, json: false, stateDir, version: false },
162+
client: {} as never,
163+
});
164+
165+
const [, data, renderHuman] = mocks.writeCommandOutput.mock.calls.at(-1) ?? [];
166+
expect(data).toEqual(
167+
expect.objectContaining({
168+
claimsOrphaned: [claim],
169+
warnings: [expect.stringContaining('was not released cleanly')],
170+
}),
171+
);
172+
const rendered = (renderHuman as () => string)();
173+
expect(rendered).toContain('Ownership of emulator-5554 was not released cleanly');
174+
expect(rendered).toContain('agent-device device release --stale');
175+
} finally {
176+
fs.rmSync(stateDir, { recursive: true, force: true });
177+
}
178+
});

‎src/daemon/__tests__/device-claims.test.ts‎

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -754,3 +754,59 @@ test('release refuses live owners and corrupt claims without touching them', asy
754754
assert.equal(fs.existsSync(claimPath(root)), true);
755755
assert.equal(fs.existsSync(path.join(root, 'corrupt.json')), true);
756756
});
757+
758+
test('release names the exact refusal for uncertain owners and misnamed claim files', async () => {
759+
const root = useClaimsRoot();
760+
const acquired = await acquireDeviceClaim({
761+
device,
762+
session: 'reused-owner',
763+
workspace: '/worktrees/reused',
764+
stateDir: root,
765+
});
766+
assert.equal(acquired.status, 'acquired');
767+
const stored = JSON.parse(fs.readFileSync(claimPath(root), 'utf8')) as Record<string, unknown>;
768+
// Same PID, different recorded start time: PID reuse, uncertain ownership.
769+
fs.writeFileSync(claimPath(root), JSON.stringify({ ...stored, ownerStartTime: 'other-start' }));
770+
// A dead-owner claim stored under a name that is not the hash of its own
771+
// device key: the claim lock protects a different path, so release refuses.
772+
fs.writeFileSync(
773+
path.join(root, 'misnamed.json'),
774+
JSON.stringify({
775+
...stored,
776+
deviceKey: 'local:android:none:misnamed-device',
777+
device: { ...(stored.device as object), id: 'misnamed-device' },
778+
ownerPid: 999_999_999,
779+
ownerStartTime: 'long-gone',
780+
session: 'misnamed-owner',
781+
}),
782+
);
783+
784+
const outcomes = await releaseProvenStaleDeviceClaims({
785+
selectors: {},
786+
reconcile: async () => ({ status: 'reconciled' }),
787+
});
788+
const reasons = new Map(outcomes.map((outcome) => [outcome.session, outcome.reason]));
789+
assert.equal(reasons.get('reused-owner'), 'owner-pid-reused');
790+
assert.equal(reasons.get('misnamed-owner'), 'claim-file-name-mismatch');
791+
assert.ok(outcomes.every((outcome) => outcome.status === 'refused'));
792+
assert.equal(fs.existsSync(path.join(root, 'misnamed.json')), true);
793+
});
794+
795+
test('release refuses a live process whose state dir is gone', async () => {
796+
const root = useClaimsRoot();
797+
const acquired = await acquireDeviceClaim({
798+
device,
799+
session: 'dir-gone-owner',
800+
workspace: '/worktrees/dir-gone',
801+
stateDir: path.join(root, 'missing-state-dir'),
802+
});
803+
assert.equal(acquired.status, 'acquired');
804+
805+
const outcomes = await releaseProvenStaleDeviceClaims({
806+
selectors: {},
807+
reconcile: async () => ({ status: 'reconciled' }),
808+
});
809+
assert.equal(outcomes[0]?.status, 'refused');
810+
assert.equal(outcomes[0]?.reason, 'owner-process-still-running');
811+
assert.equal(fs.existsSync(claimPath(root)), true);
812+
});

0 commit comments

Comments
 (0)