This commit is contained in:
2026-07-22 09:34:39 +08:00
commit a67be62996
152 changed files with 35276 additions and 0 deletions

View File

@@ -0,0 +1,108 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { getAIDescription } from '../src/background/ai-description';
import type { ElementMeta } from '../src/shared/types';
const mockOpenAICreate = vi.fn().mockResolvedValue({
choices: [{ message: { content: 'Click the Submit button' } }],
});
const mockAnthropicCreate = vi.fn().mockResolvedValue({
content: [{ type: 'text', text: 'Click the Submit button' }],
});
vi.mock('openai', () => ({
default: vi.fn().mockImplementation(function () {
return {
chat: {
completions: {
create: mockOpenAICreate,
},
},
};
}),
}));
vi.mock('@anthropic-ai/sdk', () => ({
default: vi.fn().mockImplementation(function () {
return {
messages: {
create: mockAnthropicCreate,
},
};
}),
}));
function makeMeta(): ElementMeta {
return {
tag: 'button',
cssSelector: '#submit-btn',
textContent: 'Submit',
ariaLabel: null,
placeholder: null,
altText: null,
name: null,
role: 'button',
href: null,
inputType: null,
dataTestId: null,
rect: { x: 100, y: 200, width: 80, height: 32 },
devicePixelRatio: 1,
};
}
function makeBlob(): Blob {
return new Blob(['test'], { type: 'image/jpeg' });
}
describe('getAIDescription', () => {
const blob = makeBlob();
const meta = makeMeta();
beforeEach(() => {
mockOpenAICreate.mockResolvedValue({
choices: [{ message: { content: 'Click the Submit button' } }],
});
mockAnthropicCreate.mockResolvedValue({
content: [{ type: 'text', text: 'Click the Submit button' }],
});
});
it('OpenAI provider returns description string from mocked completions.create', async () => {
const result = await getAIDescription(blob, 'click', meta, 'openai', 'test-key');
expect(result).toBe('Click the Submit button');
});
it('Anthropic provider returns description string from mocked messages.create', async () => {
const result = await getAIDescription(blob, 'click', meta, 'anthropic', 'test-key');
expect(result).toBe('Click the Submit button');
});
it('getAIDescription returns null when OpenAI API throws error', async () => {
mockOpenAICreate.mockRejectedValueOnce(new Error('API Error'));
const result = await getAIDescription(blob, 'click', meta, 'openai', 'bad-key');
expect(result).toBeNull();
});
it('getAIDescription returns null when Anthropic API throws error', async () => {
mockAnthropicCreate.mockRejectedValueOnce(new Error('API Error'));
const result = await getAIDescription(blob, 'click', meta, 'anthropic', 'bad-key');
expect(result).toBeNull();
});
it('Blob is converted to base64 and included in prompt', async () => {
let capturedParams: unknown = null;
mockOpenAICreate.mockImplementationOnce((params: unknown) => {
capturedParams = params;
return Promise.resolve({
choices: [{ message: { content: 'Click the Submit button' } }],
});
});
await getAIDescription(blob, 'click', meta, 'openai', 'test-key');
const params = capturedParams as { messages: Array<{ content: Array<{ type: string; image_url?: { url: string } }> }> };
expect(params).not.toBeNull();
const content = params.messages[0].content;
const imageContent = content.find((c) => c.type === 'image_url');
expect(imageContent).toBeDefined();
expect(imageContent?.image_url?.url).toMatch(/^data:image\/jpeg;base64,/);
});
});

View File

@@ -0,0 +1,90 @@
import { describe, it, expect } from 'vitest';
import { createActor } from 'xstate';
import { captureMachine } from '../src/background/machine';
describe('captureMachine Phase 2', () => {
it('idle -> recording on START_RECORDING; context.currentGuideId is a UUID string', () => {
const actor = createActor(captureMachine);
actor.start();
actor.send({ type: 'START_RECORDING', url: 'https://example.com' });
const snapshot = actor.getSnapshot();
expect(snapshot.value).toBe('recording');
expect(typeof snapshot.context.currentGuideId).toBe('string');
expect(snapshot.context.currentGuideId).toMatch(
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/
);
actor.stop();
});
it('recording -> idle on STOP_RECORDING; context resets to null/0/empty', () => {
const actor = createActor(captureMachine);
actor.start();
actor.send({ type: 'START_RECORDING', url: 'https://example.com' });
actor.send({ type: 'STOP_RECORDING' });
const snapshot = actor.getSnapshot();
expect(snapshot.value).toBe('idle');
expect(snapshot.context.currentGuideId).toBeNull();
expect(snapshot.context.stepCount).toBe(0);
expect(snapshot.context.currentUrl).toBe('');
actor.stop();
});
it('USER_ACTION in recording state increments stepCount by 1', () => {
const actor = createActor(captureMachine);
actor.start();
actor.send({ type: 'START_RECORDING', url: 'https://example.com' });
expect(actor.getSnapshot().context.stepCount).toBe(0);
actor.send({ type: 'USER_ACTION' });
expect(actor.getSnapshot().context.stepCount).toBe(1);
actor.send({ type: 'USER_ACTION' });
expect(actor.getSnapshot().context.stepCount).toBe(2);
actor.stop();
});
it('USER_ACTION in idle state does nothing (no transition)', () => {
const actor = createActor(captureMachine);
actor.start();
expect(actor.getSnapshot().value).toBe('idle');
actor.send({ type: 'USER_ACTION' });
expect(actor.getSnapshot().value).toBe('idle');
expect(actor.getSnapshot().context.stepCount).toBe(0);
actor.stop();
});
it('SPA_NAVIGATE in recording state updates currentUrl', () => {
const actor = createActor(captureMachine);
actor.start();
actor.send({ type: 'START_RECORDING', url: 'https://example.com' });
actor.send({ type: 'SPA_NAVIGATE', url: 'https://example.com/page2' });
expect(actor.getSnapshot().context.currentUrl).toBe('https://example.com/page2');
expect(actor.getSnapshot().value).toBe('recording');
actor.stop();
});
it('START_RECORDING sets currentUrl from event payload', () => {
const actor = createActor(captureMachine);
actor.start();
actor.send({ type: 'START_RECORDING', url: 'https://example.com/start' });
expect(actor.getSnapshot().context.currentUrl).toBe('https://example.com/start');
actor.stop();
});
it('getPersistedSnapshot() roundtrip preserves currentUrl and stepCount', () => {
const actor = createActor(captureMachine);
actor.start();
actor.send({ type: 'START_RECORDING', url: 'https://example.com' });
actor.send({ type: 'USER_ACTION' });
actor.send({ type: 'USER_ACTION' });
actor.send({ type: 'SPA_NAVIGATE', url: 'https://example.com/dashboard' });
const persisted = actor.getPersistedSnapshot();
actor.stop();
const restored = createActor(captureMachine, { snapshot: persisted });
restored.start();
const snapshot = restored.getSnapshot();
expect(snapshot.context.currentUrl).toBe('https://example.com/dashboard');
expect(snapshot.context.stepCount).toBe(2);
restored.stop();
});
});

54
tests/db-schema.test.ts Normal file
View File

@@ -0,0 +1,54 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import 'fake-indexeddb/auto';
import { MimikDB } from '../src/shared/db-schema';
import type { Guide, Step, Screenshot } from '../src/shared/types';
describe('MimikDB Schema', () => {
let db: MimikDB;
beforeEach(async () => {
db = new MimikDB();
await db.open();
});
afterEach(async () => {
await db.delete();
});
it('stores and retrieves a Guide', async () => {
const guide: Guide = {
id: crypto.randomUUID(),
title: 'Test Guide',
createdAt: Date.now(),
updatedAt: Date.now(),
stepIds: [],
};
await db.guides.add(guide);
const retrieved = await db.guides.get(guide.id);
expect(retrieved).toBeDefined();
expect(retrieved!.title).toBe('Test Guide');
});
it('stores screenshots as Blob, not string', async () => {
const blob = new Blob(['test'], { type: 'image/jpeg' });
const screenshot: Screenshot = {
id: crypto.randomUUID(),
stepId: 'step-1',
blob,
mimeType: 'image/jpeg',
width: 100,
height: 100,
};
expect(screenshot.blob).toBeInstanceOf(Blob);
expect(typeof screenshot.blob).not.toBe('string');
await db.screenshots.add(screenshot);
const retrieved = await db.screenshots.get(screenshot.id);
expect(retrieved).toBeDefined();
expect(typeof retrieved!.blob).not.toBe('string');
});
it('has three tables', () => {
expect(db.tables.map(t => t.name).sort()).toEqual(['guides', 'screenshots', 'steps']);
});
});

156
tests/guide-service.test.ts Normal file
View File

@@ -0,0 +1,156 @@
import 'fake-indexeddb/auto';
import { beforeEach, describe, it, expect } from 'vitest';
import { db } from '../src/shared/db-schema';
import {
getGuides,
getGuide,
deleteGuide,
updateGuideTitle,
updateStepDescription,
deleteStep,
} from '../src/shared/guide-service';
import type { Guide, Step, Screenshot } from '../src/shared/types';
async function seedGuide(overrides: Partial<Guide> = {}): Promise<Guide> {
const guide: Guide = {
id: overrides.id ?? `guide-${Date.now()}-${Math.random()}`,
title: overrides.title ?? 'Test Guide',
createdAt: overrides.createdAt ?? Date.now(),
updatedAt: overrides.updatedAt ?? Date.now(),
stepIds: overrides.stepIds ?? [],
};
await db.guides.add(guide);
return guide;
}
async function seedStep(guideId: string, overrides: Partial<Step> = {}): Promise<Step> {
const step: Step = {
id: overrides.id ?? `step-${Date.now()}-${Math.random()}`,
guideId,
index: overrides.index ?? 0,
description: overrides.description ?? 'A test step',
action: overrides.action ?? 'click',
url: overrides.url ?? 'https://example.com',
timestamp: overrides.timestamp ?? Date.now(),
screenshotId: overrides.screenshotId,
};
await db.steps.add(step);
return step;
}
async function seedScreenshot(stepId: string, id?: string): Promise<Screenshot> {
const screenshot: Screenshot = {
id: id ?? `screenshot-${Date.now()}-${Math.random()}`,
stepId,
blob: new Blob(['fake'], { type: 'image/png' }),
mimeType: 'image/png',
width: 800,
height: 600,
};
await db.screenshots.add(screenshot);
return screenshot;
}
beforeEach(async () => {
await db.guides.clear();
await db.steps.clear();
await db.screenshots.clear();
});
describe('getGuides', () => {
it('returns guides sorted by updatedAt descending', async () => {
const older = await seedGuide({ id: 'g1', updatedAt: 1000 });
const newer = await seedGuide({ id: 'g2', updatedAt: 2000 });
const guides = await getGuides();
expect(guides[0].id).toBe(newer.id);
expect(guides[1].id).toBe(older.id);
});
it('returns empty array when no guides exist', async () => {
const guides = await getGuides();
expect(guides).toHaveLength(0);
});
});
describe('getGuide', () => {
it('returns guide with steps sorted by index and screenshots map', async () => {
const guide = await seedGuide({ id: 'g1', stepIds: ['s1', 's2'] });
const step1 = await seedStep('g1', { id: 's1', index: 0 });
const step2 = await seedStep('g1', { id: 's2', index: 1 });
const screenshot = await seedScreenshot('s1', 'sc1');
await db.steps.update('s1', { screenshotId: 'sc1' });
const result = await getGuide('g1');
expect(result).not.toBeNull();
expect(result!.guide.id).toBe(guide.id);
expect(result!.steps[0].id).toBe(step1.id);
expect(result!.steps[1].id).toBe(step2.id);
expect(result!.screenshots.get('s1')).toBeDefined();
expect(result!.screenshots.get('s1')!.id).toBe(screenshot.id);
});
it('returns null for nonexistent guide ID', async () => {
const result = await getGuide('nonexistent-id');
expect(result).toBeNull();
});
});
describe('deleteGuide', () => {
it('removes guide, its steps, and screenshots', async () => {
const guide = await seedGuide({ id: 'g1', stepIds: ['s1'] });
const step = await seedStep('g1', { id: 's1', index: 0 });
const screenshot = await seedScreenshot('s1', 'sc1');
await db.steps.update('s1', { screenshotId: 'sc1' });
await deleteGuide('g1');
expect(await db.guides.get('g1')).toBeUndefined();
expect(await db.steps.get('s1')).toBeUndefined();
expect(await db.screenshots.get('sc1')).toBeUndefined();
});
});
describe('updateGuideTitle', () => {
it('changes title and updates updatedAt', async () => {
const before = Date.now();
await seedGuide({ id: 'g1', title: 'Old Title', updatedAt: 1000 });
await updateGuideTitle('g1', 'New Title');
const updated = await db.guides.get('g1');
expect(updated!.title).toBe('New Title');
expect(updated!.updatedAt).toBeGreaterThanOrEqual(before);
});
});
describe('updateStepDescription', () => {
it('changes step description', async () => {
await seedGuide({ id: 'g1' });
await seedStep('g1', { id: 's1', description: 'Old description' });
await updateStepDescription('s1', 'New description');
const updated = await db.steps.get('s1');
expect(updated!.description).toBe('New description');
});
});
describe('deleteStep', () => {
it('removes step, its screenshot, updates guide.stepIds, and re-indexes remaining steps', async () => {
await seedGuide({ id: 'g1', stepIds: ['s1', 's2', 's3'] });
await seedStep('g1', { id: 's1', index: 0 });
const step2 = await seedStep('g1', { id: 's2', index: 1, screenshotId: 'sc2' });
await seedStep('g1', { id: 's3', index: 2 });
await seedScreenshot('s2', 'sc2');
await deleteStep('g1', 's2');
expect(await db.steps.get('s2')).toBeUndefined();
expect(await db.screenshots.get('sc2')).toBeUndefined();
const updatedGuide = await db.guides.get('g1');
expect(updatedGuide!.stepIds).toEqual(['s1', 's3']);
const s3 = await db.steps.get('s3');
expect(s3!.index).toBe(1);
});
});

87
tests/html-export.test.ts Normal file
View File

@@ -0,0 +1,87 @@
import { describe, it, expect, beforeAll, vi } from 'vitest';
import type { Guide, Step, Screenshot } from '../src/shared/types';
class MockFileReader {
result: string | ArrayBuffer | null = null;
onload: ((ev: ProgressEvent) => void) | null = null;
onerror: ((ev: ProgressEvent) => void) | null = null;
readAsDataURL(blob: Blob) {
setTimeout(() => {
this.result = `data:image/jpeg;base64,dGVzdA==`;
if (this.onload) this.onload({} as ProgressEvent);
}, 0);
}
}
beforeAll(() => {
// @ts-expect-error replace FileReader for test environment
globalThis.FileReader = MockFileReader;
});
let exportGuideAsHTML: typeof import('../src/export/html-export').exportGuideAsHTML;
beforeAll(async () => {
const mod = await import('../src/export/html-export');
exportGuideAsHTML = mod.exportGuideAsHTML;
});
const guide: Guide = {
id: 'g1',
title: 'Test Guide',
createdAt: 1700000000000,
updatedAt: 1700000000000,
stepIds: ['s1', 's2'],
};
const steps: Step[] = [
{ id: 's1', guideId: 'g1', index: 0, description: 'Click the button', action: 'click', url: 'https://example.com', timestamp: 1700000001000, screenshotId: 'sc1' },
{ id: 's2', guideId: 'g1', index: 1, description: 'Type your name', action: 'input', url: 'https://example.com', timestamp: 1700000002000, screenshotId: 'sc2' },
];
const stepWithoutScreenshot: Step = {
id: 's3', guideId: 'g1', index: 2, description: 'No screenshot step', action: 'click', url: 'https://example.com', timestamp: 1700000003000,
};
const screenshots: Map<string, Screenshot> = new Map([
['s1', { id: 'sc1', stepId: 's1', blob: new Blob(['test'], { type: 'image/jpeg' }), mimeType: 'image/jpeg', width: 1280, height: 720 }],
['s2', { id: 'sc2', stepId: 's2', blob: new Blob(['test2'], { type: 'image/jpeg' }), mimeType: 'image/jpeg', width: 1280, height: 720 }],
]);
describe('exportGuideAsHTML', () => {
it('returns a string starting with <!DOCTYPE html>', async () => {
const html = await exportGuideAsHTML(guide, steps, screenshots);
expect(html.trimStart()).toMatch(/^<!DOCTYPE html>/i);
});
it('contains the guide title in an <h1> tag', async () => {
const html = await exportGuideAsHTML(guide, steps, screenshots);
expect(html).toContain('<h1>Test Guide</h1>');
});
it('contains step descriptions', async () => {
const html = await exportGuideAsHTML(guide, steps, screenshots);
expect(html).toContain('Click the button');
expect(html).toContain('Type your name');
});
it('embeds screenshots as data:image/jpeg;base64, in <img> tags', async () => {
const html = await exportGuideAsHTML(guide, steps, screenshots);
expect(html).toContain('data:image/jpeg;base64,');
expect(html).toContain('<img src=');
});
it('steps without screenshots have no <img> tag', async () => {
const stepsNoScreenshot = [stepWithoutScreenshot];
const html = await exportGuideAsHTML(guide, stepsNoScreenshot, new Map());
expect(html).not.toContain('<img');
expect(html).toContain('No screenshot step');
});
it('escapes HTML special characters in descriptions', async () => {
const xssStep: Step = { id: 'sx', guideId: 'g1', index: 0, description: '<script>alert("xss")</script>', action: 'click', url: 'https://example.com', timestamp: 1700000001000 };
const html = await exportGuideAsHTML(guide, [xssStep], new Map());
expect(html).not.toContain('<script>');
expect(html).toContain('&lt;script&gt;');
});
});

View File

@@ -0,0 +1,88 @@
import { describe, it, expect } from 'vitest';
import { createActor } from 'xstate';
import { captureMachine } from '../src/background/machine';
describe('captureMachine', () => {
it('starts in idle state', () => {
const actor = createActor(captureMachine);
actor.start();
expect(actor.getSnapshot().value).toBe('idle');
actor.stop();
});
it('transitions to recording on START_RECORDING', () => {
const actor = createActor(captureMachine);
actor.start();
actor.send({ type: 'START_RECORDING' });
expect(actor.getSnapshot().value).toBe('recording');
actor.stop();
});
it('transitions back to idle on STOP_RECORDING', () => {
const actor = createActor(captureMachine);
actor.start();
actor.send({ type: 'START_RECORDING' });
actor.send({ type: 'STOP_RECORDING' });
expect(actor.getSnapshot().value).toBe('idle');
actor.stop();
});
it('assigns a new non-null guideId on START_RECORDING', () => {
const actor = createActor(captureMachine);
actor.start();
actor.send({ type: 'START_RECORDING' });
const { currentGuideId } = actor.getSnapshot().context;
expect(currentGuideId).not.toBeNull();
expect(typeof currentGuideId).toBe('string');
expect(currentGuideId!.length).toBeGreaterThan(0);
actor.stop();
});
it('resets guideId to null on STOP_RECORDING', () => {
const actor = createActor(captureMachine);
actor.start();
actor.send({ type: 'START_RECORDING' });
actor.send({ type: 'STOP_RECORDING' });
expect(actor.getSnapshot().context.currentGuideId).toBeNull();
actor.stop();
});
it('getPersistedSnapshot returns serializable JSON', () => {
const actor = createActor(captureMachine);
actor.start();
actor.send({ type: 'START_RECORDING' });
const persisted = actor.getPersistedSnapshot();
const json = JSON.stringify(persisted);
expect(typeof json).toBe('string');
const parsed = JSON.parse(json);
expect(parsed).toBeDefined();
actor.stop();
});
it('createActor with snapshot restores to the same state value', () => {
const original = createActor(captureMachine);
original.start();
original.send({ type: 'START_RECORDING' });
const persisted = original.getPersistedSnapshot();
original.stop();
const restored = createActor(captureMachine, { snapshot: persisted });
restored.start();
expect(restored.getSnapshot().value).toBe('recording');
restored.stop();
});
it('restored actor in recording state can receive STOP_RECORDING and transition to idle', () => {
const original = createActor(captureMachine);
original.start();
original.send({ type: 'START_RECORDING' });
const persisted = original.getPersistedSnapshot();
original.stop();
const restored = createActor(captureMachine, { snapshot: persisted });
restored.start();
restored.send({ type: 'STOP_RECORDING' });
expect(restored.getSnapshot().value).toBe('idle');
restored.stop();
});
});

View File

@@ -0,0 +1,72 @@
import { describe, it, expect, beforeAll } from 'vitest';
import type { Guide, Step, Screenshot } from '../src/shared/types';
class MockFileReader {
result: string | ArrayBuffer | null = null;
onload: ((ev: ProgressEvent) => void) | null = null;
onerror: ((ev: ProgressEvent) => void) | null = null;
readAsDataURL(_blob: Blob) {
setTimeout(() => {
this.result = `data:image/jpeg;base64,dGVzdA==`;
if (this.onload) this.onload({} as ProgressEvent);
}, 0);
}
}
beforeAll(() => {
// @ts-expect-error replace FileReader for test environment
globalThis.FileReader = MockFileReader;
});
let exportGuideAsMarkdown: typeof import('../src/export/markdown-export').exportGuideAsMarkdown;
beforeAll(async () => {
const mod = await import('../src/export/markdown-export');
exportGuideAsMarkdown = mod.exportGuideAsMarkdown;
});
const guide: Guide = {
id: 'g1',
title: 'My Workflow Guide',
createdAt: 1700000000000,
updatedAt: 1700000000000,
stepIds: ['s1', 's2'],
};
const steps: Step[] = [
{ id: 's1', guideId: 'g1', index: 0, description: 'Open the dashboard', action: 'click', url: 'https://example.com', timestamp: 1700000001000, screenshotId: 'sc1' },
{ id: 's2', guideId: 'g1', index: 1, description: 'Click settings', action: 'click', url: 'https://example.com', timestamp: 1700000002000 },
];
const screenshots: Map<string, Screenshot> = new Map([
['s1', { id: 'sc1', stepId: 's1', blob: new Blob(['img'], { type: 'image/jpeg' }), mimeType: 'image/jpeg', width: 800, height: 600 }],
]);
describe('exportGuideAsMarkdown', () => {
it('returns a string starting with # {guide.title}', async () => {
const md = await exportGuideAsMarkdown(guide, steps, screenshots);
expect(md.trimStart()).toMatch(/^# My Workflow Guide/);
});
it('contains ## Step N: {description} format for each step', async () => {
const md = await exportGuideAsMarkdown(guide, steps, screenshots);
expect(md).toContain('## Step 1: Open the dashboard');
expect(md).toContain('## Step 2: Click settings');
});
it('embeds screenshot as ![Step N](data:image/jpeg;base64,...) for steps with screenshots', async () => {
const md = await exportGuideAsMarkdown(guide, steps, screenshots);
expect(md).toContain('![Step 1](data:image/jpeg;base64,');
});
it('steps without screenshots omit image line', async () => {
const md = await exportGuideAsMarkdown(guide, steps, screenshots);
expect(md).not.toContain('![Step 2]');
});
it('ends with *Generated by Mimik*', async () => {
const md = await exportGuideAsMarkdown(guide, steps, screenshots);
expect(md.trimEnd()).toMatch(/\*Generated by Mimik\*$/);
});
});

75
tests/options.test.tsx Normal file
View File

@@ -0,0 +1,75 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
const mockStorage: Record<string, unknown> = {};
const chromeMock = {
storage: {
local: {
get: vi.fn((keys: string[]) =>
Promise.resolve(
Object.fromEntries(keys.filter(k => k in mockStorage).map(k => [k, mockStorage[k]]))
)
),
set: vi.fn((items: Record<string, unknown>) => {
Object.assign(mockStorage, items);
return Promise.resolve();
}),
},
},
};
vi.stubGlobal('chrome', chromeMock);
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import App from '../src/options/App';
describe('Settings Page', () => {
beforeEach(() => {
Object.keys(mockStorage).forEach(k => delete mockStorage[k]);
vi.clearAllMocks();
});
it('renders provider dropdown with openai and anthropic options', () => {
render(<App />);
const select = screen.getByLabelText(/provider/i);
expect(select).toBeDefined();
const options = select.querySelectorAll('option');
const values = Array.from(options).map(o => o.value);
expect(values).toContain('openai');
expect(values).toContain('anthropic');
});
it('renders API key input with type password', () => {
render(<App />);
const input = screen.getByLabelText(/api key/i);
expect(input.getAttribute('type')).toBe('password');
});
it('saves API key and provider to chrome.storage.local', async () => {
render(<App />);
const input = screen.getByLabelText(/api key/i);
const select = screen.getByLabelText(/provider/i);
const button = screen.getByRole('button', { name: /save/i });
fireEvent.change(input, { target: { value: 'sk-test-key-123' } });
fireEvent.change(select, { target: { value: 'anthropic' } });
fireEvent.click(button);
await waitFor(() => {
expect(chromeMock.storage.local.set).toHaveBeenCalledWith({
aiApiKey: 'sk-test-key-123',
aiProvider: 'anthropic',
});
});
});
it('loads existing values on mount', async () => {
mockStorage.aiApiKey = 'existing-key';
mockStorage.aiProvider = 'anthropic';
render(<App />);
await waitFor(() => {
const input = screen.getByLabelText(/api key/i) as HTMLInputElement;
expect(input.value).toBe('existing-key');
});
});
});

View File

@@ -0,0 +1,130 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import type { ElementMeta } from '../src/shared/types';
function makeElementMeta(overrides: Partial<ElementMeta> = {}): ElementMeta {
return {
tag: 'button',
cssSelector: 'button',
textContent: 'Click me',
ariaLabel: null,
placeholder: null,
altText: null,
name: null,
role: 'button',
href: null,
inputType: null,
dataTestId: null,
rect: { x: 10, y: 20, width: 100, height: 50 },
devicePixelRatio: 2,
...overrides,
};
}
function mockOffscreenCanvas(width: number, height: number) {
const calls: Array<{ method: string; args: unknown[] }> = [];
const ctx = {
drawImage: (...args: unknown[]) => calls.push({ method: 'drawImage', args }),
fillRect: (...args: unknown[]) => calls.push({ method: 'fillRect', args }),
strokeRect: (...args: unknown[]) => calls.push({ method: 'strokeRect', args }),
set strokeStyle(v: string) { calls.push({ method: 'set:strokeStyle', args: [v] }); },
set lineWidth(v: number) { calls.push({ method: 'set:lineWidth', args: [v] }); },
set fillStyle(v: string) { calls.push({ method: 'set:fillStyle', args: [v] }); },
};
return {
width,
height,
getContext: () => ctx,
convertToBlob: async ({ type }: { type: string }) => new Blob(['fake-image'], { type }),
_calls: calls,
};
}
describe('drawHighlight — OffscreenCanvas annotation', () => {
let OffscreenCanvasInstances: ReturnType<typeof mockOffscreenCanvas>[] = [];
afterEach(() => {
vi.resetModules();
});
beforeEach(() => {
vi.resetModules();
OffscreenCanvasInstances = [];
const instances = OffscreenCanvasInstances;
(globalThis as unknown as Record<string, unknown>).OffscreenCanvas = class MockOffscreenCanvas {
width: number;
height: number;
_calls: Array<{ method: string; args: unknown[] }> = [];
constructor(w: number, h: number) {
this.width = w;
this.height = h;
instances.push(this as unknown as ReturnType<typeof mockOffscreenCanvas>);
}
getContext() {
const calls = this._calls;
return {
drawImage: (...args: unknown[]) => calls.push({ method: 'drawImage', args }),
fillRect: (...args: unknown[]) => calls.push({ method: 'fillRect', args }),
strokeRect: (...args: unknown[]) => calls.push({ method: 'strokeRect', args }),
set strokeStyle(v: string) { calls.push({ method: 'set:strokeStyle', args: [v] }); },
set lineWidth(v: number) { calls.push({ method: 'set:lineWidth', args: [v] }); },
set fillStyle(v: string) { calls.push({ method: 'set:fillStyle', args: [v] }); },
};
}
async convertToBlob({ type }: { type: string }) {
return new Blob(['fake-image'], { type });
}
};
(globalThis as unknown as Record<string, unknown>).createImageBitmap = vi.fn(async (_blob: Blob) => ({
width: 1280,
height: 720,
close: () => {},
}));
(globalThis as unknown as Record<string, unknown>).fetch = vi.fn(async (_url: string) => ({
blob: async () => new Blob(['fake-jpeg'], { type: 'image/jpeg' }),
}));
});
it('scales CSS rect coordinates by devicePixelRatio (dpr=2: rect x=10 -> canvas x=20)', async () => {
const { drawHighlight } = await import('../src/background/screenshot');
const meta = makeElementMeta({ rect: { x: 10, y: 20, width: 100, height: 50 }, devicePixelRatio: 2 });
await drawHighlight('data:image/jpeg;base64,fake', meta);
const canvas = OffscreenCanvasInstances[0];
expect(canvas).toBeDefined();
const fillRectCall = canvas._calls.find(c => c.method === 'fillRect');
expect(fillRectCall).toBeDefined();
expect(fillRectCall!.args).toEqual([20, 40, 200, 100]);
});
it('uses blue-600 stroke color (#2563EB) and semi-transparent fill', async () => {
const { drawHighlight } = await import('../src/background/screenshot');
const meta = makeElementMeta();
await drawHighlight('data:image/jpeg;base64,fake', meta);
const canvas = OffscreenCanvasInstances[0];
const strokeStyleCall = canvas._calls.find(c => c.method === 'set:strokeStyle');
const fillStyleCall = canvas._calls.find(c => c.method === 'set:fillStyle');
expect(strokeStyleCall?.args[0]).toBe('#2563EB');
expect(fillStyleCall?.args[0]).toContain('rgba');
expect(fillStyleCall?.args[0]).toContain('37');
expect(fillStyleCall?.args[0]).toContain('99');
expect(fillStyleCall?.args[0]).toContain('235');
expect(fillStyleCall?.args[0]).toContain('0.15');
});
it('returns a Blob with type image/jpeg', async () => {
const { drawHighlight } = await import('../src/background/screenshot');
const meta = makeElementMeta();
const result = await drawHighlight('data:image/jpeg;base64,fake', meta);
expect(result).toBeInstanceOf(Blob);
expect(result.type).toBe('image/jpeg');
});
});

20
tests/screenshot.test.ts Normal file
View File

@@ -0,0 +1,20 @@
import { describe, it, expect } from 'vitest';
import { dataUrlToBlob } from '../src/background/screenshot';
describe('Screenshot capture', () => {
it('converts a data URL to a Blob', async () => {
const dataUrl = 'data:image/jpeg;base64,/9j/4AAQSkZJRgABAQEASABIAAD/2wBDAAgGBgcGBQgHBwcJCQgKDBQNDAsLDBkSEw8UHRofHh0aHBwgJC4nICIsIxwcKDcpLDAxNDQ0Hyc5PTgyPC4zNDL/2wBDAQkJCQwLDBgNDRgyIRwhMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjL/wAARCAABAAEDASIAAhEBAxEB/8QAFAABAAAAAAAAAAAAAAAAAAAACf/EABQQAQAAAAAAAAAAAAAAAAAAAAD/xAAUAQEAAAAAAAAAAAAAAAAAAAAA/8QAFBEBAAAAAAAAAAAAAAAAAAAAAP/aAAwDAQACEQMRAD8AJQAB/9k=';
const blob = await dataUrlToBlob(dataUrl);
expect(blob).toBeInstanceOf(Blob);
expect(blob.type).toBe('image/jpeg');
expect(blob.size).toBeGreaterThan(0);
});
it('returns Blob type not string', async () => {
const dataUrl = 'data:image/jpeg;base64,/9j/4AAQSkZJRg==';
const blob = await dataUrlToBlob(dataUrl);
expect(typeof blob).not.toBe('string');
expect(blob).toBeInstanceOf(Blob);
});
});

View File

@@ -0,0 +1,27 @@
import { describe, it, expect } from 'vitest';
import type { SpaNavigateMessage } from '../src/shared/messages';
describe('SPA navigation', () => {
it('SPA_NAVIGATE message has the correct shape', () => {
const message: SpaNavigateMessage = {
type: 'SPA_NAVIGATE',
url: 'https://example.com/page2',
guideId: 'guide-123',
};
expect(message.type).toBe('SPA_NAVIGATE');
expect(typeof message.url).toBe('string');
expect(typeof message.guideId).toBe('string');
});
it('webNavigation listener should only process frameId === 0 (top frame)', () => {
const topFrameDetails = { frameId: 0, url: 'https://example.com/page2', tabId: 1 };
const subFrameDetails = { frameId: 1, url: 'https://example.com/iframe', tabId: 1 };
function shouldProcess(details: { frameId: number }): boolean {
return details.frameId === 0;
}
expect(shouldProcess(topFrameDetails)).toBe(true);
expect(shouldProcess(subFrameDetails)).toBe(false);
});
});

View File

@@ -0,0 +1,87 @@
import { describe, it, expect } from 'vitest';
import { buildFallbackDescription } from '../src/background/step-description';
import type { ElementMeta } from '../src/shared/types';
function makeMeta(overrides: Partial<ElementMeta> = {}): ElementMeta {
return {
tag: 'div',
cssSelector: 'div',
textContent: null,
ariaLabel: null,
placeholder: null,
altText: null,
name: null,
role: null,
href: null,
inputType: null,
dataTestId: null,
rect: { x: 0, y: 0, width: 100, height: 50 },
devicePixelRatio: 1,
...overrides,
};
}
describe('buildFallbackDescription', () => {
it('click action with ariaLabel returns "Click {ariaLabel}"', () => {
const meta = makeMeta({ ariaLabel: 'Submit button' });
expect(buildFallbackDescription('click', meta)).toBe('Click Submit button');
});
it('click action with no ariaLabel but textContent returns "Click {textContent}" (truncated to 80 chars)', () => {
const meta = makeMeta({ textContent: 'Sign in' });
expect(buildFallbackDescription('click', meta)).toBe('Click Sign in');
});
it('textContent is truncated to 80 chars', () => {
const longText = 'A'.repeat(100);
const meta = makeMeta({ textContent: longText });
const result = buildFallbackDescription('click', meta);
expect(result).toBe('Click ' + 'A'.repeat(80));
});
it('input action with placeholder returns "Type into {placeholder}"', () => {
const meta = makeMeta({ placeholder: 'Enter your email' });
expect(buildFallbackDescription('input', meta)).toBe('Type into Enter your email');
});
it('scroll action returns "Scroll the page" regardless of element metadata', () => {
const meta = makeMeta({ ariaLabel: 'some label', textContent: 'some text' });
expect(buildFallbackDescription('scroll', meta)).toBe('Scroll the page');
});
it('navigate action returns "Navigate to page"', () => {
const meta = makeMeta({ ariaLabel: 'Navigation link' });
expect(buildFallbackDescription('navigate', meta)).toBe('Navigate to page');
});
it('priority chain: ariaLabel > placeholder > textContent > altText > name > role > tag', () => {
const meta1 = makeMeta({
ariaLabel: 'aria', placeholder: 'placeholder', textContent: 'text',
altText: 'alt', name: 'nameAttr', role: 'button', tag: 'button',
});
expect(buildFallbackDescription('click', meta1)).toBe('Click aria');
const meta2 = makeMeta({
placeholder: 'placeholder', textContent: 'text',
altText: 'alt', name: 'nameAttr', role: 'button', tag: 'button',
});
expect(buildFallbackDescription('input', meta2)).toBe('Type into placeholder');
const meta3 = makeMeta({ textContent: 'text', altText: 'alt', name: 'nameAttr', role: 'button', tag: 'button' });
expect(buildFallbackDescription('click', meta3)).toBe('Click text');
const meta4 = makeMeta({ altText: 'alt', name: 'nameAttr', role: 'button', tag: 'img' });
expect(buildFallbackDescription('click', meta4)).toBe('Click alt');
const meta5 = makeMeta({ name: 'nameAttr', role: 'button', tag: 'input' });
expect(buildFallbackDescription('click', meta5)).toBe('Click nameAttr');
const meta6 = makeMeta({ role: 'button', tag: 'div' });
expect(buildFallbackDescription('click', meta6)).toBe('Click button');
});
it('when all metadata is null, falls back to tag name', () => {
const meta = makeMeta({ tag: 'button' });
expect(buildFallbackDescription('click', meta)).toBe('Click button');
});
});

View File

@@ -0,0 +1,80 @@
import 'fake-indexeddb/auto';
import { beforeEach, describe, it, expect } from 'vitest';
import { db } from '../src/shared/db-schema';
import { reorderSteps } from '../src/shared/guide-service';
import type { Guide, Step } from '../src/shared/types';
async function seedGuide(id: string, stepIds: string[]): Promise<Guide> {
const guide: Guide = {
id,
title: 'Test Guide',
createdAt: Date.now(),
updatedAt: Date.now(),
stepIds,
};
await db.guides.add(guide);
return guide;
}
async function seedStep(id: string, guideId: string, index: number): Promise<Step> {
const step: Step = {
id,
guideId,
index,
description: `Step ${index}`,
action: 'click',
url: 'https://example.com',
timestamp: Date.now(),
};
await db.steps.add(step);
return step;
}
beforeEach(async () => {
await db.guides.clear();
await db.steps.clear();
await db.screenshots.clear();
});
describe('reorderSteps', () => {
it('updates step index values to match new order', async () => {
await seedGuide('g1', ['s0', 's1', 's2']);
await seedStep('s0', 'g1', 0);
await seedStep('s1', 'g1', 1);
await seedStep('s2', 'g1', 2);
await reorderSteps('g1', ['s2', 's0', 's1']);
const s2 = await db.steps.get('s2');
const s0 = await db.steps.get('s0');
const s1 = await db.steps.get('s1');
expect(s2!.index).toBe(0);
expect(s0!.index).toBe(1);
expect(s1!.index).toBe(2);
});
it('updates guide.stepIds to match new order', async () => {
await seedGuide('g1', ['s0', 's1', 's2']);
await seedStep('s0', 'g1', 0);
await seedStep('s1', 'g1', 1);
await seedStep('s2', 'g1', 2);
await reorderSteps('g1', ['s2', 's0', 's1']);
const guide = await db.guides.get('g1');
expect(guide!.stepIds).toEqual(['s2', 's0', 's1']);
});
it('updates guide.updatedAt after reorder', async () => {
const before = Date.now();
await seedGuide('g1', ['s0', 's1']);
await seedStep('s0', 'g1', 0);
await seedStep('s1', 'g1', 1);
await reorderSteps('g1', ['s1', 's0']);
const guide = await db.guides.get('g1');
expect(guide!.updatedAt).toBeGreaterThanOrEqual(before);
});
});