test(frontend): set up Vitest with jsdom and run tests in the image build
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 43s
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 43s
- Add vitest, jsdom and Testing Library as dev dependencies, replace the placeholder test:run script with vitest run. - Add outliner tests for Backspace merging and caret placement on click. - Run the frontend tests in the Dockerfile before the Vite build. - Update docs (README, architecture, FLAW-19). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
2b5291dc7b
commit
106a34d260
11 files changed
+1466
-73
No files matched your search
Generated
+1286
-61
File diff suppressed because it is too large.
Load diff
@@ -8,7 +8,8 @@
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"lint": "echo 'Linting passed'",
|
||||
"test:run": "echo 'Frontend checks passed'"
|
||||
"test": "vitest",
|
||||
"test:run": "vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
"@types/prismjs": "^1.26.6",
|
||||
@@ -19,13 +20,17 @@
|
||||
"react-dom": "^18.3.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@testing-library/dom": "^10.4.2",
|
||||
"@testing-library/react": "^16.3.3",
|
||||
"@types/react": "^18.3.12",
|
||||
"@types/react-dom": "^18.3.1",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"jsdom": "^25.0.1",
|
||||
"postcss": "^8.4.49",
|
||||
"tailwindcss": "^3.4.17",
|
||||
"typescript": "^5.6.3",
|
||||
"vite": "^5.4.11"
|
||||
"vite": "^5.4.11",
|
||||
"vitest": "^2.1.9"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import React from 'react';
|
||||
import { describe, it, expect, afterEach } from 'vitest';
|
||||
import { render, fireEvent } from '@testing-library/react';
|
||||
import { OutlinerBlock, mapRenderedOffsetToRaw } from '../OutlinerBlock';
|
||||
import { DocumentBlock } from '../../../lib/types';
|
||||
|
||||
describe('mapRenderedOffsetToRaw', () => {
|
||||
it('is the identity for plain text', () => {
|
||||
expect(mapRenderedOffsetToRaw('Hallo Welt', 'Hallo Welt', 4)).toBe(4);
|
||||
});
|
||||
it('skips bold markers', () => {
|
||||
expect(mapRenderedOffsetToRaw('Hallo **Welt** du', 'Hallo Welt du', 8)).toBe(10);
|
||||
});
|
||||
it('maps behind closing markers', () => {
|
||||
expect(mapRenderedOffsetToRaw('a **b** c', 'a b c', 4)).toBe(8);
|
||||
});
|
||||
it('maps into a link label', () => {
|
||||
expect(mapRenderedOffsetToRaw('x [Seite](https://a.de) y', 'x Seite y', 4)).toBe(5);
|
||||
});
|
||||
it('handles the empty placeholder', () => {
|
||||
expect(mapRenderedOffsetToRaw('', '', 1)).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('clicking into an unfocused bullet', () => {
|
||||
afterEach(() => {
|
||||
delete (document as any).caretRangeFromPoint;
|
||||
});
|
||||
|
||||
it('places the caret at the click position instead of the end', () => {
|
||||
const block = { id: 'b1', type: 'bullet', content: 'Hallo **Welt** du', level: 0, position: 1 } as DocumentBlock;
|
||||
const { container } = render(
|
||||
<OutlinerBlock
|
||||
docId="d"
|
||||
block={block}
|
||||
index={0}
|
||||
isLast
|
||||
maxAllowedLevel={1}
|
||||
hasChildren={false}
|
||||
isFocused={false}
|
||||
canEdit
|
||||
onUpdateBlock={() => {}}
|
||||
onCreateBlockBelow={() => {}}
|
||||
onDeleteBlock={() => {}}
|
||||
onIndentBlock={() => {}}
|
||||
onNavigateFocus={() => {}}
|
||||
registerTextareaRef={() => {}}
|
||||
/>
|
||||
);
|
||||
const ta = container.querySelector('textarea') as HTMLTextAreaElement;
|
||||
const strong = container.querySelector('strong') as HTMLElement; // rendered "Welt"
|
||||
// jsdom has no layout: pretend the click hit between "We" and "lt"
|
||||
(document as any).caretRangeFromPoint = () => {
|
||||
const r = document.createRange();
|
||||
r.setStart(strong.firstChild!, 2);
|
||||
return r;
|
||||
};
|
||||
|
||||
fireEvent.click(strong);
|
||||
|
||||
expect(document.activeElement).toBe(ta);
|
||||
expect(ta.selectionStart).toBe(10); // "Hallo **We|lt** du"
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,79 @@
|
||||
import React, { useState } from 'react';
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { render, fireEvent, screen, act } from '@testing-library/react';
|
||||
import { OutlinerTree } from '../OutlinerTree';
|
||||
import { DocumentBlock } from '../../../lib/types';
|
||||
|
||||
// Holds the latest block state rendered by the harness
|
||||
let live: DocumentBlock[] = [];
|
||||
|
||||
const Harness: React.FC<{ initial: DocumentBlock[] }> = ({ initial }) => {
|
||||
const [blocks, setBlocks] = useState(initial);
|
||||
live = blocks;
|
||||
return (
|
||||
<OutlinerTree
|
||||
docId="d1"
|
||||
blocks={blocks}
|
||||
canEdit
|
||||
onUpdateBlock={(b) => setBlocks((p) => p.map((x) => (x.id === b.id ? b : x)))}
|
||||
onUpdateBlocks={(u) => setBlocks((p) => p.map((x) => u.find((y) => y.id === x.id) || x))}
|
||||
onCreateBlock={() => {}}
|
||||
onDeleteBlock={(id) => setBlocks((p) => p.filter((x) => x.id !== id))}
|
||||
onMoveBlock={() => {}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const mk = (id: string, content: string, level = 0, type: any = 'bullet'): DocumentBlock =>
|
||||
({ id, type, content, level, position: 1 }) as DocumentBlock;
|
||||
|
||||
const area = (text: string) => screen.getAllByDisplayValue(text)[0] as HTMLTextAreaElement;
|
||||
|
||||
const backspaceAt = async (ta: HTMLTextAreaElement, pos = 0) => {
|
||||
ta.focus();
|
||||
ta.setSelectionRange(pos, pos);
|
||||
await act(async () => {
|
||||
fireEvent.keyDown(ta, { key: 'Backspace' });
|
||||
});
|
||||
};
|
||||
|
||||
describe('Backspace at the start of a bullet', () => {
|
||||
it('appends its text to the previous bullet', async () => {
|
||||
render(<Harness initial={[mk('a', 'Hallo '), mk('b', 'Welt')]} />);
|
||||
await backspaceAt(area('Welt'));
|
||||
expect(live.map((b) => b.content)).toEqual(['Hallo Welt']);
|
||||
});
|
||||
|
||||
it('works for legacy blocks without a type', async () => {
|
||||
render(<Harness initial={[mk('a', 'A', 0, undefined), mk('b', 'B', 0, '')]} />);
|
||||
await backspaceAt(area('B'));
|
||||
expect(live.map((b) => b.content)).toEqual(['AB']);
|
||||
});
|
||||
|
||||
it('moves children up under the previous bullet', async () => {
|
||||
render(<Harness initial={[mk('a', 'A'), mk('b', 'B'), mk('c', 'C', 1)]} />);
|
||||
await backspaceAt(area('B'));
|
||||
expect(live.map((b) => [b.content, b.level])).toEqual([
|
||||
['AB', 0],
|
||||
['C', 1],
|
||||
]);
|
||||
});
|
||||
|
||||
it('does nothing when the caret is not at the start', async () => {
|
||||
render(<Harness initial={[mk('a', 'A'), mk('b', 'BB')]} />);
|
||||
await backspaceAt(area('BB'), 1);
|
||||
expect(live).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('does not merge the first bullet', async () => {
|
||||
render(<Harness initial={[mk('a', 'A'), mk('b', 'B')]} />);
|
||||
await backspaceAt(area('A'));
|
||||
expect(live).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('does not merge into a code block', async () => {
|
||||
render(<Harness initial={[mk('a', 'x = 1', 0, 'code'), mk('b', 'B')]} />);
|
||||
await backspaceAt(area('B'));
|
||||
expect(live).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,9 @@
|
||||
import { afterEach } from 'vitest';
|
||||
import { cleanup } from '@testing-library/react';
|
||||
|
||||
// jsdom has no layout engine, so scrolling is a no-op here
|
||||
Element.prototype.scrollIntoView = () => {};
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
@@ -0,0 +1,10 @@
|
||||
import { defineConfig } from 'vitest/config';
|
||||
import react from '@vitejs/plugin-react';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
test: {
|
||||
environment: 'jsdom',
|
||||
setupFiles: ['./src/test/setup.ts'],
|
||||
},
|
||||
});
|
||||
Reference in new issue
Block a user