magnitude-test
Version:
A TypeScript client for running automated UI tests through the Magnitude testing platform
213 lines (212 loc) • 7.87 kB
JavaScript
import cuid2 from "@paralleldrive/cuid2";
import { getTestWorkerData, postToParent, testFunctions, messageEmitter, hooks } from "./util";
import { TestCaseAgent } from "@/agent";
import { TestStateTracker } from "@/runner/state";
import { buildDefaultBrowserAgentOptions } from "magnitude-core";
import { sendTelemetry } from "@/runner/telemetry";
import { testPromptStack } from "@/worker/testDeclaration";
// This module has to be separate so it only gets imported once after possible compilation by jiti.
const workerData = getTestWorkerData();
const generateId = cuid2.init({ length: 12 });
export function registerTest(testFn, title, url) {
const testId = generateId();
testFunctions.set(testId, testFn);
postToParent({
type: 'registered',
test: {
id: testId,
title,
url,
filepath: workerData.relativeFilePath,
group: currentGroup?.name
}
});
}
let beforeAllExecuted = false;
let beforeAllError = null;
let afterAllExecuted = false;
let isShuttingDown = false;
let pendingAfterEach = new Set();
// No state reset is needed because each test file is run in a separate worker
let currentGroup;
export function setCurrentGroup(group) {
currentGroup = group;
}
export function currentGroupOptions() {
return structuredClone(currentGroup?.options) ?? {};
}
messageEmitter.removeAllListeners('message');
messageEmitter.on('message', async (message) => {
if (message.type === 'graceful_shutdown') {
isShuttingDown = true;
if (pendingAfterEach.size > 0) {
try {
await Promise.all([...pendingAfterEach].map(async (_testId) => {
for (const afterEachHook of hooks.afterEach) {
await afterEachHook();
}
}));
}
catch (error) {
console.error("afterEach hooks failed during graceful shutdown:", error);
}
}
if (!afterAllExecuted) {
try {
for (const afterAllHook of hooks.afterAll) {
await afterAllHook();
}
afterAllExecuted = true;
}
catch (error) {
console.error("afterAll hook failed during graceful shutdown:\n", error);
}
}
postToParent({ type: 'graceful_shutdown_complete' });
return;
}
if (message.type !== 'execute')
return;
// Don't start new tests if shutting down
if (isShuttingDown) {
postToParent({
type: 'test_error',
testId: message.test.id,
error: 'Test cancelled due to graceful shutdown'
});
return;
}
const { test, browserOptions, llm, grounding, telemetry } = message;
const testFn = testFunctions.get(test.id);
if (!testFn) {
postToParent({
type: 'test_error',
testId: test.id,
error: `Test function not found: ${test.id}`
});
return;
}
try {
const promptStack = testPromptStack[test.title] || [];
const prompt = promptStack.length > 0 ? promptStack.join('\n') : undefined;
const { agentOptions: defaultAgentOptions, browserOptions: defaultBrowserOptions } = buildDefaultBrowserAgentOptions({
agentOptions: { llm, ...(prompt ? { prompt } : {}) },
browserOptions: {
url: test.url,
browser: browserOptions,
grounding
}
});
const agent = new TestCaseAgent({
// disable telemetry to keep test run telemetry seperate from general automation telemetry
agentOptions: { ...defaultAgentOptions, telemetry: false },
browserOptions: defaultBrowserOptions,
});
const tracker = new TestStateTracker(agent);
tracker.events.on('stateChanged', (state) => {
postToParent({
type: 'test_state_change',
testId: test.id,
state
});
});
await agent.start();
let finalState;
let finalResult;
try {
if (!beforeAllExecuted && hooks.beforeAll.length > 0) {
try {
for (const beforeAllHook of hooks.beforeAll) {
await beforeAllHook();
}
}
catch (error) {
console.error("beforeAll hooks failed:", error);
beforeAllError = error instanceof Error ? error : new Error(String(error));
}
finally {
beforeAllExecuted = true;
}
}
if (beforeAllError) {
throw new Error(`beforeAll hook failed: ${beforeAllError.message}`);
}
for (const beforeEachHook of hooks.beforeEach) {
try {
await beforeEachHook();
}
catch (error) {
console.error(`beforeEach hook failed for test '${test.title}':`, error);
throw error;
}
}
pendingAfterEach.add(test.id);
await testFn(agent);
if (!isShuttingDown) {
pendingAfterEach.delete(test.id);
for (const afterEachHook of hooks.afterEach) {
try {
await afterEachHook();
}
catch (error) {
console.error(`afterEach hook failed for test '${test.title}':`, error);
throw error;
}
}
}
finalState = {
...tracker.getState(),
status: 'passed',
doneAt: Date.now()
};
finalResult = { passed: true };
}
catch (error) {
if (!isShuttingDown) {
pendingAfterEach.delete(test.id);
try {
for (const afterEachHook of hooks.afterEach) {
await afterEachHook();
}
}
catch (afterEachError) {
console.error(`afterEach hook failed for failing test '${test.title}':`, afterEachError);
const originalMessage = error instanceof Error ? error.message : String(error);
const afterEachMessage = afterEachError instanceof Error ? afterEachError.message : String(afterEachError);
error = new Error(`Test failed: ${originalMessage}. Additionally, afterEach hook failed: ${afterEachMessage}`);
}
}
const failure = {
message: error instanceof Error ? error.message : String(error)
};
finalState = {
...tracker.getState(),
failure: failure,
status: 'failed',
doneAt: Date.now()
};
finalResult = { passed: false, failure };
}
await agent.stop();
postToParent({
type: 'test_state_change',
testId: test.id,
state: finalState
});
if (finalState && (telemetry ?? true))
await sendTelemetry(finalState);
postToParent({
type: 'test_result',
testId: test.id,
result: finalResult ??
{ passed: false, failure: { message: "Test result doesn't exist" } },
});
}
catch (error) {
postToParent({
type: 'test_error',
error: error instanceof Error ? error.message : String(error),
testId: test.id,
});
}
});