mirror of
https://github.com/expo/eas-cli.git
synced 2026-11-14 10:08:06 -10:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d5a39d2e7f |
No files matched your search
@@ -0,0 +1,164 @@
|
||||
import { Config } from '@oclif/core';
|
||||
|
||||
import { ExpoGraphqlClient } from '../../../commandUtils/context/contextUtils/createGraphqlClient';
|
||||
import { DeviceRunSessionMutation } from '../../../graphql/mutations/DeviceRunSessionMutation';
|
||||
import Log from '../../../log';
|
||||
import {
|
||||
DeviceRunSessionAnnotationUploadError,
|
||||
appendAndUploadDeviceRunSessionAnnotationAsync,
|
||||
createDeviceRunSessionAnnotation,
|
||||
} from '../../../simulator/annotations';
|
||||
import { EAS_SIMULATOR_SESSION_ID, loadSimulatorEnvAsync } from '../../../simulator/env';
|
||||
import { enableJsonOutput, printJsonOnlyOutput } from '../../../utils/json';
|
||||
import SimulatorAnnotate from '../annotate';
|
||||
|
||||
jest.mock('../../../graphql/mutations/DeviceRunSessionMutation');
|
||||
jest.mock('../../../log');
|
||||
jest.mock('../../../simulator/annotations', () => ({
|
||||
...jest.requireActual('../../../simulator/annotations'),
|
||||
appendAndUploadDeviceRunSessionAnnotationAsync: jest.fn(),
|
||||
createDeviceRunSessionAnnotation: jest.fn(),
|
||||
}));
|
||||
jest.mock('../../../simulator/env', () => ({
|
||||
...jest.requireActual('../../../simulator/env'),
|
||||
loadSimulatorEnvAsync: jest.fn(),
|
||||
}));
|
||||
jest.mock('../../../utils/json');
|
||||
|
||||
const mockAppendAndUpload = jest.mocked(appendAndUploadDeviceRunSessionAnnotationAsync);
|
||||
const mockCreateAnnotation = jest.mocked(createDeviceRunSessionAnnotation);
|
||||
const mockCreateUploadSession = jest.mocked(
|
||||
DeviceRunSessionMutation.createAnnotationLogUploadSessionAsync
|
||||
);
|
||||
const mockEnableJsonOutput = jest.mocked(enableJsonOutput);
|
||||
const mockLoadSimulatorEnvAsync = jest.mocked(loadSimulatorEnvAsync);
|
||||
const mockLog = jest.mocked(Log.log);
|
||||
const mockPrintJsonOnlyOutput = jest.mocked(printJsonOnlyOutput);
|
||||
|
||||
function getMockOclifConfig(): Config {
|
||||
const config = new Config({ root: __dirname });
|
||||
config.runHook = async () => ({ failures: [], successes: [] });
|
||||
return config;
|
||||
}
|
||||
|
||||
describe(SimulatorAnnotate, () => {
|
||||
const graphqlClient = {} as ExpoGraphqlClient;
|
||||
const annotation = {
|
||||
v: 1 as const,
|
||||
annotationId: 'annotation-id',
|
||||
ts: '2026-08-11T12:00:00.000Z',
|
||||
category: 'commentary' as const,
|
||||
message: 'Opening settings.',
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
delete process.env[EAS_SIMULATOR_SESSION_ID];
|
||||
mockCreateAnnotation.mockReturnValue(annotation);
|
||||
mockCreateUploadSession.mockResolvedValue({
|
||||
artifact: {
|
||||
id: 'artifact-id',
|
||||
downloadUrl: 'https://example.test/annotations',
|
||||
},
|
||||
uploadSession: {
|
||||
url: 'https://storage.test/annotations',
|
||||
headers: { 'x-goog-content-length-range': '0,5242880' },
|
||||
},
|
||||
});
|
||||
mockAppendAndUpload.mockResolvedValue({
|
||||
filePath: '/data/annotations.ndjson',
|
||||
fileSizeBytes: 123,
|
||||
});
|
||||
mockLoadSimulatorEnvAsync.mockImplementation(async () => {
|
||||
process.env[EAS_SIMULATOR_SESSION_ID] = 'session-from-env';
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
delete process.env[EAS_SIMULATOR_SESSION_ID];
|
||||
});
|
||||
|
||||
it('appends commentary to the dotenv session and uploads it directly', async () => {
|
||||
const command = createCommand(['Opening settings.']);
|
||||
|
||||
await command.runAsync();
|
||||
|
||||
expect(mockCreateAnnotation).toHaveBeenCalledWith({
|
||||
category: 'commentary',
|
||||
message: 'Opening settings.',
|
||||
});
|
||||
expect(mockLoadSimulatorEnvAsync).toHaveBeenCalledWith('/test/project');
|
||||
expect(mockCreateUploadSession).toHaveBeenCalledWith(graphqlClient, 'session-from-env');
|
||||
expect(mockAppendAndUpload).toHaveBeenCalledWith({
|
||||
annotation,
|
||||
deviceRunSessionId: 'session-from-env',
|
||||
uploadSession: {
|
||||
downloadUrl: 'https://example.test/annotations',
|
||||
uploadUrl: 'https://storage.test/annotations',
|
||||
uploadHeaders: { 'x-goog-content-length-range': '0,5242880' },
|
||||
},
|
||||
});
|
||||
expect(mockLog).toHaveBeenCalledWith(
|
||||
'Added commentary annotation to simulator session session-from-env.'
|
||||
);
|
||||
});
|
||||
|
||||
it('accepts an explicit session and category and prints JSON output', async () => {
|
||||
const command = createCommand([
|
||||
'Enabling dark mode.',
|
||||
'--id',
|
||||
'explicit-session',
|
||||
'--category',
|
||||
'decision',
|
||||
'--json',
|
||||
]);
|
||||
|
||||
await command.runAsync();
|
||||
|
||||
expect(mockEnableJsonOutput).toHaveBeenCalled();
|
||||
expect(mockCreateAnnotation).toHaveBeenCalledWith({
|
||||
category: 'decision',
|
||||
message: 'Enabling dark mode.',
|
||||
});
|
||||
expect(mockCreateUploadSession).toHaveBeenCalledWith(graphqlClient, 'explicit-session');
|
||||
expect(mockPrintJsonOnlyOutput).toHaveBeenCalledWith({
|
||||
annotation,
|
||||
artifactId: 'artifact-id',
|
||||
deviceRunSessionId: 'explicit-session',
|
||||
fileSizeBytes: 123,
|
||||
});
|
||||
expect(mockLog).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('requires a session id', async () => {
|
||||
mockLoadSimulatorEnvAsync.mockResolvedValue();
|
||||
const command = createCommand(['Opening settings.']);
|
||||
|
||||
await expect(command.runAsync()).rejects.toThrow('No simulator session ID provided');
|
||||
expect(mockCreateUploadSession).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('explains that a failed upload remains in the local spool', async () => {
|
||||
mockAppendAndUpload.mockRejectedValue(
|
||||
new DeviceRunSessionAnnotationUploadError(
|
||||
'/data/annotations.ndjson',
|
||||
new Error('network unavailable')
|
||||
)
|
||||
);
|
||||
const command = createCommand(['Opening settings.']);
|
||||
|
||||
await expect(command.runAsync()).rejects.toThrow(
|
||||
'The annotation was saved locally but could not be uploaded'
|
||||
);
|
||||
});
|
||||
|
||||
function createCommand(argv: string[]): SimulatorAnnotate {
|
||||
const command = new SimulatorAnnotate(argv, getMockOclifConfig());
|
||||
// @ts-expect-error getContextAsync is protected
|
||||
jest.spyOn(command, 'getContextAsync').mockResolvedValue({
|
||||
loggedIn: { graphqlClient },
|
||||
projectDir: '/test/project',
|
||||
});
|
||||
return command;
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,114 @@
|
||||
import { Args, Flags } from '@oclif/core';
|
||||
|
||||
import EasCommand from '../../commandUtils/EasCommand';
|
||||
import {
|
||||
EasNonInteractiveAndJsonFlags,
|
||||
resolveNonInteractiveAndJsonFlags,
|
||||
} from '../../commandUtils/flags';
|
||||
import { DeviceRunSessionMutation } from '../../graphql/mutations/DeviceRunSessionMutation';
|
||||
import Log from '../../log';
|
||||
import {
|
||||
EAS_SIMULATOR_SESSION_ID,
|
||||
SIMULATOR_DOTENV_FILE_NAME,
|
||||
loadSimulatorEnvAsync,
|
||||
} from '../../simulator/env';
|
||||
import {
|
||||
DEVICE_RUN_SESSION_ANNOTATION_CATEGORIES,
|
||||
DeviceRunSessionAnnotationUploadError,
|
||||
appendAndUploadDeviceRunSessionAnnotationAsync,
|
||||
createDeviceRunSessionAnnotation,
|
||||
} from '../../simulator/annotations';
|
||||
import { enableJsonOutput, printJsonOnlyOutput } from '../../utils/json';
|
||||
|
||||
export default class SimulatorAnnotate extends EasCommand {
|
||||
static override hidden = true;
|
||||
static override aliases = ['sim:annotate'];
|
||||
static override description = '[EXPERIMENTAL] add agent commentary to a remote simulator session';
|
||||
|
||||
static override args = {
|
||||
message: Args.string({
|
||||
required: true,
|
||||
description: 'Concise commentary to attach to the simulator session.',
|
||||
}),
|
||||
};
|
||||
|
||||
static override flags = {
|
||||
id: Flags.string({
|
||||
description: `Simulator session ID. Defaults to ${SIMULATOR_DOTENV_FILE_NAME}.`,
|
||||
}),
|
||||
category: Flags.option({
|
||||
description: 'Kind of commentary being recorded.',
|
||||
options: [...DEVICE_RUN_SESSION_ANNOTATION_CATEGORIES],
|
||||
default: 'commentary',
|
||||
})(),
|
||||
...EasNonInteractiveAndJsonFlags,
|
||||
};
|
||||
|
||||
static override contextDefinition = {
|
||||
...this.ContextOptions.LoggedIn,
|
||||
...this.ContextOptions.ProjectDir,
|
||||
};
|
||||
|
||||
async runAsync(): Promise<void> {
|
||||
const { args, flags } = await this.parse(SimulatorAnnotate);
|
||||
const { json: jsonFlag, nonInteractive } = resolveNonInteractiveAndJsonFlags(flags);
|
||||
if (jsonFlag) {
|
||||
enableJsonOutput();
|
||||
}
|
||||
|
||||
const annotation = createDeviceRunSessionAnnotation({
|
||||
category: flags.category,
|
||||
message: args.message,
|
||||
});
|
||||
const {
|
||||
projectDir,
|
||||
loggedIn: { graphqlClient },
|
||||
} = await this.getContextAsync(SimulatorAnnotate, { nonInteractive });
|
||||
await loadSimulatorEnvAsync(projectDir);
|
||||
const deviceRunSessionId = flags.id ?? process.env[EAS_SIMULATOR_SESSION_ID];
|
||||
if (!deviceRunSessionId) {
|
||||
throw new Error(
|
||||
`No simulator session ID provided. Pass --id, or run \`eas simulator:start\` first to write ${SIMULATOR_DOTENV_FILE_NAME}.`
|
||||
);
|
||||
}
|
||||
|
||||
const { artifact, uploadSession } =
|
||||
await DeviceRunSessionMutation.createAnnotationLogUploadSessionAsync(
|
||||
graphqlClient,
|
||||
deviceRunSessionId
|
||||
);
|
||||
|
||||
let fileSizeBytes: number;
|
||||
try {
|
||||
({ fileSizeBytes } = await appendAndUploadDeviceRunSessionAnnotationAsync({
|
||||
annotation,
|
||||
deviceRunSessionId,
|
||||
uploadSession: {
|
||||
downloadUrl: artifact.downloadUrl,
|
||||
uploadUrl: uploadSession.url,
|
||||
uploadHeaders: uploadSession.headers,
|
||||
},
|
||||
}));
|
||||
} catch (error) {
|
||||
if (error instanceof DeviceRunSessionAnnotationUploadError) {
|
||||
throw new Error(
|
||||
`The annotation was saved locally but could not be uploaded. The next annotation command will retry the complete log.`,
|
||||
{ cause: error }
|
||||
);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (jsonFlag) {
|
||||
printJsonOnlyOutput({
|
||||
annotation,
|
||||
artifactId: artifact.id,
|
||||
deviceRunSessionId,
|
||||
fileSizeBytes,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
Log.log(`Added ${annotation.category} annotation to simulator session ${deviceRunSessionId}.`);
|
||||
}
|
||||
}
|
||||
@@ -10,7 +10,64 @@ import {
|
||||
EnsureDeviceRunSessionStoppedMutationVariables,
|
||||
} from '../generated';
|
||||
|
||||
export type DeviceRunSessionAnnotationLogUploadSession = {
|
||||
artifact: {
|
||||
id: string;
|
||||
downloadUrl: string;
|
||||
};
|
||||
uploadSession: {
|
||||
url: string;
|
||||
headers: Record<string, string>;
|
||||
};
|
||||
};
|
||||
|
||||
type CreateDeviceRunSessionAnnotationLogUploadSessionMutation = {
|
||||
deviceRunSession: {
|
||||
createAnnotationLogUploadSession: DeviceRunSessionAnnotationLogUploadSession;
|
||||
};
|
||||
};
|
||||
|
||||
type CreateDeviceRunSessionAnnotationLogUploadSessionMutationVariables = {
|
||||
deviceRunSessionId: string;
|
||||
};
|
||||
|
||||
export const DeviceRunSessionMutation = {
|
||||
async createAnnotationLogUploadSessionAsync(
|
||||
graphqlClient: ExpoGraphqlClient,
|
||||
deviceRunSessionId: string
|
||||
): Promise<DeviceRunSessionAnnotationLogUploadSession> {
|
||||
const data = await withErrorHandlingAsync(
|
||||
graphqlClient
|
||||
.mutation<
|
||||
CreateDeviceRunSessionAnnotationLogUploadSessionMutation,
|
||||
CreateDeviceRunSessionAnnotationLogUploadSessionMutationVariables
|
||||
>(
|
||||
// This field is deployed in WWW before the CLI schema snapshot can be regenerated.
|
||||
// oxlint-disable graphql/template-strings
|
||||
gql`
|
||||
mutation CreateDeviceRunSessionAnnotationLogUploadSession($deviceRunSessionId: ID!) {
|
||||
deviceRunSession {
|
||||
createAnnotationLogUploadSession(deviceRunSessionId: $deviceRunSessionId) {
|
||||
artifact {
|
||||
id
|
||||
downloadUrl
|
||||
}
|
||||
uploadSession {
|
||||
url
|
||||
headers
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
// oxlint-enable graphql/template-strings
|
||||
{ deviceRunSessionId },
|
||||
{ noRetry: true }
|
||||
)
|
||||
.toPromise()
|
||||
);
|
||||
return data.deviceRunSession.createAnnotationLogUploadSession;
|
||||
},
|
||||
async createDeviceRunSessionAsync(
|
||||
graphqlClient: ExpoGraphqlClient,
|
||||
deviceRunSessionInput: CreateDeviceRunSessionInput
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
import fs from 'fs-extra';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
|
||||
import fetch, { RequestError, Response } from '../../fetch';
|
||||
import {
|
||||
DeviceRunSessionAnnotationUploadError,
|
||||
appendAndUploadDeviceRunSessionAnnotationAsync,
|
||||
createDeviceRunSessionAnnotation,
|
||||
getDeviceRunSessionAnnotationDirectory,
|
||||
} from '../annotations';
|
||||
|
||||
jest.mock('../../fetch', () => ({
|
||||
__esModule: true,
|
||||
...jest.requireActual('../../fetch'),
|
||||
default: jest.fn(),
|
||||
}));
|
||||
|
||||
const mockFetch = jest.mocked(fetch);
|
||||
const deviceRunSessionId = 'device-run-session-id';
|
||||
const uploadSession = {
|
||||
downloadUrl: 'https://example.test/annotations',
|
||||
uploadUrl: 'https://storage.test/annotations',
|
||||
uploadHeaders: { 'x-goog-content-length-range': '0,5242880' },
|
||||
};
|
||||
|
||||
describe('simulator annotations', () => {
|
||||
let dataDirectory: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
jest.clearAllMocks();
|
||||
dataDirectory = await fs.mkdtemp(path.join(os.tmpdir(), 'eas-cli-annotations-'));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await fs.remove(dataDirectory);
|
||||
});
|
||||
|
||||
it('seeds a missing spool, appends NDJSON, and uploads the complete file', async () => {
|
||||
mockFetch
|
||||
.mockRejectedValueOnce(new RequestError('not found', new Response('', { status: 404 })))
|
||||
.mockResolvedValueOnce(new Response('', { status: 200 }));
|
||||
const annotation = createDeviceRunSessionAnnotation({
|
||||
annotationId: 'annotation-id',
|
||||
category: 'intent',
|
||||
message: ' Opening settings next. ',
|
||||
timestamp: new Date('2026-08-11T12:00:00.000Z'),
|
||||
});
|
||||
|
||||
const result = await appendAndUploadDeviceRunSessionAnnotationAsync({
|
||||
annotation,
|
||||
dataDirectory,
|
||||
deviceRunSessionId,
|
||||
uploadSession,
|
||||
});
|
||||
|
||||
const expectedContents = `${JSON.stringify({
|
||||
v: 1,
|
||||
annotationId: 'annotation-id',
|
||||
ts: '2026-08-11T12:00:00.000Z',
|
||||
category: 'intent',
|
||||
message: 'Opening settings next.',
|
||||
})}\n`;
|
||||
await expect(fs.readFile(result.filePath, 'utf8')).resolves.toBe(expectedContents);
|
||||
expect(result.fileSizeBytes).toBe(Buffer.byteLength(expectedContents));
|
||||
expect(mockFetch).toHaveBeenNthCalledWith(1, uploadSession.downloadUrl);
|
||||
expect(mockFetch).toHaveBeenNthCalledWith(2, uploadSession.uploadUrl, {
|
||||
method: 'PUT',
|
||||
headers: uploadSession.uploadHeaders,
|
||||
body: Buffer.from(expectedContents),
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps appending to the local spool and replaces the remote object with all annotations', async () => {
|
||||
mockFetch
|
||||
.mockRejectedValueOnce(new RequestError('not found', new Response('', { status: 404 })))
|
||||
.mockResolvedValue(new Response('', { status: 200 }));
|
||||
const firstAnnotation = createDeviceRunSessionAnnotation({
|
||||
annotationId: 'first',
|
||||
category: 'observation',
|
||||
message: 'The settings screen is open.',
|
||||
timestamp: new Date('2026-08-11T12:00:00.000Z'),
|
||||
});
|
||||
const secondAnnotation = createDeviceRunSessionAnnotation({
|
||||
annotationId: 'second',
|
||||
category: 'decision',
|
||||
message: 'Enabling dark mode.',
|
||||
timestamp: new Date('2026-08-11T12:00:01.000Z'),
|
||||
});
|
||||
|
||||
await appendAndUploadDeviceRunSessionAnnotationAsync({
|
||||
annotation: firstAnnotation,
|
||||
dataDirectory,
|
||||
deviceRunSessionId,
|
||||
uploadSession,
|
||||
});
|
||||
mockFetch.mockClear();
|
||||
await appendAndUploadDeviceRunSessionAnnotationAsync({
|
||||
annotation: secondAnnotation,
|
||||
dataDirectory,
|
||||
deviceRunSessionId,
|
||||
uploadSession,
|
||||
});
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledTimes(1);
|
||||
const uploaded = mockFetch.mock.calls[0][1]?.body;
|
||||
expect(Buffer.isBuffer(uploaded) ? uploaded.toString('utf8') : null).toBe(
|
||||
`${JSON.stringify(firstAnnotation)}\n${JSON.stringify(secondAnnotation)}\n`
|
||||
);
|
||||
});
|
||||
|
||||
it('downloads an existing remote log when the local spool is missing', async () => {
|
||||
const existingAnnotation = createDeviceRunSessionAnnotation({
|
||||
annotationId: 'remote',
|
||||
category: 'commentary',
|
||||
message: 'Existing commentary.',
|
||||
timestamp: new Date('2026-08-11T12:00:00.000Z'),
|
||||
});
|
||||
const nextAnnotation = createDeviceRunSessionAnnotation({
|
||||
annotationId: 'local',
|
||||
category: 'result',
|
||||
message: 'The change worked.',
|
||||
timestamp: new Date('2026-08-11T12:00:01.000Z'),
|
||||
});
|
||||
mockFetch
|
||||
.mockResolvedValueOnce(
|
||||
new Response(`${JSON.stringify(existingAnnotation)}\n`, { status: 200 })
|
||||
)
|
||||
.mockResolvedValueOnce(new Response('', { status: 200 }));
|
||||
|
||||
await appendAndUploadDeviceRunSessionAnnotationAsync({
|
||||
annotation: nextAnnotation,
|
||||
dataDirectory,
|
||||
deviceRunSessionId,
|
||||
uploadSession,
|
||||
});
|
||||
|
||||
const uploaded = mockFetch.mock.calls[1][1]?.body;
|
||||
expect(Buffer.isBuffer(uploaded) ? uploaded.toString('utf8') : null).toBe(
|
||||
`${JSON.stringify(existingAnnotation)}\n${JSON.stringify(nextAnnotation)}\n`
|
||||
);
|
||||
});
|
||||
|
||||
it('retains the appended spool when the upload fails', async () => {
|
||||
mockFetch
|
||||
.mockRejectedValueOnce(new RequestError('not found', new Response('', { status: 404 })))
|
||||
.mockRejectedValueOnce(new Error('network unavailable'));
|
||||
const annotation = createDeviceRunSessionAnnotation({
|
||||
annotationId: 'pending',
|
||||
category: 'commentary',
|
||||
message: 'This should be retried.',
|
||||
});
|
||||
|
||||
await expect(
|
||||
appendAndUploadDeviceRunSessionAnnotationAsync({
|
||||
annotation,
|
||||
dataDirectory,
|
||||
deviceRunSessionId,
|
||||
uploadSession,
|
||||
})
|
||||
).rejects.toBeInstanceOf(DeviceRunSessionAnnotationUploadError);
|
||||
|
||||
const filePath = path.join(
|
||||
getDeviceRunSessionAnnotationDirectory(dataDirectory, deviceRunSessionId),
|
||||
'annotations.ndjson'
|
||||
);
|
||||
await expect(fs.readFile(filePath, 'utf8')).resolves.toBe(`${JSON.stringify(annotation)}\n`);
|
||||
});
|
||||
|
||||
it('rejects empty and oversized commentary', () => {
|
||||
expect(() =>
|
||||
createDeviceRunSessionAnnotation({ category: 'commentary', message: ' ' })
|
||||
).toThrow('must not be empty');
|
||||
expect(() =>
|
||||
createDeviceRunSessionAnnotation({ category: 'commentary', message: 'x'.repeat(2_001) })
|
||||
).toThrow('must not exceed 2000 characters');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,209 @@
|
||||
import { randomUUID } from 'crypto';
|
||||
import fs from 'fs-extra';
|
||||
import path from 'path';
|
||||
|
||||
import fetch, { RequestError } from '../fetch';
|
||||
import { getDataDirectory } from '../utils/paths';
|
||||
|
||||
export const DEVICE_RUN_SESSION_ANNOTATION_CATEGORIES = [
|
||||
'commentary',
|
||||
'intent',
|
||||
'observation',
|
||||
'decision',
|
||||
'result',
|
||||
] as const;
|
||||
export type DeviceRunSessionAnnotationCategory =
|
||||
(typeof DEVICE_RUN_SESSION_ANNOTATION_CATEGORIES)[number];
|
||||
|
||||
export const MAX_DEVICE_RUN_SESSION_ANNOTATION_MESSAGE_LENGTH = 2_000;
|
||||
export const MAX_DEVICE_RUN_SESSION_ANNOTATION_COUNT = 500;
|
||||
export const MAX_DEVICE_RUN_SESSION_ANNOTATION_LOG_SIZE_BYTES = 5 * 1024 * 1024;
|
||||
|
||||
const ANNOTATION_FILE_NAME = 'annotations.ndjson';
|
||||
const LOCK_FILE_NAME = 'annotations.lock';
|
||||
const LOCK_RETRY_INTERVAL_MS = 50;
|
||||
const LOCK_WAIT_TIMEOUT_MS = 10_000;
|
||||
const STALE_LOCK_AGE_MS = 2 * 60_000;
|
||||
|
||||
export type DeviceRunSessionAnnotation = {
|
||||
v: 1;
|
||||
annotationId: string;
|
||||
ts: string;
|
||||
category: DeviceRunSessionAnnotationCategory;
|
||||
message: string;
|
||||
};
|
||||
|
||||
export type DeviceRunSessionAnnotationUploadSession = {
|
||||
downloadUrl: string;
|
||||
uploadUrl: string;
|
||||
uploadHeaders: Record<string, string>;
|
||||
};
|
||||
|
||||
export class DeviceRunSessionAnnotationUploadError extends Error {
|
||||
constructor(
|
||||
public readonly filePath: string,
|
||||
cause: unknown
|
||||
) {
|
||||
super('Could not upload the simulator session annotation log.', { cause });
|
||||
}
|
||||
}
|
||||
|
||||
export function createDeviceRunSessionAnnotation({
|
||||
category,
|
||||
message,
|
||||
annotationId = randomUUID(),
|
||||
timestamp = new Date(),
|
||||
}: {
|
||||
category: DeviceRunSessionAnnotationCategory;
|
||||
message: string;
|
||||
annotationId?: string;
|
||||
timestamp?: Date;
|
||||
}): DeviceRunSessionAnnotation {
|
||||
const normalizedMessage = message.trim();
|
||||
if (normalizedMessage.length === 0) {
|
||||
throw new Error('Annotation message must not be empty.');
|
||||
}
|
||||
if (normalizedMessage.length > MAX_DEVICE_RUN_SESSION_ANNOTATION_MESSAGE_LENGTH) {
|
||||
throw new Error(
|
||||
`Annotation message must not exceed ${MAX_DEVICE_RUN_SESSION_ANNOTATION_MESSAGE_LENGTH} characters.`
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
v: 1,
|
||||
annotationId,
|
||||
ts: timestamp.toISOString(),
|
||||
category,
|
||||
message: normalizedMessage,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Maintains a durable local spool and replaces the remote object after each append.
|
||||
* The local lock supports concurrent commands on one machine; remote replacement assumes a
|
||||
* single active writer for a session, with sequential handoff supported by downloading the log.
|
||||
*/
|
||||
export async function appendAndUploadDeviceRunSessionAnnotationAsync({
|
||||
annotation,
|
||||
deviceRunSessionId,
|
||||
uploadSession,
|
||||
dataDirectory = getDataDirectory(),
|
||||
}: {
|
||||
annotation: DeviceRunSessionAnnotation;
|
||||
deviceRunSessionId: string;
|
||||
uploadSession: DeviceRunSessionAnnotationUploadSession;
|
||||
dataDirectory?: string;
|
||||
}): Promise<{ filePath: string; fileSizeBytes: number }> {
|
||||
const sessionDirectory = getDeviceRunSessionAnnotationDirectory(
|
||||
dataDirectory,
|
||||
deviceRunSessionId
|
||||
);
|
||||
await fs.ensureDir(sessionDirectory);
|
||||
|
||||
return await withAnnotationFileLockAsync(sessionDirectory, async () => {
|
||||
const filePath = path.join(sessionDirectory, ANNOTATION_FILE_NAME);
|
||||
if (!(await fs.pathExists(filePath))) {
|
||||
await initializeAnnotationFileAsync(filePath, uploadSession.downloadUrl);
|
||||
}
|
||||
|
||||
const currentContents = await fs.readFile(filePath, 'utf8');
|
||||
const annotationCount = currentContents.split('\n').filter(line => line.trim()).length;
|
||||
if (annotationCount >= MAX_DEVICE_RUN_SESSION_ANNOTATION_COUNT) {
|
||||
throw new Error(
|
||||
`Simulator sessions support at most ${MAX_DEVICE_RUN_SESSION_ANNOTATION_COUNT} annotations.`
|
||||
);
|
||||
}
|
||||
|
||||
const separator = currentContents.length > 0 && !currentContents.endsWith('\n') ? '\n' : '';
|
||||
const appendedContents = `${currentContents}${separator}${JSON.stringify(annotation)}\n`;
|
||||
const fileSizeBytes = Buffer.byteLength(appendedContents);
|
||||
if (fileSizeBytes > MAX_DEVICE_RUN_SESSION_ANNOTATION_LOG_SIZE_BYTES) {
|
||||
throw new Error('Simulator session annotation log exceeds the 5 MiB limit.');
|
||||
}
|
||||
|
||||
await fs.writeFile(filePath, appendedContents, { mode: 0o600 });
|
||||
try {
|
||||
await uploadAnnotationFileAsync(filePath, uploadSession);
|
||||
} catch (error) {
|
||||
throw new DeviceRunSessionAnnotationUploadError(filePath, error);
|
||||
}
|
||||
return { filePath, fileSizeBytes };
|
||||
});
|
||||
}
|
||||
|
||||
export function getDeviceRunSessionAnnotationDirectory(
|
||||
dataDirectory: string,
|
||||
deviceRunSessionId: string
|
||||
): string {
|
||||
return path.join(dataDirectory, 'simulator-sessions', deviceRunSessionId);
|
||||
}
|
||||
|
||||
async function initializeAnnotationFileAsync(filePath: string, downloadUrl: string): Promise<void> {
|
||||
let contents = '';
|
||||
try {
|
||||
const response = await fetch(downloadUrl);
|
||||
const downloaded = await response.buffer();
|
||||
if (downloaded.byteLength > MAX_DEVICE_RUN_SESSION_ANNOTATION_LOG_SIZE_BYTES) {
|
||||
throw new Error('Existing simulator session annotation log exceeds the 5 MiB limit.');
|
||||
}
|
||||
contents = downloaded.toString('utf8');
|
||||
} catch (error) {
|
||||
if (!(error instanceof RequestError && error.response.status === 404)) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
await fs.writeFile(filePath, contents, { flag: 'wx', mode: 0o600 });
|
||||
}
|
||||
|
||||
async function uploadAnnotationFileAsync(
|
||||
filePath: string,
|
||||
uploadSession: DeviceRunSessionAnnotationUploadSession
|
||||
): Promise<void> {
|
||||
const body = await fs.readFile(filePath);
|
||||
await fetch(uploadSession.uploadUrl, {
|
||||
method: 'PUT',
|
||||
headers: uploadSession.uploadHeaders,
|
||||
body,
|
||||
});
|
||||
}
|
||||
|
||||
async function withAnnotationFileLockAsync<T>(
|
||||
sessionDirectory: string,
|
||||
callback: () => Promise<T>
|
||||
): Promise<T> {
|
||||
const lockPath = path.join(sessionDirectory, LOCK_FILE_NAME);
|
||||
const deadline = Date.now() + LOCK_WAIT_TIMEOUT_MS;
|
||||
let fileDescriptor: number | undefined;
|
||||
|
||||
while (fileDescriptor === undefined) {
|
||||
try {
|
||||
fileDescriptor = await fs.open(lockPath, 'wx', 0o600);
|
||||
} catch (error) {
|
||||
if (!isErrorWithCode(error, 'EEXIST')) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
const stat = await fs.stat(lockPath).catch(() => null);
|
||||
if (stat && Date.now() - stat.mtimeMs > STALE_LOCK_AGE_MS) {
|
||||
await fs.remove(lockPath);
|
||||
continue;
|
||||
}
|
||||
if (Date.now() >= deadline) {
|
||||
throw new Error('Timed out waiting for another annotation upload to finish.');
|
||||
}
|
||||
await new Promise(resolve => setTimeout(resolve, LOCK_RETRY_INTERVAL_MS));
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
return await callback();
|
||||
} finally {
|
||||
await fs.close(fileDescriptor);
|
||||
await fs.remove(lockPath);
|
||||
}
|
||||
}
|
||||
|
||||
function isErrorWithCode(error: unknown, code: string): boolean {
|
||||
return typeof error === 'object' && error !== null && 'code' in error && error.code === code;
|
||||
}
|
||||
Reference in new issue
Block a user