Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions src/__tests__/client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -319,6 +319,34 @@ test('client close normalizes target shutdown results', async () => {
assert.equal(appClose.shutdown, undefined);
});

test('client close surfaces the daemon savedScript path on both sessions.close and apps.close (#1258)', async () => {
const savedScriptPath = '/tmp/agent-device/flows/login.healed.ad';
const setup = createTransport(async () => ({
ok: true,
data: { session: 'qa', savedScript: savedScriptPath },
}));
const client = createAgentDeviceClient(setup.config, { transport: setup.transport });

const sessionClose = await client.sessions.close({ saveScript: true });
const appClose = await client.apps.close({ saveScript: true });

// The committed artifact path round-trips so a Node client that requested
// publication learns where the file landed.
assert.equal(sessionClose.savedScript, savedScriptPath);
assert.equal(appClose.savedScript, savedScriptPath);
});

test('client close omits savedScript when the daemon published nothing (#1258)', async () => {
const setup = createTransport(async () => ({ ok: true, data: { session: 'qa' } }));
const client = createAgentDeviceClient(setup.config, { transport: setup.transport });

const sessionClose = await client.sessions.close({});
const appClose = await client.apps.close({});

assert.equal(sessionClose.savedScript, undefined);
assert.equal(appClose.savedScript, undefined);
});

test('observability.perf projects structured frame area to daemon positionals', async () => {
const setup = createTransport(async (req) => {
if (req.command === 'perf') {
Expand Down
2 changes: 2 additions & 0 deletions src/agent-device-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -187,6 +187,7 @@ export function createAgentDeviceClient(
session,
shutdown: normalizeTargetShutdownResult(data.shutdown),
provider: readObject(data.provider),
savedScript: readOptionalString(data, 'savedScript'),
identifiers: { session },
};
} finally {
Expand Down Expand Up @@ -259,6 +260,7 @@ export function createAgentDeviceClient(
session,
closedApp: options.app,
shutdown: normalizeTargetShutdownResult(data.shutdown),
savedScript: readOptionalString(data, 'savedScript'),
identifiers: { session },
};
},
Expand Down
28 changes: 27 additions & 1 deletion src/client/client-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -252,6 +252,12 @@ export type SessionCloseResult = {
session: string;
shutdown?: TargetShutdownResult;
provider?: CloudProviderSessionResult;
/**
* #1258: absolute path of the committed session/healed script when this close
* published one (`close --save-script`, or a repair-armed session's finalize)
* — so a client that requested publication learns where the file landed.
*/
savedScript?: string;
identifiers: AgentDeviceIdentifiers;
};

Expand Down Expand Up @@ -292,6 +298,8 @@ export type AppOpenOptions = AgentDeviceRequestOverrides &
launchArgs?: string[];
relaunch?: boolean;
saveScript?: boolean | string;
/** #1258: overwrite an existing --save-script target instead of refusing. Alias: --overwrite. */
force?: boolean;
deviceHub?: boolean;
testIme?: boolean;
noRecord?: boolean;
Expand All @@ -316,12 +324,21 @@ export type AppOpenResult = {
export type AppCloseOptions = AgentDeviceRequestOverrides & {
app?: string;
shutdown?: boolean;
saveScript?: boolean | string;
/** #1258: overwrite an existing --save-script target instead of refusing. Alias: --overwrite. */
force?: boolean;
};

export type AppCloseResult = {
session: string;
closedApp?: string;
shutdown?: TargetShutdownResult;
/**
* #1258: absolute path of the committed session/healed script when this close
* published one (`close --save-script`, or a repair-armed session's finalize)
* — so a client that requested publication learns where the file landed.
*/
savedScript?: string;
identifiers: AgentDeviceIdentifiers;
};

Expand Down Expand Up @@ -866,6 +883,8 @@ export type ReplayRunOptions = AgentDeviceRequestOverrides &
* `<stem>.healed.ad` when the repair ends with `close --save-script`.
*/
saveScript?: boolean | string;
/** #1258: overwrite an existing --save-script target instead of refusing. Alias: --overwrite. */
force?: boolean;
};

export type ReplayTestOptions = AgentDeviceRequestOverrides &
Expand Down Expand Up @@ -1072,6 +1091,8 @@ export type InternalRequestOptions = AgentDeviceClientConfig &
relaunch?: boolean;
shutdown?: boolean;
saveScript?: boolean | string;
/** #1258: overwrite an existing --save-script target instead of refusing. Alias: --overwrite. */
force?: boolean;
deviceHub?: boolean;
testIme?: boolean;
noRecord?: boolean;
Expand Down Expand Up @@ -1110,7 +1131,12 @@ export type AgentDeviceClient = {
options?: AgentDeviceRequestOverrides & Pick<AgentDeviceClientConfig, 'stateDir'>,
) => Promise<string>;
close: (
options?: AgentDeviceRequestOverrides & { shutdown?: boolean },
options?: AgentDeviceRequestOverrides & {
shutdown?: boolean;
saveScript?: boolean | string;
/** #1258: overwrite an existing --save-script target instead of refusing. Alias: --overwrite. */
force?: boolean;
},
) => Promise<SessionCloseResult>;
artifacts: (options?: CloudArtifactsOptions) => Promise<AgentArtifactsResult>;
};
Expand Down
7 changes: 4 additions & 3 deletions src/commands/cli-grammar/flag-definitions-connection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -195,10 +195,11 @@ export const CONNECTION_FLAG_DEFINITIONS: readonly FlagDefinition[] = [
},
{
key: 'force',
names: ['--force'],
names: ['--force', '--overwrite'],
type: 'boolean',
usageLabel: '--force',
usageDescription: 'Force connection state replacement when reconnecting',
usageLabel: '--force, --overwrite',
usageDescription:
'Force replacement of existing state (connect: replace an active connection; --save-script: overwrite an existing target instead of refusing)',
},
{
key: 'noLogin',
Expand Down
11 changes: 10 additions & 1 deletion src/commands/management/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,9 @@ const openCommandMetadata = defineFieldCommandMetadata(
),
relaunch: booleanField('Force relaunch.'),
saveScript: jsonSchemaField<boolean | string>({ oneOf: [booleanSchema(), stringSchema()] }),
force: booleanField(
'Overwrite an existing --save-script target instead of refusing (alias: --overwrite).',
),
deviceHub: booleanField('Use Xcode Device Hub when surfacing Apple simulators.'),
testIme: booleanField(
'Activate the headless Android test IME for deterministic Unicode text entry (default on for emulators; opt-in on real devices).',
Expand All @@ -65,6 +68,9 @@ const closeCommandMetadata = defineFieldCommandMetadata(
app: stringField('Optional app to close.'),
shutdown: booleanField('Shutdown the session/device where supported.'),
saveScript: jsonSchemaField<boolean | string>({ oneOf: [booleanSchema(), stringSchema()] }),
force: booleanField(
'Overwrite an existing --save-script target instead of refusing (alias: --overwrite).',
),
},
);

Expand Down Expand Up @@ -131,6 +137,7 @@ const openCliSchema = {
'deviceHub',
'testIme',
'saveScript',
'force',
'noRecord',
'relaunch',
'surface',
Expand All @@ -143,7 +150,7 @@ const openCliSchema = {

const closeCliSchema = {
positionalArgs: ['app?'],
allowedFlags: ['saveScript', 'shutdown'],
allowedFlags: ['saveScript', 'force', 'shutdown'],
} as const satisfies CommandSchemaOverride;

const appsCliReader: CliReader = (_positionals, flags) => ({
Expand All @@ -161,6 +168,7 @@ const openCliReader: CliReader = (positionals, flags) => ({
launchArgs: flags.launchArgs,
relaunch: flags.relaunch,
saveScript: flags.saveScript,
force: flags.force,
deviceHub: flags.deviceHub,
testIme: flags.testIme,
noRecord: flags.noRecord,
Expand All @@ -175,6 +183,7 @@ const closeCliReader: CliReader = (positionals, flags) => ({
app: positionals[0],
shutdown: flags.shutdown,
saveScript: flags.saveScript,
force: flags.force,
});

const appsDaemonWriter: DaemonWriter = direct(PUBLIC_COMMANDS.apps);
Expand Down
5 changes: 5 additions & 0 deletions src/commands/replay/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,9 @@ export const replayCommandMetadata = defineFieldCommandMetadata(
// from the first replay attempt; optional string value is the healed
// script's output path.
saveScript: jsonSchemaField<boolean | string>({ oneOf: [booleanSchema(), stringSchema()] }),
// #1258: overwrite an existing --save-script target (arm-time preflight +
// publish) instead of refusing. Alias: --overwrite.
force: booleanField(),
},
);

Expand Down Expand Up @@ -96,6 +99,7 @@ const replayCliSchema = {
'timeoutMs',
'out',
'saveScript',
'force',
],
} as const satisfies CommandSchemaOverride;

Expand Down Expand Up @@ -130,6 +134,7 @@ export const replayCliReader: CliReader = (positionals, flags) => ({
resumeFrom: flags.replayFrom,
resumePlanDigest: flags.replayPlanDigest,
saveScript: flags.saveScript,
force: flags.force,
});

export const testCliReader: CliReader = (positionals, flags) => ({
Expand Down
71 changes: 71 additions & 0 deletions src/daemon/__tests__/session-script-writer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -283,6 +283,77 @@ test('write() now refuses to clobber a stale PARTIAL (non-sentinel) .healed.ad a
expect(fs.readFileSync(healedPath, 'utf8')).toBe(before);
});

// --- #1258: `--force`/`--overwrite` opts into replacing an existing target
// atomically instead of refusing. ---

test('write(session, { force: true }) overwrites an existing COMPLETE target atomically', () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-script-writer-force-'));
const writer = new SessionScriptWriter(path.join(root, 'sessions'));
const healedPath = path.join(root, 'flows', 'login.healed.ad');
fs.mkdirSync(path.dirname(healedPath), { recursive: true });
fs.writeFileSync(
healedPath,
`context platform=ios device="x"\nclick id="old"\n${HEAL_COMPLETE_SENTINEL}\n`,
);

const session = makeIosSession('default', {
recordSession: true,
saveScriptBoundary: 0,
saveScriptComplete: true,
saveScriptPath: healedPath,
saveScriptDefaultedHealedPath: true,
actions: [action({ command: 'click', positionals: ['id="new"'] })],
});

const result = writer.write(session, { force: true });
expect(result.written).toBe(true);
expect(result.written && result.path).toBe(healedPath);
const parsed = parseReplayScriptDetailed(fs.readFileSync(healedPath, 'utf8'));
expect(parsed.actions.map((a) => a.positionals[0])).toEqual(['id="new"']);
// The overwrite is atomic (rename-replace): only the published target
// remains in the directory, no stray temp file left behind.
expect(fs.readdirSync(path.dirname(healedPath))).toEqual([path.basename(healedPath)]);
});

test('write(session, { force: true }) overwrites an existing target for ORDINARY (non-repair) recording too', () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-script-writer-force-ordinary-'));
const writer = new SessionScriptWriter(path.join(root, 'sessions'));
const outPath = path.join(root, 'flows', 'existing.ad');
fs.mkdirSync(path.dirname(outPath), { recursive: true });
fs.writeFileSync(outPath, 'context platform=ios device="x"\nclick id="old"\n');

const session = makeIosSession('default', {
recordSession: true,
// No saveScriptBoundary: ordinary open/close --save-script, not a repair.
saveScriptPath: outPath,
actions: [action({ command: 'click', positionals: ['id="new"'] })],
});

const result = writer.write(session, { force: true });
expect(result.written).toBe(true);
const parsed = parseReplayScriptDetailed(fs.readFileSync(outPath, 'utf8'));
expect(parsed.actions.map((a) => a.positionals[0])).toEqual(['id="new"']);
});

test('write(session) without { force: true } still refuses, even when a prior write in the same test used force', () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-script-writer-force-default-'));
const writer = new SessionScriptWriter(path.join(root, 'sessions'));
const outPath = path.join(root, 'flows', 'existing.ad');
fs.mkdirSync(path.dirname(outPath), { recursive: true });
fs.writeFileSync(outPath, 'context platform=ios device="x"\nclick id="old"\n');
const before = fs.readFileSync(outPath, 'utf8');

const session = makeIosSession('default', {
recordSession: true,
saveScriptPath: outPath,
actions: [action({ command: 'click', positionals: ['id="new"'] })],
});

// Default (no options / force omitted): refuse-on-exist, unchanged.
expect(() => writer.write(session)).toThrow(/already exists/);
expect(fs.readFileSync(outPath, 'utf8')).toBe(before);
});

// The lock/lease machinery previously here (acquireLease/releaseLease/
// stealExpiredLease/the three-writer interleaving) is gone: the publish
// primitive is now a single exclusive `linkSync`, which already decides a
Expand Down
Loading
Loading