Compare commits

...
Author SHA1 Message Date
szdziedzic d5a39d2e7f [eas-cli] Add simulator session annotations 2026-08-12 15:14:04 +02:00
5 changed files with 722 additions and 0 deletions

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;
}