From a98a339ee616f53e54d3f6b9a720d59f717be626 Mon Sep 17 00:00:00 2001 From: Diego Imbert Date: Mon, 11 May 2026 12:26:21 +0200 Subject: [PATCH] tests --- cli/package-lock.json | 36 ++--- frontend/src/lib/test-setup.ts | 10 ++ frontend/src/lib/userDraft.test.ts | 215 +++++++++++++++++++++++++++++ 3 files changed, 243 insertions(+), 18 deletions(-) create mode 100644 frontend/src/lib/userDraft.test.ts diff --git a/cli/package-lock.json b/cli/package-lock.json index 2033d89833..f14fd32f8d 100644 --- a/cli/package-lock.json +++ b/cli/package-lock.json @@ -19,18 +19,18 @@ "open": "^10.0.0", "svelte": "^5.45.2", "tar-stream": "^3.1.7", - "windmill-parser-wasm-csharp": "*", - "windmill-parser-wasm-go": "*", - "windmill-parser-wasm-java": "*", - "windmill-parser-wasm-nu": "*", - "windmill-parser-wasm-php": "*", - "windmill-parser-wasm-py": "^1.693.1", - "windmill-parser-wasm-py-imports": "^1.693.1", - "windmill-parser-wasm-regex": "*", - "windmill-parser-wasm-ruby": "*", - "windmill-parser-wasm-rust": "*", - "windmill-parser-wasm-ts": "^1.693.1", - "windmill-parser-wasm-yaml": "*", + "windmill-parser-wasm-csharp": "1.510.1", + "windmill-parser-wasm-go": "1.510.1", + "windmill-parser-wasm-java": "1.510.1", + "windmill-parser-wasm-nu": "1.510.1", + "windmill-parser-wasm-php": "1.647.1", + "windmill-parser-wasm-py": "1.693.1", + "windmill-parser-wasm-py-imports": "1.693.1", + "windmill-parser-wasm-regex": "1.692.0", + "windmill-parser-wasm-ruby": "1.526.1", + "windmill-parser-wasm-rust": "1.647.1", + "windmill-parser-wasm-ts": "1.695.0", + "windmill-parser-wasm-yaml": "1.593.0", "windmill-yaml-validator": "1.1.1", "ws": "8.18.0", "yaml": "^2.7.0" @@ -1438,9 +1438,9 @@ "integrity": "sha512-FC0KbREe2G/sa/9kYIR930wmWw+VL6PvEIqg12J3dsJes3A+0x5JIUPT/jeD+c24DrG0ko/Ub7yDnYs56Bem7g==" }, "node_modules/windmill-parser-wasm-regex": { - "version": "1.639.0", - "resolved": "https://registry.npmjs.org/windmill-parser-wasm-regex/-/windmill-parser-wasm-regex-1.639.0.tgz", - "integrity": "sha512-qvYM4sYxB6M0xrqwBljS2fWqOMk6rp++60TRltJnzZDzVaWQrKjTGwNMmfepGAIWy1OGVKp0SCVERhe2P+O6tQ==" + "version": "1.692.0", + "resolved": "https://registry.npmjs.org/windmill-parser-wasm-regex/-/windmill-parser-wasm-regex-1.692.0.tgz", + "integrity": "sha512-BHGTxrinZJ9ef6hFxbKiBqBEr5uqgG/QySOgMA5r1LswO9n/8fyGswr8JcPT2kGaoeoweV6/RQ+RHVaOhosnKw==" }, "node_modules/windmill-parser-wasm-ruby": { "version": "1.526.1", @@ -1453,9 +1453,9 @@ "integrity": "sha512-9yGLYZX2Hn9TdTqGY/5Fp50ftzgUsrfBkSK9vJkKJd5Amyg+yXLBGzd8pz6Org+4uxMenz/16wpsgijvo6uhhQ==" }, "node_modules/windmill-parser-wasm-ts": { - "version": "1.693.1", - "resolved": "https://registry.npmjs.org/windmill-parser-wasm-ts/-/windmill-parser-wasm-ts-1.693.1.tgz", - "integrity": "sha512-xrPgVWwQbOWJKiz68wBDNMrKVOtCY/utyhzSx0kFYIWm/QH/6L8k6LLjo4DOn+PnlBNRXc7qum0sDyB8IuuJYQ==" + "version": "1.695.0", + "resolved": "https://registry.npmjs.org/windmill-parser-wasm-ts/-/windmill-parser-wasm-ts-1.695.0.tgz", + "integrity": "sha512-9EFxeRZWmfb7EyhSlcG7dzTTKETPRYAvpRlxxLkhhtI5I219wFgI7kwrMpz4stXHJj/aqBknVv66NQHRstSJmw==" }, "node_modules/windmill-parser-wasm-yaml": { "version": "1.593.0", diff --git a/frontend/src/lib/test-setup.ts b/frontend/src/lib/test-setup.ts index 8966a34f96..6b82fcefb6 100644 --- a/frontend/src/lib/test-setup.ts +++ b/frontend/src/lib/test-setup.ts @@ -61,6 +61,16 @@ Object.defineProperty(globalThis, 'sessionStorage', { writable: true }) +// Some modules (e.g. svelte5Utils.useLocalStorageValue) gate browser-only +// behavior on `typeof window`. Provide a minimal window so they don't +// short-circuit during tests. +if (typeof (globalThis as any).window === 'undefined') { + Object.defineProperty(globalThis, 'window', { + value: globalThis, + writable: true + }) +} + vi.mock('@codingame/monaco-vscode-standalone-typescript-language-features/worker', () => ({ TypeScriptWorker: class TypeScriptWorker { private _mockScriptSnapshot?: { diff --git a/frontend/src/lib/userDraft.test.ts b/frontend/src/lib/userDraft.test.ts new file mode 100644 index 0000000000..18b27b91f5 --- /dev/null +++ b/frontend/src/lib/userDraft.test.ts @@ -0,0 +1,215 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest' + +// Capture onDestroy callbacks so we can simulate component teardown without +// a real component context. +const onDestroyCallbacks: Array<() => void> = [] + +vi.mock('svelte', async (importOriginal) => { + const actual = (await importOriginal()) as Record + return { + ...actual, + onDestroy: (fn: () => void) => { + onDestroyCallbacks.push(fn) + } + } +}) + +// Imported AFTER vi.mock so the module sees the mocked onDestroy. +const { UserDraft, __resetUserDraftForTesting } = await import('./userDraft.svelte') +const { workspaceStore } = await import('./stores') + +function flushDestroyCallbacks(): void { + const callbacks = onDestroyCallbacks.splice(0, onDestroyCallbacks.length) + for (const cb of callbacks) cb() +} + +beforeEach(() => { + __resetUserDraftForTesting() + onDestroyCallbacks.length = 0 + localStorage.clear() + workspaceStore.set('test_ws') +}) + +describe('UserDraft.save / get / remove (no observers)', () => { + it('save writes to localStorage under the workspace-scoped key', () => { + UserDraft.save('flow', 'u/me/myflow', { value: { hello: 'world' } }) + + const raw = localStorage.getItem('userdraft/w/test_ws/flow/u/me/myflow') + expect(raw).toBe(JSON.stringify({ value: { hello: 'world' } })) + }) + + it('get reads from localStorage when no observer is registered', () => { + localStorage.setItem( + 'userdraft/w/test_ws/script/u/me/script1', + JSON.stringify({ value: 'code' }) + ) + + expect(UserDraft.get('script', 'u/me/script1')).toEqual({ value: 'code' }) + }) + + it('get returns undefined when nothing is stored', () => { + expect(UserDraft.get('flow', 'u/me/missing')).toBeUndefined() + }) + + it('get returns undefined when stored value is malformed', () => { + localStorage.setItem('userdraft/w/test_ws/flow/u/me/bad', 'not-json') + expect(UserDraft.get('flow', 'u/me/bad')).toBeUndefined() + }) + + it('remove clears the localStorage entry', () => { + UserDraft.save('app', 'u/me/app1', { value: { grid: [] } }) + expect(localStorage.getItem('userdraft/w/test_ws/app/u/me/app1')).not.toBeNull() + + UserDraft.remove('app', 'u/me/app1') + expect(localStorage.getItem('userdraft/w/test_ws/app/u/me/app1')).toBeNull() + }) + + it('uses the workspace from opts when provided', () => { + UserDraft.save('flow', 'u/me/f', { value: 1 }, { workspace: 'other_ws' }) + + expect(localStorage.getItem('userdraft/w/other_ws/flow/u/me/f')).toBe( + JSON.stringify({ value: 1 }) + ) + // Default workspace key must remain empty. + expect(localStorage.getItem('userdraft/w/test_ws/flow/u/me/f')).toBeNull() + }) + + it('supports trigger kinds as item kinds', () => { + UserDraft.save('kafka', 'u/me/topic1', { value: { brokers: ['localhost:9092'] } }) + + const raw = localStorage.getItem('userdraft/w/test_ws/kafka/u/me/topic1') + expect(raw).toBe(JSON.stringify({ value: { brokers: ['localhost:9092'] } })) + }) + + it('throws when neither opts.workspace nor $workspaceStore is set', () => { + workspaceStore.set(undefined) + expect(() => UserDraft.save('flow', 'u/me/x', { value: 1 })).toThrow(/no workspace/) + }) +}) + +describe('UserDraft.use() — observer sync', () => { + it('loads the existing localStorage value on first use', () => { + localStorage.setItem( + 'userdraft/w/test_ws/flow/u/me/loaded', + JSON.stringify({ value: 'preloaded' }) + ) + + const handle = UserDraft.use<{ value: string }>('flow', 'u/me/loaded') + expect(handle.draft).toEqual({ value: 'preloaded' }) + }) + + it('two handles on the same key share the same underlying state', () => { + const a = UserDraft.use<{ value: number }>('flow', 'u/me/shared') + const b = UserDraft.use<{ value: number }>('flow', 'u/me/shared') + + a.draft = { value: 42 } + expect(b.draft).toEqual({ value: 42 }) + + b.draft = { value: 99 } + expect(a.draft).toEqual({ value: 99 }) + }) + + it('save() propagates to live use() handles', () => { + const handle = UserDraft.use<{ value: number }>('flow', 'u/me/observed') + expect(handle.draft).toBeUndefined() + + UserDraft.save('flow', 'u/me/observed', { value: 7 }) + expect(handle.draft).toEqual({ value: 7 }) + + // And the underlying localStorage entry is updated too. + expect(localStorage.getItem('userdraft/w/test_ws/flow/u/me/observed')).toBe( + JSON.stringify({ value: 7 }) + ) + }) + + it('remove() propagates to live use() handles and clears localStorage', () => { + UserDraft.save('flow', 'u/me/removed', { value: 1 }) + const handle = UserDraft.use<{ value: number }>('flow', 'u/me/removed') + expect(handle.draft).toEqual({ value: 1 }) + + UserDraft.remove('flow', 'u/me/removed') + expect(handle.draft).toBeUndefined() + expect(localStorage.getItem('userdraft/w/test_ws/flow/u/me/removed')).toBeNull() + }) + + it('writes through the handle setter persist to localStorage', () => { + const handle = UserDraft.use<{ value: string }>('flow', 'u/me/setter') + handle.draft = { value: 'persisted' } + + expect(localStorage.getItem('userdraft/w/test_ws/flow/u/me/setter')).toBe( + JSON.stringify({ value: 'persisted' }) + ) + }) + + it('setting handle.draft = undefined removes the localStorage entry', () => { + const handle = UserDraft.use<{ value: string }>('flow', 'u/me/clear') + handle.draft = { value: 'temp' } + expect(localStorage.getItem('userdraft/w/test_ws/flow/u/me/clear')).not.toBeNull() + + handle.draft = undefined + expect(localStorage.getItem('userdraft/w/test_ws/flow/u/me/clear')).toBeNull() + expect(handle.draft).toBeUndefined() + }) + + it('two handles in different workspaces are isolated', () => { + const a = UserDraft.use<{ value: number }>('flow', 'u/me/iso', { workspace: 'ws_a' }) + const b = UserDraft.use<{ value: number }>('flow', 'u/me/iso', { workspace: 'ws_b' }) + + a.draft = { value: 1 } + b.draft = { value: 2 } + + expect(a.draft).toEqual({ value: 1 }) + expect(b.draft).toEqual({ value: 2 }) + }) + + it('save() falls back to localStorage when no handle is registered', () => { + UserDraft.save('flow', 'u/me/noobs', { value: 'fallback' }) + // First use() afterwards loads the persisted value. + const handle = UserDraft.use<{ value: string }>('flow', 'u/me/noobs') + expect(handle.draft).toEqual({ value: 'fallback' }) + }) +}) + +describe('UserDraft.use() — reference counting & cleanup', () => { + it('destroys the entry when the last handle is released', () => { + // First handle acquires the entry. + const a = UserDraft.use<{ value: number }>('flow', 'u/me/ref') + a.draft = { value: 1 } + + // Second handle increments the count. + const b = UserDraft.use<{ value: number }>('flow', 'u/me/ref') + expect(b.draft).toEqual({ value: 1 }) + + // onDestroy for both handles got registered. + expect(onDestroyCallbacks.length).toBe(2) + + // Releasing one handle keeps the entry alive — save() still updates handle a. + const firstCb = onDestroyCallbacks.shift()! + firstCb() + + UserDraft.save('flow', 'u/me/ref', { value: 2 }) + expect(a.draft).toEqual({ value: 2 }) + + // Releasing the second handle drops the entry; subsequent save() + // must go straight to localStorage rather than mutating in-memory + // state (which no longer exists). + const secondCb = onDestroyCallbacks.shift()! + secondCb() + + UserDraft.save('flow', 'u/me/ref', { value: 3 }) + expect(localStorage.getItem('userdraft/w/test_ws/flow/u/me/ref')).toBe( + JSON.stringify({ value: 3 }) + ) + }) + + it('a fresh use() after cleanup re-reads the latest persisted value', () => { + const a = UserDraft.use<{ value: string }>('flow', 'u/me/cycle') + a.draft = { value: 'first' } + flushDestroyCallbacks() + + // After all handles release, a brand-new use() must pick up the + // value persisted to localStorage from the previous round. + const b = UserDraft.use<{ value: string }>('flow', 'u/me/cycle') + expect(b.draft).toEqual({ value: 'first' }) + }) +})