Skip to content
Open
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
3,991 changes: 2,596 additions & 1,395 deletions package-lock.json

Large diffs are not rendered by default.

4 changes: 2 additions & 2 deletions packages/magnitude-core/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "magnitude-core",
"version": "0.3.1",
"name": "@ddwang/magnitude-core",
"version": "0.3.1-ddwang.1",
"description": "Magnitude e2e testing agent",
"publishConfig": {
"access": "public"
Expand Down
7 changes: 6 additions & 1 deletion packages/magnitude-core/src/actions/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ export interface Action {
[key: string]: any
}

export type ActionIntent = ClickIntent | TypeIntent | ScrollIntent | SwitchTabIntent; // really we want switch tab to be an option only if >1 tab
export type ActionIntent = ClickIntent | HoverIntent | TypeIntent | ScrollIntent | SwitchTabIntent; // really we want switch tab to be an option only if >1 tab
export type Intent = ActionIntent | CheckIntent;
//export type Recipe = Ingredient[];

Expand All @@ -19,6 +19,11 @@ export interface ClickIntent {
target: string;
}

export interface HoverIntent {
variant: 'hover';
target: string;
}

export interface TypeIntent {
variant: 'type';
target: string;
Expand Down
15 changes: 15 additions & 0 deletions packages/magnitude-core/src/actions/webActions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,20 @@ export const mouseDoubleClickAction = createAction({
render: ({ x, y }) => `⊙ double click (${x}, ${y})`
});

export const mouseHoverAction = createAction({
name: 'mouse:hover',
description: "Hover over an element to reveal tooltips, dropdown menus, or hidden content",
schema: z.object({
x: z.number().int(),
y: z.number().int(),
}),
resolver: async ({ input: { x, y }, agent }) => {
const web = agent.require(BrowserConnector);
await web.getHarness().hover({ x, y });
},
render: ({ x, y }) => `◎ hover (${x}, ${y})`
});

export const mouseRightClickAction = createAction({
name: 'mouse:right_click',
schema: z.object({
Expand Down Expand Up @@ -197,6 +211,7 @@ export const webActions = [
clickCoordAction,
mouseDoubleClickAction,
mouseRightClickAction,
mouseHoverAction,
scrollCoordAction,
mouseDragAction,
newTabAction,
Expand Down
1 change: 1 addition & 0 deletions packages/magnitude-core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ export * from '@/common';
export * from "@/telemetry";
export { buildDefaultBrowserAgentOptions } from "@/ai/util";
export { logger } from './logger';
export { z } from 'zod';
//export { ModelUsage } from '@/ai/modelHarness';

setLogLevel('OFF');
57 changes: 57 additions & 0 deletions packages/magnitude-core/src/web/browserProvider.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import { describe, expect, test, beforeEach } from 'bun:test';
import { BrowserProvider } from '@/web/browserProvider';

beforeEach(async () => {
// Ensure clean state before each test
await BrowserProvider.reset();
});

describe('BrowserProvider.reset()', () => {
test('is idempotent when no instance exists', async () => {
expect((globalThis as any).__magnitude__?.browserProvider).toBeUndefined();
// Should not throw
await BrowserProvider.reset();
expect((globalThis as any).__magnitude__?.browserProvider).toBeUndefined();
});

test('is idempotent when instance exists but has no browsers', async () => {
// Force creation of singleton with no active browsers
BrowserProvider.getInstance();
expect((globalThis as any).__magnitude__.browserProvider).toBeDefined();

await BrowserProvider.reset();
expect((globalThis as any).__magnitude__.browserProvider).toBeUndefined();
});

test('closes active browsers and clears singleton', async () => {
const instance = BrowserProvider.getInstance();

// Create a context which launches a real browser
await instance.newContext({ launchOptions: { headless: true } });

// Verify a browser is tracked
expect(Object.keys((instance as any).activeBrowsers).length).toBeGreaterThan(0);

await BrowserProvider.reset();

// Singleton should be cleared
expect((globalThis as any).__magnitude__.browserProvider).toBeUndefined();

// activeBrowsers on the old instance should be empty
expect(Object.keys((instance as any).activeBrowsers).length).toBe(0);
});

test('does not throw if browser is already closed', async () => {
const instance = BrowserProvider.getInstance();
await instance.newContext({ launchOptions: { headless: true } });

// Close the browser before reset
const activeBrowser = Object.values((instance as any).activeBrowsers)[0] as any;
const browser = await activeBrowser.browserPromise;
await browser.close();

// reset() should still succeed
await BrowserProvider.reset();
expect((globalThis as any).__magnitude__.browserProvider).toBeUndefined();
});
});
15 changes: 15 additions & 0 deletions packages/magnitude-core/src/web/browserProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,21 @@ export class BrowserProvider {
return (globalThis as any).__magnitude__.browserProvider;
}

public static async reset(): Promise<void> {
const instance: BrowserProvider | undefined = (globalThis as any).__magnitude__?.browserProvider;
if (!instance) return;

await Promise.allSettled(
Object.values(instance.activeBrowsers).map(async (activeBrowser) => {
const browser = await activeBrowser.browserPromise;
await browser.close();
})
);

instance.activeBrowsers = {};
(globalThis as any).__magnitude__.browserProvider = undefined;
}

private async _launchOrReuseBrowser(options: LaunchOptions): Promise<ActiveBrowser> {
// hash options
const hash = objectHash({
Expand Down
13 changes: 12 additions & 1 deletion packages/magnitude-core/src/web/harness.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { Page, Browser, BrowserContext, PageScreenshotOptions } from "playwright";
import { ClickWebAction, ScrollWebAction, SwitchTabWebAction, TypeWebAction, WebAction } from '@/web/types';
import { ClickWebAction, HoverWebAction, ScrollWebAction, SwitchTabWebAction, TypeWebAction, WebAction } from '@/web/types';
import { PageStabilityAnalyzer } from "./stability";
import { parseTypeContent } from "./util";
import { ActionVisualizer, ActionVisualizerOptions } from "./visualizer";
Expand Down Expand Up @@ -288,6 +288,15 @@ export class WebHarness { // implements StateComponent
await this.visualizer.showAll();
}

async hover({ x, y }: { x: number, y: number }, options?: { transform: boolean }) {
if (options?.transform ?? true) ({ x, y } = await this.transformCoordinates({ x, y }));
await Promise.all([
this.visualizer.moveVirtualCursor(x, y),
this.page.mouse.move(x, y, { steps: 20 })
]);
await this.waitForStability();
}

async rightClick({ x, y }: { x: number, y: number }, options?: { transform: boolean }) {
if (options?.transform ?? true) ({ x, y } = await this.transformCoordinates({ x, y }));
await this._click(x, y, { button: "right" });
Expand Down Expand Up @@ -391,6 +400,8 @@ export class WebHarness { // implements StateComponent
async executeAction(action: WebAction) {
if (action.variant === 'click') {
await this.click(action);
} else if (action.variant === 'hover') {
await this.hover(action);
} else if (action.variant === 'type') {
await this.clickAndType(action);
} else if (action.variant === 'scroll') {
Expand Down
8 changes: 7 additions & 1 deletion packages/magnitude-core/src/web/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ export type Base64Image = `data:image/${'jpeg'|'png'|'gif'};base64,${string}`;
// variant: ActionVariant
// }

export type WebAction = NavigateWebAction | ClickWebAction | TypeWebAction | ScrollWebAction | SwitchTabWebAction;
export type WebAction = NavigateWebAction | ClickWebAction | HoverWebAction | TypeWebAction | ScrollWebAction | SwitchTabWebAction;

// Currently only emitted synthetically
export interface NavigateWebAction {
Expand All @@ -28,6 +28,12 @@ export interface ClickWebAction {
y: number
}

export interface HoverWebAction {
variant: 'hover'
x: number
y: number
}

export interface TypeWebAction {
variant: 'type'
x: number
Expand Down
16 changes: 16 additions & 0 deletions packages/magnitude-core/src/web/zodReexport.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { describe, expect, test } from 'bun:test';

describe('z re-export', () => {
test('z.object() returns a valid schema via entry point', async () => {
const { z } = await import('@/index');
const schema = z.object({ name: z.string() });
const result = schema.safeParse({ name: 'test' });
expect(result.success).toBe(true);
});

test('re-exported z is the same zod instance', async () => {
const { z: reexported } = await import('@/index');
const { z: direct } = await import('zod');
expect(reexported).toBe(direct);
});
});
11 changes: 6 additions & 5 deletions packages/magnitude-test/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,8 @@
"default": "./dist/index.cjs"
},
"import": {
"types": "./dist/index.d.mts",
"default": "./dist/index.mjs"
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
}
},
"repository": {
Expand Down Expand Up @@ -59,27 +59,28 @@
"license": "Apache-2.0",
"devDependencies": {
"@types/node": "^22.13.4",
"@types/react": "^18.2.0",
"@types/react": "^19.0.0",
"pkgroll": "^2.10.0",
"typescript": "~5.7.2",
"playwright": "npm:rebrowser-playwright@^1.52.0"
},
"dependencies": {
"@commander-js/extra-typings": "^14.0.0",
"@paralleldrive/cuid2": "^2.2.2",
"@types/terminal-kit": "^2.5.7",
"chalk": "^5.4.1",
"commander": "^14.0.0",
"dotenv": "^16.5.0",
"esbuild": "^0.25.1",
"glob": "^11.0.1",
"ink": "^6.0.0",
"ink-spinner": "^5.0.0",
"jiti": "^2.4.2",
"log-update": "^6.1.0",
"magnitude-core": "0.3.1",
"pino": "^9.6.0",
"pino-pretty": "^13.0.0",
"playwright": "^1.51.0",
"posthog-node": "^4.18.0",
"react": "^19.1.0",
"std-env": "^3.9.0",
"zod": "^3.24.2"
}
Expand Down
13 changes: 13 additions & 0 deletions packages/magnitude-test/src/term-app/components/FailureDisplay.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import React from 'react';
import { Text } from 'ink';
import { TestFailure } from '@/runner/state';

interface FailureDisplayProps {
failure: TestFailure;
}

export function FailureDisplay({ failure }: FailureDisplayProps) {
return (
<Text color="red">{'↳ '}{failure.message ?? 'Unknown error details'}</Text>
);
}
113 changes: 113 additions & 0 deletions packages/magnitude-test/src/term-app/components/InkApp.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
import React, { useState, useEffect, useMemo } from 'react';
import { Box, Text, useApp, useInput, useStdout } from 'ink';
import { RegisteredTest, MagnitudeConfig } from '@/discovery/types';
import { AllTestStates } from '../types';
import { MAX_APP_WIDTH } from '../constants';
import { TitleBar } from './TitleBar';
import { TestDisplay } from './TestDisplay';
import { TestGroupDisplay } from './TestGroupDisplay';
import { Summary } from './Summary';

export interface StateBridge {
update: (states: AllTestStates) => void;
setModel: (model: string) => void;
}

interface InkAppProps {
tests: RegisteredTest[];
config: MagnitudeConfig;
initialStates: AllTestStates;
onReady: (bridge: StateBridge) => void;
}

function groupRegisteredTestsForDisplay(tests: RegisteredTest[]):
Record<string, { ungrouped: RegisteredTest[]; groups: Record<string, RegisteredTest[]> }> {
const files: Record<string, { ungrouped: RegisteredTest[]; groups: Record<string, RegisteredTest[]> }> = {};
for (const test of tests) {
if (!files[test.filepath]) {
files[test.filepath] = { ungrouped: [], groups: {} };
}
if (test.group) {
if (!files[test.filepath].groups[test.group]) {
files[test.filepath].groups[test.group] = [];
}
files[test.filepath].groups[test.group].push(test);
} else {
files[test.filepath].ungrouped.push(test);
}
}
return files;
}

export function InkApp({ tests, config, initialStates, onReady }: InkAppProps) {
const [testStates, setTestStates] = useState<AllTestStates>(initialStates);
const [model, setModel] = useState('');
const { exit } = useApp();
const { stdout } = useStdout();

const showActions = config.display?.showActions ?? true;
const showThoughts = config.display?.showThoughts ?? false;

useEffect(() => {
onReady({ update: setTestStates, setModel });
}, []);

useInput((_input, key) => {
if (key.ctrl && _input === 'c') {
exit();
}
});

const width = Math.min(stdout?.columns ?? MAX_APP_WIDTH, MAX_APP_WIDTH);
const grouped = useMemo(() => groupRegisteredTestsForDisplay(tests), [tests]);

return (
<Box flexDirection="column" width={width}>
<TitleBar model={model} />
<Box flexDirection="column" paddingX={1}>
{Object.entries(grouped).map(([filepath, { ungrouped, groups }]) => (
<Box key={filepath} flexDirection="column">
<Text bold color="blueBright">{'☰ '}{filepath}</Text>

{ungrouped.map(test => {
const state = testStates[test.id];
if (!state) return null;
return (
<Box key={test.id} marginLeft={2}>
<TestDisplay
title={test.title}
state={state}
showActions={showActions}
showThoughts={showThoughts}
/>
</Box>
);
})}

{Object.entries(groups).map(([groupName, groupTests]) => (
<TestGroupDisplay key={groupName} groupName={groupName}>
{groupTests.map(test => {
const state = testStates[test.id];
if (!state) return null;
return (
<TestDisplay
key={test.id}
title={test.title}
state={state}
showActions={showActions}
showThoughts={showThoughts}
/>
);
})}
</TestGroupDisplay>
))}
</Box>
))}

<Box marginTop={1}>
<Summary testStates={testStates} registeredTests={tests} model={model} />
</Box>
</Box>
</Box>
);
}
Loading