1
This commit is contained in:
108
tests/ai-description.test.ts
Normal file
108
tests/ai-description.test.ts
Normal 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,/);
|
||||
});
|
||||
});
|
||||
90
tests/capture-machine.test.ts
Normal file
90
tests/capture-machine.test.ts
Normal 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
54
tests/db-schema.test.ts
Normal 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
156
tests/guide-service.test.ts
Normal 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
87
tests/html-export.test.ts
Normal 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('<script>');
|
||||
});
|
||||
});
|
||||
88
tests/machine-persistence.test.ts
Normal file
88
tests/machine-persistence.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
72
tests/markdown-export.test.ts
Normal file
72
tests/markdown-export.test.ts
Normal 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  for steps with screenshots', async () => {
|
||||
const md = await exportGuideAsMarkdown(guide, steps, screenshots);
|
||||
expect(md).toContain(';
|
||||
});
|
||||
|
||||
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
75
tests/options.test.tsx
Normal 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');
|
||||
});
|
||||
});
|
||||
});
|
||||
130
tests/screenshot-annotation.test.ts
Normal file
130
tests/screenshot-annotation.test.ts
Normal 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
20
tests/screenshot.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
27
tests/spa-navigation.test.ts
Normal file
27
tests/spa-navigation.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
87
tests/step-description.test.ts
Normal file
87
tests/step-description.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
80
tests/step-reorder.test.ts
Normal file
80
tests/step-reorder.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user