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
7 changes: 7 additions & 0 deletions .changeset/simulator-control-paths.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
'eas-cli': patch
---

Require explicit `host:` simulator-host upload paths and report missing local files.

Missing local absolute paths now require the `host:` prefix. Actions that require upload IDs accept local files and reject host paths with an explanation.
123 changes: 113 additions & 10 deletions packages/eas-cli/src/commands/simulator/__tests__/control.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,10 @@ jest.mock('../../../ora', () => ({
}),
}));
jest.mock('../../../utils/json');
jest.mock('fs-extra', () => {
const actual = jest.requireActual<typeof import('fs-extra')>('fs-extra');
return { ...actual, stat: jest.fn(actual.stat) };
});

type DeviceRunSessionById = DeviceRunSessionByIdQuery['deviceRunSessions']['byId'];

Expand Down Expand Up @@ -236,12 +240,23 @@ describe(SimulatorControl, () => {
expect(close).toHaveBeenCalledTimes(1);
});

it('explains local and explicit host upload paths without running an action', async () => {
await createCommand(['media.add', '--help', '--id', 'session-123']).runAsync();

expect(jest.mocked(Log.log)).toHaveBeenCalledWith(
' File paths are local. Use host:/absolute/path for files on the simulator host.'
);
expect(actionRequests()).toEqual([]);
expect(close).toHaveBeenCalledTimes(1);
});

it('prints the host action schema as JSON for help', async () => {
await createCommand(['app.install', '--help', '--json', '--id', 'session-123']).runAsync();

expect(mockPrintJsonOnlyOutput).toHaveBeenCalledWith({
action: 'app.install',
...catalog['app.install'],
description: expect.stringContaining('host:/absolute/path'),
});
expect(actionRequests()).toEqual([]);
expect(close).toHaveBeenCalledTimes(1);
Expand Down Expand Up @@ -328,6 +343,39 @@ describe(SimulatorControl, () => {
]);
});

it('rejects host paths for ID-only uploads and does not advertise them', async () => {
served = {
...catalog,
'app.file.upload': {
type: 'object',
properties: { uploadId: { type: 'string' } },
upload: 'uploadId',
uploadAs: 'id',
},
};
await expect(
createCommand(['app.file.upload', 'host:/tmp/a.json', '--id', 'session-123']).runAsync()
).rejects.toThrow('requires a local file');
expect(actionRequests()).toEqual([]);
await createCommand(['app.file.upload', '--help', '--id', 'session-123']).runAsync();
const lines = jest.mocked(Log.log).mock.calls.map(([line]) => String(line));
expect(lines.join('\n')).not.toContain('host:/absolute/path');
expect(lines.join('\n')).toContain('requires a local file');
await createCommand(['app.file.upload', '--help', '--json', '--id', 'session-123']).runAsync();
expect(mockPrintJsonOnlyOutput).toHaveBeenLastCalledWith(
expect.objectContaining({
description: expect.stringContaining('requires a local file'),
})
);
});

it('explains how to upload a local filename beginning with host:', async () => {
await expect(
createCommand(['media.add', 'host:photo.png', '--id', 'session-123']).runAsync()
).rejects.toThrow('./host:photo.png');
expect(actionRequests()).toEqual([]);
});

it('keeps an upload that the action goes on reading', async () => {
const localFile = path.join(tmpDir, 'feed.mp4');
await fs.writeFile(localFile, 'video bytes');
Expand Down Expand Up @@ -395,17 +443,65 @@ describe(SimulatorControl, () => {
expect(actionRequests()).toEqual([]);
});

it('passes a path that is not a local file through unchanged', async () => {
actionResult = ok('');
it('refuses a missing absolute local file without running an action', async () => {
const missing = path.join(tmpDir, 'missing.png');
await expect(
createCommand(['media.add', missing, '--id', 'session-123']).runAsync()
).rejects.toThrow('does not exist on this computer');
expect(actionRequests()).toEqual([]);
});

await createCommand([
'app.install',
'/Users/runner/MyApp.ipa',
'--id',
'session-123',
]).runAsync();
it('preserves a local filesystem failure without sending an action', async () => {
const denied = Object.assign(new Error('Permission denied'), { code: 'EACCES' });
const stat = jest
.mocked<(path: string) => Promise<fs.Stats>>(fs.stat)
.mockRejectedValueOnce(denied);
try {
await expect(
createCommand([
'media.add',
path.join(tmpDir, 'private.png'),
'--id',
'session-123',
]).runAsync()
).rejects.toBe(denied);
expect(actionRequests()).toEqual([]);
expect(close).toHaveBeenCalledTimes(1);
} finally {
stat
.mockReset()
.mockImplementation(jest.requireActual<typeof import('fs-extra')>('fs-extra').stat);
}
});

expect(actionRequests()).toEqual([['app.install', { path: '/Users/runner/MyApp.ipa' }]]);
it('passes an explicit host path through without uploading a matching local file', async () => {
const localFile = path.join(tmpDir, 'existing.png');
await fs.writeFile(localFile, 'local bytes');
await createCommand(['media.add', `host:${localFile}`, '--id', 'session-123']).runAsync();
expect(actionRequests()).toEqual([['media.add', { path: localFile }]]);
});

it.each(['host:relative.png', 'host:'])('rejects a non-absolute host path: %s', async value => {
await expect(
createCommand(['media.add', value, '--id', 'session-123']).runAsync()
).rejects.toThrow('absolute');
expect(actionRequests()).toEqual([]);
});

it('uploads a local filename beginning with host: when explicitly relative', async () => {
const previous = process.cwd();
await fs.writeFile(path.join(tmpDir, 'host:photo.png'), 'png bytes');
try {
process.chdir(tmpDir);
await createCommand(['media.add', './host:photo.png', '--id', 'session-123']).runAsync();
expect(actionRequests().map(([action]) => action)).toEqual([
'upload.append',
'media.add',
'upload.remove',
]);
} finally {
process.chdir(previous);
}
});

it('downloads the file an action returns', async () => {
Expand Down Expand Up @@ -443,7 +539,14 @@ describe(SimulatorControl, () => {
it('lists the actions as JSON when no action is provided', async () => {
await createCommand(['--id', 'session-123', '--json']).runAsync();

expect(mockPrintJsonOnlyOutput).toHaveBeenCalledWith(catalog);
expect(mockPrintJsonOnlyOutput).toHaveBeenCalledWith(
expect.objectContaining({
'app.install': expect.objectContaining({
description: expect.stringContaining('host:/absolute/path'),
}),
'hardwareKeyboard.set': catalog['hardwareKeyboard.set'],
})
);
expect(actionRequests()).toEqual([]);
});

Expand Down
57 changes: 51 additions & 6 deletions packages/eas-cli/src/commands/simulator/control.ts
Original file line number Diff line number Diff line change
Expand Up @@ -148,7 +148,11 @@ export default class SimulatorControl extends EasCommand {

private printCatalog(catalog: HostActionCatalog, jsonFlag: boolean): void {
if (jsonFlag) {
printJsonOnlyOutput(catalog);
printJsonOnlyOutput(
Object.fromEntries(
Object.entries(catalog).map(([name, entry]) => [name, withUploadPathHelp(entry)])
)
);
return;
}
const names = Object.keys(catalog).sort();
Expand All @@ -167,7 +171,7 @@ export default class SimulatorControl extends EasCommand {

private printActionHelp(action: string, entry: HostActionCatalogEntry, jsonFlag: boolean): void {
if (jsonFlag) {
printJsonOnlyOutput({ ...entry, action });
printJsonOnlyOutput({ ...withUploadPathHelp(entry), action });
return;
}
Log.newLine();
Expand Down Expand Up @@ -198,6 +202,11 @@ export default class SimulatorControl extends EasCommand {
' Action arguments refer to the simulator host. Use --output for the local filename.'
);
}
const uploadHelp = uploadPathHelp(entry);
if (uploadHelp) {
Log.newLine();
Log.log(` ${uploadHelp}`);
}
Log.newLine();
}

Expand Down Expand Up @@ -270,22 +279,58 @@ export default class SimulatorControl extends EasCommand {
}
}

function uploadPathHelp(entry: HostActionCatalogEntry): string | undefined {
if (!entry.upload) {
return undefined;
}
return entry.uploadAs === 'id'
? 'This action requires a local file to upload. host: paths are not supported.'
: 'File paths are local. Use host:/absolute/path for files on the simulator host.';
}

function withUploadPathHelp(entry: HostActionCatalogEntry): HostActionCatalogEntry {
const help = uploadPathHelp(entry);
return help
? { ...entry, description: [entry.description, help].filter(Boolean).join('\n\n') }
: entry;
}

async function findLocalUploadAsync(
{ upload: param, uploadWhen = {} }: HostActionCatalogEntry,
{ upload: param, uploadWhen = {}, uploadAs }: HostActionCatalogEntry,
params: Record<string, unknown>
): Promise<{ param: string; path: string } | null> {
const value = param ? params[param] : undefined;
const applies = Object.entries(uploadWhen).every(([name, expected]) => params[name] === expected);
if (!param || typeof value !== 'string' || !applies) {
return null;
}
const stat = await fs.stat(value).catch(() => null);
if (value.startsWith('host:')) {
if (uploadAs === 'id') {
throw new Error(
'This action requires a local file to upload; host: paths are not supported.'
);
}
const hostPath = value.slice('host:'.length);
if (!path.posix.isAbsolute(hostPath)) {
throw new Error(
`A host: path must be absolute, for example host:/tmp/app.tar.gz. For a local filename beginning with host:, use ./${value}.`
);
}
params[param] = hostPath;
return null;
}
const stat = await fs.stat(value).catch((error: NodeJS.ErrnoException) => {
if (error.code === 'ENOENT' || error.code === 'ENOTDIR') {
return null;
}
throw error;
});
if (stat?.isDirectory()) {
throw new Error(`${value} is a folder. Pass the path of a file.`);
}
if (!stat && !path.isAbsolute(value)) {
if (!stat) {
throw new Error(
`${value} does not exist on this computer. Pass the path of a local file, or an absolute path on the simulator host.`
`${value} does not exist on this computer. Pass a local file, or prefix an absolute simulator-host path with host:.`
);
}
return stat ? { param, path: value } : null;
Expand Down
Loading