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
56 changes: 56 additions & 0 deletions src/commands/robots/_shared.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import { describe, expect, test } from 'bun:test';
import type { AnyRobotsJob } from './_shared.ts';
import { assertJobCompleted } from './_shared.ts';

const baseJob = {
id: 'rjob_xyz',
workflow: 'summarize',
created_at: 0,
updated_at: 0,
units_consumed: 0,
parameters: { asset_id: 'asset_x' },
} as unknown as AnyRobotsJob;

describe('assertJobCompleted', () => {
test('returns silently on status=completed', () => {
expect(() =>
assertJobCompleted({ ...baseJob, status: 'completed' } as AnyRobotsJob),
).not.toThrow();
});

test('throws on status=errored', () => {
expect(() =>
assertJobCompleted({ ...baseJob, status: 'errored' } as AnyRobotsJob),
).toThrow(/errored/i);
});

test('throws on status=cancelled', () => {
expect(() =>
assertJobCompleted({ ...baseJob, status: 'cancelled' } as AnyRobotsJob),
).toThrow(/cancelled/i);
});

test('includes job.errors details when present', () => {
const job = {
...baseJob,
status: 'errored',
errors: [
{ type: 'processing_error', message: 'asset not ready' },
{ type: 'timeout', message: 'took too long' },
],
} as unknown as AnyRobotsJob;
expect(() => assertJobCompleted(job)).toThrow(
/processing_error: asset not ready.*timeout: took too long/,
);
});

test('includes job id in the error message', () => {
expect(() =>
assertJobCompleted({
...baseJob,
id: 'rjob_abc123',
status: 'errored',
} as AnyRobotsJob),
).toThrow(/rjob_abc123/);
});
});
125 changes: 125 additions & 0 deletions src/commands/robots/_shared.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
import { readFile } from 'node:fs/promises';
import type Mux from '@mux/mux-node';
import type {
AskQuestionsJob,
FindKeyMomentsJob,
GenerateChaptersJob,
ModerateJob,
SummarizeJob,
TranslateCaptionsJob,
} from '@mux/mux-node/resources/robots-preview/jobs';

export type AnyRobotsJob =
| AskQuestionsJob
| FindKeyMomentsJob
| GenerateChaptersJob
| ModerateJob
| SummarizeJob
| TranslateCaptionsJob;

export type RobotsWorkflow = AnyRobotsJob['workflow'];

export const FILE_MUTEX_MSG =
'--file cannot be combined with other parameter flags. Use one or the other.';

const TERMINAL_STATUSES = new Set(['completed', 'cancelled', 'errored']);

export function isTerminalStatus(status: string): boolean {
return TERMINAL_STATUSES.has(status);
}

export async function loadJobParameters<T extends { asset_id?: string }>(
filePath: string,
assetIdFromPositional: string,
): Promise<T> {
let content: string;
try {
content = await readFile(filePath, 'utf-8');
} catch (err) {
if ((err as NodeJS.ErrnoException).code === 'ENOENT') {
throw new Error(`Config file not found: ${filePath}`);
}
throw err;
}

let parsed: T;
try {
parsed = JSON.parse(content) as T;
} catch (err) {
throw new Error(
`Invalid JSON in config file ${filePath}: ${(err as Error).message}`,
);
}

if (parsed.asset_id && parsed.asset_id !== assetIdFromPositional) {
throw new Error(
`asset_id in config file (${parsed.asset_id}) does not match positional argument (${assetIdFromPositional}).`,
);
}
parsed.asset_id = assetIdFromPositional;
return parsed;
}

export function retrieveRobotsJob(
mux: Mux,
workflow: RobotsWorkflow,
jobId: string,
): Promise<AnyRobotsJob> {
switch (workflow) {
case 'ask-questions':
return mux.robotsPreview.jobs.askQuestions.retrieve(jobId);
case 'find-key-moments':
return mux.robotsPreview.jobs.findKeyMoments.retrieve(jobId);
case 'generate-chapters':
return mux.robotsPreview.jobs.generateChapters.retrieve(jobId);
case 'moderate':
return mux.robotsPreview.jobs.moderate.retrieve(jobId);
case 'summarize':
return mux.robotsPreview.jobs.summarize.retrieve(jobId);
case 'translate-captions':
return mux.robotsPreview.jobs.translateCaptions.retrieve(jobId);
}
}

export function assertJobCompleted(job: AnyRobotsJob): void {
if (job.status === 'completed') return;
const details = job.errors?.length
? `: ${job.errors.map((e) => `${e.type}: ${e.message}`).join('; ')}`
: '';
throw new Error(`Job ${job.id} ended with status "${job.status}"${details}`);
}

export async function pollForRobotsJob(
mux: Mux,
workflow: RobotsWorkflow,
jobId: string,
jsonOutput: boolean,
): Promise<AnyRobotsJob> {
const POLL_INTERVAL_MS = 3000;
const MAX_POLL_TIME_MS = 15 * 60 * 1000;
const start = Date.now();

if (!jsonOutput) {
process.stderr.write('Waiting for job to complete');
}

while (Date.now() - start < MAX_POLL_TIME_MS) {
const job = await retrieveRobotsJob(mux, workflow, jobId);
if (isTerminalStatus(job.status)) {
if (!jsonOutput) {
process.stderr.write(` ${job.status}!\n`);
}
return job;
}
Comment thread
cursor[bot] marked this conversation as resolved.
if (!jsonOutput) {
process.stderr.write('.');
}
await sleep(POLL_INTERVAL_MS);
}

throw new Error(`Timed out waiting for job ${jobId} to complete`);
}

function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
126 changes: 123 additions & 3 deletions src/commands/robots/ask-questions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,24 +7,31 @@ import {
spyOn,
test,
} from 'bun:test';
import { askQuestionsCommand } from './ask-questions.ts';
import { mkdtemp, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { askQuestionsCommand, parseQuestion } from './ask-questions.ts';

// Note: These tests focus on CLI flag parsing and command structure
// They do NOT test the actual Mux API integration (that's tested via E2E)

describe('mux robots ask-questions', () => {
let tempDir: string;
let exitSpy: Mock<typeof process.exit>;
let consoleErrorSpy: Mock<typeof console.error>;

beforeEach(() => {
beforeEach(async () => {
tempDir = await mkdtemp(join(tmpdir(), 'mux-cli-robots-test-'));

exitSpy = spyOn(process, 'exit').mockImplementation((() => {
throw new Error('process.exit called');
}) as never);

consoleErrorSpy = spyOn(console, 'error').mockImplementation(() => {});
});

afterEach(() => {
afterEach(async () => {
await rm(tempDir, { recursive: true, force: true });
exitSpy?.mockRestore();
consoleErrorSpy?.mockRestore();
});
Expand Down Expand Up @@ -71,6 +78,20 @@ describe('mux robots ask-questions', () => {
.find((o) => o.name === 'json');
expect(opt).toBeDefined();
});

test('has --wait flag', () => {
const opt = askQuestionsCommand
.getOptions()
.find((o) => o.name === 'wait');
expect(opt).toBeDefined();
});

test('has --file flag', () => {
const opt = askQuestionsCommand
.getOptions()
.find((o) => o.name === 'file');
expect(opt).toBeDefined();
});
});

describe('Input validation', () => {
Expand All @@ -84,4 +105,103 @@ describe('mux robots ask-questions', () => {
expect(exitSpy).toHaveBeenCalled();
});
});

describe('parseQuestion helper', () => {
test('plain question without pipe yields no answer_options', () => {
expect(parseQuestion('What is this?')).toEqual({
question: 'What is this?',
});
});

test('question with pipe splits answer_options on commas', () => {
expect(parseQuestion('How many speakers?|one,two,three or more')).toEqual(
{
question: 'How many speakers?',
answer_options: ['one', 'two', 'three or more'],
},
);
});

test('trims whitespace around question and options', () => {
expect(parseQuestion(' Q? | a , b , c ')).toEqual({
question: 'Q?',
answer_options: ['a', 'b', 'c'],
});
});

test('splits only on first pipe so options may contain pipes', () => {
expect(parseQuestion('Choose?|a|b,c|d')).toEqual({
question: 'Choose?',
answer_options: ['a|b', 'c|d'],
});
});

test('throws on empty question', () => {
expect(() => parseQuestion('|a,b')).toThrow(/question/i);
});

test('throws when pipe is present but options list is empty', () => {
expect(() => parseQuestion('Q?|')).toThrow(/answer_options/i);
});
});

describe('--file mode', () => {
test('errors when config file does not exist', async () => {
const configPath = join(tempDir, 'nope.json');
try {
await askQuestionsCommand.parse(['asset_abc', '--file', configPath]);
} catch (_error) {
// Expected
}
expect(exitSpy).toHaveBeenCalledWith(1);
const msg = consoleErrorSpy.mock.calls[0]?.[0] ?? '';
expect(msg).toMatch(/file not found/i);
});

test('errors when config file is invalid JSON', async () => {
const configPath = join(tempDir, 'bad.json');
await writeFile(configPath, '{ bad');
try {
await askQuestionsCommand.parse(['asset_abc', '--file', configPath]);
} catch (_error) {
// Expected
}
expect(exitSpy).toHaveBeenCalledWith(1);
const msg = consoleErrorSpy.mock.calls[0]?.[0] ?? '';
expect(msg).toMatch(/invalid json/i);
});

test('errors when --file combined with --question', async () => {
const configPath = join(tempDir, 'config.json');
await writeFile(
configPath,
JSON.stringify({
questions: [{ question: 'What is this?' }],
}),
);
try {
await askQuestionsCommand.parse([
'asset_abc',
'--file',
configPath,
'--question',
'Other?',
]);
} catch (_error) {
// Expected
}
expect(exitSpy).toHaveBeenCalledWith(1);
const msg = consoleErrorSpy.mock.calls[0]?.[0] ?? '';
expect(msg).toMatch(/--file cannot be combined/i);
});

test('requires either --file or --question', async () => {
try {
await askQuestionsCommand.parse(['asset_abc']);
} catch (_error) {
// Expected
}
expect(exitSpy).toHaveBeenCalled();
});
});
});
Loading
Loading