Branding & Identity
Logos, color, typography, and design systems for scale.
Moat will create a .moat directory in your project with markdown task logging and Cursor integration.
You'll select your project folder in the next step.
-Moat will create a .moat directory in your project with markdown task logging and Cursor integration.
You'll select your project folder in the next step.
-
- This will delete ${count} screenshot${count !== 1 ? 's' : ''} from completed tasks. The task data will be preserved.
-
- This action cannot be undone.
-
No ${currentTabFilter} tasks
-${emptyContent.text}
- ${emptyContent.showButton ? '' : ''} -No tasks yet
+Use the tools to annotate elements on the page
+Connect a project to get started
+Click "Connect Project" to select a folder
+No ${currentTab} tasks
+Use the tools to annotate elements on the page
+Open console to see test results...
+ + + + + + + + + + + + + + + + + + + + +``` + +Then open in browser: `file:///path/to/tests/v2/test-runner.html` + +## Test Structure + +Each test file follows this pattern: + +```javascript +const runner = new TestRunner(); +const { describe, it, beforeEach, afterEach, expect } = (() => { + return { + describe: runner.describe.bind(runner), + it: runner.it.bind(runner), + beforeEach: runner.beforeEach.bind(runner), + afterEach: runner.afterEach.bind(runner), + expect + }; +})(); + +describe('JTBD-XX: Job description', () => { + let mocks; + + beforeEach(() => { + mocks = setupMocks(); + }); + + afterEach(() => { + mocks.reset(); + }); + + it('should do something', () => { + // Test code + expect(actual).toBe(expected); + }); +}); +``` + +## Assertion API + +```javascript +expect(value).toBe(expected) // Strict equality (===) +expect(value).toEqual(expected) // Deep equality (JSON.stringify) +expect(value).toBeTruthy() // Truthy check +expect(value).toBeFalsy() // Falsy check +expect(value).toBeNull() // Null check +expect(value).toBeUndefined() // Undefined check +expect(array).toContain(item) // Array/string contains +expect(array).toHaveLength(length) // Length check +expect(obj).toHaveProperty(prop, value) // Property check +expect(fn).toThrow(error) // Exception check +expect(value).toBeInstanceOf(constructor) // Instance check + +// Negation +expect(value).not.toBe(expected) +``` + +## Adding New Tests + +1. **Identify the JTBD** from `JOBS-TO-BE-DONE.md` +2. **Create or add to test file** for the appropriate category +3. **Write test case**: + ```javascript + describe('JTBD-XX: Job description', () => { + it('should verify specific behavior', () => { + // Arrange + const input = setupTestData(); + + // Act + const result = functionUnderTest(input); + + // Assert + expect(result).toBe(expected); + }); + }); + ``` +4. **Update `run-all.js`** if needed to include new test file +5. **Run tests** and verify +6. **Update JTBD status** in `JOBS-TO-BE-DONE.md` + +## Debugging Tests + +### Enable Verbose Logging + +```javascript +// In chrome-mock.js +const mocks = setupMocks(); +mocks.chromeMock.enableDebug(); +``` + +### Check Available Modules + +```javascript +console.log('TaskStore:', !!window.MoatTaskStore); +console.log('MarkdownGenerator:', !!window.MoatMarkdownGenerator); +console.log('Persistence:', !!window.MoatPersistence); +``` + +### Inspect Test Results + +```javascript +const results = await runner.run(); +console.log(results.stats); +console.log(results.failedTests); +``` + +## Coverage Goals + +- **161 total JTBDs** identified from V1 codebase +- **~135 V2-applicable JTBDs** (excluding V1 sidebar-specific) +- **Target: 80%+ coverage** for V2 migration + +## Current Status + +Run tests to see current status. Results will show: +- β Passing tests +- β Failing tests +- β³ Not yet tested +- π« Not applicable to V2 + +## Continuous Testing + +For V2 development: +1. Write tests BEFORE implementing features (TDD) +2. Run tests AFTER each change +3. Update JTBD status as tests pass +4. Aim for 100% pass rate before V2 release + +## Troubleshooting + +### "Module not found" errors +- Ensure utility files are loaded before tests +- Check file paths in script tags +- Verify files exist in expected locations + +### Tests fail with "not available" messages +- Utility modules (TaskStore, MarkdownGenerator, etc.) are not loaded +- Load them before running tests + +### File System Access API errors +- Mocks should handle this automatically +- Verify `setupMocks()` is called in `beforeEach` + +### IndexedDB errors +- Mocks should handle this automatically +- Check that `indexedDB` is properly mocked + +## Contributing + +When adding tests: +1. Follow existing patterns +2. Reference JTBD ID in test name +3. Keep tests focused and isolated +4. Use mocks to avoid side effects +5. Document complex test logic + +## License + +Same as Drawbridge project. diff --git a/chrome-extension/tests/v2/chrome-mock.js b/chrome-extension/tests/v2/chrome-mock.js new file mode 100644 index 0000000..e57b458 --- /dev/null +++ b/chrome-extension/tests/v2/chrome-mock.js @@ -0,0 +1,475 @@ +/** + * Chrome API Mocks for Testing + * + * Provides mock implementations of Chrome extension APIs and File System Access API + * to enable testing without a browser extension environment. + */ + +class ChromeMock { + constructor() { + this.runtime = { + lastError: null, + onMessage: { + addListener: (callback) => { + this._messageListeners = this._messageListeners || []; + this._messageListeners.push(callback); + }, + removeListener: (callback) => { + if (this._messageListeners) { + const index = this._messageListeners.indexOf(callback); + if (index > -1) { + this._messageListeners.splice(index, 1); + } + } + } + }, + sendMessage: (message, callback) => { + // Simulate async response + setTimeout(() => { + if (message.type === 'CAPTURE_SCREENSHOT') { + callback({ + success: true, + dataUrl: 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==' + }); + } else { + callback({ success: false, error: 'Unknown message type' }); + } + }, 0); + }, + getURL: (path) => { + return `chrome-extension://mock-extension-id/${path}`; + } + }; + + this.tabs = { + captureVisibleTab: (windowId, options, callback) => { + // Return a mock base64 image + setTimeout(() => { + callback('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg=='); + }, 0); + }, + sendMessage: (tabId, message, callback) => { + setTimeout(() => { + callback({ success: true }); + }, 0); + } + }; + + this.action = { + _actionListeners: [], + onClicked: { + addListener: (callback) => { + this.action._actionListeners.push(callback); + } + } + }; + + this.sidePanel = { + open: async ({ tabId }) => { + console.log(`Mock: Side panel opened for tab ${tabId}`); + return Promise.resolve(); + }, + setOptions: async (options) => { + console.log('Mock: Side panel options set', options); + return Promise.resolve(); + }, + getOptions: async ({ tabId }) => { + return Promise.resolve({ enabled: true, path: 'sidepanel/sidepanel.html' }); + } + }; + + this.storage = { + local: { + data: {}, + get: (keys, callback) => { + const result = {}; + if (typeof keys === 'string') { + if (this.storage.local.data.hasOwnProperty(keys)) { + result[keys] = this.storage.local.data[keys]; + } + } else if (Array.isArray(keys)) { + keys.forEach(key => { + if (this.storage.local.data.hasOwnProperty(key)) { + result[key] = this.storage.local.data[key]; + } + }); + } + if (callback) { + setTimeout(() => callback(result), 0); + } + return Promise.resolve(result); + }, + set: (items, callback) => { + Object.assign(this.storage.local.data, items); + if (callback) setTimeout(callback, 0); + return Promise.resolve(); + }, + remove: (keys, callback) => { + if (Array.isArray(keys)) { + keys.forEach(key => delete this.storage.local.data[key]); + } else { + delete this.storage.local.data[keys]; + } + if (callback) setTimeout(callback, 0); + return Promise.resolve(); + } + } + }; + } + + reset() { + this.runtime.lastError = null; + this.storage.local.data = {}; + } +} + +/** + * Mock File System Access API + */ +class FileSystemMock { + constructor() { + this.fileSystem = new Map(); + } + + /** + * Mock file handle + */ + createFileHandle(name, content = '') { + return { + kind: 'file', + name, + _content: content, + + async getFile() { + return { + name, + size: this._content.length, + type: 'application/json', + text: async () => this._content, + arrayBuffer: async () => new TextEncoder().encode(this._content).buffer, + slice: () => new Blob([this._content]) + }; + }, + + async createWritable(options = {}) { + const fileHandle = this; + const writable = { + _buffer: options.keepExistingData ? fileHandle._content : '', + + async write(data) { + if (typeof data === 'string') { + this._buffer = data; + } else if (data instanceof Blob) { + this._buffer = await data.text(); + } + }, + + async close() { + fileHandle._content = this._buffer; + } + }; + + return writable; + } + }; + } + + /** + * Mock directory handle + */ + createDirectoryHandle(name, path = '/') { + const fullPath = `${path}${name}/`; + + const handle = { + kind: 'directory', + name, + _path: fullPath, + _files: new Map(), + _directories: new Map(), + + async getFileHandle(fileName, options = {}) { + if (this._files.has(fileName)) { + return this._files.get(fileName); + } + + if (options.create) { + const fileHandle = this.createFileHandle(fileName); + this._files.set(fileName, fileHandle); + return fileHandle; + } + + throw new Error(`File not found: ${fileName}`); + }, + + async getDirectoryHandle(dirName, options = {}) { + if (this._directories.has(dirName)) { + return this._directories.get(dirName); + } + + if (options.create) { + const dirHandle = this.createDirectoryHandle(dirName, fullPath); + this._directories.set(dirName, dirHandle); + return dirHandle; + } + + throw new Error(`Directory not found: ${dirName}`); + }, + + async queryPermission(options = {}) { + return 'granted'; + }, + + async requestPermission(options = {}) { + return 'granted'; + }, + + async removeEntry(name, options = {}) { + if (this._files.has(name)) { + this._files.delete(name); + return; + } + if (this._directories.has(name)) { + this._directories.delete(name); + return; + } + throw new Error(`Entry not found: ${name}`); + }, + + async *values() { + for (const file of this._files.values()) { + yield file; + } + for (const dir of this._directories.values()) { + yield dir; + } + }, + + [Symbol.asyncIterator]() { + return this.values(); + } + }; + + // Bind factory methods so nested handles work + const self = this; + handle.createFileHandle = (name, content) => self.createFileHandle(name, content); + handle.createDirectoryHandle = (name, path) => self.createDirectoryHandle(name, path || fullPath); + + return handle; + } + + /** + * Mock showDirectoryPicker + */ + async showDirectoryPicker(options = {}) { + // Simulate user selecting a directory + const dirHandle = this.createDirectoryHandle('test-project', '/'); + return dirHandle; + } + + reset() { + this.fileSystem.clear(); + } +} + +/** + * Mock IndexedDB for persistence testing + */ +class IndexedDBMock { + constructor() { + this.databases = new Map(); + } + + open(name, version) { + if (!this.databases.has(name)) { + this.databases.set(name, { stores: new Map() }); + } + const db = this.databases.get(name); + const self = this; + + const dbResult = { + name, + version, + objectStoreNames: { + contains: (storeName) => { + return db.stores.has(storeName); + } + }, + createObjectStore: (storeName, options) => { + const store = { + name: storeName, + data: new Map(), + indexes: new Map(), + createIndex: (indexName, keyPath, opts) => { + store.indexes.set(indexName, { keyPath, options: opts }); + } + }; + db.stores.set(storeName, store); + return store; + }, + transaction: (storeNames, mode) => { + return { + objectStore: (storeName) => { + const store = db.stores.get(storeName); + + const makeRequest = (result) => { + const req = { onsuccess: null, onerror: null, result }; + Promise.resolve().then(() => { + if (req.onsuccess) req.onsuccess({ target: req }); + }); + return req; + }; + + return { + get: (key) => makeRequest(store?.data.get(key) || undefined), + put: (value) => { + if (store) store.data.set(value.id, value); + return makeRequest(value.id); + }, + delete: (key) => { + if (store) store.data.delete(key); + return makeRequest(undefined); + }, + getAll: () => makeRequest(Array.from(store?.data.values() || [])) + }; + } + }; + } + }; + + const request = { + onsuccess: null, + onerror: null, + onupgradeneeded: null, + result: dbResult + }; + + // Fire callbacks asynchronously like real IndexedDB + Promise.resolve().then(() => { + // Fire onupgradeneeded first (for new databases) + if (request.onupgradeneeded) { + request.onupgradeneeded({ target: request, oldVersion: 0, newVersion: version }); + } + // Then fire onsuccess + if (request.onsuccess) { + request.onsuccess({ target: request }); + } + }); + + return request; + } + + deleteDatabase(name) { + this.databases.delete(name); + } + + reset() { + this.databases.clear(); + } +} + +/** + * Setup global mocks + */ +function setupMocks() { + const chromeMock = new ChromeMock(); + const fileSystemMock = new FileSystemMock(); + const indexedDBMock = new IndexedDBMock(); + + // Set up global objects + global.chrome = chromeMock; + global.showDirectoryPicker = fileSystemMock.showDirectoryPicker.bind(fileSystemMock); + global.indexedDB = indexedDBMock; + + // Mock window/document if not available + if (typeof window === 'undefined') { + global.window = { + location: { + href: 'http://localhost:3000/', + origin: 'http://localhost:3000' + }, + localStorage: { + data: {}, + getItem: (key) => global.window.localStorage.data[key] || null, + setItem: (key, value) => { global.window.localStorage.data[key] = value; }, + removeItem: (key) => { delete global.window.localStorage.data[key]; }, + clear: () => { global.window.localStorage.data = {}; } + }, + dispatchEvent: (event) => {}, + addEventListener: (event, handler) => {}, + removeEventListener: (event, handler) => {}, + innerWidth: 1920, + innerHeight: 1080, + devicePixelRatio: 1, + fetch: async (url) => { + // Mock fetch for template loading + return { + ok: true, + status: 200, + text: async () => '# Mock Template Content' + }; + } + }; + } + + if (typeof document === 'undefined') { + global.document = { + createElement: (tag) => ({ + tagName: tag.toUpperCase(), + style: {}, + classList: { + add: () => {}, + remove: () => {}, + contains: () => false + }, + setAttribute: () => {}, + getAttribute: () => null, + appendChild: () => {}, + removeChild: () => {}, + addEventListener: () => {}, + removeEventListener: () => {}, + querySelector: () => null, + querySelectorAll: () => [] + }), + body: { + appendChild: () => {}, + removeChild: () => {}, + classList: { + add: () => {}, + remove: () => {} + } + }, + head: { + appendChild: () => {} + } + }; + } + + return { + chromeMock, + fileSystemMock, + indexedDBMock, + reset: () => { + chromeMock.reset(); + fileSystemMock.reset(); + indexedDBMock.reset(); + if (global.window?.localStorage) { + global.window.localStorage.clear(); + } + } + }; +} + +// Export for Node.js and browser +if (typeof module !== 'undefined' && module.exports) { + module.exports = { + ChromeMock, + FileSystemMock, + IndexedDBMock, + setupMocks + }; +} else { + window.ChromeMock = ChromeMock; + window.FileSystemMock = FileSystemMock; + window.IndexedDBMock = IndexedDBMock; + window.setupMocks = setupMocks; +} diff --git a/chrome-extension/tests/v2/connection.test.js b/chrome-extension/tests/v2/connection.test.js new file mode 100644 index 0000000..dcccc7a --- /dev/null +++ b/chrome-extension/tests/v2/connection.test.js @@ -0,0 +1,394 @@ +/** + * Connection & Project Setup Tests + * Tests for JTBD-01 through JTBD-13 + */ + +const runner = new TestRunner(); +const describe = runner.describe.bind(runner); +const it = runner.it.bind(runner); +const beforeEach = runner.beforeEach.bind(runner); +const afterEach = runner.afterEach.bind(runner); + +// Persistence is created fresh per-test in beforeEach to avoid stale DB references + +describe('JTBD-01: User can connect to a project directory', () => { + let mocks; + + beforeEach(() => { + mocks = setupMocks(); + }); + + afterEach(() => { + mocks.reset(); + }); + + it('should allow user to select a directory', async () => { + const dirHandle = await window.showDirectoryPicker(); + + expect(dirHandle).toBeTruthy(); + expect(dirHandle.kind).toBe('directory'); + expect(dirHandle.name).toBe('test-project'); + }); + + it('should store directory handle reference', async () => { + const dirHandle = await window.showDirectoryPicker(); + window.directoryHandle = dirHandle; + + expect(window.directoryHandle).toBeTruthy(); + expect(window.directoryHandle.name).toBe('test-project'); + }); + + it('should handle user canceling directory picker', async () => { + // Mock user canceling + const originalPicker = window.showDirectoryPicker; + window.showDirectoryPicker = async () => { + throw new DOMException('User cancelled', 'AbortError'); + }; + + try { + await window.showDirectoryPicker(); + throw new Error('Should have thrown'); + } catch (error) { + expect(error.name).toBe('AbortError'); + } finally { + window.showDirectoryPicker = originalPicker; + } + }); +}); + +describe('JTBD-02: System creates .moat/ subdirectory in selected project', () => { + let mocks; + + beforeEach(() => { + mocks = setupMocks(); + }); + + afterEach(() => { + mocks.reset(); + }); + + it('should create .moat directory if it does not exist', async () => { + const projectRoot = await window.showDirectoryPicker(); + const moatDir = await projectRoot.getDirectoryHandle('.moat', { create: true }); + + expect(moatDir).toBeTruthy(); + expect(moatDir.kind).toBe('directory'); + expect(moatDir.name).toBe('.moat'); + }); + + it('should not fail if .moat directory already exists', async () => { + const projectRoot = await window.showDirectoryPicker(); + const moatDir1 = await projectRoot.getDirectoryHandle('.moat', { create: true }); + const moatDir2 = await projectRoot.getDirectoryHandle('.moat', { create: true }); + + expect(moatDir1).toBeTruthy(); + expect(moatDir2).toBeTruthy(); + }); +}); + +describe('JTBD-03: System creates screenshots/ subdirectory proactively', () => { + let mocks; + + beforeEach(() => { + mocks = setupMocks(); + }); + + afterEach(() => { + mocks.reset(); + }); + + it('should create screenshots directory inside .moat', async () => { + const projectRoot = await window.showDirectoryPicker(); + const moatDir = await projectRoot.getDirectoryHandle('.moat', { create: true }); + const screenshotsDir = await moatDir.getDirectoryHandle('screenshots', { create: true }); + + expect(screenshotsDir).toBeTruthy(); + expect(screenshotsDir.kind).toBe('directory'); + expect(screenshotsDir.name).toBe('screenshots'); + }); + + it('should handle existing screenshots directory', async () => { + const projectRoot = await window.showDirectoryPicker(); + const moatDir = await projectRoot.getDirectoryHandle('.moat', { create: true }); + + // Create once + const screenshotsDir1 = await moatDir.getDirectoryHandle('screenshots', { create: true }); + // Create again (should not throw) + const screenshotsDir2 = await moatDir.getDirectoryHandle('screenshots', { create: true }); + + expect(screenshotsDir1).toBeTruthy(); + expect(screenshotsDir2).toBeTruthy(); + }); +}); + +describe('JTBD-04: System deploys workflow templates to project', () => { + let mocks; + + beforeEach(() => { + mocks = setupMocks(); + }); + + afterEach(() => { + mocks.reset(); + }); + + it('should deploy drawbridge-workflow.md template', async () => { + const projectRoot = await window.showDirectoryPicker(); + const moatDir = await projectRoot.getDirectoryHandle('.moat', { create: true }); + + // Simulate template deployment + const templateContent = '# Drawbridge Workflow'; + const fileHandle = await moatDir.getFileHandle('drawbridge-workflow.md', { create: true }); + const writable = await fileHandle.createWritable(); + await writable.write(templateContent); + await writable.close(); + + // Verify file was written + const file = await fileHandle.getFile(); + const content = await file.text(); + + expect(content).toBe(templateContent); + }); + + it('should deploy README.md template', async () => { + const projectRoot = await window.showDirectoryPicker(); + const moatDir = await projectRoot.getDirectoryHandle('.moat', { create: true }); + + const templateContent = '# Moat - Connected Project'; + const fileHandle = await moatDir.getFileHandle('README.md', { create: true }); + const writable = await fileHandle.createWritable(); + await writable.write(templateContent); + await writable.close(); + + const file = await fileHandle.getFile(); + const content = await file.text(); + + expect(content).toBe(templateContent); + }); + + it('should deploy bridge.md to .claude/commands/', async () => { + const projectRoot = await window.showDirectoryPicker(); + const claudeDir = await projectRoot.getDirectoryHandle('.claude', { create: true }); + const commandsDir = await claudeDir.getDirectoryHandle('commands', { create: true }); + + const templateContent = '# Bridge Command'; + const fileHandle = await commandsDir.getFileHandle('bridge.md', { create: true }); + const writable = await fileHandle.createWritable(); + await writable.write(templateContent); + await writable.close(); + + const file = await fileHandle.getFile(); + const content = await file.text(); + + expect(content).toBe(templateContent); + }); +}); + +describe('JTBD-05: System persists connection to IndexedDB', () => { + let mocks; + let persistence; + + beforeEach(() => { + mocks = setupMocks(); + if (window.MoatPersistence) { + persistence = new window.MoatPersistence(); + } + }); + + afterEach(() => { + mocks.reset(); + }); + + it('should store directory handle in IndexedDB', async () => { + if (!persistence) { + throw new Error('Persistence module not loaded β check run-all.js module setup'); + } + + const dirHandle = await window.showDirectoryPicker(); + const projectPath = 'test-project'; + + const success = await persistence.persistProjectConnection(dirHandle, projectPath); + + expect(success).toBe(true); + }); + + it('should include metadata with stored handle', async () => { + if (!persistence) { + throw new Error('Persistence module not loaded β check run-all.js module setup'); + } + + const dirHandle = await window.showDirectoryPicker(); + const projectPath = 'test-project'; + + await persistence.persistProjectConnection(dirHandle, projectPath); + + // Retrieve and verify + const projectId = `project_${window.location.origin}`; + const stored = await persistence.getDirectoryHandle(projectId); + + expect(stored).toBeTruthy(); + expect(stored.path).toBe(projectPath); + expect(stored.origin).toBe(window.location.origin); + }); +}); + +describe('JTBD-06: System restores connection from IndexedDB on page load', () => { + let mocks; + let persistence; + + beforeEach(() => { + mocks = setupMocks(); + if (window.MoatPersistence) { + persistence = new window.MoatPersistence(); + } + }); + + afterEach(() => { + mocks.reset(); + }); + + it('should retrieve stored connection', async () => { + if (!persistence) { + throw new Error('Persistence module not loaded β check run-all.js module setup'); + } + + // Store first + const dirHandle = await window.showDirectoryPicker(); + await persistence.persistProjectConnection(dirHandle, 'test-project'); + + // Restore + const restored = await persistence.restoreProjectConnection(); + + expect(restored.success).toBe(true); + expect(restored.path).toBe('test-project'); + expect(restored.moatDirectory).toBeTruthy(); + }); + + it('should return failure when no connection stored', async () => { + if (!persistence) { + throw new Error('Persistence module not loaded β check run-all.js module setup'); + } + + const restored = await persistence.restoreProjectConnection(); + + expect(restored.success).toBe(false); + expect(restored.reason).toBeTruthy(); + }); +}); + +describe('JTBD-07: System verifies directory handle permissions', () => { + let mocks; + let persistence; + + beforeEach(() => { + mocks = setupMocks(); + if (window.MoatPersistence) { + persistence = new window.MoatPersistence(); + } + }); + + afterEach(() => { + mocks.reset(); + }); + + it('should verify readwrite permission', async () => { + if (!persistence) { + throw new Error('Persistence module not loaded β check run-all.js module setup'); + } + + const dirHandle = await window.showDirectoryPicker(); + const hasPermission = await persistence.verifyPermission(dirHandle, 'readwrite'); + + expect(hasPermission).toBe(true); + }); + + it('should request permission if not granted', async () => { + if (!persistence) { + throw new Error('Persistence module not loaded β check run-all.js module setup'); + } + + const dirHandle = await window.showDirectoryPicker(); + const permission = await persistence.requestPermission(dirHandle, 'readwrite'); + + expect(permission).toBe(true); + }); +}); + +describe('JTBD-13: System loads existing tasks on connection', () => { + let mocks; + + beforeEach(() => { + mocks = setupMocks(); + }); + + afterEach(() => { + mocks.reset(); + }); + + it('should load tasks from moat-tasks-detail.json', async () => { + if (!window.MoatTaskStore) { + throw new Error('TaskStore module not loaded β check run-all.js module setup'); + } + + const dirHandle = await window.showDirectoryPicker(); + const moatDir = await dirHandle.getDirectoryHandle('.moat', { create: true }); + + // Create sample tasks JSON + const sampleTasks = [ + { + id: 'task-1', + title: 'Test Task', + comment: 'This is a test task', + selector: '.test-button', + status: 'to do', + timestamp: Date.now() + } + ]; + + const fileHandle = await moatDir.getFileHandle('moat-tasks-detail.json', { create: true }); + const writable = await fileHandle.createWritable(); + await writable.write(JSON.stringify(sampleTasks)); + await writable.close(); + + // Load tasks + const taskStore = new window.MoatTaskStore.TaskStore(); + taskStore.initialize(moatDir); + await taskStore.loadTasksFromFile(); + + const loadedTasks = taskStore.getAllTasks(); + + expect(loadedTasks).toHaveLength(1); + expect(loadedTasks[0].id).toBe('task-1'); + expect(loadedTasks[0].title).toBe('Test Task'); + }); + + it('should handle empty tasks file', async () => { + if (!window.MoatTaskStore) { + throw new Error('TaskStore module not loaded β check run-all.js module setup'); + } + + const dirHandle = await window.showDirectoryPicker(); + const moatDir = await dirHandle.getDirectoryHandle('.moat', { create: true }); + + // Create empty file + const fileHandle = await moatDir.getFileHandle('moat-tasks-detail.json', { create: true }); + const writable = await fileHandle.createWritable(); + await writable.write(''); + await writable.close(); + + const taskStore = new window.MoatTaskStore.TaskStore(); + taskStore.initialize(moatDir); + await taskStore.loadTasksFromFile(); + + const loadedTasks = taskStore.getAllTasks(); + + expect(loadedTasks).toHaveLength(0); + }); +}); + +// Export runner for test execution +if (typeof module !== 'undefined' && module.exports) { + module.exports = runner; +} else { + window.connectionTestRunner = runner; +} diff --git a/chrome-extension/tests/v2/filesystem.test.js b/chrome-extension/tests/v2/filesystem.test.js new file mode 100644 index 0000000..7b1343b --- /dev/null +++ b/chrome-extension/tests/v2/filesystem.test.js @@ -0,0 +1,317 @@ +/** + * File System & Persistence Tests + * Tests for JTBD-101 through JTBD-111 + */ + +const runner = new TestRunner(); +const describe = runner.describe.bind(runner); +const it = runner.it.bind(runner); +const beforeEach = runner.beforeEach.bind(runner); +const afterEach = runner.afterEach.bind(runner); + +describe('JTBD-101: System reads JSON file from directory', () => { + let mocks; + + beforeEach(() => { + mocks = setupMocks(); + }); + + afterEach(() => { + mocks.reset(); + }); + + it('should read JSON file content', async () => { + const dirHandle = await window.showDirectoryPicker(); + const moatDir = await dirHandle.getDirectoryHandle('.moat', { create: true }); + + // Write test data + const testData = { test: 'data' }; + const fileHandle = await moatDir.getFileHandle('test.json', { create: true }); + const writable = await fileHandle.createWritable(); + await writable.write(JSON.stringify(testData)); + await writable.close(); + + // Read it back + const file = await fileHandle.getFile(); + const content = await file.text(); + const parsed = JSON.parse(content); + + expect(parsed.test).toBe('data'); + }); + + it('should handle empty JSON file', async () => { + const dirHandle = await window.showDirectoryPicker(); + const moatDir = await dirHandle.getDirectoryHandle('.moat', { create: true }); + + const fileHandle = await moatDir.getFileHandle('empty.json', { create: true }); + const file = await fileHandle.getFile(); + const content = await file.text(); + + expect(content).toBe(''); + }); +}); + +describe('JTBD-102: System writes JSON file to directory', () => { + let mocks; + + beforeEach(() => { + mocks = setupMocks(); + }); + + afterEach(() => { + mocks.reset(); + }); + + it('should write JSON data to file', async () => { + const dirHandle = await window.showDirectoryPicker(); + const moatDir = await dirHandle.getDirectoryHandle('.moat', { create: true }); + + const data = { foo: 'bar', count: 42 }; + const fileHandle = await moatDir.getFileHandle('output.json', { create: true }); + const writable = await fileHandle.createWritable(); + await writable.write(JSON.stringify(data)); + await writable.close(); + + // Verify + const file = await fileHandle.getFile(); + const content = await file.text(); + const parsed = JSON.parse(content); + + expect(parsed.foo).toBe('bar'); + expect(parsed.count).toBe(42); + }); +}); + +describe('JTBD-103: System creates file if it does not exist', () => { + let mocks; + + beforeEach(() => { + mocks = setupMocks(); + }); + + afterEach(() => { + mocks.reset(); + }); + + it('should create file with create:true option', async () => { + const dirHandle = await window.showDirectoryPicker(); + const moatDir = await dirHandle.getDirectoryHandle('.moat', { create: true }); + + // File does not exist yet + const fileHandle = await moatDir.getFileHandle('new-file.json', { create: true }); + + expect(fileHandle).toBeTruthy(); + expect(fileHandle.kind).toBe('file'); + expect(fileHandle.name).toBe('new-file.json'); + }); + + it('should throw error if file doesn\'t exist and create:false', async () => { + const dirHandle = await window.showDirectoryPicker(); + const moatDir = await dirHandle.getDirectoryHandle('.moat', { create: true }); + + try { + await moatDir.getFileHandle('non-existent.json', { create: false }); + throw new Error('Should have thrown'); + } catch (error) { + expect(error.message).toContain('not found'); + } + }); +}); + +describe('JTBD-104: System truncates file before writing', () => { + let mocks; + + beforeEach(() => { + mocks = setupMocks(); + }); + + afterEach(() => { + mocks.reset(); + }); + + it('should overwrite existing content with keepExistingData:false', async () => { + const dirHandle = await window.showDirectoryPicker(); + const moatDir = await dirHandle.getDirectoryHandle('.moat', { create: true }); + + const fileHandle = await moatDir.getFileHandle('overwrite.json', { create: true }); + + // Write initial content + let writable = await fileHandle.createWritable(); + await writable.write('old content that should be overwritten'); + await writable.close(); + + // Overwrite with truncation + writable = await fileHandle.createWritable({ keepExistingData: false }); + await writable.write('new content'); + await writable.close(); + + // Verify + const file = await fileHandle.getFile(); + const content = await file.text(); + + expect(content).toBe('new content'); + expect(content).not.toContain('old content'); + }); +}); + +describe('JTBD-106: System stores directory handle in IndexedDB', () => { + let mocks; + let persistence; + + beforeEach(() => { + mocks = setupMocks(); + if (window.MoatPersistence) { + persistence = new window.MoatPersistence(); + } + }); + + afterEach(() => { + mocks.reset(); + }); + + it('should store directory handle with metadata', async () => { + if (!persistence) { + throw new Error('Persistence module not loaded β check run-all.js module setup'); + } + + const dirHandle = await window.showDirectoryPicker(); + const projectId = 'test-project-123'; + const metadata = { + path: 'test-project', + origin: 'http://localhost:3000', + connectedAt: new Date().toISOString() + }; + + const success = await persistence.storeDirectoryHandle(projectId, dirHandle, metadata); + + expect(success).toBe(true); + }); +}); + +describe('JTBD-107: System retrieves directory handle from IndexedDB', () => { + let mocks; + let persistence; + + beforeEach(() => { + mocks = setupMocks(); + if (window.MoatPersistence) { + persistence = new window.MoatPersistence(); + } + }); + + afterEach(() => { + mocks.reset(); + }); + + it('should retrieve stored directory handle', async () => { + if (!persistence) { + throw new Error('Persistence module not loaded β check run-all.js module setup'); + } + + const dirHandle = await window.showDirectoryPicker(); + const projectId = 'test-project-123'; + + // Store first + await persistence.storeDirectoryHandle(projectId, dirHandle, { path: 'test' }); + + // Retrieve + const retrieved = await persistence.getDirectoryHandle(projectId); + + expect(retrieved).toBeTruthy(); + expect(retrieved.handle).toBeTruthy(); + expect(retrieved.path).toBe('test'); + }); + + it('should return null for non-existent handle', async () => { + if (!persistence) { + throw new Error('Persistence module not loaded β check run-all.js module setup'); + } + + const retrieved = await persistence.getDirectoryHandle('non-existent-id'); + + expect(retrieved).toBeNull(); + }); +}); + +describe('JTBD-108: System verifies stored handle is still valid', () => { + let mocks; + let persistence; + + beforeEach(() => { + mocks = setupMocks(); + if (window.MoatPersistence) { + persistence = new window.MoatPersistence(); + } + }); + + afterEach(() => { + mocks.reset(); + }); + + it('should test directory access', async () => { + if (!persistence) { + throw new Error('Persistence module not loaded β check run-all.js module setup'); + } + + const dirHandle = await window.showDirectoryPicker(); + const isValid = await persistence.testDirectoryAccess(dirHandle); + + expect(isValid).toBe(true); + }); + + it('should return false for invalid handle', async () => { + if (!persistence) { + throw new Error('Persistence module not loaded β check run-all.js module setup'); + } + + const isValid = await persistence.testDirectoryAccess(null); + + expect(isValid).toBe(false); + }); +}); + +describe('JTBD-110: System removes invalid handles from storage', () => { + let mocks; + let persistence; + + beforeEach(() => { + mocks = setupMocks(); + if (window.MoatPersistence) { + persistence = new window.MoatPersistence(); + } + }); + + afterEach(() => { + mocks.reset(); + }); + + it('should remove directory handle from storage', async () => { + if (!persistence) { + throw new Error('Persistence module not loaded β check run-all.js module setup'); + } + + const dirHandle = await window.showDirectoryPicker(); + const projectId = 'test-project-to-remove'; + + // Store first + await persistence.storeDirectoryHandle(projectId, dirHandle, {}); + + // Verify it's there + let retrieved = await persistence.getDirectoryHandle(projectId); + expect(retrieved).toBeTruthy(); + + // Remove it + await persistence.removeDirectoryHandle(projectId); + + // Verify it's gone + retrieved = await persistence.getDirectoryHandle(projectId); + expect(retrieved).toBeNull(); + }); +}); + +// Export runner for test execution +if (typeof module !== 'undefined' && module.exports) { + module.exports = runner; +} else { + window.filesystemTestRunner = runner; +} diff --git a/chrome-extension/tests/v2/manual-test.js b/chrome-extension/tests/v2/manual-test.js new file mode 100644 index 0000000..76fd0c7 --- /dev/null +++ b/chrome-extension/tests/v2/manual-test.js @@ -0,0 +1,652 @@ +/** + * Drawbridge V2 Manual Test via Puppeteer + * + * Tests the extension loaded in Chromium with the demo site. + * Some features (Side Panel, File System Access) can't be fully automated + * but we can verify extension loading, content script injection, and UI behavior. + */ + +const puppeteer = require('puppeteer-core'); +const path = require('path'); +const http = require('http'); +const fs = require('fs'); + +const EXTENSION_PATH = path.resolve(__dirname, '../../'); +const DEMO_PATH = path.resolve(__dirname, '../../../demo'); +const RESULTS = []; + +function log(status, test, detail = '') { + const icon = status === 'PASS' ? 'β ' : status === 'FAIL' ? 'β' : 'β οΈ'; + console.log(`${icon} ${test}${detail ? ` β ${detail}` : ''}`); + RESULTS.push({ status, test, detail }); +} + +// Simple static file server for demo site +function startDemoServer() { + return new Promise((resolve) => { + const server = http.createServer((req, res) => { + let filePath = path.join(DEMO_PATH, req.url === '/' ? 'index.html' : req.url); + const ext = path.extname(filePath); + const mimeTypes = { + '.html': 'text/html', + '.css': 'text/css', + '.js': 'application/javascript', + '.png': 'image/png', + '.svg': 'image/svg+xml' + }; + + fs.readFile(filePath, (err, data) => { + if (err) { + res.writeHead(404); + res.end('Not found'); + return; + } + res.writeHead(200, { 'Content-Type': mimeTypes[ext] || 'text/plain' }); + res.end(data); + }); + }); + + server.listen(3456, () => { + console.log('π Demo server running on http://localhost:3456\n'); + resolve(server); + }); + }); +} + +async function run() { + console.log('π Drawbridge V2 Manual Test Suite'); + console.log('='.repeat(60)); + console.log(`Extension: ${EXTENSION_PATH}`); + console.log(`Demo site: ${DEMO_PATH}\n`); + + const server = await startDemoServer(); + + let browser; + try { + // Launch Chromium with extension loaded + browser = await puppeteer.launch({ + executablePath: '/usr/bin/chromium', + headless: false, // Extensions require non-headless... but we're in a sandbox + args: [ + '--headless=new', // "new" headless mode supports extensions + `--disable-extensions-except=${EXTENSION_PATH}`, + `--load-extension=${EXTENSION_PATH}`, + '--no-sandbox', + '--disable-setuid-sandbox', + '--disable-gpu', + '--disable-dev-shm-usage', + '--window-size=1280,800' + ] + }); + + // === TEST 1: Extension loads successfully === + try { + const pages = await browser.pages(); + log('PASS', 'T01: Browser launched with extension'); + } catch (e) { + log('FAIL', 'T01: Browser launched with extension', e.message); + return; + } + + // === TEST 2: Get extension ID === + let extensionId; + try { + // Navigate to a real page first to trigger extension activation + const setupPage = await browser.newPage(); + await setupPage.goto('http://localhost:3456', { waitUntil: 'networkidle2', timeout: 10000 }); + + // Wait for service worker to spin up (can be slow in headless) + for (let attempt = 0; attempt < 10; attempt++) { + await new Promise(r => setTimeout(r, 500)); + const targets = browser.targets(); + const extTarget = targets.find(t => + t.type() === 'service_worker' && t.url().includes('chrome-extension://') + ); + if (extTarget) { + extensionId = extTarget.url().split('/')[2]; + break; + } + // Also check other target types + const bgTarget = targets.find(t => + t.url().includes('chrome-extension://') && t.url().includes('background') + ); + if (bgTarget) { + extensionId = bgTarget.url().split('/')[2]; + break; + } + } + + if (extensionId) { + log('PASS', 'T02: Extension ID found', extensionId); + } else { + // Last resort: check manifest for known extension ID patterns + const targets = browser.targets(); + const anyExt = targets.find(t => t.url().includes('chrome-extension://')); + if (anyExt) { + extensionId = anyExt.url().split('/')[2]; + log('PASS', 'T02: Extension ID found (fallback)', extensionId); + } else { + log('WARN', 'T02: Extension ID not found', `Targets: ${targets.map(t => t.type()).join(', ')}`); + } + } + await setupPage.close(); + } catch (e) { + log('FAIL', 'T02: Extension ID found', e.message); + } + + // === TEST 3: Content script responds to ping via background relay === + // In V2, content scripts run in Chrome's isolated world and don't inject + // visible DOM markers (Google Fonts injection was V1). The correct way to + // verify is to ping the content script through the background service worker, + // which is how the side panel communicates in production. + const page = await browser.newPage(); + try { + await page.goto('http://localhost:3456', { waitUntil: 'networkidle2', timeout: 15000 }); + + // Give content script time to initialize + await new Promise(r => setTimeout(r, 2000)); + + // Use the background script to relay a ping to the content script + // We access the service worker and ask it to sendMessage to the tab + const swTarget = browser.targets().find(t => + t.type() === 'service_worker' && t.url().includes('background.js') + ); + + if (swTarget) { + const swWorker = await swTarget.worker(); + const tabId = await page.evaluate(() => { + // This won't work from main world, so we'll use another approach + return null; + }); + + // Alternative: use chrome.tabs.sendMessage from side panel context + if (extensionId) { + const spPage = await browser.newPage(); + await spPage.goto(`chrome-extension://${extensionId}/sidepanel/sidepanel.html`, { + waitUntil: 'networkidle2', timeout: 10000 + }); + await new Promise(r => setTimeout(r, 1000)); + + // The side panel's connectWithRetry pings the content script + const contentScriptReady = await spPage.evaluate(async () => { + // Use the side panel's own ping logic + try { + const [tab] = await chrome.tabs.query({ active: true, currentWindow: true }); + if (!tab?.id) return { ready: false, reason: 'no active tab' }; + + return new Promise((resolve) => { + chrome.tabs.sendMessage(tab.id, { action: 'ping' }, (response) => { + if (chrome.runtime.lastError) { + resolve({ ready: false, reason: chrome.runtime.lastError.message }); + } else { + resolve({ ready: response?.ready === true, response }); + } + }); + }); + } catch (e) { + return { ready: false, reason: e.message }; + } + }); + + if (contentScriptReady.ready) { + log('PASS', 'T03: Content script responds to ping', JSON.stringify(contentScriptReady)); + } else { + log('WARN', 'T03: Content script ping failed', JSON.stringify(contentScriptReady)); + } + await spPage.close(); + } else { + log('WARN', 'T03: Skipped β extension ID not available'); + } + } else { + log('WARN', 'T03: Skipped β service worker not found'); + } + } catch (e) { + log('FAIL', 'T03: Content script ping', e.message); + } + + // === TEST 4: Content script handles GET_CONNECTION_STATUS === + // Verify the content script handles V2 message types correctly + try { + if (extensionId) { + const spPage = await browser.newPage(); + await spPage.goto(`chrome-extension://${extensionId}/sidepanel/sidepanel.html`, { + waitUntil: 'networkidle2', timeout: 10000 + }); + await new Promise(r => setTimeout(r, 1000)); + + const statusResult = await spPage.evaluate(async () => { + try { + const [tab] = await chrome.tabs.query({ active: true, currentWindow: true }); + if (!tab?.id) return { success: false, reason: 'no active tab' }; + + return new Promise((resolve) => { + chrome.tabs.sendMessage(tab.id, { type: 'GET_CONNECTION_STATUS' }, (response) => { + if (chrome.runtime.lastError) { + resolve({ success: false, reason: chrome.runtime.lastError.message }); + } else { + resolve({ success: true, response }); + } + }); + }); + } catch (e) { + return { success: false, reason: e.message }; + } + }); + + if (statusResult.success && statusResult.response) { + log('PASS', 'T04: Content script handles GET_CONNECTION_STATUS', + `connected: ${statusResult.response.connected}, path: "${statusResult.response.path}"`); + } else { + log('WARN', 'T04: GET_CONNECTION_STATUS failed', JSON.stringify(statusResult)); + } + await spPage.close(); + } else { + log('WARN', 'T04: Skipped β extension ID not available'); + } + } catch (e) { + log('FAIL', 'T04: GET_CONNECTION_STATUS', e.message); + } + + // === TEST 5: Content CSS injected (not moat.css) === + // NOTE: Extension-injected CSS (via manifest content_scripts.css) creates + // anonymous stylesheets with no href. Check for known CSS rules instead. + try { + const cssCheck = await page.evaluate(() => { + const sheets = Array.from(document.styleSheets); + const hasMoatCss = sheets.some(s => s.href && s.href.includes('moat.css')); + + // content.css defines rules for .float-comment-mode, .float-highlight, etc. + // Extension-injected CSS has no href, so scan rules in anonymous sheets. + let hasContentCssRules = false; + for (const sheet of sheets) { + try { + if (sheet.href) continue; // Skip linked sheets β we want injected ones + const rules = Array.from(sheet.cssRules || []); + const hasFloatRule = rules.some(r => + r.selectorText && ( + r.selectorText.includes('.float-comment-mode') || + r.selectorText.includes('.float-highlight') || + r.selectorText.includes('.float-drawing-canvas') + ) + ); + if (hasFloatRule) { + hasContentCssRules = true; + break; + } + } catch (e) { + // Cross-origin stylesheet β skip + } + } + + return { hasContentCssRules, hasMoatCss, sheetCount: sheets.length }; + }); + + if (cssCheck.hasContentCssRules && !cssCheck.hasMoatCss) { + log('PASS', 'T05: V2 content.css rules injected, moat.css removed'); + } else if (!cssCheck.hasMoatCss && !cssCheck.hasContentCssRules) { + log('WARN', 'T05: No moat.css (V1 removed) but content.css rules not detected', JSON.stringify(cssCheck)); + } else if (cssCheck.hasMoatCss) { + log('FAIL', 'T05: V1 moat.css still present', JSON.stringify(cssCheck)); + } else { + log('PASS', 'T05: V2 content.css rules injected', JSON.stringify(cssCheck)); + } + } catch (e) { + log('FAIL', 'T05: CSS injection check', e.message); + } + + // === TEST 6: No moat.js sidebar injected (V1 removed) === + try { + const noMoat = await page.evaluate(() => { + const moatSidebar = document.getElementById('moat-sidebar') || + document.querySelector('.moat-sidebar') || + document.querySelector('[data-moat]'); + return !moatSidebar; + }); + + if (noMoat) { + log('PASS', 'T06: No V1 moat sidebar injected into page'); + } else { + log('FAIL', 'T06: V1 moat sidebar still present in page'); + } + } catch (e) { + log('FAIL', 'T06: No V1 moat sidebar check', e.message); + } + + // === TEST 7: Manifest has correct V2 configuration === + try { + const manifestPath = path.join(EXTENSION_PATH, 'manifest.json'); + const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8')); + + const checks = { + version: manifest.version === '2.0.0', + sidePanel: manifest.permissions?.includes('sidePanel'), + hasSidePanelConfig: !!manifest.side_panel?.default_path, + noMoatJs: !manifest.content_scripts?.[0]?.js?.includes('moat.js'), + noMoatCss: !manifest.content_scripts?.[0]?.css?.includes('moat.css'), + hasContentCss: manifest.content_scripts?.[0]?.css?.includes('content.css') + }; + + const allPass = Object.values(checks).every(v => v); + if (allPass) { + log('PASS', 'T07: Manifest V2 configuration correct', JSON.stringify(checks)); + } else { + log('FAIL', 'T07: Manifest V2 configuration', JSON.stringify(checks)); + } + } catch (e) { + log('FAIL', 'T07: Manifest V2 configuration', e.message); + } + + // === TEST 8: Side Panel HTML exists and is well-formed === + try { + if (extensionId) { + const spPage = await browser.newPage(); + await spPage.goto(`chrome-extension://${extensionId}/sidepanel/sidepanel.html`, { + waitUntil: 'networkidle2', timeout: 10000 + }); + + const spCheck = await spPage.evaluate(() => { + return { + title: document.title, + hasApp: !!document.getElementById('drawbridge-app'), + hasTaskContainer: !!document.getElementById('task-container'), + hasTabs: document.querySelectorAll('.tab').length, + hasConnectBtn: !!document.getElementById('connect-btn'), + hasToolsBtn: !!document.getElementById('tools-btn'), + hasSettingsBtn: !!document.getElementById('settings-btn'), + hasToolsMenu: !!document.getElementById('tools-menu'), + hasProjectMenu: !!document.getElementById('project-menu') + }; + }); + + const allPresent = spCheck.hasApp && spCheck.hasTaskContainer && + spCheck.hasTabs === 3 && spCheck.hasConnectBtn && + spCheck.hasToolsBtn && spCheck.hasSettingsBtn; + + if (allPresent) { + log('PASS', 'T08: Side Panel HTML structure correct', JSON.stringify(spCheck)); + } else { + log('FAIL', 'T08: Side Panel HTML structure', JSON.stringify(spCheck)); + } + await spPage.close(); + } else { + log('WARN', 'T08: Skipped β extension ID not available'); + } + } catch (e) { + log('FAIL', 'T08: Side Panel HTML', e.message); + } + + // === TEST 9: Side Panel JS initializes === + try { + if (extensionId) { + const spPage = await browser.newPage(); + await spPage.goto(`chrome-extension://${extensionId}/sidepanel/sidepanel.html`, { + waitUntil: 'networkidle2', timeout: 10000 + }); + + // Wait for JS to initialize + await new Promise(r => setTimeout(r, 1000)); + + const jsCheck = await spPage.evaluate(() => { + return { + // Check if event listeners are set up by looking for UI state + connectionBanner: document.getElementById('connection-banner')?.className || '', + tabsExist: document.querySelectorAll('.tab').length, + activeTab: document.querySelector('.tab.active')?.dataset?.status || '', + emptyState: !!document.querySelector('.empty-state'), + badgesExist: !!document.getElementById('todo-badge') + }; + }); + + const initialized = jsCheck.tabsExist === 3 && + jsCheck.activeTab === 'to do' && + jsCheck.emptyState; + + if (initialized) { + log('PASS', 'T09: Side Panel JS initialized', `Active tab: "${jsCheck.activeTab}", disconnected state shown`); + } else { + log('FAIL', 'T09: Side Panel JS initialization', JSON.stringify(jsCheck)); + } + await spPage.close(); + } else { + log('WARN', 'T09: Skipped β extension ID not available'); + } + } catch (e) { + log('FAIL', 'T09: Side Panel JS initialization', e.message); + } + + // === TEST 10: Side Panel theme toggle === + try { + if (extensionId) { + const spPage = await browser.newPage(); + await spPage.goto(`chrome-extension://${extensionId}/sidepanel/sidepanel.html`, { + waitUntil: 'networkidle2', timeout: 10000 + }); + await new Promise(r => setTimeout(r, 500)); + + const themeBefore = await spPage.evaluate(() => { + return document.documentElement.getAttribute('data-theme'); + }); + + await spPage.click('#settings-btn'); + await new Promise(r => setTimeout(r, 300)); + + const themeAfter = await spPage.evaluate(() => { + return document.documentElement.getAttribute('data-theme'); + }); + + if (themeBefore !== themeAfter) { + log('PASS', 'T10: Theme toggle works', `${themeBefore} β ${themeAfter}`); + } else { + log('FAIL', 'T10: Theme toggle', `Theme didn't change: ${themeBefore}`); + } + await spPage.close(); + } else { + log('WARN', 'T10: Skipped β extension ID not available'); + } + } catch (e) { + log('FAIL', 'T10: Theme toggle', e.message); + } + + // === TEST 11: Side Panel tab switching === + try { + if (extensionId) { + const spPage = await browser.newPage(); + await spPage.goto(`chrome-extension://${extensionId}/sidepanel/sidepanel.html`, { + waitUntil: 'networkidle2', timeout: 10000 + }); + await new Promise(r => setTimeout(r, 500)); + + // Click "Doing" tab + await spPage.click('.tab[data-status="doing"]'); + await new Promise(r => setTimeout(r, 200)); + + const doingActive = await spPage.evaluate(() => { + return document.querySelector('.tab.active')?.dataset?.status; + }); + + // Click "Done" tab + await spPage.click('.tab[data-status="done"]'); + await new Promise(r => setTimeout(r, 200)); + + const doneActive = await spPage.evaluate(() => { + return document.querySelector('.tab.active')?.dataset?.status; + }); + + if (doingActive === 'doing' && doneActive === 'done') { + log('PASS', 'T11: Tab switching works', 'to do β doing β done'); + } else { + log('FAIL', 'T11: Tab switching', `doing=${doingActive}, done=${doneActive}`); + } + await spPage.close(); + } else { + log('WARN', 'T11: Skipped β extension ID not available'); + } + } catch (e) { + log('FAIL', 'T11: Tab switching', e.message); + } + + // === TEST 12: Side Panel tools menu === + try { + if (extensionId) { + const spPage = await browser.newPage(); + await spPage.goto(`chrome-extension://${extensionId}/sidepanel/sidepanel.html`, { + waitUntil: 'networkidle2', timeout: 10000 + }); + await new Promise(r => setTimeout(r, 500)); + + // Tools menu should be hidden initially + const hiddenBefore = await spPage.evaluate(() => { + return document.getElementById('tools-menu')?.classList.contains('hidden'); + }); + + // Click tools button + await spPage.click('#tools-btn'); + await new Promise(r => setTimeout(r, 200)); + + const hiddenAfter = await spPage.evaluate(() => { + return document.getElementById('tools-menu')?.classList.contains('hidden'); + }); + + // Check menu items + const menuItems = await spPage.evaluate(() => { + return Array.from(document.querySelectorAll('#tools-menu .menu-item')) + .map(item => item.dataset.action); + }); + + if (hiddenBefore && !hiddenAfter && menuItems.includes('comment') && menuItems.includes('rectangle')) { + log('PASS', 'T12: Tools menu opens with Comment + Rectangle', menuItems.join(', ')); + } else { + log('FAIL', 'T12: Tools menu', `hidden: ${hiddenBefore}β${hiddenAfter}, items: ${menuItems}`); + } + await spPage.close(); + } else { + log('WARN', 'T12: Skipped β extension ID not available'); + } + } catch (e) { + log('FAIL', 'T12: Tools menu', e.message); + } + + // === TEST 13: Demo site renders correctly === + try { + const demoCheck = await page.evaluate(() => { + return { + title: document.title, + hasHero: !!document.querySelector('h1'), + hasNav: !!document.querySelector('nav'), + bodyText: document.body?.innerText?.length || 0 + }; + }); + + if (demoCheck.bodyText > 100) { + log('PASS', 'T13: Demo site renders correctly', `Title: ${demoCheck.title}, Body: ${demoCheck.bodyText} chars`); + } else { + log('FAIL', 'T13: Demo site rendering', JSON.stringify(demoCheck)); + } + } catch (e) { + log('FAIL', 'T13: Demo site rendering', e.message); + } + + // === TEST 14: Background service worker active === + try { + const targets = browser.targets(); + const swTarget = targets.find(t => + t.type() === 'service_worker' && t.url().includes('background.js') + ); + + if (swTarget) { + log('PASS', 'T14: Background service worker active', swTarget.url()); + } else { + // Check all targets + const allTargets = targets.map(t => `${t.type()}: ${t.url()}`); + log('WARN', 'T14: Background service worker', `Not found. Targets: ${allTargets.join(', ')}`); + } + } catch (e) { + log('FAIL', 'T14: Background service worker', e.message); + } + + // === TEST 15: No console errors on demo page === + try { + const newPage = await browser.newPage(); + const consoleErrors = []; + newPage.on('console', msg => { + if (msg.type() === 'error') { + consoleErrors.push(msg.text()); + } + }); + + await newPage.goto('http://localhost:3456', { waitUntil: 'networkidle2', timeout: 10000 }); + await new Promise(r => setTimeout(r, 2000)); // Wait for content script + + // Filter out expected/benign errors + const realErrors = consoleErrors.filter(e => + !e.includes('favicon') && + !e.includes('net::ERR') && + !e.includes('Failed to load resource') + ); + + if (realErrors.length === 0) { + log('PASS', 'T15: No console errors on demo page'); + } else { + log('FAIL', 'T15: Console errors detected', realErrors.join(' | ')); + } + await newPage.close(); + } catch (e) { + log('FAIL', 'T15: Console error check', e.message); + } + + } catch (e) { + console.error('π₯ Fatal error:', e.message); + console.error(e.stack); + } finally { + if (browser) await browser.close(); + server.close(); + + // Print summary + console.log('\n' + '='.repeat(60)); + console.log('π MANUAL TEST RESULTS'); + console.log('='.repeat(60)); + + const passed = RESULTS.filter(r => r.status === 'PASS').length; + const failed = RESULTS.filter(r => r.status === 'FAIL').length; + const warned = RESULTS.filter(r => r.status === 'WARN').length; + + console.log(`β Passed: ${passed}`); + console.log(`β Failed: ${failed}`); + console.log(`β οΈ Warned: ${warned}`); + console.log(`π Pass Rate: ${RESULTS.length > 0 ? ((passed / RESULTS.length) * 100).toFixed(1) : 0}%`); + + if (failed > 0) { + console.log('\nβ Failures:'); + RESULTS.filter(r => r.status === 'FAIL').forEach(r => { + console.log(` β’ ${r.test}: ${r.detail}`); + }); + } + + console.log('='.repeat(60)); + + // Save results to file + const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19); + const commit = require('child_process').execSync('git rev-parse --short HEAD', { cwd: path.resolve(__dirname, '../../../') }).toString().trim(); + const resultsDir = path.resolve(__dirname, 'results'); + const resultsFile = path.join(resultsDir, `manual-${timestamp}-${commit}.md`); + + let md = `# Manual Test Results\n\n`; + md += `- **Date:** ${new Date().toISOString()}\n`; + md += `- **Commit:** ${commit}\n`; + md += `- **Branch:** v2\n`; + md += `- **Passed:** ${passed} | **Failed:** ${failed} | **Warned:** ${warned}\n`; + md += `- **Pass Rate:** ${RESULTS.length > 0 ? ((passed / RESULTS.length) * 100).toFixed(1) : 0}%\n\n`; + md += `## Results\n\n`; + md += `| Status | Test | Detail |\n`; + md += `|--------|------|--------|\n`; + RESULTS.forEach(r => { + const icon = r.status === 'PASS' ? 'β ' : r.status === 'FAIL' ? 'β' : 'β οΈ'; + md += `| ${icon} ${r.status} | ${r.test} | ${r.detail || 'β'} |\n`; + }); + + fs.writeFileSync(resultsFile, md); + console.log(`\nπ Results saved: ${resultsFile}`); + } +} + +run(); diff --git a/chrome-extension/tests/v2/markdown.test.js b/chrome-extension/tests/v2/markdown.test.js new file mode 100644 index 0000000..04255dd --- /dev/null +++ b/chrome-extension/tests/v2/markdown.test.js @@ -0,0 +1,317 @@ +/** + * Markdown File Generation Tests + * Tests for JTBD-57 through JTBD-66 + */ + +const runner = new TestRunner(); +const describe = runner.describe.bind(runner); +const it = runner.it.bind(runner); +const beforeEach = runner.beforeEach.bind(runner); +const afterEach = runner.afterEach.bind(runner); + +describe('JTBD-57: System generates markdown from task array', () => { + let mocks; + + beforeEach(() => { + mocks = setupMocks(); + }); + + afterEach(() => { + mocks.reset(); + }); + + it('should generate valid markdown structure', () => { + if (!window.MoatMarkdownGenerator) { + throw new Error('MarkdownGenerator module not loaded β check run-all.js module setup'); + } + + const tasks = [ + { + id: 'task-1', + title: 'Test Task', + comment: 'This is a test task comment', + selector: '.test-button', + status: 'to do', + timestamp: Date.now() + } + ]; + + const markdown = window.MoatMarkdownGenerator.generateMarkdownFromTasks(tasks); + + expect(markdown).toContain('# Moat Tasks'); + expect(markdown).toContain('**Total**: 1'); + expect(markdown).toContain('Test Task'); + }); + + it('should handle empty task array', () => { + if (!window.MoatMarkdownGenerator) { + throw new Error('MarkdownGenerator module not loaded β check run-all.js module setup'); + } + + const markdown = window.MoatMarkdownGenerator.generateMarkdownFromTasks([]); + + expect(markdown).toContain('# Moat Tasks'); + expect(markdown).toContain('**Total**: 0'); + expect(markdown).toContain('press "F" to begin making annotations'); + }); +}); + +describe('JTBD-60: System displays task summary statistics', () => { + let mocks; + + beforeEach(() => { + mocks = setupMocks(); + }); + + afterEach(() => { + mocks.reset(); + }); + + it('should calculate and display task statistics', () => { + if (!window.MoatMarkdownGenerator) { + throw new Error('MarkdownGenerator module not loaded β check run-all.js module setup'); + } + + const tasks = [ + { id: '1', title: 'Task 1', comment: 'Test 1', selector: '.test1', status: 'to do', timestamp: 1 }, + { id: '2', title: 'Task 2', comment: 'Test 2', selector: '.test2', status: 'doing', timestamp: 2 }, + { id: '3', title: 'Task 3', comment: 'Test 3', selector: '.test3', status: 'done', timestamp: 3 } + ]; + + const markdown = window.MoatMarkdownGenerator.generateMarkdownFromTasks(tasks); + + expect(markdown).toContain('**Total**: 3'); + expect(markdown).toContain('**To Do**: 1'); + expect(markdown).toContain('**Doing**: 1'); + expect(markdown).toContain('**Done**: 1'); + }); +}); + +describe('JTBD-61: System converts status to checkbox format', () => { + let mocks; + + beforeEach(() => { + mocks = setupMocks(); + }); + + afterEach(() => { + mocks.reset(); + }); + + it('should convert statuses to correct checkbox format', () => { + if (!window.MoatMarkdownGenerator) { + throw new Error('MarkdownGenerator module not loaded β check run-all.js module setup'); + } + + expect(window.MoatMarkdownGenerator.statusToCheckbox('to do')).toBe('[ ]'); + expect(window.MoatMarkdownGenerator.statusToCheckbox('doing')).toBe('[~]'); + expect(window.MoatMarkdownGenerator.statusToCheckbox('done')).toBe('[x]'); + }); +}); + +describe('JTBD-62: System truncates long comments in markdown', () => { + let mocks; + + beforeEach(() => { + mocks = setupMocks(); + }); + + afterEach(() => { + mocks.reset(); + }); + + it('should truncate comments longer than 60 characters', () => { + if (!window.MoatMarkdownGenerator) { + throw new Error('MarkdownGenerator module not loaded β check run-all.js module setup'); + } + + const longComment = 'This is a very long comment that should definitely be truncated because it exceeds the maximum length'; + const truncated = window.MoatMarkdownGenerator.truncateComment(longComment, 60); + + expect(truncated.length).toBeLessThanOrEqual(60); + expect(truncated).toContain('...'); + }); + + it('should not truncate short comments', () => { + if (!window.MoatMarkdownGenerator) { + throw new Error('MarkdownGenerator module not loaded β check run-all.js module setup'); + } + + const shortComment = 'Short comment'; + const result = window.MoatMarkdownGenerator.truncateComment(shortComment, 60); + + expect(result).toBe('Short comment'); + expect(result).not.toContain('...'); + }); +}); + +describe('JTBD-63: System numbers tasks sequentially', () => { + let mocks; + + beforeEach(() => { + mocks = setupMocks(); + }); + + afterEach(() => { + mocks.reset(); + }); + + it('should number tasks starting from 1', () => { + if (!window.MoatMarkdownGenerator) { + throw new Error('MarkdownGenerator module not loaded β check run-all.js module setup'); + } + + const tasks = [ + { id: '1', title: 'Task 1', comment: 'Test 1', selector: '.test1', status: 'to do', timestamp: 1 }, + { id: '2', title: 'Task 2', comment: 'Test 2', selector: '.test2', status: 'to do', timestamp: 2 }, + { id: '3', title: 'Task 3', comment: 'Test 3', selector: '.test3', status: 'to do', timestamp: 3 } + ]; + + const markdown = window.MoatMarkdownGenerator.generateMarkdownFromTasks(tasks); + + expect(markdown).toContain('1. [ ] Task 1'); + expect(markdown).toContain('2. [ ] Task 2'); + expect(markdown).toContain('3. [ ] Task 3'); + }); +}); + +describe('JTBD-64: System includes timestamp in markdown footer', () => { + let mocks; + + beforeEach(() => { + mocks = setupMocks(); + }); + + afterEach(() => { + mocks.reset(); + }); + + it('should include generation timestamp', () => { + if (!window.MoatMarkdownGenerator) { + throw new Error('MarkdownGenerator module not loaded β check run-all.js module setup'); + } + + const tasks = []; + const markdown = window.MoatMarkdownGenerator.generateMarkdownFromTasks(tasks); + + expect(markdown).toContain('_Generated:'); + expect(markdown).toContain('_Source: moat-tasks-detail.json_'); + }); +}); + +describe('JTBD-66: System sorts tasks chronologically in markdown', () => { + let mocks; + + beforeEach(() => { + mocks = setupMocks(); + }); + + afterEach(() => { + mocks.reset(); + }); + + it('should sort tasks by timestamp (oldest first)', () => { + if (!window.MoatMarkdownGenerator) { + throw new Error('MarkdownGenerator module not loaded β check run-all.js module setup'); + } + + const tasks = [ + { id: '3', title: 'Task 3', comment: 'Third', selector: '.test3', status: 'to do', timestamp: 3000 }, + { id: '1', title: 'Task 1', comment: 'First', selector: '.test1', status: 'to do', timestamp: 1000 }, + { id: '2', title: 'Task 2', comment: 'Second', selector: '.test2', status: 'to do', timestamp: 2000 } + ]; + + const markdown = window.MoatMarkdownGenerator.generateMarkdownFromTasks(tasks); + + // Verify order in markdown + const task1Index = markdown.indexOf('Task 1'); + const task2Index = markdown.indexOf('Task 2'); + const task3Index = markdown.indexOf('Task 3'); + + expect(task1Index).toBeLessThan(task2Index); + expect(task2Index).toBeLessThan(task3Index); + }); +}); + +describe('JTBD-58: System writes markdown to file', () => { + let mocks; + + beforeEach(() => { + mocks = setupMocks(); + }); + + afterEach(() => { + mocks.reset(); + }); + + it('should write markdown content to file', async () => { + if (!window.MoatMarkdownGenerator) { + throw new Error('MarkdownGenerator module not loaded β check run-all.js module setup'); + } + + const dirHandle = await window.showDirectoryPicker(); + const moatDir = await dirHandle.getDirectoryHandle('.moat', { create: true }); + window.directoryHandle = moatDir; + + const markdownContent = '# Test Markdown\n\nTest content'; + await window.MoatMarkdownGenerator.writeMarkdownToFile(markdownContent); + + // Verify file was written + const fileHandle = await moatDir.getFileHandle('moat-tasks.md'); + const file = await fileHandle.getFile(); + const content = await file.text(); + + expect(content).toBe(markdownContent); + }); +}); + +describe('JTBD-59: System rebuilds markdown file completely', () => { + let mocks; + + beforeEach(() => { + mocks = setupMocks(); + }); + + afterEach(() => { + mocks.reset(); + }); + + it('should rebuild markdown file from task array', async () => { + if (!window.MoatMarkdownGenerator) { + throw new Error('MarkdownGenerator module not loaded β check run-all.js module setup'); + } + + const dirHandle = await window.showDirectoryPicker(); + const moatDir = await dirHandle.getDirectoryHandle('.moat', { create: true }); + window.directoryHandle = moatDir; + + const tasks = [ + { + id: 'task-1', + title: 'Test Task', + comment: 'Test comment', + selector: '.test', + status: 'to do', + timestamp: Date.now() + } + ]; + + await window.MoatMarkdownGenerator.rebuildMarkdownFile(tasks); + + // Verify file content + const fileHandle = await moatDir.getFileHandle('moat-tasks.md'); + const file = await fileHandle.getFile(); + const content = await file.text(); + + expect(content).toContain('# Moat Tasks'); + expect(content).toContain('Test Task'); + expect(content).toContain('**Total**: 1'); + }); +}); + +// Export runner for test execution +if (typeof module !== 'undefined' && module.exports) { + module.exports = runner; +} else { + window.markdownTestRunner = runner; +} diff --git a/chrome-extension/tests/v2/results/manual-2026-02-12T20-47-10-fd7dbe5.md b/chrome-extension/tests/v2/results/manual-2026-02-12T20-47-10-fd7dbe5.md new file mode 100644 index 0000000..26e883f --- /dev/null +++ b/chrome-extension/tests/v2/results/manual-2026-02-12T20-47-10-fd7dbe5.md @@ -0,0 +1,27 @@ +# Manual Test Results + +- **Date:** 2026-02-12T20:47:10.881Z +- **Commit:** fd7dbe5 +- **Branch:** v2 +- **Passed:** 13 | **Failed:** 1 | **Warned:** 1 +- **Pass Rate:** 86.7% + +## Results + +| Status | Test | Detail | +|--------|------|--------| +| β PASS | T01: Browser launched with extension | β | +| β PASS | T02: Extension ID found | memplnhgfnplgadhfoljnchlnpjdeoip | +| β οΈ WARN | T03: Content script partially loaded | {"safeStorage":false,"taskStore":false,"markdownGen":false,"persistence":false} | +| β FAIL | T04: Chrome runtime available in content script | β | +| β PASS | T05: moat.css not injected (V1 removed) | {"hasContentCss":false,"hasMoatCss":false,"sheetCount":2} | +| β PASS | T06: No V1 moat sidebar injected into page | β | +| β PASS | T07: Manifest V2 configuration correct | {"version":true,"sidePanel":true,"hasSidePanelConfig":true,"noMoatJs":true,"noMoatCss":true,"hasContentCss":true} | +| β PASS | T08: Side Panel HTML structure correct | {"title":"Drawbridge","hasApp":true,"hasTaskContainer":true,"hasTabs":3,"hasConnectBtn":true,"hasToolsBtn":true,"hasSettingsBtn":true,"hasToolsMenu":true,"hasProjectMenu":true} | +| β PASS | T09: Side Panel JS initialized | Active tab: "to do", disconnected state shown | +| β PASS | T10: Theme toggle works | light β dark | +| β PASS | T11: Tab switching works | to do β doing β done | +| β PASS | T12: Tools menu opens with Comment + Rectangle | comment, rectangle | +| β PASS | T13: Demo site renders correctly | Title: Moss&Mint Studio β Design & Web Agency, Body: 1421 chars | +| β PASS | T14: Background service worker active | chrome-extension://memplnhgfnplgadhfoljnchlnpjdeoip/background.js | +| β PASS | T15: No console errors on demo page | β | diff --git a/chrome-extension/tests/v2/results/manual-2026-02-12T22-23-33-ae923f0.md b/chrome-extension/tests/v2/results/manual-2026-02-12T22-23-33-ae923f0.md new file mode 100644 index 0000000..f7db9e7 --- /dev/null +++ b/chrome-extension/tests/v2/results/manual-2026-02-12T22-23-33-ae923f0.md @@ -0,0 +1,27 @@ +# Manual Test Results + +- **Date:** 2026-02-12T22:23:33.286Z +- **Commit:** ae923f0 +- **Branch:** v2 +- **Passed:** 12 | **Failed:** 2 | **Warned:** 1 +- **Pass Rate:** 80.0% + +## Results + +| Status | Test | Detail | +|--------|------|--------| +| β PASS | T01: Browser launched with extension | β | +| β PASS | T02: Extension ID found | memplnhgfnplgadhfoljnchlnpjdeoip | +| β FAIL | T03: Content script not detected | {"googleFonts":false,"preconnect1":false,"preconnect2":false} | +| β FAIL | T04: Content script DOM modifications not detected | {"googleFonts":false,"injectedStylesheets":0} | +| β οΈ WARN | T05: No moat.css (V1 removed) but content.css rules not detected | {"hasContentCssRules":false,"hasMoatCss":false,"sheetCount":2} | +| β PASS | T06: No V1 moat sidebar injected into page | β | +| β PASS | T07: Manifest V2 configuration correct | {"version":true,"sidePanel":true,"hasSidePanelConfig":true,"noMoatJs":true,"noMoatCss":true,"hasContentCss":true} | +| β PASS | T08: Side Panel HTML structure correct | {"title":"Drawbridge","hasApp":true,"hasTaskContainer":true,"hasTabs":3,"hasConnectBtn":true,"hasToolsBtn":true,"hasSettingsBtn":true,"hasToolsMenu":true,"hasProjectMenu":true} | +| β PASS | T09: Side Panel JS initialized | Active tab: "to do", disconnected state shown | +| β PASS | T10: Theme toggle works | light β dark | +| β PASS | T11: Tab switching works | to do β doing β done | +| β PASS | T12: Tools menu opens with Comment + Rectangle | comment, rectangle | +| β PASS | T13: Demo site renders correctly | Title: Moss&Mint Studio β Design & Web Agency, Body: 1421 chars | +| β PASS | T14: Background service worker active | chrome-extension://memplnhgfnplgadhfoljnchlnpjdeoip/background.js | +| β PASS | T15: No console errors on demo page | β | diff --git a/chrome-extension/tests/v2/results/manual-2026-02-12T22-24-53-ae923f0.md b/chrome-extension/tests/v2/results/manual-2026-02-12T22-24-53-ae923f0.md new file mode 100644 index 0000000..bd15843 --- /dev/null +++ b/chrome-extension/tests/v2/results/manual-2026-02-12T22-24-53-ae923f0.md @@ -0,0 +1,27 @@ +# Manual Test Results + +- **Date:** 2026-02-12T22:24:53.139Z +- **Commit:** ae923f0 +- **Branch:** v2 +- **Passed:** 12 | **Failed:** 0 | **Warned:** 3 +- **Pass Rate:** 80.0% + +## Results + +| Status | Test | Detail | +|--------|------|--------| +| β PASS | T01: Browser launched with extension | β | +| β PASS | T02: Extension ID found | memplnhgfnplgadhfoljnchlnpjdeoip | +| β οΈ WARN | T03: Content script ping failed | {"ready":false,"reason":"Could not establish connection. Receiving end does not exist."} | +| β οΈ WARN | T04: GET_CONNECTION_STATUS failed | {"success":false,"reason":"Could not establish connection. Receiving end does not exist."} | +| β οΈ WARN | T05: No moat.css (V1 removed) but content.css rules not detected | {"hasContentCssRules":false,"hasMoatCss":false,"sheetCount":2} | +| β PASS | T06: No V1 moat sidebar injected into page | β | +| β PASS | T07: Manifest V2 configuration correct | {"version":true,"sidePanel":true,"hasSidePanelConfig":true,"noMoatJs":true,"noMoatCss":true,"hasContentCss":true} | +| β PASS | T08: Side Panel HTML structure correct | {"title":"Drawbridge","hasApp":true,"hasTaskContainer":true,"hasTabs":3,"hasConnectBtn":true,"hasToolsBtn":true,"hasSettingsBtn":true,"hasToolsMenu":true,"hasProjectMenu":true} | +| β PASS | T09: Side Panel JS initialized | Active tab: "to do", disconnected state shown | +| β PASS | T10: Theme toggle works | light β dark | +| β PASS | T11: Tab switching works | to do β doing β done | +| β PASS | T12: Tools menu opens with Comment + Rectangle | comment, rectangle | +| β PASS | T13: Demo site renders correctly | Title: Moss&Mint Studio β Design & Web Agency, Body: 1421 chars | +| β PASS | T14: Background service worker active | chrome-extension://memplnhgfnplgadhfoljnchlnpjdeoip/background.js | +| β PASS | T15: No console errors on demo page | β | diff --git a/chrome-extension/tests/v2/results/unit-2026-02-12T20-46-12-fd7dbe5.md b/chrome-extension/tests/v2/results/unit-2026-02-12T20-46-12-fd7dbe5.md new file mode 100644 index 0000000..73bd61d --- /dev/null +++ b/chrome-extension/tests/v2/results/unit-2026-02-12T20-46-12-fd7dbe5.md @@ -0,0 +1,9 @@ +# Unit Test Results + +- **Date:** 2026-02-12T20:46:12.479Z +- **Commit:** fd7dbe5 +- **Branch:** v2 +- **Total:** 79 | **Passed:** 79 | **Failed:** 0 | **Skipped:** 0 +- **Pass Rate:** 100.0% + +## β All tests passed! diff --git a/chrome-extension/tests/v2/results/unit-2026-02-12T22-21-31-f2a490c.md b/chrome-extension/tests/v2/results/unit-2026-02-12T22-21-31-f2a490c.md new file mode 100644 index 0000000..59e21ff --- /dev/null +++ b/chrome-extension/tests/v2/results/unit-2026-02-12T22-21-31-f2a490c.md @@ -0,0 +1,9 @@ +# Unit Test Results + +- **Date:** 2026-02-12T22:21:31.635Z +- **Commit:** f2a490c +- **Branch:** v2 +- **Total:** 79 | **Passed:** 79 | **Failed:** 0 | **Skipped:** 0 +- **Pass Rate:** 100.0% + +## β All tests passed! diff --git a/chrome-extension/tests/v2/results/unit-2026-02-12T22-23-07-ae923f0.md b/chrome-extension/tests/v2/results/unit-2026-02-12T22-23-07-ae923f0.md new file mode 100644 index 0000000..393b214 --- /dev/null +++ b/chrome-extension/tests/v2/results/unit-2026-02-12T22-23-07-ae923f0.md @@ -0,0 +1,9 @@ +# Unit Test Results + +- **Date:** 2026-02-12T22:23:07.311Z +- **Commit:** ae923f0 +- **Branch:** v2 +- **Total:** 79 | **Passed:** 79 | **Failed:** 0 | **Skipped:** 0 +- **Pass Rate:** 100.0% + +## β All tests passed! diff --git a/chrome-extension/tests/v2/results/unit-2026-02-12T23-20-07-2be8f0e.md b/chrome-extension/tests/v2/results/unit-2026-02-12T23-20-07-2be8f0e.md new file mode 100644 index 0000000..f9f9d7b --- /dev/null +++ b/chrome-extension/tests/v2/results/unit-2026-02-12T23-20-07-2be8f0e.md @@ -0,0 +1,9 @@ +# Unit Test Results + +- **Date:** 2026-02-12T23:20:07.501Z +- **Commit:** 2be8f0e +- **Branch:** v2 +- **Total:** 79 | **Passed:** 79 | **Failed:** 0 | **Skipped:** 0 +- **Pass Rate:** 100.0% + +## β All tests passed! diff --git a/chrome-extension/tests/v2/run-all.js b/chrome-extension/tests/v2/run-all.js new file mode 100644 index 0000000..9e0d3a8 --- /dev/null +++ b/chrome-extension/tests/v2/run-all.js @@ -0,0 +1,182 @@ +/** + * Run All V2 Tests (Node.js) + * + * Sets up globals to match browser environment, then loads and runs each test file. + */ + +// 1. Load test framework + mocks +const { TestRunner, expect } = require('./test-runner.js'); +const { setupMocks } = require('./chrome-mock.js'); + +// 2. Make them global (test files expect browser-style globals) +global.TestRunner = TestRunner; +global.expect = expect; +global.setupMocks = setupMocks; + +// 3. Setup initial mocks so chrome/window/document exist +const mocks = setupMocks(); + +// 4. Ensure showDirectoryPicker is on window +global.window.showDirectoryPicker = global.showDirectoryPicker; + +// 5. Load utility modules and wire them to window.* globals +// The modules use `module.exports` in Node.js, so require() returns +// the exports but never sets window.*. We bridge that here. +try { + const taskStoreModule = require('../../utils/taskStore.js'); + global.window.MoatTaskStore = taskStoreModule; + console.log('β Loaded: MoatTaskStore'); +} catch (e) { + console.warn('β οΈ Could not load taskStore.js:', e.message); + global.window.MoatTaskStore = null; +} + +try { + const markdownModule = require('../../utils/markdownGenerator.js'); + global.window.MoatMarkdownGenerator = markdownModule; + console.log('β Loaded: MoatMarkdownGenerator'); +} catch (e) { + console.warn('β οΈ Could not load markdownGenerator.js:', e.message); + global.window.MoatMarkdownGenerator = null; +} + +try { + const persistenceModule = require('../../utils/persistence.js'); + global.window.MoatPersistence = persistenceModule.MoatPersistence; + console.log('β Loaded: MoatPersistence'); +} catch (e) { + console.warn('β οΈ Could not load persistence.js:', e.message); + global.window.MoatPersistence = null; +} + +try { + const safeStorageModule = require('../../utils/safeStorage.js'); + global.window.MoatSafeStorage = safeStorageModule.MoatSafeStorage; + console.log('β Loaded: MoatSafeStorage'); +} catch (e) { + console.warn('β οΈ Could not load safeStorage.js:', e.message); + global.window.MoatSafeStorage = null; +} + +// 6. Collect runners from each test file +const testFiles = [ + './connection.test.js', + './tasks.test.js', + './markdown.test.js', + './filesystem.test.js', + './v2-architecture.test.js' +]; + +(async function main() { + console.log('\nπ Drawbridge V2 Test Suite'); + console.log('='.repeat(60)); + console.log(`Running ${testFiles.length} test files...\n`); + + let totalTests = 0; + let totalPassed = 0; + let totalFailed = 0; + let totalSkipped = 0; + const allFailures = []; + + for (const file of testFiles) { + console.log(`\n${'β'.repeat(60)}`); + console.log(`π ${file}`); + console.log('β'.repeat(60)); + + try { + // Clear require cache so each file gets fresh state + delete require.cache[require.resolve(file)]; + + // Intercept TestRunner constructor to capture the runner instance + let capturedRunner = null; + const OrigRunner = TestRunner; + + global.TestRunner = class extends OrigRunner { + constructor() { + super(); + capturedRunner = this; + } + }; + + require(file); + + global.TestRunner = OrigRunner; + + if (capturedRunner) { + await capturedRunner.run(); + const results = capturedRunner.getResults(); + totalTests += results.stats.total; + totalPassed += results.stats.passed; + totalFailed += results.stats.failed; + totalSkipped += results.stats.skipped; + allFailures.push(...results.failedTests.map(f => ({ ...f, file }))); + } else { + console.log(' β οΈ No test runner found in file'); + } + } catch (error) { + console.error(` β Failed to load: ${error.message}`); + console.error(` ${error.stack?.split('\n').slice(1, 3).join('\n ')}`); + } + } + + // Summary + console.log('\n\n' + '='.repeat(60)); + console.log('π OVERALL RESULTS'); + console.log('='.repeat(60)); + console.log(`Total: ${totalTests}`); + console.log(`β Passed: ${totalPassed}`); + console.log(`β Failed: ${totalFailed}`); + console.log(`βοΈ Skipped: ${totalSkipped}`); + + const passRate = totalTests > 0 ? ((totalPassed / totalTests) * 100).toFixed(1) : 0; + console.log(`\nπ Pass Rate: ${passRate}%`); + + if (allFailures.length > 0) { + console.log('\nβ Failures:'); + allFailures.forEach(({ suite, test, error, file }) => { + console.log(` β’ [${file}] ${suite} β ${test}`); + console.log(` ${error.message}`); + }); + } + + if (totalFailed === 0) { + console.log('\nπ All tests passed!'); + } + + console.log('='.repeat(60)); + + // Save results to file + const fs = require('fs'); + const pathMod = require('path'); + const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19); + let commit = 'unknown'; + try { + commit = require('child_process').execSync('git rev-parse --short HEAD', { cwd: pathMod.resolve(__dirname, '../../../') }).toString().trim(); + } catch(e) {} + const resultsDir = pathMod.resolve(__dirname, 'results'); + if (!fs.existsSync(resultsDir)) fs.mkdirSync(resultsDir, { recursive: true }); + const resultsFile = pathMod.join(resultsDir, `unit-${timestamp}-${commit}.md`); + + let md = `# Unit Test Results\n\n`; + md += `- **Date:** ${new Date().toISOString()}\n`; + md += `- **Commit:** ${commit}\n`; + md += `- **Branch:** v2\n`; + md += `- **Total:** ${totalTests} | **Passed:** ${totalPassed} | **Failed:** ${totalFailed} | **Skipped:** ${totalSkipped}\n`; + md += `- **Pass Rate:** ${passRate}%\n\n`; + + if (allFailures.length > 0) { + md += `## Failures\n\n`; + md += `| File | Suite | Test | Error |\n`; + md += `|------|-------|------|-------|\n`; + allFailures.forEach(({ file, suite, test, error }) => { + md += `| ${file} | ${suite} | ${test} | ${error.message.replace(/\|/g, '\\|')} |\n`; + }); + } else { + md += `## β All tests passed!\n`; + } + + fs.writeFileSync(resultsFile, md); + console.log(`\nπ Results saved: ${resultsFile}`); + + process.exit(totalFailed > 0 ? 1 : 0); +})(); diff --git a/chrome-extension/tests/v2/tasks.test.js b/chrome-extension/tests/v2/tasks.test.js new file mode 100644 index 0000000..8c48618 --- /dev/null +++ b/chrome-extension/tests/v2/tasks.test.js @@ -0,0 +1,571 @@ +/** + * Task Management (CRUD) Tests + * Tests for JTBD-42 through JTBD-56 + */ + +const runner = new TestRunner(); +const describe = runner.describe.bind(runner); +const it = runner.it.bind(runner); +const beforeEach = runner.beforeEach.bind(runner); +const afterEach = runner.afterEach.bind(runner); + +describe('JTBD-42: System adds task to TaskStore', () => { + let taskStore; + let mocks; + + beforeEach(() => { + mocks = setupMocks(); + if (window.MoatTaskStore) { + taskStore = new window.MoatTaskStore.TaskStore(); + } + }); + + afterEach(() => { + mocks.reset(); + }); + + it('should add a new task', () => { + if (!taskStore) { + throw new Error('TaskStore module not loaded β check run-all.js module setup'); + } + + const taskData = { + title: 'Test Task', + comment: 'This is a test comment', + selector: '.test-element', + boundingRect: { x: 0, y: 0, w: 100, h: 50 } + }; + + const task = taskStore.addTask(taskData); + + expect(task).toBeTruthy(); + expect(task.id).toBeTruthy(); + expect(task.title).toBe('Test Task'); + expect(task.comment).toBe('This is a test comment'); + expect(task.status).toBe('to do'); + }); + + it('should generate UUID for task ID', () => { + if (!taskStore) { + throw new Error('TaskStore module not loaded β check run-all.js module setup'); + } + + const task = taskStore.addTask({ + title: 'Test', + comment: 'Test comment', + selector: '.test' + }); + + // UUID v4 format: xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx + const uuidPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; + expect(task.id.match(uuidPattern)).toBeTruthy(); + }); + + it('should add timestamp to task', () => { + if (!taskStore) { + throw new Error('TaskStore module not loaded β check run-all.js module setup'); + } + + const beforeTime = Date.now(); + const task = taskStore.addTask({ + title: 'Test', + comment: 'Test comment', + selector: '.test' + }); + const afterTime = Date.now(); + + expect(task.timestamp).toBeGreaterThanOrEqual(beforeTime); + expect(task.timestamp).toBeLessThanOrEqual(afterTime); + }); +}); + +describe('JTBD-43: System validates task object structure', () => { + let taskStore; + let mocks; + + beforeEach(() => { + mocks = setupMocks(); + if (window.MoatTaskStore) { + taskStore = new window.MoatTaskStore.TaskStore(); + } + }); + + afterEach(() => { + mocks.reset(); + }); + + it('should require title and comment', () => { + if (!taskStore) { + throw new Error('TaskStore module not loaded β check run-all.js module setup'); + } + + expect(() => { + taskStore.addTask({ title: 'Test' }); // Missing comment + }).toThrow(); + + expect(() => { + taskStore.addTask({ comment: 'Test comment' }); // Missing title + }).toThrow(); + }); + + it('should validate task status values', () => { + if (!taskStore) { + throw new Error('TaskStore module not loaded β check run-all.js module setup'); + } + + const task = taskStore.addTask({ + title: 'Test', + comment: 'Test comment', + selector: '.test' + }); + + // Valid status change + const updated = taskStore.updateTaskStatus(task.id, 'doing'); + expect(updated.status).toBe('doing'); + + // Invalid status change + expect(() => { + taskStore.updateTaskStatus(task.id, 'invalid-status'); + }).toThrow(); + }); + + it('should allow null selector for freeform rectangles', () => { + if (!taskStore) { + throw new Error('TaskStore module not loaded β check run-all.js module setup'); + } + + const task = taskStore.addTask({ + title: 'Freeform Task', + comment: 'Rectangle annotation', + selector: null, + boundingRect: { x: 10, y: 10, w: 100, h: 100 } + }); + + expect(task.selector).toBeNull(); + expect(task.boundingRect).toBeTruthy(); + }); +}); + +describe('JTBD-44: System deduplicates identical tasks', () => { + let taskStore; + let mocks; + + beforeEach(() => { + mocks = setupMocks(); + if (window.MoatTaskStore) { + taskStore = new window.MoatTaskStore.TaskStore(); + } + }); + + afterEach(() => { + mocks.reset(); + }); + + it('should detect duplicate tasks with same selector and comment', () => { + if (!taskStore) { + throw new Error('TaskStore module not loaded β check run-all.js module setup'); + } + + const taskData = { + title: 'Duplicate Test', + comment: 'Same comment', + selector: '.same-selector' + }; + + const task1 = taskStore.addTask(taskData); + const task2 = taskStore.addTask(taskData); + + // Should return same task (deduplicated) + expect(task2.id).toBe(task1.id); + expect(taskStore.getAllTasks()).toHaveLength(1); + }); + + it('should not deduplicate completed tasks', () => { + if (!taskStore) { + throw new Error('TaskStore module not loaded β check run-all.js module setup'); + } + + const taskData = { + title: 'Test', + comment: 'Same comment', + selector: '.same-selector' + }; + + const task1 = taskStore.addTask(taskData); + taskStore.updateTaskStatus(task1.id, 'done'); + + const task2 = taskStore.addTask(taskData); + + // Should create new task since first is done + expect(task2.id).not.toBe(task1.id); + expect(taskStore.getAllTasks()).toHaveLength(2); + }); + + it('should detect duplicate freeform rectangles', () => { + if (!taskStore) { + throw new Error('TaskStore module not loaded β check run-all.js module setup'); + } + + const taskData = { + title: 'Rectangle', + comment: 'Same comment', + selector: null, + boundingRect: { x: 10, y: 10, w: 100, h: 100 } + }; + + const task1 = taskStore.addTask(taskData); + const task2 = taskStore.addTask(taskData); + + // Should detect as duplicate (within 10px threshold) + expect(task2.id).toBe(task1.id); + expect(taskStore.getAllTasks()).toHaveLength(1); + }); +}); + +describe('JTBD-48: System updates task status', () => { + let taskStore; + let mocks; + + beforeEach(() => { + mocks = setupMocks(); + if (window.MoatTaskStore) { + taskStore = new window.MoatTaskStore.TaskStore(); + } + }); + + afterEach(() => { + mocks.reset(); + }); + + it('should update task status', () => { + if (!taskStore) { + throw new Error('TaskStore module not loaded β check run-all.js module setup'); + } + + const task = taskStore.addTask({ + title: 'Test', + comment: 'Test comment', + selector: '.test' + }); + + expect(task.status).toBe('to do'); + + taskStore.updateTaskStatus(task.id, 'doing'); + expect(task.status).toBe('doing'); + + taskStore.updateTaskStatus(task.id, 'done'); + expect(task.status).toBe('done'); + }); + + it('should add lastModified timestamp on status update', () => { + if (!taskStore) { + throw new Error('TaskStore module not loaded β check run-all.js module setup'); + } + + const task = taskStore.addTask({ + title: 'Test', + comment: 'Test comment', + selector: '.test' + }); + + const beforeUpdate = Date.now(); + taskStore.updateTaskStatus(task.id, 'doing'); + const afterUpdate = Date.now(); + + expect(task.lastModified).toBeTruthy(); + expect(task.lastModified).toBeGreaterThanOrEqual(beforeUpdate); + expect(task.lastModified).toBeLessThanOrEqual(afterUpdate); + }); + + it('should return null when updating non-existent task', () => { + if (!taskStore) { + throw new Error('TaskStore module not loaded β check run-all.js module setup'); + } + + const result = taskStore.updateTaskStatus('non-existent-id', 'doing'); + expect(result).toBeNull(); + }); +}); + +describe('JTBD-51: System gets task by ID', () => { + let taskStore; + let mocks; + + beforeEach(() => { + mocks = setupMocks(); + if (window.MoatTaskStore) { + taskStore = new window.MoatTaskStore.TaskStore(); + } + }); + + afterEach(() => { + mocks.reset(); + }); + + it('should retrieve task by ID', () => { + if (!taskStore) { + throw new Error('TaskStore module not loaded β check run-all.js module setup'); + } + + const task = taskStore.addTask({ + title: 'Test', + comment: 'Test comment', + selector: '.test' + }); + + const retrieved = taskStore.getTaskById(task.id); + + expect(retrieved).toBeTruthy(); + expect(retrieved.id).toBe(task.id); + expect(retrieved.title).toBe('Test'); + }); + + it('should return null for non-existent ID', () => { + if (!taskStore) { + throw new Error('TaskStore module not loaded β check run-all.js module setup'); + } + + const retrieved = taskStore.getTaskById('non-existent-id'); + expect(retrieved).toBeNull(); + }); +}); + +describe('JTBD-52: System gets all tasks sorted by timestamp', () => { + let taskStore; + let mocks; + + beforeEach(() => { + mocks = setupMocks(); + if (window.MoatTaskStore) { + taskStore = new window.MoatTaskStore.TaskStore(); + } + }); + + afterEach(() => { + mocks.reset(); + }); + + it('should return tasks sorted newest first', () => { + if (!taskStore) { + throw new Error('TaskStore module not loaded β check run-all.js module setup'); + } + + const task1 = taskStore.addTask({ + title: 'Task 1', + comment: 'First task', + selector: '.test1' + }); + // Ensure task1 has an earlier timestamp + task1.timestamp = Date.now() - 1000; + + const task2 = taskStore.addTask({ + title: 'Task 2', + comment: 'Second task', + selector: '.test2' + }); + + const tasks = taskStore.getAllTasks(); + + expect(tasks).toHaveLength(2); + // Newest first (reverse chronological) + expect(tasks[0].id).toBe(task2.id); + expect(tasks[1].id).toBe(task1.id); + }); +}); + +describe('JTBD-53: System gets tasks in chronological order', () => { + let taskStore; + let mocks; + + beforeEach(() => { + mocks = setupMocks(); + if (window.MoatTaskStore) { + taskStore = new window.MoatTaskStore.TaskStore(); + } + }); + + afterEach(() => { + mocks.reset(); + }); + + it('should return tasks sorted oldest first', () => { + if (!taskStore) { + throw new Error('TaskStore module not loaded β check run-all.js module setup'); + } + + const task1 = taskStore.addTask({ + title: 'Task 1', + comment: 'First task', + selector: '.test1' + }); + + const task2 = taskStore.addTask({ + title: 'Task 2', + comment: 'Second task', + selector: '.test2' + }); + + const tasks = taskStore.getAllTasksChronological(); + + expect(tasks).toHaveLength(2); + // Oldest first (chronological) + expect(tasks[0].id).toBe(task1.id); + expect(tasks[1].id).toBe(task2.id); + }); +}); + +describe('JTBD-54: System calculates task statistics', () => { + let taskStore; + let mocks; + + beforeEach(() => { + mocks = setupMocks(); + if (window.MoatTaskStore) { + taskStore = new window.MoatTaskStore.TaskStore(); + } + }); + + afterEach(() => { + mocks.reset(); + }); + + it('should count tasks by status', () => { + if (!taskStore) { + throw new Error('TaskStore module not loaded β check run-all.js module setup'); + } + + const task1 = taskStore.addTask({ + title: 'Task 1', + comment: 'Test 1', + selector: '.test1' + }); + + const task2 = taskStore.addTask({ + title: 'Task 2', + comment: 'Test 2', + selector: '.test2' + }); + + const task3 = taskStore.addTask({ + title: 'Task 3', + comment: 'Test 3', + selector: '.test3' + }); + + taskStore.updateTaskStatus(task2.id, 'doing'); + taskStore.updateTaskStatus(task3.id, 'done'); + + const stats = taskStore.getTaskStats(); + + expect(stats.total).toBe(3); + expect(stats['to do']).toBe(1); + expect(stats['doing']).toBe(1); + expect(stats['done']).toBe(1); + }); + + it('should handle empty task list', () => { + if (!taskStore) { + throw new Error('TaskStore module not loaded β check run-all.js module setup'); + } + + const stats = taskStore.getTaskStats(); + + expect(stats.total).toBe(0); + expect(stats['to do']).toBe(0); + expect(stats['doing']).toBe(0); + expect(stats['done']).toBe(0); + }); +}); + +describe('JTBD-46: System saves tasks to JSON file', () => { + let taskStore; + let mocks; + + beforeEach(async () => { + mocks = setupMocks(); + if (window.MoatTaskStore) { + taskStore = new window.MoatTaskStore.TaskStore(); + const dirHandle = await window.showDirectoryPicker(); + const moatDir = await dirHandle.getDirectoryHandle('.moat', { create: true }); + taskStore.initialize(moatDir); + } + }); + + afterEach(() => { + mocks.reset(); + }); + + it('should save tasks to moat-tasks-detail.json', async () => { + if (!taskStore) { + throw new Error('TaskStore module not loaded β check run-all.js module setup'); + } + + taskStore.addTask({ + title: 'Test Task', + comment: 'Test comment', + selector: '.test' + }); + + await taskStore.saveTasksToFile(); + + // Verify file was written + const fileHandle = await taskStore.directoryHandle.getFileHandle('moat-tasks-detail.json'); + const file = await fileHandle.getFile(); + const content = await file.text(); + const tasks = JSON.parse(content); + + expect(tasks).toHaveLength(1); + expect(tasks[0].title).toBe('Test Task'); + }); +}); + +describe('JTBD-50: System removes task by ID', () => { + let taskStore; + let mocks; + + beforeEach(() => { + mocks = setupMocks(); + if (window.MoatTaskStore) { + taskStore = new window.MoatTaskStore.TaskStore(); + } + }); + + afterEach(() => { + mocks.reset(); + }); + + it('should remove task from store', () => { + if (!taskStore) { + throw new Error('TaskStore module not loaded β check run-all.js module setup'); + } + + const task = taskStore.addTask({ + title: 'Test', + comment: 'Test comment', + selector: '.test' + }); + + expect(taskStore.getAllTasks()).toHaveLength(1); + + const removed = taskStore.removeTask(task.id); + + expect(removed).toBe(true); + expect(taskStore.getAllTasks()).toHaveLength(0); + }); + + it('should return false for non-existent task', () => { + if (!taskStore) { + throw new Error('TaskStore module not loaded β check run-all.js module setup'); + } + + const removed = taskStore.removeTask('non-existent-id'); + expect(removed).toBe(false); + }); +}); + +// Export runner for test execution +if (typeof module !== 'undefined' && module.exports) { + module.exports = runner; +} else { + window.tasksTestRunner = runner; +} diff --git a/chrome-extension/tests/v2/test-runner.html b/chrome-extension/tests/v2/test-runner.html new file mode 100644 index 0000000..643da46 --- /dev/null +++ b/chrome-extension/tests/v2/test-runner.html @@ -0,0 +1,288 @@ + + + + + +Click "Run All Tests" to start testing...
+A boutique studio crafting brand systems, websites, and frontβend builds. Seamless (mostly), fast, and a bit obsessed with details.
Strategy β design β build. Less backβandβfourth, more flow.
+End-to-end digital solutions that drive results. From strategy to launch, we make it seamless.
Logos, color, typography, and design systems for scale.
Marketing sites and product UI with components that actually componet.
Accessible, performant, and maintainble builds your team can own.
Toggle the card state or nudge the layout. Notice the odd paddings and the slightly off alignment (on purpose).
Transparent starting points. Every engagement is bespoke-ish.
$8,000+ / proj
-$8,000+ / proj
+$15,000+ / proj
+$25,000+ / proj
+