diff --git a/.cursor/rules/test-location.mdc b/.cursor/rules/test-location.mdc new file mode 100644 index 00000000..c717f429 --- /dev/null +++ b/.cursor/rules/test-location.mdc @@ -0,0 +1,5 @@ +--- +description: Describes where to write test files +globs: *.test.ts +--- +test files are placed in the same folder of the file they test. For example [get-files.test.ts](mdc:plugma/src/tasks/common/get-files.test.ts) is beside [get-files.ts](mdc:plugma/src/tasks/common/get-files.ts) \ No newline at end of file diff --git a/.cursor/rules/vitest.mdc b/.cursor/rules/vitest.mdc new file mode 100644 index 00000000..87e351eb --- /dev/null +++ b/.cursor/rules/vitest.mdc @@ -0,0 +1,163 @@ +--- +description: Vitest usage guide +globs: **/*.test.ts +--- + # Vitest Usage Guide + +## Basic Test Structure + +Tests are organized following these patterns: + +~~~typescript +import { beforeEach, describe, expect, test, vi } from 'vitest'; + +describe('Component/Feature Name', () => { + // Setup and teardown + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe('Specific Aspect', () => { + test('should do something specific', () => { + // Test implementation + }); + }); +}); +~~~ + +## Mocking Patterns + +### Module Mocking + +1. **Hoisted Mocks**: Use `vi.hoisted()` for mocks needed across multiple tests: +~~~typescript +const mocks = vi.hoisted(() => ({ + registerCleanup: vi.fn(), + getDirName: vi.fn(), +})); + +vi.mock('#utils/cleanup.js', () => ({ + registerCleanup: mocks.registerCleanup, +})); +~~~ + +2. **Partial Module Mocks**: Keep original functionality while mocking specific exports: +~~~typescript +vi.mock('node:fs', () => ({ + ...vi.importActual('node:fs'), + readFileSync: vi.fn((path) => { + // Custom implementation + }), +})); +~~~ + +3. **External Module Mocks**: Mock external dependencies: +~~~typescript +vi.mock('vite', () => ({ + createServer: vi.fn(), +})); +~~~ + +### Mock Implementations + +1. **Mock Classes**: Create mock implementations for complex objects: +~~~typescript +class MockWebSocketServer extends EventEmitter { + clients = new Set(); + close = vi.fn().mockResolvedValue(undefined); + // ... other methods +} +~~~ + +2. **Mock Functions**: Use `vi.fn()` with specific implementations: +~~~typescript +const mockFn = vi.fn() + .mockReturnValue(defaultValue) + .mockImplementation((arg) => { + // Custom implementation + }); +~~~ + +3. **Async Mocks**: Handle promises in mocks: +~~~typescript +mockFunction.mockResolvedValue(value); // for Promise.resolve +mockFunction.mockRejectedValue(error); // for Promise.reject +~~~ + +## Testing Patterns + +### Cleanup and Reset + +1. **Clear Mocks**: Reset all mocks before each test: +~~~typescript +beforeEach(() => { + vi.clearAllMocks(); +}); +~~~ + +2. **Mock Reset**: Reset specific mocks: +~~~typescript +mockFunction.mockClear(); // Clear usage data +mockFunction.mockReset(); // Clear usage and implementation +mockFunction.mockRestore(); // Restore original implementation +~~~ + +### Assertions + +1. **Function Calls**: +~~~typescript +expect(mockFn).toHaveBeenCalled(); +expect(mockFn).toHaveBeenCalledWith(expect.any(Function)); +expect(mockFn).toHaveBeenCalledTimes(1); +~~~ + +2. **Objects and Values**: +~~~typescript +expect(result).toBe(value); // Strict equality +expect(object).toEqual(expected); // Deep equality +expect(object).toMatchObject(partial); // Partial object match +~~~ + +3. **Async Tests**: +~~~typescript +await expect(asyncFn()).rejects.toThrow('error message'); +await expect(asyncFn()).resolves.toBe(value); +~~~ + +### Event Testing + +1. **Event Emitters**: +~~~typescript +mockServer.emit('connection', mockClient, { url: '?source=test' }); +expect(mockClient.send).toHaveBeenCalledWith( + expect.stringContaining('"event":"client_list"') +); +~~~ + +2. **Error Events**: +~~~typescript +await expect( + () => new Promise((_, reject) => { + server.on('error', reject); + server.emit('error', new Error('Test error')); + }) +).rejects.toThrow('Expected error'); +~~~ + +## Best Practices + +1. **Organize Tests**: Group related tests using nested `describe` blocks +2. **Mock Isolation**: Use `beforeEach` to reset mocks and state +3. **Type Safety**: Maintain type safety in mocks using TypeScript interfaces +4. **Error Cases**: Test both success and error scenarios +5. **Clear Descriptions**: Use descriptive test names that explain the expected behavior +6. **Avoid Test Interdependence**: Each test should be independent and not rely on other tests + +## Common Gotchas + +1. **Async Cleanup**: Always handle async cleanup in tests +2. **Mock Scope**: Be aware of mock hoisting and scoping rules +3. **Event Order**: Consider event timing in async tests +4. **Type Assertions**: Use proper type assertions for mocked objects +5. **Promise Handling**: Always await promises in async tests +~~~ diff --git a/.editorconfig b/.editorconfig index 92e643ca..c357259e 100644 --- a/.editorconfig +++ b/.editorconfig @@ -3,7 +3,6 @@ root = true [*] charset = utf-8 end_of_line = lf -indent_size = 4 indent_style = tab insert_final_newline = true trim_trailing_whitespace = true diff --git a/.prettierrc b/.prettierrc deleted file mode 100644 index 2974d138..00000000 --- a/.prettierrc +++ /dev/null @@ -1,16 +0,0 @@ -{ - "useTabs": true, - "semi": false, - "singleQuote": true, - "printWidth": 120, - "braceStyle": "collapse,preserve-inline", - "overrides": [ - { - "files": "*.md", - "options": { - "useTabs": false, - "tabWidth": 4 - } - } - ] -} diff --git a/package-lock.json b/package-lock.json index 1dbfc3d4..4817dae2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,7 +17,8 @@ "devDependencies": { "@dotenv-run/esbuild": "^1.4.0", "@vercel/analytics": "^1.3.1", - "taskr": "^1.1.0" + "taskr": "^1.1.0", + "vitest": "^3.0.4" } }, "node_modules/@ampproject/remapping": { @@ -72,7 +73,6 @@ "os": [ "aix" ], - "peer": true, "engines": { "node": ">=12" } @@ -89,7 +89,6 @@ "os": [ "android" ], - "peer": true, "engines": { "node": ">=12" } @@ -106,7 +105,6 @@ "os": [ "android" ], - "peer": true, "engines": { "node": ">=12" } @@ -123,7 +121,6 @@ "os": [ "android" ], - "peer": true, "engines": { "node": ">=12" } @@ -140,7 +137,6 @@ "os": [ "darwin" ], - "peer": true, "engines": { "node": ">=12" } @@ -157,7 +153,6 @@ "os": [ "darwin" ], - "peer": true, "engines": { "node": ">=12" } @@ -174,7 +169,6 @@ "os": [ "freebsd" ], - "peer": true, "engines": { "node": ">=12" } @@ -191,7 +185,6 @@ "os": [ "freebsd" ], - "peer": true, "engines": { "node": ">=12" } @@ -208,7 +201,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=12" } @@ -225,7 +217,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=12" } @@ -242,7 +233,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=12" } @@ -259,7 +249,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=12" } @@ -276,7 +265,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=12" } @@ -293,7 +281,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=12" } @@ -310,7 +297,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=12" } @@ -327,7 +313,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=12" } @@ -344,7 +329,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=12" } @@ -361,7 +345,6 @@ "os": [ "netbsd" ], - "peer": true, "engines": { "node": ">=12" } @@ -378,7 +361,6 @@ "os": [ "openbsd" ], - "peer": true, "engines": { "node": ">=12" } @@ -395,7 +377,6 @@ "os": [ "sunos" ], - "peer": true, "engines": { "node": ">=12" } @@ -412,7 +393,6 @@ "os": [ "win32" ], - "peer": true, "engines": { "node": ">=12" } @@ -429,7 +409,6 @@ "os": [ "win32" ], - "peer": true, "engines": { "node": ">=12" } @@ -446,7 +425,6 @@ "os": [ "win32" ], - "peer": true, "engines": { "node": ">=12" } @@ -718,8 +696,7 @@ "optional": true, "os": [ "android" - ], - "peer": true + ] }, "node_modules/@rollup/rollup-android-arm64": { "version": "4.24.0", @@ -732,8 +709,7 @@ "optional": true, "os": [ "android" - ], - "peer": true + ] }, "node_modules/@rollup/rollup-darwin-arm64": { "version": "4.24.0", @@ -746,8 +722,7 @@ "optional": true, "os": [ "darwin" - ], - "peer": true + ] }, "node_modules/@rollup/rollup-darwin-x64": { "version": "4.24.0", @@ -760,8 +735,7 @@ "optional": true, "os": [ "darwin" - ], - "peer": true + ] }, "node_modules/@rollup/rollup-linux-arm-gnueabihf": { "version": "4.24.0", @@ -774,8 +748,7 @@ "optional": true, "os": [ "linux" - ], - "peer": true + ] }, "node_modules/@rollup/rollup-linux-arm-musleabihf": { "version": "4.24.0", @@ -788,8 +761,7 @@ "optional": true, "os": [ "linux" - ], - "peer": true + ] }, "node_modules/@rollup/rollup-linux-arm64-gnu": { "version": "4.24.0", @@ -802,8 +774,7 @@ "optional": true, "os": [ "linux" - ], - "peer": true + ] }, "node_modules/@rollup/rollup-linux-arm64-musl": { "version": "4.24.0", @@ -816,8 +787,7 @@ "optional": true, "os": [ "linux" - ], - "peer": true + ] }, "node_modules/@rollup/rollup-linux-powerpc64le-gnu": { "version": "4.24.0", @@ -830,8 +800,7 @@ "optional": true, "os": [ "linux" - ], - "peer": true + ] }, "node_modules/@rollup/rollup-linux-riscv64-gnu": { "version": "4.24.0", @@ -844,8 +813,7 @@ "optional": true, "os": [ "linux" - ], - "peer": true + ] }, "node_modules/@rollup/rollup-linux-s390x-gnu": { "version": "4.24.0", @@ -858,8 +826,7 @@ "optional": true, "os": [ "linux" - ], - "peer": true + ] }, "node_modules/@rollup/rollup-linux-x64-gnu": { "version": "4.24.0", @@ -872,8 +839,7 @@ "optional": true, "os": [ "linux" - ], - "peer": true + ] }, "node_modules/@rollup/rollup-linux-x64-musl": { "version": "4.24.0", @@ -886,8 +852,7 @@ "optional": true, "os": [ "linux" - ], - "peer": true + ] }, "node_modules/@rollup/rollup-win32-arm64-msvc": { "version": "4.24.0", @@ -900,8 +865,7 @@ "optional": true, "os": [ "win32" - ], - "peer": true + ] }, "node_modules/@rollup/rollup-win32-ia32-msvc": { "version": "4.24.0", @@ -914,8 +878,7 @@ "optional": true, "os": [ "win32" - ], - "peer": true + ] }, "node_modules/@rollup/rollup-win32-x64-msvc": { "version": "4.24.0", @@ -928,8 +891,7 @@ "optional": true, "os": [ "win32" - ], - "peer": true + ] }, "node_modules/@sveltejs/vite-plugin-svelte": { "version": "3.1.2", @@ -974,8 +936,7 @@ "version": "1.0.6", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.6.tgz", "integrity": "sha512-AYnb1nQyY49te+VRAVgmzfcgjYS91mY5P0TKUDCLEM+gNnA+3T6rWITXRLYCpahpqSQbN5cE+gHpnPyXjHWxcw==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/@types/node": { "version": "22.7.6", @@ -1008,6 +969,119 @@ } } }, + "node_modules/@vitest/expect": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.0.4.tgz", + "integrity": "sha512-Nm5kJmYw6P2BxhJPkO3eKKhGYKRsnqJqf+r0yOGRKpEP+bSCBDsjXgiu1/5QFrnPMEgzfC38ZEjvCFgaNBC0Eg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "3.0.4", + "@vitest/utils": "3.0.4", + "chai": "^5.1.2", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.0.4.tgz", + "integrity": "sha512-gEef35vKafJlfQbnyOXZ0Gcr9IBUsMTyTLXsEQwuyYAerpHqvXhzdBnDFuHLpFqth3F7b6BaFr4qV/Cs1ULx5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "3.0.4", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.17" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^5.0.0 || ^6.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.0.4.tgz", + "integrity": "sha512-ts0fba+dEhK2aC9PFuZ9LTpULHpY/nd6jhAQ5IMU7Gaj7crPCTdCFfgvXxruRBLFS+MLraicCuFXxISEq8C93g==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.0.4.tgz", + "integrity": "sha512-dKHzTQ7n9sExAcWH/0sh1elVgwc7OJ2lMOBrAm73J7AH6Pf9T12Zh3lNE1TETZaqrWFXtLlx3NVrLRb5hCK+iw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "3.0.4", + "pathe": "^2.0.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.0.4.tgz", + "integrity": "sha512-+p5knMLwIk7lTQkM3NonZ9zBewzVp9EVkVpvNta0/PlFWpiqLaRcF4+33L1it3uRUCh0BGLOaXPPGEjNKfWb4w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.0.4", + "magic-string": "^0.30.17", + "pathe": "^2.0.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.0.4.tgz", + "integrity": "sha512-sXIMF0oauYyUy2hN49VFTYodzEAu744MmGcPR3ZBsPM20G+1/cSW/n1U+3Yu/zHxX2bIDe1oJASOkml+osTU6Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyspy": "^3.0.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.0.4.tgz", + "integrity": "sha512-8BqC1ksYsHtbWH+DfpOAKrFw3jl3Uf9J7yeFh85Pz52IWuh1hBBtyfEbRNNZNjl8H8A5yMLH9/t+k7HIKzQcZQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.0.4", + "loupe": "^3.1.2", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, "node_modules/acorn": { "version": "8.12.1", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.12.1.tgz", @@ -1070,6 +1144,16 @@ "node": ">= 0.4" } }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/axobject-query": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz", @@ -1105,6 +1189,33 @@ "concat-map": "0.0.1" } }, + "node_modules/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/chai": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-5.1.2.tgz", + "integrity": "sha512-aGtmf24DW6MLHHG5gCx4zaI3uBq3KRtxeVs0DjFH6Z0rDNbsvTxFASFvdj79pxjxZ8/5u3PIiN3IwEIQkiiuPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "assertion-error": "^2.0.1", + "check-error": "^2.1.1", + "deep-eql": "^5.0.1", + "loupe": "^3.1.0", + "pathval": "^2.0.0" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -1128,6 +1239,16 @@ "integrity": "sha512-mT8iDcrh03qDGRRmoA2hmBJnxpllMR+0/0qlzjqZES6NdiWDcZkCNAk4rPFZ9Q85r27unkiNNg8ZOiwZXBHwcA==", "license": "MIT" }, + "node_modules/check-error": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.1.tgz", + "integrity": "sha512-OAlb+T7V4Op9OwdkjmguYRqncdlx5JiofwOAUkmTF+jNdHwzTaTs4sRAGpzLF3oOz5xAyDGrPgeIDFQmDOTiJw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 16" + } + }, "node_modules/chokidar": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.1.tgz", @@ -1214,9 +1335,9 @@ } }, "node_modules/debug": { - "version": "4.3.7", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz", - "integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==", + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.0.tgz", + "integrity": "sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==", "license": "MIT", "dependencies": { "ms": "^2.1.3" @@ -1230,6 +1351,16 @@ } } }, + "node_modules/deep-eql": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", + "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/deepmerge": { "version": "4.3.1", "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", @@ -1267,13 +1398,19 @@ "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", "license": "MIT" }, + "node_modules/es-module-lexer": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.6.0.tgz", + "integrity": "sha512-qqnD1yMU6tk/jnaMosogGySTZP8YtUgAffA9nMN+E/rjxcfRQ6IEk7IiozUjgxKoFHBGjTLnrHB/YC45r/59EQ==", + "dev": true, + "license": "MIT" + }, "node_modules/esbuild": { "version": "0.21.3", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.3.tgz", "integrity": "sha512-Kgq0/ZsAPzKrbOjCQcjoSmPoWhlcVnGAUo7jvaLHoxW1Drto0KGkR1xBNg2Cp43b9ImvxmPEJZ9xkfcnqPsfBw==", "hasInstallScript": true, "license": "MIT", - "peer": true, "bin": { "esbuild": "bin/esbuild" }, @@ -1320,11 +1457,20 @@ "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", "license": "MIT", - "peer": true, "dependencies": { "@types/estree": "^1.0.0" } }, + "node_modules/expect-type": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.1.0.tgz", + "integrity": "sha512-bFi65yM+xZgk+u/KRIpekdSYkTB5W1pEf0Lt8Q8Msh7b+eQ7LXVtIB1Bkm4fvclDEL1b2CZkMhv2mOeF8tMdkA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/external-editor": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/external-editor/-/external-editor-3.1.0.tgz", @@ -1373,7 +1519,6 @@ "os": [ "darwin" ], - "peer": true, "engines": { "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } @@ -1516,10 +1661,17 @@ "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", "license": "MIT" }, + "node_modules/loupe": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.1.3.tgz", + "integrity": "sha512-kkIp7XSkP78ZxJEsSxW3712C6teJVoeHHwgo9zJ380de7IYyJ2ISlxojcH2pC5OFLewESmnRi/+XCDIEEVyoug==", + "dev": true, + "license": "MIT" + }, "node_modules/magic-string": { - "version": "0.30.11", - "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.11.tgz", - "integrity": "sha512-+Wri9p0QHMy+545hKww7YAu5NyzF8iomPL/RQazugQ9+Ez4Ic3mERMd8ZTX5rfK944j+560ZJi8iAwgak1Ac7A==", + "version": "0.30.17", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.17.tgz", + "integrity": "sha512-sNPKHvyjVf7gyjwS4xGTaW/mCnF8wnjtifKBEhxfZ7E/S8tQ0rssrwGNn6q8JH/ohItJfSQp9mBtQYuTlH5QnA==", "license": "MIT", "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0" @@ -1669,6 +1821,23 @@ "node": ">=0.10.0" } }, + "node_modules/pathe": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.2.tgz", + "integrity": "sha512-15Ztpk+nov8DR524R4BF7uEuzESgzUEAV4Ah7CUMNGXdE5ELuvxElxGXndBl32vMSsWa1jpNf22Z+Er3sKwq+w==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathval": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.0.tgz", + "integrity": "sha512-vE7JKRyES09KiunauX7nd2Q9/L7lhok4smP9RZTDeD4MVs72Dp2qNFVz39Nz5a0FVEW0BJR6C0DYrq6unoziZA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.16" + } + }, "node_modules/periscopic": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/periscopic/-/periscopic-3.1.0.tgz", @@ -1685,8 +1854,7 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.0.tgz", "integrity": "sha512-TQ92mBOW0l3LeMeyLV6mzy/kWr8lkd/hp3mTg7wYK7zJhuBStmGMBG0BdeDZS/dZx1IukaX6Bk11zcln25o1Aw==", - "license": "ISC", - "peer": true + "license": "ISC" }, "node_modules/postcss": { "version": "8.4.47", @@ -1707,7 +1875,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "nanoid": "^3.3.7", "picocolors": "^1.1.0", @@ -1728,7 +1895,6 @@ } ], "license": "MIT", - "peer": true, "bin": { "nanoid": "bin/nanoid.cjs" }, @@ -1769,7 +1935,6 @@ "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.24.0.tgz", "integrity": "sha512-DOmrlGSXNk1DM0ljiQA+i+o0rSLhtii1je5wgk60j49d1jHT5YYttBv1iWOnYSTG+fZZESUOSNiAl89SIet+Cg==", "license": "MIT", - "peer": true, "dependencies": { "@types/estree": "1.0.6" }, @@ -1830,6 +1995,13 @@ "integrity": "sha512-qepMx2JxAa5jjfzxG79yPPq+8BuFToHd1hm7kI+Z4zAq1ftQiP7HcxMhDDItrbtwVeLg/cY2JnKnrcFkmiswNA==", "dev": true }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, "node_modules/signal-exit": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", @@ -1847,11 +2019,24 @@ "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", "license": "BSD-3-Clause", - "peer": true, "engines": { "node": ">=0.10.0" } }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "3.8.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.8.0.tgz", + "integrity": "sha512-Bc3YwwCB+OzldMxOXJIIvC6cPRWr/LxOp48CdQTOkPyk/t4JWWJbrilwBd7RJzKV8QW7tJkcgAmeuLLJugl5/w==", + "dev": true, + "license": "MIT" + }, "node_modules/string-width": { "version": "4.2.3", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", @@ -1950,6 +2135,13 @@ "node": ">= 4.6" } }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, "node_modules/tinydate": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/tinydate/-/tinydate-1.3.0.tgz", @@ -1960,6 +2152,43 @@ "node": ">=4" } }, + "node_modules/tinyexec": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", + "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinypool": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.0.2.tgz", + "integrity": "sha512-al6n+QEANGFOMf/dmUMsuS5/r9B06uwlyNjZZql/zv8J7ybHCgoihBNORZCY2mzUuAnomQa2JdhyHKzZxPCrFA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + } + }, + "node_modules/tinyrainbow": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-2.0.0.tgz", + "integrity": "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tinyspy": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-3.0.2.tgz", + "integrity": "sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/tmp": { "version": "0.0.33", "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.0.33.tgz", @@ -2002,7 +2231,6 @@ "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.8.tgz", "integrity": "sha512-FqrItQ4DT1NC4zCUqMB4c4AZORMKIa0m8/URVCZ77OZ/QSNeJ54bU1vrFADbDsuwfIPcgknRkmqakQcgnL4GiQ==", "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.21.3", "postcss": "^8.4.43", @@ -2057,6 +2285,29 @@ } } }, + "node_modules/vite-node": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.0.4.tgz", + "integrity": "sha512-7JZKEzcYV2Nx3u6rlvN8qdo3QV7Fxyt6hx+CCKz9fbWxdX5IvUOmTWEAxMrWxaiSf7CKGLJQ5rFu8prb/jBjOA==", + "dev": true, + "license": "MIT", + "dependencies": { + "cac": "^6.7.14", + "debug": "^4.4.0", + "es-module-lexer": "^1.6.0", + "pathe": "^2.0.2", + "vite": "^5.0.0 || ^6.0.0" + }, + "bin": { + "vite-node": "vite-node.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, "node_modules/vitefu": { "version": "0.2.5", "resolved": "https://registry.npmjs.org/vitefu/-/vitefu-0.2.5.tgz", @@ -2071,6 +2322,93 @@ } } }, + "node_modules/vitest": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.0.4.tgz", + "integrity": "sha512-6XG8oTKy2gnJIFTHP6LD7ExFeNLxiTkK3CfMvT7IfR8IN+BYICCf0lXUQmX7i7JoxUP8QmeP4mTnWXgflu4yjw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "3.0.4", + "@vitest/mocker": "3.0.4", + "@vitest/pretty-format": "^3.0.4", + "@vitest/runner": "3.0.4", + "@vitest/snapshot": "3.0.4", + "@vitest/spy": "3.0.4", + "@vitest/utils": "3.0.4", + "chai": "^5.1.2", + "debug": "^4.4.0", + "expect-type": "^1.1.0", + "magic-string": "^0.30.17", + "pathe": "^2.0.2", + "std-env": "^3.8.0", + "tinybench": "^2.9.0", + "tinyexec": "^0.3.2", + "tinypool": "^1.0.2", + "tinyrainbow": "^2.0.0", + "vite": "^5.0.0 || ^6.0.0", + "vite-node": "3.0.4", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/debug": "^4.1.12", + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "@vitest/browser": "3.0.4", + "@vitest/ui": "3.0.4", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/debug": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/wrap-ansi": { "version": "6.2.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", diff --git a/package.json b/package.json index 7295bf65..79626dc6 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,7 @@ "devDependencies": { "@dotenv-run/esbuild": "^1.4.0", "@vercel/analytics": "^1.3.1", - "taskr": "^1.1.0" + "taskr": "^1.1.0", + "vitest": "^3.0.4" } } diff --git a/packages/plugma/.cursor/notes/.gitignore b/packages/plugma/.cursor/notes/.gitignore new file mode 100644 index 00000000..d6b7ef32 --- /dev/null +++ b/packages/plugma/.cursor/notes/.gitignore @@ -0,0 +1,2 @@ +* +!.gitignore diff --git a/packages/plugma/.cursor/rules/code-style.mdc b/packages/plugma/.cursor/rules/code-style.mdc new file mode 100644 index 00000000..ce707f63 --- /dev/null +++ b/packages/plugma/.cursor/rules/code-style.mdc @@ -0,0 +1,10 @@ +--- +description: Preferences regarding code style +globs: *.ts,*.js,*.svelte,*.tsx +--- +- Your notes folder is `.cursor/notes` (at the root of the workspace or projects in monorepos) is yours to use. Only you will ever read the contents in there. Use it to store plans, task lists, notes, learnings, and important reminders for you. +- When your response contains the code of a markdown file, code blocks inside said markdown file MUST use "~~~" +- when importing node native modules, like fs or url, always prepend them with 'node:', like in 'node:fs' and 'node:url' +- Always document exported functions, classes, types, etc with a robust TSDoc comment. Also document complex functions, even if not exported. +- Use "for ... of" loops instead `.forEach`. +- to prevent issues with commands being aliased, always use `command BUILTIN` for built-in commands, like `command cat` or `command ls`. \ No newline at end of file diff --git a/packages/plugma/.cursorignore b/packages/plugma/.cursorignore new file mode 100644 index 00000000..b723f6cc --- /dev/null +++ b/packages/plugma/.cursorignore @@ -0,0 +1,2 @@ +# Add directories or file patterns to ignore during indexing (e.g. foo/ or *.csv) +archive/ diff --git a/packages/plugma/.cursorrules b/packages/plugma/.cursorrules new file mode 100644 index 00000000..9ca5c148 --- /dev/null +++ b/packages/plugma/.cursorrules @@ -0,0 +1,32 @@ +- Your notes folder is `.claude-notes` (at the root of the workspace or projects in monorepos) is yours to use. Only you will ever read the contents in there. Use it to store plans, task lists, notes, learnings, and important reminders for you. +- When your response contains the code of a markdown file, code blocks inside said markdown file MUST use "~~~" +- when importing node native modules, like fs or url, always prepend them with 'node:', like in 'node:fs' and 'node:url' +- Always document exported functions, classes, types, etc with a robust TSDoc comment. Also document complex functions, even if not exported. +- Use "for ... of" loops instead `.forEach`. +- to prevent issues with commands being aliased, always use `command BUILTIN` for built-in commands, like `command cat` or `command ls`. +- When fixing test failures: + 1. Analyze test structure and organization: + - Verify all imports are hoisted to the top + - Check that vi.hoisted() is used for mock objects + - Ensure mock implementations are defined before use + 2. Review mock setup and implementation: + - Verify centralized mocks object contains all required mocks + - Check mock functions return appropriate values/promises + - Ensure consistent mocking patterns are used + 3. Validate module mocking: + - Confirm all external modules are mocked with vi.mock() + - Verify mocked implementations reference hoisted mocks + - Check mock implementations match expected behavior + 4. Inspect test lifecycle management: + - Review beforeEach/afterEach setup and cleanup + - Verify mocks are cleared between test runs + - Check initialization state is correct + 5. Debug test execution: + - Run tests and capture detailed error output + - Analyze failure messages and stack traces + - Fix issues iteratively, verifying each change + 6. Common failure points to check: + - File operations match mock filesystem state + - Async operations resolve in expected order + - Event handlers and watchers trigger correctly + - Collections/sets have proper initial state diff --git a/packages/plugma/.editorconfig b/packages/plugma/.editorconfig new file mode 100644 index 00000000..9ed9efd3 --- /dev/null +++ b/packages/plugma/.editorconfig @@ -0,0 +1,3 @@ +tab_width = 2 +indent_size = 2 +indent_style = space diff --git a/packages/plugma/.gitignore b/packages/plugma/.gitignore new file mode 100644 index 00000000..404abb22 --- /dev/null +++ b/packages/plugma/.gitignore @@ -0,0 +1 @@ +coverage/ diff --git a/packages/plugma/.vscode/extensions.json b/packages/plugma/.vscode/extensions.json new file mode 100644 index 00000000..4116f52a --- /dev/null +++ b/packages/plugma/.vscode/extensions.json @@ -0,0 +1,13 @@ +{ + "recommendations": [ + "streetsidesoftware.code-spell-checker", + "streetsidesoftware.code-spell-checker-portuguese", + "streetsidesoftware.code-spell-checker-portuguese-brazilian", + "ms-vsliveshare.vsliveshare", + "svelte.svelte-vscode", + "gplane.dprint2", + "biomejs.biome", + "oven.bun-vscode", + "foxundermoon.shell-format" + ] +} diff --git a/packages/plugma/.vscode/launch.json b/packages/plugma/.vscode/launch.json new file mode 100644 index 00000000..de72bd1d --- /dev/null +++ b/packages/plugma/.vscode/launch.json @@ -0,0 +1,94 @@ +{ + "version": "0.2.0", + "compounds": [ + { + "name": "Run Figma then Attach", + "configurations": ["Run Figma", "Wait to Attach to Figma"], + "stopAll": false, + "presentation": { + "hidden": false, + // __ will place this at the top of the debug menu + "group": "__", + "order": 1 + } + } + ], + "configurations": [ + { + "name": "Run Figma", + "type": "node", + "request": "launch", + "cwd": "${workspaceFolder}", + "internalConsoleOptions": "neverOpen", + "runtimeExecutable": "/Applications/Figma.app/Contents/MacOS/Figma", + "runtimeArgs": [ + "--args", + "--remote-debugging-port=9222", + "--inspect", + "--log-level=2", + "-v=2" + ], + "outputCapture": "std", + "autoAttachChildProcesses": true, + "console": "integratedTerminal", + "presentation": { + "hidden": false, + "group": "Figma", + "order": 1 + } + }, + { + "name": "Debug Jest Tests", + "type": "node", + "request": "launch", + "runtimeArgs": [ + "--inspect-brk", + "${workspaceRoot}/../../../../node_modules/.bin/jest", + "--runInBand" + ], + "console": "integratedTerminal", + "internalConsoleOptions": "neverOpen" + }, + { + "preLaunchTask": "Wait for it", + "name": "Wait to Attach to Figma", + "type": "chrome", + "request": "attach", + "address": "localhost", + "port": 9222, + "targetSelection": "pick", + "pauseForSourceMap": true, + "pathMapping": { + "/file/": "${workspaceFolder}/", + "../src/": "${workspaceFolder}/src/", + "src/": "${workspaceFolder}/src/" + }, + "presentation": { + "hidden": true, + "group": "Figma", + "order": 2 + }, + "internalConsoleOptions": "openOnSessionStart" + }, + { + "name": "Attach to Figma", + "type": "chrome", + "request": "attach", + "address": "localhost", + "port": 9222, + "targetSelection": "pick", + "pauseForSourceMap": true, + "pathMapping": { + "/file/": "${workspaceFolder}/", + "../src/": "${workspaceFolder}/src/", + "src/": "${workspaceFolder}/src/" + }, + "presentation": { + "hidden": false, + "group": "Figma", + "order": 2 + }, + "internalConsoleOptions": "openOnSessionStart" + } + ] +} diff --git a/packages/plugma/.vscode/settings.json b/packages/plugma/.vscode/settings.json new file mode 100644 index 00000000..5289e4cf --- /dev/null +++ b/packages/plugma/.vscode/settings.json @@ -0,0 +1,67 @@ +{ + "editor.formatOnSave": true, + "editor.defaultFormatter": "gplane.dprint2", + "[javascript]": { + "editor.defaultFormatter": "biomejs.biome" + }, + "[typescript]": { + "editor.defaultFormatter": "biomejs.biome" + }, + "[json]": { + "editor.defaultFormatter": "biomejs.biome" + }, + "[svelte]": { + "editor.defaultFormatter": "svelte.svelte-vscode" + }, + "explorer.fileNesting.enabled": true, + "explorer.fileNesting.patterns": { + "Config Files.md": "biome.*, dprint.*, .env*, *.cjs, *.config.ts, *.setup.ts, .git*, *.config, *.config.json, *config.js, *config.mjs, *config.cjs, *config.ts, *config.toml, *config.yaml, *config.yml, *rc, *rc.json, *rc.js, *rc.mjs, *rc.cjs, *rc.ts, *rc.toml, *rc.yaml, *rc.yml, *ignore, Dockerfile*, docker-*, *.toml, package-lock.json, yarn.lock, pnpm-lock.yaml, .prototools, rollup.*, renovate.json, .size-limit.*, vitest*", + "*.json": "${capture}.lock", + "*.ts": "${capture}.test.ts" + }, + "search.exclude": { + "**/coverage": true, + "**/dist": true, + "**/node_modules": true + }, + "typescript.enablePromptUseWorkspaceTsdk": true, + "typescript.preferences.autoImportFileExcludePatterns": ["dist/**"], + "typescript.tsdk": "node_modules/typescript/lib", + "cSpell.words": [ + "@endindex", + "antfu", + "Automator", + "biomejs", + "clickoutside", + "commitlint", + "conventionalcommits", + "dprint", + "endindex", + "Fong", + "plugma", + "stylelint", + "texthighlight", + "tokilabs", + "typecheck" + ], + "gutterpreview.enableReferenceLookup": true, + "gutterpreview.currentColorForSVG": "#CCC", + "svelte.enable-ts-plugin": true, + "svelte-autoimport.doubleQuotes": false, + "svelte.plugin.svelte.format.config.singleQuote": true, + "prettier.jsxSingleQuote": true, + "prettier.singleQuote": true, + "yaml.format.singleQuote": true, + "[jsonc]": { + "editor.defaultFormatter": "biomejs.biome" + }, + "editor.codeActionsOnSave": { + // "source.organizeImports": "always", + // "source.fixAll": "always", + // "source.fixAll.biome": "always", + // "source.organizeImports.biome": "always", + // "source.removeUnusedImports": "always", + // "source.sortImports": "always" + }, + "vitest.rootConfig": "vitest.config.ts" +} diff --git a/packages/plugma/.vscode/tasks.json b/packages/plugma/.vscode/tasks.json new file mode 100644 index 00000000..53990be0 --- /dev/null +++ b/packages/plugma/.vscode/tasks.json @@ -0,0 +1,71 @@ +{ + // See https://go.microsoft.com/fwlink/?LinkId=733558 + // for the documentation about the tasks.json format + "version": "2.0.0", + "tasks": [ + { + "label": "Wait for it", + "detail": "Sleeps for 10 seconds", + "type": "shell", + "command": "sleep 10", + "isBackground": false, + "windows": { + "command": "ping 127.0.0.1 -n 10 > nul" + }, + "runOptions": { + "instanceLimit": 1, + }, + "promptOnClose": false, + "hide": false, + "presentation": { + + "reveal": "never", + "panel": "dedicated", + "showReuseMessage": false, + "close": true, + "echo": false + }, + "problemMatcher": [] + }, + { + "label": "RunFigma", + "type": "shell", + "command": "/Applications/Figma.app/Contents/MacOS/Figma", + "detail": "Runs figma with remote debugging and inspect enabled", + "isBackground": true, + "args": [ + "--args", + "--remote-debugging-port=9222", + "--inspect", + "--log-level=2", + "-v=2", + "--no-sandbox", + "--log-file=${workspaceFolder}/figma.log" + ], + "promptOnClose": true, + "runOptions": { + "instanceLimit": 1 + }, + "presentation": { + "echo": true, + "reveal": "always", + "focus": false, + "panel": "new", + "showReuseMessage": true, + "clear": false + }, + "problemMatcher": { + "owner": "electron", + "fileLocation": "autoDetect", + "pattern": { + "regexp": "^(.*):(\\d+):(\\d+):\\s+(warning|error):\\s+(.*)$", + "file": 1, + "line": 2, + "column": 3, + "severity": 4, + "message": 5 + } + } + } + ] +} diff --git a/packages/plugma/Changelog.md b/packages/plugma/Changelog.md new file mode 100644 index 00000000..334858c9 --- /dev/null +++ b/packages/plugma/Changelog.md @@ -0,0 +1,6 @@ +# Changelog + +## 1.3.0 + +- Conversion of the codebase to typescript +- diff --git a/packages/plugma/Config Files.md b/packages/plugma/Config Files.md new file mode 100644 index 00000000..7d394a01 --- /dev/null +++ b/packages/plugma/Config Files.md @@ -0,0 +1,4 @@ +# Configuration Files + +This mainly serves as a way to group configuration files together via a setting in the `.vscode/settings.json` file. +But we can also add documentation here in the future to help onboard new contributors. diff --git a/packages/plugma/apps/DevToolbar.html b/packages/plugma/apps/DevToolbar.html deleted file mode 100644 index ee7ff3fc..00000000 --- a/packages/plugma/apps/DevToolbar.html +++ /dev/null @@ -1,5 +0,0 @@ - - -
\ No newline at end of file diff --git a/packages/plugma/apps/apps-architecture.md b/packages/plugma/apps/apps-architecture.md new file mode 100644 index 00000000..1c53d34b --- /dev/null +++ b/packages/plugma/apps/apps-architecture.md @@ -0,0 +1,184 @@ +# A Tale of 3 Apps + +## Overview + +Plugma orchestrates three apps to enable modern plugin development with browser-based hot reloading while maintaining secure communication with Figma: + +1. **FigmaBridge** - The secure communication bridge (formerly PluginWindow) +2. **DevServer** - The development environment host (formerly ViteApp) +3. **Plugin UI** - The user's plugin interface + +## Fourth Layer: Runtime Core + +~~~mermaid +graph TD + R[plugma-runtime] --> F[Figma Integration] + R --> S[State Management] + R --> I[API Interception] + R --> M[Message Routing] +~~~ + +**Key Responsibilities**: +- Figma API interception/proxying +- Window state management +- Client storage operations +- Message validation/transformation +- Cross-environment consistency + +**Injection Points**: +- Development: Injected into both FigmaBridge and Plugin UI +- Production: Only injected into Plugin UI + +## Apps Roles & Injection Points + +### FigmaBridge +- **Role**: Acts as a secure bridge between Figma and the development environment +- **Injection**: Used only during development/preview (`plugma dev` or `plugma preview`) +- **Location**: Injected into `ui.html` as a wrapper around the user's Plugin UI +- **Key Responsibilities**: + - Manages iframe containing DevServer/Plugin UI + - Handles bi-directional message relay between: + - Figma (parent window) + - Plugin UI (iframe) + - WebSocket server + - Syncs Figma styles and classes + - Provides developer toolbar + - Monitors server status + +### DevServer +- **Role**: Hosts the user's Plugin UI in development +- **Injection**: Only used during development/preview +- **Location**: Served by Vite dev server, loaded in FigmaBridge's iframe +- **Key Responsibilities**: + - Provides hot module replacement (HMR) + - Handles WebSocket communication + - Manages developer tools state + - Provides fallback error displays + +### Plugin UI +- **Role**: The user's actual plugin interface +- **Injection**: Always present (dev, preview, and production) +- **Location**: + - Dev/Preview: Inside DevServer + - Production: Direct in Figma +- **Key Responsibilities**: + - Implements the plugin's interface + - Handles user interactions + - Communicates with Figma's plugin API + +## Command Behavior + +### Development (`plugma dev`) +- Uses FigmaBridge and DevServer +- Full development features (HMR, dev tools) +- Unminified code for better debugging +- WebSocket server optional (--ws flag) + +### Preview (`plugma preview`) +- Uses FigmaBridge and DevServer +- Production-like build (minified/optimized) +- Development features still available +- WebSocket server enabled by default +- Plugin window starts minimized + +### Build (`plugma build`) +- Creates final production bundle +- No FigmaBridge or DevServer included +- Direct compilation of Plugin UI +- Minified and optimized for production +- No development features + +## Communication Flow + +~~~ +Development/Preview: +┌─────────────┐ ┌────────────────┐ ┌─────────────┐ +│ │ │ FigmaBridge │ │ │ +│ Figma │ ◄─────► │ (bridge) │ ◄─────► │ Plugin UI │ +│ │ │ │ │ │ +└─────────────┘ └────────────────┘ └─────────────┘ + ▲ + │ + ▼ + ┌─────────────┐ + │ WebSocket │ + │ Server │ + └─────────────┘ + +Production (after build): +┌─────────────┐ ┌─────────────┐ +│ │ │ │ +│ Figma │ ◄─────► │ Plugin UI │ +│ │ │ │ +└─────────────┘ └─────────────┘ +~~~ + +## Why Three Apps? + +1. **Sandboxing & Security** + - Figma plugins run in a highly restricted sandbox environment + - The Plugin UI can only communicate with Figma through `postMessage` and specific APIs + - Direct communication between a browser-based dev server and Figma is not possible + +2. **Development vs Production** + - DevServer is purely for development - it includes HMR, dev tools, and other development features + - These development features would bloat the production bundle and potentially cause issues in Figma + - By having FigmaBridge separate, we can strip out all development features in production + +3. **Message Routing Complexity** + - When developing in a browser, we need to simulate Figma's message passing + - FigmaBridge acts as a "virtual Figma" in the browser, maintaining the same messaging patterns + - This ensures the Plugin UI works the same in development as it will in production + +If we tried to do everything in DevServer: +1. We'd have to include all the Figma message handling code in the production bundle +2. Development features would be harder to strip out +3. The architecture would be less flexible for future enhancements +4. We'd lose the clear separation between development environment and production code + +The three-app approach gives us a clean separation of concerns: +- FigmaBridge: Handles Figma integration and message routing +- DevServer: Handles development experience and hot reloading +- Plugin UI: Focuses purely on plugin functionality + +This makes the codebase more maintainable and ensures plugins work consistently in both development and production environments. + +## Implementation Details + +### CLI Integration +1. During `plugma dev`: + - FigmaBridge is injected into `ui.html` + - DevServer serves the development version of the Plugin UI + - WebSocket server enables real-time communication + +2. During `plugma build`: + - Only essential production code is included + - Development-specific features are stripped out + +### Message Handling +- Messages flow through multiple contexts: + 1. Figma → FigmaBridge → Plugin UI + 2. Plugin UI → FigmaBridge → Figma + 3. WebSocket ↔ All Apps + +### Style Synchronization +- FigmaBridge monitors Figma styles/classes +- Changes are propagated to DevServer/Plugin UI +- Ensures consistent appearance during development + +## Benefits +1. **Developer Experience** + - Hot reloading + - Browser developer tools + - Real-time style updates + +2. **Production Ready** + - Clean separation of concerns + - Minimal production bundle + - Maintained plugin functionality + +3. **Flexibility** + - Support for various UI frameworks + - Extensible architecture + - Framework-agnostic approach +~~~ diff --git a/packages/plugma/apps/dev-server/App.svelte b/packages/plugma/apps/dev-server/App.svelte new file mode 100644 index 00000000..cf93b637 --- /dev/null +++ b/packages/plugma/apps/dev-server/App.svelte @@ -0,0 +1,288 @@ + + + + + + + + +{#if !(isInsideIframe || isInsideFigma)} + {#if isServerActive} + {#if !isWebsocketsEnabled} + + {:else if !isWebsocketServerActive} + + {:else if $pluginWindowClients.length < 1} + + {/if} + {:else} + + {/if} +{/if} diff --git a/packages/plugma/apps/dev-server/README.md b/packages/plugma/apps/dev-server/README.md new file mode 100644 index 00000000..d4388cf3 --- /dev/null +++ b/packages/plugma/apps/dev-server/README.md @@ -0,0 +1,54 @@ +# DevServer + +The main application component that handles communication between Figma and the plugin's UI, manages WebSocket connections, and handles various UI states. + +## App.svelte + +The main component that orchestrates the plugin's functionality. Here's what it does: + +### Core Features + +1. **Figma Integration** + - Detects if running inside Figma or an iframe + - Handles Figma-specific keyboard shortcuts (⌘P for plugin re-run) + - Manages Figma styles and class synchronization + +2. **WebSocket Communication** + - Sets up bidirectional communication between plugin UI and server + - Maintains connection status and handles reconnection + - Intercepts and relays messages when running outside Figma + +3. **Message Handling** + - Implements custom message event handling + - Manages postMessage interception for non-Figma environments + - Maintains style synchronization between Figma and plugin UI + +4. **UI State Management** + - Shows connection status when not in Figma + - Handles developer tools integration + - Manages toolbar visibility + +### Technical Details + +- Uses Svelte for reactivity and component management +- Implements WebSocket connection monitoring +- Stores Figma styles in localStorage for persistence across reloads +- Provides fallback behavior when running outside Figma + +### States & Conditions + +The component handles several states: + +- WebSocket server connection status +- Figma connection status +- Developer tools status +- Plugin window client connections + +### Environment Detection + +Automatically detects and adapts behavior based on: + +- Whether it's running inside Figma +- Whether it's running in an iframe +- WebSocket availability +- Server connection status diff --git a/packages/plugma/apps/dev-server/index.html b/packages/plugma/apps/dev-server/index.html new file mode 100644 index 00000000..25abe2f2 --- /dev/null +++ b/packages/plugma/apps/dev-server/index.html @@ -0,0 +1,2 @@ +
+ diff --git a/packages/plugma/apps/dev-server/main.ts b/packages/plugma/apps/dev-server/main.ts new file mode 100644 index 00000000..709daf66 --- /dev/null +++ b/packages/plugma/apps/dev-server/main.ts @@ -0,0 +1,12 @@ +import App from './App.svelte'; + +// if (!PLUGMA_APP_NAME) { +// throw new Error('PLUGMA_APP_NAME environment variable is not defined'); +// } + +const app = new App({ + // biome-ignore lint/style/noNonNullAssertion: + target: document.getElementById('dev-server')!, +}); + +export default app; diff --git a/packages/plugma/apps/dev-server/vite.config.ts b/packages/plugma/apps/dev-server/vite.config.ts new file mode 100644 index 00000000..d65159fe --- /dev/null +++ b/packages/plugma/apps/dev-server/vite.config.ts @@ -0,0 +1,5 @@ +import { defineConfig } from 'vite'; + +import { createAppConfig } from '../vite.create-app-config'; + +export default defineConfig(createAppConfig('dev-server')); diff --git a/packages/plugma/apps/figma-bridge/App.svelte b/packages/plugma/apps/figma-bridge/App.svelte new file mode 100644 index 00000000..12c6e60a --- /dev/null +++ b/packages/plugma/apps/figma-bridge/App.svelte @@ -0,0 +1,160 @@ + + +{#if $isDeveloperToolsActive} + +{/if} + + + + +{#if $isLocalhostWithoutPort} + +{:else if !isServerActive} + +{/if} + + diff --git a/packages/plugma/apps/figma-bridge/README.md b/packages/plugma/apps/figma-bridge/README.md new file mode 100644 index 00000000..fe5ef526 --- /dev/null +++ b/packages/plugma/apps/figma-bridge/README.md @@ -0,0 +1,65 @@ +# Plugin Window App + +The Plugin Window app serves as a wrapper/container for Figma plugins, providing essential functionality for plugin-Figma communication, developer tools, and server status monitoring. + +## Core Features + +- **Iframe Management**: Hosts the plugin's UI in an iframe, handling URL monitoring and redirects +- **Bi-directional Communication**: Relays messages between: + - Figma (parent window) + - Plugin UI (iframe) + - WebSocket server +- **Figma Styles Sync**: Monitors and syncs Figma's classes and styles to ensure UI consistency +- **Developer Tools**: Provides a toolbar for development when developer tools are active +- **Server Status**: Monitors and displays the development server status + +## Architecture + +### Communication Flow + +~~~ +┌─────────────┐ ┌────────────────┐ ┌─────────────┐ +│ │ │ Plugin Window │ │ │ +│ Figma │ ◄─────► │ (wrapper) │ ◄─────► │ Plugin UI │ +│ │ │ │ │ │ +└─────────────┘ └────────────────┘ └─────────────┘ + ▲ + │ + ▼ + ┌─────────────┐ + │ WebSocket │ + │ Server │ + └─────────────┘ +~~~ + +### Key Components + +1. **WebSocket Setup** + - Establishes communication channels between all parties + - Handles message routing and relay + +2. **Figma Integration** + - Syncs Figma's classes and styles + - Monitors style changes and propagates updates + - Handles Figma-specific window management + +3. **Development Features** + - Developer toolbar (when dev tools are active) + - Server status monitoring + - Error reporting for localhost issues + +### Event Handling + +The app implements several observers and event handlers: +- Style and class changes in Figma +- Server status monitoring +- Developer tools status +- Window resizing +- Message relay between different contexts + +## Technical Details + +- Uses Svelte for the UI framework +- Implements real-time bi-directional communication +- Monitors both HTML classes and stylesheet changes +- Provides fallback error displays for server issues diff --git a/packages/plugma/apps/figma-bridge/app.css b/packages/plugma/apps/figma-bridge/app.css new file mode 100644 index 00000000..e5484a45 --- /dev/null +++ b/packages/plugma/apps/figma-bridge/app.css @@ -0,0 +1,28 @@ +.figma-dark { + --elevation-400-menu-panel: + 0px 10px 16px rgba(0, 0, 0, 0.35), 0px 2px 5px rgba(0, 0, 0, 0.35), inset 0px 0.5px 0px rgba(255, 255, 255, 0.08), inset 0px 0px 0.5px rgba( + 255, + 255, + 255, + 0.35 + ); +} + +.figma-light { + --elevation-400-menu-panel: 0px 0px 0.5px rgba(0, 0, 0, 0.12), 0px 10px 16px rgba(0, 0, 0, 0.12), 0px 2px 5px rgba(0, 0, 0, 0.15); +} + +:where(html) { + --radius-medium: 0.3125rem; + --radius-large: 0.8125rem; + --ramp-grey-900: #1e1e1e; + --ramp-grey-700: #383838; + --color-bg-menu: var(--ramp-grey-900); + --color-border-menu: var(--ramp-grey-700); +} + +#figma-bridge { + display: flex; + flex-direction: column; + flex-grow: 1; +} diff --git a/packages/plugma/apps/figma-bridge/components/Button.svelte b/packages/plugma/apps/figma-bridge/components/Button.svelte new file mode 100644 index 00000000..e4c36a48 --- /dev/null +++ b/packages/plugma/apps/figma-bridge/components/Button.svelte @@ -0,0 +1,109 @@ + + + + +{#if href} + + {#if loading} + + {:else} + + {/if} + +{:else} + +{/if} + + diff --git a/packages/plugma/apps/figma-bridge/components/Counter.svelte b/packages/plugma/apps/figma-bridge/components/Counter.svelte new file mode 100644 index 00000000..979b4dfc --- /dev/null +++ b/packages/plugma/apps/figma-bridge/components/Counter.svelte @@ -0,0 +1,10 @@ + + + diff --git a/packages/plugma/apps/figma-bridge/components/Dropdown.svelte b/packages/plugma/apps/figma-bridge/components/Dropdown.svelte new file mode 100644 index 00000000..2af082b7 --- /dev/null +++ b/packages/plugma/apps/figma-bridge/components/Dropdown.svelte @@ -0,0 +1,113 @@ + + + + + diff --git a/packages/plugma/apps/figma-bridge/components/DropdownDivider.svelte b/packages/plugma/apps/figma-bridge/components/DropdownDivider.svelte new file mode 100644 index 00000000..428eae7d --- /dev/null +++ b/packages/plugma/apps/figma-bridge/components/DropdownDivider.svelte @@ -0,0 +1,10 @@ + + + diff --git a/packages/plugma/apps/figma-bridge/components/DropdownItem.svelte b/packages/plugma/apps/figma-bridge/components/DropdownItem.svelte new file mode 100644 index 00000000..de5c89b2 --- /dev/null +++ b/packages/plugma/apps/figma-bridge/components/DropdownItem.svelte @@ -0,0 +1,24 @@ + + + + + diff --git a/packages/plugma/apps/figma-bridge/components/Icon.svelte b/packages/plugma/apps/figma-bridge/components/Icon.svelte new file mode 100644 index 00000000..d7a63b91 --- /dev/null +++ b/packages/plugma/apps/figma-bridge/components/Icon.svelte @@ -0,0 +1,91 @@ + + +
+ {#if svg === "horizontal-ellipsis"} + + + + + + {/if} + + {#if svg === "socket-connected"} + + + + + {/if} + + {#if svg === "socket-disconnected"} + + + + {/if} +
+ + diff --git a/packages/plugma/apps/figma-bridge/components/Select.svelte b/packages/plugma/apps/figma-bridge/components/Select.svelte new file mode 100644 index 00000000..e14dde13 --- /dev/null +++ b/packages/plugma/apps/figma-bridge/components/Select.svelte @@ -0,0 +1,178 @@ + + +
+ +
+ +
+
+ + diff --git a/packages/plugma/apps/figma-bridge/components/Toolbar.svelte b/packages/plugma/apps/figma-bridge/components/Toolbar.svelte new file mode 100644 index 00000000..e5ff391b --- /dev/null +++ b/packages/plugma/apps/figma-bridge/components/Toolbar.svelte @@ -0,0 +1,198 @@ + + + +
+ + + + +
+ +
+ + +
+ +
+ {nodeCount} nodes selected +
+
+ + diff --git a/packages/plugma/test/sandbox/src/assets/svelte.svg b/packages/plugma/test/sandbox/src/assets/svelte.svg new file mode 100644 index 00000000..c5e08481 --- /dev/null +++ b/packages/plugma/test/sandbox/src/assets/svelte.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/plugma/test/sandbox/src/code/expect.ts b/packages/plugma/test/sandbox/src/code/expect.ts new file mode 100644 index 00000000..c55e68f7 --- /dev/null +++ b/packages/plugma/test/sandbox/src/code/expect.ts @@ -0,0 +1,77 @@ +import type { expect as ChaiExpect } from "chai"; + +/** + * Type representing a chain entry with method name and optional arguments + */ +export type ChainExpr = [methodName: string, args?: unknown[]]; + +/** + * Type for the proxy object that records the chain + */ +// export interface ChainRecorder { +// chain: ChainEntry[]; +// toString(): ChainEntry[]; +// to: ChainRecorder; +// equal(expected: unknown): ChainRecorder; +// equals(expected: unknown): ChainRecorder; +// [key: string]: unknown; +// } + +/** + * Creates a proxy-based chain recorder that mimics Chai's expect API structure. + * Each method call in the chain is recorded as a tuple of [methodName, ...args]. + * + * @param value - The initial value to start the expectation chain + * @returns A proxy object that records the chain of method calls + * + * @example + * ```ts + * const A = "aaa"; + * const B = "bbb"; + * expect(A).to.equals(B) + * // Returns: [['expect', ['aaa']], ['to'], ['equals', ['bbb']]] + * ``` + */ +export const expect = ((value: unknown): ReturnType => { + const chain: ChainExpr[] = [['expect', [value]]]; + + const handler: ProxyHandler = { + get: (_target: object, prop: string | symbol) => { + if (typeof prop !== 'string') return undefined; + + // Return a proxy for any property access + return new Proxy(() => {}, { + // Handle method calls + apply: (_target: object, _thisArg: unknown, args: unknown[]) => { + chain.push([prop, args]); + return proxy; + }, + // Handle property access + get: (_target: object, nextProp: string | symbol) => { + chain.push([prop]); + // Return a new proxy for chaining + return new Proxy(() => {}, handler); + } + }); + } + }; + + const proxy = new Proxy({}, handler) as ReturnType; + + // Override toString to return the chain when the proxy is coerced to a string + Object.defineProperty(proxy, 'toString', { + value: () => chain, + writable: false, + enumerable: false, + configurable: true + }); + + // Also expose the chain directly + Object.defineProperty(proxy, 'chain', { + get: () => chain, + enumerable: false, + configurable: true + }); + + return proxy; +}) as unknown as typeof ChaiExpect; diff --git a/packages/plugma/test/sandbox/src/components/Button.svelte b/packages/plugma/test/sandbox/src/components/Button.svelte new file mode 100644 index 00000000..9fd140b3 --- /dev/null +++ b/packages/plugma/test/sandbox/src/components/Button.svelte @@ -0,0 +1,23 @@ + + +{#if href} + {@render children()} +{:else} + +{/if} + + diff --git a/packages/plugma/test/sandbox/src/components/Icon.svelte b/packages/plugma/test/sandbox/src/components/Icon.svelte new file mode 100644 index 00000000..ecd928de --- /dev/null +++ b/packages/plugma/test/sandbox/src/components/Icon.svelte @@ -0,0 +1,47 @@ + + +{#if svg === 'plugma'} + + + + + + + + + + +{/if} + +{#if svg === 'plus'} + + + + + + + + +{/if} diff --git a/packages/plugma/test/sandbox/src/components/Input.svelte b/packages/plugma/test/sandbox/src/components/Input.svelte new file mode 100644 index 00000000..03d7bb7e --- /dev/null +++ b/packages/plugma/test/sandbox/src/components/Input.svelte @@ -0,0 +1,96 @@ + + +
+
+ +
+
+ + diff --git a/packages/plugma/test/sandbox/src/main.ts b/packages/plugma/test/sandbox/src/main.ts new file mode 100644 index 00000000..19029646 --- /dev/null +++ b/packages/plugma/test/sandbox/src/main.ts @@ -0,0 +1,53 @@ +// import type { TestMessage } from "plugma/testing"; +// import { handleTestMessage } from "plugma/testing"; + +console.log("[MAIN] Registering tests"); +import "../tests"; + +import { handleTestMessage } from "plugma/testing/figma"; + +export default function () { + figma.showUI(__html__, { width: 300, height: 260, themeColors: true }); + + figma.ui.onmessage = async (message) => { + if (message?.event !== "ping" && message?.event !== "pong") { + console.log("[FIGMA MAIN] Received message:", message); + } + + if (message.type === "CREATE_RECTANGLES") { + let i = 0; + + const rectangles = []; + while (i < message.count) { + const rect = figma.createRectangle(); + rect.x = i * 150; + rect.y = 0; + rect.resize(100, 100); + rect.fills = [ + { + type: "SOLID", + color: { r: Math.random(), g: Math.random(), b: Math.random() }, + }, + ]; // Random color + rectangles.push(rect); + + i++; + } + + figma.viewport.scrollAndZoomIntoView(rectangles); + } + + handleTestMessage(message); + }; + + function postNodeCount() { + const nodeCount = figma.currentPage.selection.length; + + figma.ui.postMessage({ + type: "POST_NODE_COUNT", + count: nodeCount, + }); + } + + figma.on("selectionchange", postNodeCount); +} diff --git a/packages/plugma/test/sandbox/src/styles.css b/packages/plugma/test/sandbox/src/styles.css new file mode 100644 index 00000000..ef72d487 --- /dev/null +++ b/packages/plugma/test/sandbox/src/styles.css @@ -0,0 +1,51 @@ +:root { + font-family: Inter, system-ui, Helvetica, Arial, sans-serif; + font-display: optional; + font-size: 16px; +} + +html { + background-color: var(--figma-color-bg); + color: var(--figma-color-text); +} + +body { + font-size: 11px; +} + +* { + box-sizing: border-box; +} + +input { + border: none; + background-color: transparent; + font-size: inherit; +} + +button { + border: none; + background: transparent; + appearance: none; + font-size: inherit; + color: inherit; +} + +i18n-text { + display: contents; +} + +:root { + --radius-full: 9999px; + --radius-large: 0.8125rem; + --radius-medium: 0.3125rem; + --radius-none: 0; + --radius-small: 0.125rem; + --spacer-0: 0; + --spacer-1: 0.25rem; + --spacer-2: 0.5rem; + --spacer-3: 1rem; + --spacer-4: 1.5rem; + --spacer-5: 2rem; + --spacer-6: 2.5rem; +} diff --git a/packages/plugma/test/sandbox/src/ui.ts b/packages/plugma/test/sandbox/src/ui.ts new file mode 100644 index 00000000..5043bce4 --- /dev/null +++ b/packages/plugma/test/sandbox/src/ui.ts @@ -0,0 +1,9 @@ +import { mount } from 'svelte' +import './styles.css' +import App from './App.svelte' + +const app = mount(App, { + target: document.getElementById('app')!, +}) + +export default app diff --git a/packages/plugma/test/sandbox/svelte.config.js b/packages/plugma/test/sandbox/svelte.config.js new file mode 100644 index 00000000..9c111bf1 --- /dev/null +++ b/packages/plugma/test/sandbox/svelte.config.js @@ -0,0 +1,7 @@ +import { vitePreprocess } from '@sveltejs/vite-plugin-svelte' + +export default { + // Consult https://svelte.dev/docs#compile-time-svelte-preprocess + // for more information about preprocessors + preprocess: vitePreprocess(), +} diff --git a/packages/plugma/test/sandbox/tests/index.ts b/packages/plugma/test/sandbox/tests/index.ts new file mode 100644 index 00000000..09e02f62 --- /dev/null +++ b/packages/plugma/test/sandbox/tests/index.ts @@ -0,0 +1,12 @@ +import { registry } from 'plugma/testing/figma'; + +/** + * Register all test files by importing them + * This ensures tests are available in Figma when the plugin loads + */ +//@index('./*.test.ts', f => `import "${f.path}";`) +import './rectangle-color.test'; +//@endindex + +// Log registered tests for debugging +console.log('[TEST] Registered tests:', registry.getTestNames()); diff --git a/packages/plugma/test/sandbox/tests/rectangle-color.test.ts b/packages/plugma/test/sandbox/tests/rectangle-color.test.ts new file mode 100644 index 00000000..fc4b0000 --- /dev/null +++ b/packages/plugma/test/sandbox/tests/rectangle-color.test.ts @@ -0,0 +1,43 @@ +import { expect, test } from 'plugma/testing'; + +const TEST_COLOR = { + r: Math.random(), + g: Math.random(), + b: Math.random(), +}; + +test('creates a rectangle with specific color', async () => { + const rect = figma.createRectangle(); + + rect.x = 0; + rect.y = 0; + rect.resize(100, 100); + rect.fills = [{ type: 'SOLID', color: TEST_COLOR }]; + + expect(rect.type).to.equal('RECTANGLE'); + expect(rect.width).to.equal(100); + expect(rect.height).to.equal(100); + expect(rect.fills[0].type).to.equal('SOLID'); + + const color = (rect.fills[0] as SolidPaint).color; + expect(color.r).to.be.approximately(TEST_COLOR.r, 0.0001); + expect(color.g).to.be.approximately(TEST_COLOR.g, 0.0001); + expect(color.b).to.be.approximately(TEST_COLOR.b, 0.0001); +}); + +test("verifies the last created rectangle's color", async () => { + const lastNode = + figma.currentPage.children[figma.currentPage.children.length - 1]; + + expect(lastNode.type).to.equal('RECTANGLE'); + + const firstFill = ((lastNode as RectangleNode).fills as Paint[]).at(0); + + const color = (firstFill as SolidPaint).color; + + expect(firstFill?.type).to.equal('SOLID'); + + expect(color.r).to.be.approximately(TEST_COLOR.r, 0.0001); + expect(color.g).to.be.approximately(TEST_COLOR.g, 0.0001); + expect(color.b).to.be.approximately(TEST_COLOR.b, 0.0001); +}); diff --git a/packages/plugma/test/sandbox/tsconfig.json b/packages/plugma/test/sandbox/tsconfig.json new file mode 100644 index 00000000..58b26a8b --- /dev/null +++ b/packages/plugma/test/sandbox/tsconfig.json @@ -0,0 +1,25 @@ +{ + "extends": "@tsconfig/svelte/tsconfig.json", + "compilerOptions": { + "target": "ESNext", + "useDefineForClassFields": true, + "module": "ESNext", + "resolveJsonModule": true, + /** + * Typecheck JS in `.svelte` and `.js` files by default. + * Disable checkJs if you'd like to use dynamic types in JS. + * Note that setting allowJs false does not prevent the use + * of JS in `.svelte` files. + */ + "allowJs": true, + "checkJs": true, + "isolatedModules": true, + "moduleDetection": "force", + "typeRoots": ["node_modules/@figma", "node_modules/@types"], + "paths": { + "#testing": ["./src/testing/index.ts"], + "#testing/*": ["./src/testing/*"] + } + }, + "include": ["src/**/*.ts", "src/**/*.js", "src/**/*.svelte"] +} diff --git a/packages/plugma/test/sandbox/ui.html b/packages/plugma/test/sandbox/ui.html new file mode 100644 index 00000000..69ee24f8 --- /dev/null +++ b/packages/plugma/test/sandbox/ui.html @@ -0,0 +1,38 @@ + + + + + + + + + + + + + + + + + <!--[ PLUGIN_NAME ]--> + + + +
+ + + diff --git a/packages/plugma/test/sandbox/vite.config.js b/packages/plugma/test/sandbox/vite.config.js new file mode 100644 index 00000000..2f6fdafa --- /dev/null +++ b/packages/plugma/test/sandbox/vite.config.js @@ -0,0 +1,9 @@ +import { defineConfig } from "vite"; +import { svelte } from "@sveltejs/vite-plugin-svelte"; + +// https://vite.dev/config/ +export default defineConfig(() => { + return { + plugins: [svelte()] + } +}); diff --git a/packages/plugma/test/sandbox/vitest.config.ts b/packages/plugma/test/sandbox/vitest.config.ts new file mode 100644 index 00000000..967b07ca --- /dev/null +++ b/packages/plugma/test/sandbox/vitest.config.ts @@ -0,0 +1,9 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + environment: "node", + // setupFiles: ['src/test/setup.ts'], + include: ["src/**/*.test.ts", "tests/*.test.ts"], + }, +}); diff --git a/packages/plugma/test/utils/environment.ts b/packages/plugma/test/utils/environment.ts new file mode 100644 index 00000000..ba0d09af --- /dev/null +++ b/packages/plugma/test/utils/environment.ts @@ -0,0 +1,155 @@ +import { existsSync } from 'node:fs'; +import { mkdir, readFile, rm, writeFile } from 'node:fs/promises'; +import { join } from 'node:path'; +import { vi } from 'vitest'; +import { mockFs } from '../mocks/fs/mock-fs.js'; +import { mockCleanup } from '../mocks/mock-cleanup.js'; +import { mockWebSocket } from '../mocks/server/mock-websocket.js'; +import { mockVite } from '../mocks/vite/mock-vite.js'; + +/** + * Options for setting up a test environment + */ +export interface TestEnvironmentOptions { + /** Files to create in the test environment */ + files?: Record; + /** Port to use for servers (default: random) */ + port?: number; + /** Enable debug logging */ + debug?: boolean; + /** Custom test directory (default: test/sandbox) */ + testDir?: string; +} + +/** + * Sets up a test environment with the specified files and configuration + * Creates a temporary directory with the plugin files and mocks necessary services + * + * @param options - Test environment configuration + * @returns Cleanup function to remove the test environment + */ +export async function setupTestEnvironment( + options: TestEnvironmentOptions, +): Promise<() => Promise> { + const { + files, + port = Math.floor(Math.random() * 10000) + 3000, + debug = false, + testDir = 'test/sandbox', + } = options; + + // Clear mock fs + mockFs.clear(); + + // Add template file + await mockFs.writeFile( + join(testDir, 'dist', 'apps', 'figma-bridge.html'), + ` + + + + Figma Bridge + + +
+ + + + `, + ); + + // Add test files to mock fs + for (const [path, content] of Object.entries(files || {})) { + const fullPath = join(testDir, path); + await mockFs.writeFile(fullPath, content); + } + + // Mock fs functions + const originalMkdir = mkdir; + const originalWriteFile = writeFile; + const originalRm = rm; + const originalExistsSync = existsSync; + const originalReadFile = readFile; + + // @ts-expect-error - Mocking global functions + global.mkdir = mockFs.mkdir.bind(mockFs); + // @ts-expect-error - Mocking global functions + global.writeFile = mockFs.writeFile.bind(mockFs); + // @ts-expect-error - Mocking global functions + global.rm = mockFs.rm.bind(mockFs); + // @ts-expect-error - Mocking global functions + global.existsSync = (path: string) => mockFs.exists(path); + // @ts-expect-error - Mocking global functions + global.readFile = mockFs.readFile.bind(mockFs); + + // Mock Vite + vi.mock('vite', () => mockVite); + + // Mock other services + mockWebSocket.clearMessageHandlers(); + mockCleanup(); + + // Mock process.cwd() to return test directory + const originalCwd = process.cwd; + process.cwd = vi.fn(() => testDir); + + return async () => { + // Restore original functions + // @ts-expect-error - Restoring global functions + global.mkdir = originalMkdir; + // @ts-expect-error - Restoring global functions + global.writeFile = originalWriteFile; + // @ts-expect-error - Restoring global functions + global.rm = originalRm; + // @ts-expect-error - Restoring global functions + global.existsSync = originalExistsSync; + // @ts-expect-error - Restoring global functions + global.readFile = originalReadFile; + + // Restore process.cwd + process.cwd = originalCwd; + + // Clear mock fs + mockFs.clear(); + }; +} + +/** + * Triggers a file change in the test environment + * + * @param filePath - Path to the file to change + * @param content - New content for the file + */ +export async function triggerFileChange( + filePath: string, + content: string, +): Promise { + const fullPath = join(process.cwd(), filePath); + const dir = fullPath.split('/').slice(0, -1).join('/'); + await mkdir(dir, { recursive: true }); + await writeFile(fullPath, content); +} + +/** + * Waits for a condition to be true + * + * @param condition - Condition to wait for + * @param timeout - Maximum time to wait in milliseconds + * @param interval - Time between checks in milliseconds + */ +export async function waitForCondition( + condition: () => boolean | Promise, + timeout = 5000, + interval = 100, +): Promise { + const start = Date.now(); + + while (Date.now() - start < timeout) { + if (await condition()) { + return; + } + await new Promise((resolve) => setTimeout(resolve, interval)); + } + + throw new Error(`Condition not met within ${timeout}ms`); +} diff --git a/packages/plugma/test/utils/process.ts b/packages/plugma/test/utils/process.ts new file mode 100644 index 00000000..382797ed --- /dev/null +++ b/packages/plugma/test/utils/process.ts @@ -0,0 +1,256 @@ +import { dev } from "#commands/dev.js"; +import { preview } from "#commands/preview.js"; +import type { + DevCommandOptions, + PreviewCommandOptions, +} from "#commands/types.js"; +import { vi } from "vitest"; + +/** + * Interface for a command process handle + */ +export interface CommandProcess { + /** Terminates the command process */ + terminate: () => Promise; + /** Command options used */ + options: DevCommandOptions | PreviewCommandOptions; +} + +/** + * Waits for a specified duration + * + * @param ms - Duration to wait in milliseconds + * @returns Promise that resolves after the duration + * + * @example + * ```ts + * // Wait for 1 second + * await waitFor(1000); + * ``` + */ +export async function waitFor(ms: number): Promise { + await new Promise((resolve) => setTimeout(resolve, ms)); +} + +/** + * Result of executing a command while waiting for specific output + */ +export interface ExecuteUntilOutputResult { + /** Whether the expected output was found within the timeout */ + matched: boolean; + /** The captured console output */ + output: string; + /** Time waited in milliseconds */ + elapsed: number; +} + +/** + * Strips ANSI color codes from a string + */ +function stripAnsi(str: string): string { + return str.replace( + /[\u001b\u009b][[()#;?]*(?:[0-9]{1,4}(?:;[0-9]{0,4})*)?[0-9A-ORZcf-nqry=><]/g, + "", + ); +} + +/** + * Strips log level prefixes from a string + */ +function stripLogPrefixes(str: string): string { + return str + .replace(/^\[.*?\]\s+/gm, "") // Remove [prefix] style tags + .replace(/^(?:INFO|DEBUG|ERROR|WARNING|SUCCESS):\s*/gim, "") // Remove log level prefixes + .replace(/^(?:INFO|DEBUG|ERROR|WARNING|SUCCESS):\s*/gim, "") // Run again to catch any remaining prefixes + .trim(); +} + +/** + * Executes a command until specific console output is detected + * + * @param pattern - Regular expression to match against console output + * @param fn - Function that returns a CommandProcess + * @param timeout - Maximum time to wait in milliseconds (default: 5000) + * @returns Promise resolving to an object containing the match result and captured output + * + * @example + * ```ts + * const result = await executeUntilOutput( + * /Server started/, + * () => startDevCommand({ debug: true }) + * ); + * + * if (result.matched) { + * expect(result.output).toContain('Server started successfully'); + * } else { + * console.log('Server did not start within timeout. Output:', result.output); + * } + * ``` + */ +export async function executeUntilOutput( + pattern: RegExp, + fn: () => CommandProcess, + timeout = 5000, +): Promise { + const consoleSpy = vi.spyOn(console, "log"); + const consoleInfoSpy = vi.spyOn(console, "info"); + const consoleSuccessSpy = vi.spyOn(console, "success"); + const process = fn(); + const startTime = Date.now(); + let capturedOutput = ""; + let matched = false; + + try { + const result = await new Promise((resolve) => { + // Set up timeout + const timeoutId = setTimeout(() => { + console.error( + "DEBUG - Timeout reached. Final output:", + capturedOutput.trim(), + ); + resolve({ + matched, + output: capturedOutput.trim(), + elapsed: Date.now() - startTime, + }); + }, timeout); + + // Set up console spies + const checkOutput = (...args: unknown[]) => { + const output = args.join(" "); + capturedOutput += `${output}\n`; + + // Strip ANSI color codes and log level prefixes before testing the pattern + const strippedOutput = stripLogPrefixes(stripAnsi(capturedOutput)); + + console.error("DEBUG - Raw output:", output); + console.error("DEBUG - Stripped output:", strippedOutput); + console.error("DEBUG - Pattern:", pattern); + console.error( + "DEBUG - Pattern test result:", + pattern.test(strippedOutput), + ); + + if (!matched && pattern.test(strippedOutput)) { + matched = true; + clearTimeout(timeoutId); + resolve({ + matched: true, + output: capturedOutput.trim(), + elapsed: Date.now() - startTime, + }); + } + }; + + consoleSpy.mockImplementation(checkOutput); + consoleInfoSpy.mockImplementation(checkOutput); + consoleSuccessSpy.mockImplementation(checkOutput); + }); + + return result; + } finally { + consoleSpy.mockRestore(); + consoleInfoSpy.mockRestore(); + consoleSuccessSpy.mockRestore(); + await process.terminate(); + } +} + +/** + * Executes a command for a specified duration and then terminates it + * + * @param duration - Duration in milliseconds to run the command + * @param fn - Function that returns a CommandProcess + * @returns Promise that resolves when the duration has elapsed and command is terminated + * + * @example + * ```ts + * await executeForDuration(3000, () => startDevCommand({ + * debug: true, + * command: 'dev', + * cwd: sandboxDir, + * })); + * ``` + */ +export async function executeForDuration( + duration: number, + fn: () => CommandProcess, +): Promise { + const process = fn(); + try { + await waitFor(duration); + } finally { + await process.terminate(); + } +} + +/** + * Starts the dev command in the background + * + * @param options - Dev command options + * @returns Command process handle + */ +export function startDevCommand(options: DevCommandOptions): CommandProcess { + let cleanup: (() => Promise) | undefined; + let isTerminated = false; + + // Start the command + const running = dev({ + ...options, + cwd: options.cwd || process.cwd(), + onCleanup: async (cleanupFn) => { + cleanup = cleanupFn; + }, + }).catch((error) => { + if (!isTerminated) { + console.error("Dev command failed:", error); + } + }); + + return { + options, + terminate: async () => { + isTerminated = true; + if (cleanup) { + await cleanup(); + } + await running; + }, + }; +} + +/** + * Starts the preview command in the background + * + * @param options - Preview command options + * @returns Command process handle + */ +export function startPreviewCommand( + options: PreviewCommandOptions, +): CommandProcess { + let cleanup: (() => Promise) | undefined; + let isTerminated = false; + + // Start the command + const running = preview({ + ...options, + onCleanup: async (cleanupFn) => { + cleanup = cleanupFn; + }, + }).catch((error) => { + if (!isTerminated) { + console.error("Preview command failed:", error); + } + }); + + return { + options, + terminate: async () => { + isTerminated = true; + if (cleanup) { + await cleanup(); + } + await running; + }, + }; +} diff --git a/packages/plugma/tmp/index.html b/packages/plugma/tmp/index.html deleted file mode 100644 index e8533d50..00000000 --- a/packages/plugma/tmp/index.html +++ /dev/null @@ -1,34 +0,0 @@ - - - - - - - - - - - - - - - - - - - {pluginName} - - - - -
- - - - - diff --git a/packages/plugma/tsconfig.build.json b/packages/plugma/tsconfig.build.json new file mode 100644 index 00000000..ac6cabe7 --- /dev/null +++ b/packages/plugma/tsconfig.build.json @@ -0,0 +1,35 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "rootDir": "./src", + "baseUrl": "src", + "declaration": true, + "paths": { + "#core": ["core/index.ts"], + "#core/*": ["core/*"], + "#commands": ["commands/index.ts"], + "#commands/*": ["commands/*"], + "#tasks": ["tasks/index.ts"], + "#tasks/*": ["tasks/*"], + "#utils": ["utils/index.ts"], + "#utils/*": ["utils/*"], + "#vite-plugins": ["vite-plugins/index.ts"], + "#vite-plugins/*": ["vite-plugins/*"], + "#testing": ["testing/index.ts"], + "#testing/*": ["testing/*"], + "#packageJson": ["../package.json"] + }, + "sourceMap": true, + "sourceRoot": "src" + }, + "include": ["src/**/*.ts", "src/**/*.tsx"], + "exclude": [ + "archive", + "dist", + "node_modules", + "test", + "**/*.test.ts", + "**/*.test.tsx", + "**/__tests__/**" + ] +} diff --git a/packages/plugma/tsconfig.json b/packages/plugma/tsconfig.json index 2e1aa3dd..84930fa1 100644 --- a/packages/plugma/tsconfig.json +++ b/packages/plugma/tsconfig.json @@ -2,9 +2,43 @@ "compilerOptions": { "esModuleInterop": true, "isolatedModules": true, - "lib": ["DOM", "ES2020"], - "module": "ES2020", - "moduleResolution": "Node", - "strict": true - } + "skipLibCheck": true, + "lib": ["DOM", "esnext"], + "module": "esnext", + "target": "esnext", + "moduleResolution": "node", + "outDir": "./dist", + "rootDir": "./", + "strict": true, + "resolveJsonModule": true, + "types": ["@figma/plugin-typings"], + "baseUrl": ".", + "paths": { + "#core": ["src/core/index.ts"], + "#core/*": ["src/core/*"], + "#commands": ["src/commands/index.ts"], + "#commands/*": ["src/commands/*"], + "#tasks": ["src/tasks/index.ts"], + "#tasks/*": ["src/tasks/*"], + "#utils": ["src/utils/index.ts"], + "#utils/*": ["src/utils/*"], + "#vite-plugins": ["src/vite-plugins/index.ts"], + "#vite-plugins/*": ["src/vite-plugins/*"], + "#test": ["test/index.ts"], + "#test/*": ["test/*"], + "#testing": ["src/testing/index.ts"], + "#testing/*": ["src/testing/*"], + "#packageJson": ["package.json"] + } + }, + "include": [ + "src/**/*.ts", + "src/**/*.svelte", + "src/**/*.tsx", + "src/**/*.test.ts", + "test/**/*.ts", + "./package.json", + "src/testing/figma/test.figma.ts" + ], + "exclude": ["archive", "dist", "node_modules"] } diff --git a/packages/plugma/vitest.config.ts b/packages/plugma/vitest.config.ts new file mode 100644 index 00000000..406db0da --- /dev/null +++ b/packages/plugma/vitest.config.ts @@ -0,0 +1,29 @@ +import { resolve } from 'node:path'; +import tsconfigPaths from 'vite-tsconfig-paths'; +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + globals: true, + environment: 'node', + include: ['src/**/*.test.ts'], + testTimeout: 5000, + coverage: { + provider: 'v8', + reporter: ['text', 'html'], + include: ['src/**/*.ts'], + exclude: ['src/**/*.test.ts', 'src/**/__tests__/**'], + }, + }, + plugins: [tsconfigPaths()], + resolve: { + alias: { + '#core': resolve(__dirname, 'src/core'), + '#commands': resolve(__dirname, 'src/commands'), + '#utils': resolve(__dirname, 'src/utils'), + '#vite-plugins': resolve(__dirname, 'src/vite-plugins'), + '#tasks': resolve(__dirname, 'src/tasks'), + '#test': resolve(__dirname, 'test'), + }, + }, +});