Add vitest harness for utils.js with 17 starter tests

This commit is contained in:
tux tucker 2026-04-26 22:43:03 -04:00
parent 56a18c99b6
commit 950254598b
8 changed files with 1964 additions and 7 deletions

1
.gitignore vendored Normal file
View file

@ -0,0 +1 @@
node_modules/

View file

@ -91,6 +91,14 @@ Any export producing more than one file should always create a ZIP — never tri
When merging `testing``main` for a release, flip both manifest names to drop "Beta" as part of the merge.
## Testing
- **Vitest** lives in `src/`. Run tests with `npm test` (one-shot) or `npm run test:watch` (watch mode) from `src/`.
- Test files live in `src/tests/` and import from `src/chrome/utils.js` (the canonical copy).
- `firefox/utils.js` is a mirror — if it drifts from `chrome/utils.js`, the tests won't catch it. Keep them in sync per the existing rule.
- `utils.js` has a conditional `module.exports` block at the bottom that fires only when `module` is defined (Node/vitest). Browser extensions ignore it because the global is undefined.
- `node_modules/` and `package-lock.json` live under `src/` and are gitignored / not part of the release ZIPs.
## Architecture Notes
### Content Script Injection

22
TODO.md
View file

@ -118,6 +118,12 @@
- Click the org ID row in the browse settings dropdown to copy it
- Toast confirms "Org ID copied to clipboard"
- **Vitest unit tests for `utils.js`** (post-v1.9.1)
- 17 starter tests covering the recently-fixed bugs and core export logic
- Regression coverage for `tool_use.name === 'artifacts'` filter, branch traversal, file extension mapping
- `npm test` from `src/`; tests live in `src/tests/`
- Canonical source is `chrome/utils.js`; `firefox/utils.js` mirror must stay in sync
## Pending 🔄
### Critical Priority 🔴
@ -202,10 +208,11 @@
- Filter by project, model, artifact
- **Advanced settings menu**
- *Language settings*
- *Custom CSS*
- *Regex mode*
- *Custom date/time format*
- Verbosity toggle & Debug log
- Language settings
- Custom CSS
- Regex mode
- Custom date/time format
- Custom format string (e.g. `%d/%m/%Y %H:%M`)
- Toggle time display on/off
@ -233,7 +240,7 @@
- Option to use regex patterns in the search bar
- Toggle between plain text and regex mode
- **Help / tutorial in settings menu** (low priority)
- **Help / tutorial in settings menu**
- Add a help/getting started option to the settings dropdown
- Quick overview of features, export options, keyboard shortcuts
@ -241,6 +248,11 @@
- Export progress spinner
- Test connection spinner
- **Update screenshots**
- Include browse page and popup
- Include dark and light mode
- 1280x800 or 640x400 jpeg or 24-bit png (no alpha)
## Bugs 🐛
(none currently open)

View file

@ -581,4 +581,19 @@ function extractArtifactFiles(data, artifactFormat = 'original') {
return artifactFiles;
}
// Functions are available globally in the browser context
// No need for module.exports in browser extensions
// In Node (vitest), expose them via module.exports for testing
if (typeof module !== 'undefined' && module.exports) {
module.exports = {
getCurrentBranch,
convertToMarkdown,
convertToText,
downloadFile,
extractArtifactsFromMessage,
extractArtifactsFromText,
extractArtifacts,
getFileExtension,
isProgrammingLanguage,
convertArtifactFormat,
extractArtifactFiles,
};
}

View file

@ -581,4 +581,19 @@ function extractArtifactFiles(data, artifactFormat = 'original') {
return artifactFiles;
}
// Functions are available globally in the browser context
// No need for module.exports in browser extensions
// In Node (vitest), expose them via module.exports for testing
if (typeof module !== 'undefined' && module.exports) {
module.exports = {
getCurrentBranch,
convertToMarkdown,
convertToText,
downloadFile,
extractArtifactsFromMessage,
extractArtifactsFromText,
extractArtifacts,
getFileExtension,
isProgrammingLanguage,
convertArtifactFormat,
extractArtifactFiles,
};
}

1583
package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

12
package.json Normal file
View file

@ -0,0 +1,12 @@
{
"name": "claude-exporter-tests",
"private": true,
"description": "Test harness for Claude Exporter (chrome/utils.js is canonical; firefox/utils.js must mirror).",
"scripts": {
"test": "vitest run",
"test:watch": "vitest"
},
"devDependencies": {
"vitest": "^3.2.4"
}
}

311
tests/utils.test.js Normal file
View file

@ -0,0 +1,311 @@
import { describe, it, expect } from 'vitest';
import utils from '../chrome/utils.js';
const {
getCurrentBranch,
convertToMarkdown,
extractArtifactsFromMessage,
extractArtifactFiles,
getFileExtension,
isProgrammingLanguage,
} = utils;
// Regression coverage for the bug fixed in v1.9.1: bash/web_search/repl
// tool_use entries used to slip through as fake artifacts. Now gated on
// `tool_use.name === 'artifacts'`.
describe('extractArtifactsFromMessage — tool name filter', () => {
it('rejects a bash tool_use even with code_block display content', () => {
const message = {
content: [
{
type: 'tool_use',
name: 'bash',
display_content: {
type: 'code_block',
code: 'ls -la',
language: 'bash',
filename: 'cmd.sh',
},
},
],
};
expect(extractArtifactsFromMessage(message)).toEqual([]);
});
it('rejects a web_search tool_use', () => {
const message = {
content: [
{
type: 'tool_use',
name: 'web_search',
display_content: {
type: 'code_block',
code: 'results...',
language: 'json',
},
},
],
};
expect(extractArtifactsFromMessage(message)).toEqual([]);
});
it('extracts an artifacts tool_use with code_block format', () => {
const message = {
content: [
{
type: 'tool_use',
name: 'artifacts',
display_content: {
type: 'code_block',
code: 'def hello():\n pass',
language: 'python',
filename: 'hello.py',
},
},
],
};
const artifacts = extractArtifactsFromMessage(message);
expect(artifacts).toHaveLength(1);
expect(artifacts[0].title).toBe('hello');
expect(artifacts[0].language).toBe('python');
expect(artifacts[0].content).toBe('def hello():\n pass');
});
it('extracts an artifacts tool_use with json_block format when filename is present', () => {
const message = {
content: [
{
type: 'tool_use',
name: 'artifacts',
display_content: {
type: 'json_block',
json_block: JSON.stringify({
filename: 'app.js',
language: 'javascript',
code: 'console.log("hi");',
}),
},
},
],
};
const artifacts = extractArtifactsFromMessage(message);
expect(artifacts).toHaveLength(1);
expect(artifacts[0].title).toBe('app');
});
it('rejects a json_block artifacts entry that has no filename', () => {
const message = {
content: [
{
type: 'tool_use',
name: 'artifacts',
display_content: {
type: 'json_block',
json_block: JSON.stringify({
code: 'echo hi',
}),
},
},
],
};
expect(extractArtifactsFromMessage(message)).toEqual([]);
});
});
describe('extractArtifactFiles — end-to-end', () => {
function makeConversationWithMessages(messages) {
const last = messages[messages.length - 1];
return {
current_leaf_message_uuid: last.uuid,
chat_messages: messages,
};
}
it('returns artifact files only from real artifact tool calls', () => {
const data = makeConversationWithMessages([
{
uuid: 'm1',
sender: 'human',
content: [{ type: 'text', text: 'make me something' }],
parent_message_uuid: '00000000-0000-0000-0000-000000000000',
},
{
uuid: 'm2',
sender: 'assistant',
content: [
{
type: 'tool_use',
name: 'artifacts',
display_content: {
type: 'code_block',
code: '<h1>hi</h1>',
language: 'html',
filename: 'page.html',
},
},
{
type: 'tool_use',
name: 'bash',
display_content: {
type: 'code_block',
code: 'ls',
language: 'bash',
filename: 'noise.sh',
},
},
],
parent_message_uuid: 'm1',
},
]);
const files = extractArtifactFiles(data);
expect(files).toHaveLength(1);
expect(files[0].filename).toMatch(/\.html$/);
});
it('deduplicates duplicate filenames with a counter suffix', () => {
const data = makeConversationWithMessages([
{
uuid: 'm1',
sender: 'assistant',
content: [
{
type: 'tool_use',
name: 'artifacts',
display_content: {
type: 'code_block',
code: 'a',
language: 'javascript',
filename: 'app.js',
},
},
{
type: 'tool_use',
name: 'artifacts',
display_content: {
type: 'code_block',
code: 'b',
language: 'javascript',
filename: 'app.js',
},
},
],
parent_message_uuid: '00000000-0000-0000-0000-000000000000',
},
]);
const files = extractArtifactFiles(data);
expect(files).toHaveLength(2);
const names = files.map(f => f.filename);
expect(new Set(names).size).toBe(2); // both unique
});
});
describe('getCurrentBranch', () => {
it('returns empty array when there are no messages', () => {
expect(getCurrentBranch({ chat_messages: [], current_leaf_message_uuid: 'x' })).toEqual([]);
});
it('returns empty array when leaf uuid is missing', () => {
expect(getCurrentBranch({ chat_messages: [{ uuid: 'a' }] })).toEqual([]);
});
it('walks from leaf back to root in chronological order', () => {
const data = {
current_leaf_message_uuid: 'm3',
chat_messages: [
{ uuid: 'm1', parent_message_uuid: 'root', text: 'first' },
{ uuid: 'm2', parent_message_uuid: 'm1', text: 'second' },
{ uuid: 'm3', parent_message_uuid: 'm2', text: 'third' },
],
};
const branch = getCurrentBranch(data);
expect(branch.map(m => m.uuid)).toEqual(['m1', 'm2', 'm3']);
});
it('only includes messages on the current branch (ignores siblings)', () => {
// m1 → m2a → m3 (current leaf), m1 → m2b is a sibling branch and should be excluded
const data = {
current_leaf_message_uuid: 'm3',
chat_messages: [
{ uuid: 'm1', parent_message_uuid: 'root', text: 'first' },
{ uuid: 'm2a', parent_message_uuid: 'm1', text: 'kept' },
{ uuid: 'm2b', parent_message_uuid: 'm1', text: 'sibling' },
{ uuid: 'm3', parent_message_uuid: 'm2a', text: 'leaf' },
],
};
const branch = getCurrentBranch(data);
expect(branch.map(m => m.uuid)).toEqual(['m1', 'm2a', 'm3']);
});
});
describe('getFileExtension', () => {
it('maps common programming languages correctly', () => {
expect(getFileExtension('javascript')).toBe('.js');
expect(getFileExtension('python')).toBe('.py');
expect(getFileExtension('bash')).toBe('.sh');
});
it('falls back to .txt for unknown languages', () => {
expect(getFileExtension('totally-not-a-language')).toBe('.txt');
});
});
describe('isProgrammingLanguage', () => {
it('recognizes common programming languages', () => {
expect(isProgrammingLanguage('javascript')).toBe(true);
expect(isProgrammingLanguage('python')).toBe(true);
expect(isProgrammingLanguage('rust')).toBe(true);
});
it('rejects markup/document formats', () => {
expect(isProgrammingLanguage('markdown')).toBe(false);
});
});
describe('convertToMarkdown — smoke test', () => {
it('renders both human and assistant message text', () => {
const data = {
name: 'Test Chat',
model: 'claude-sonnet-4-5-20250929',
created_at: '2026-04-01T12:00:00Z',
updated_at: '2026-04-01T12:00:00Z',
current_leaf_message_uuid: 'm2',
chat_messages: [
{
uuid: 'm1',
sender: 'human',
content: [{ type: 'text', text: 'Hello there' }],
parent_message_uuid: '00000000-0000-0000-0000-000000000000',
},
{
uuid: 'm2',
sender: 'assistant',
content: [{ type: 'text', text: 'General Kenobi' }],
parent_message_uuid: 'm1',
},
],
};
const md = convertToMarkdown(data, false);
expect(md).toContain('Hello there');
expect(md).toContain('General Kenobi');
});
it('includes metadata block when includeMetadata is true', () => {
const data = {
name: 'My Chat',
model: 'claude-opus-4-5-20251101',
created_at: '2026-04-01T12:00:00Z',
updated_at: '2026-04-01T12:00:00Z',
current_leaf_message_uuid: 'm1',
chat_messages: [
{
uuid: 'm1',
sender: 'human',
content: [{ type: 'text', text: 'hi' }],
parent_message_uuid: '00000000-0000-0000-0000-000000000000',
},
],
};
const md = convertToMarkdown(data, true);
expect(md).toContain('My Chat');
});
});