Files
windmill/frontend/src/lib/components/assets/AssetGraph/pipelineEditorState.test.ts
T
Ruben FiszelandClaude Fable 5 f7efb646bf fix(pipelines): live materialize/dataset editing — stale graph, phantom drafts, stale Save-all deploys (#9990)
* fix(pipelines): live materialize/dataset edits reflect on the graph; no phantom draft after deploy

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(pipelines): Save all deploys the open pane's live buffer, not the stale draft snapshot

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(pipelines): pin deployedFromPane to the shipped content so mid-deploy keystrokes still promote to a draft

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(pipelines): draft rename ping-pong loop, stale rename deploys, inactive-draft input lineage

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(pipelines): dedupe inferred-lineage overlay against accumulated edges; first draft teardown still captures reads

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(pipelines): record an authoritative empty read capture on uncaptured draft entries

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(pipelines): teardown skip compares lineage too, so access-only overrides still persist

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* style(pipelines): compress persist-back guard comments to the invariant

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 22:00:39 +02:00

107 lines
4.4 KiB
TypeScript

import { describe, it, expect } from 'vitest'
import { PipelineEditorState } from './pipelineEditorState.svelte'
import type { PipelineDraft } from './pipelineAiHelpers'
import type { AssetKind } from '$lib/gen'
// handleDraftPersist defers its commit a microtask (so a same-batch discard can
// win); flush that microtask before asserting.
const flushMicrotasks = () => new Promise<void>((resolve) => queueMicrotask(() => resolve()))
function draft(content: string, outputAssets?: { kind: AssetKind; path: string }[]): PipelineDraft {
return {
localId: 'pe-1',
script: { path: 'f/x/n', language: 'duckdb', content } as PipelineDraft['script'],
outputAssets
}
}
describe('PipelineEditorState.handleDraftPersist', () => {
// Regression: a no-output draft has `outputAssets: undefined`; the details pane
// infers an empty `writes: []`. Both mean "no writes", so persisting unchanged
// content+writes must be a no-op. The earlier `undefined === 0` length check made
// it false, so every persist re-wrote the drafts Map with an equivalent object —
// re-triggering the pane's emit → graph re-derive → persist, an infinite
// microtask loop that froze the tab. The Map reference must stay identical.
it('is idempotent for a no-output draft (undefined outputAssets vs empty inferred writes)', async () => {
const pe = new PipelineEditorState()
pe.drafts = new Map([['f/x/n', draft('SELECT 1', undefined)]])
const before = pe.drafts
pe.handleDraftPersist('f/x/n', { content: 'SELECT 1', writes: [] })
await flushMicrotasks()
expect(pe.drafts).toBe(before)
})
it('re-writes the drafts Map when the content actually changes', async () => {
const pe = new PipelineEditorState()
pe.drafts = new Map([['f/x/n', draft('SELECT 1', undefined)]])
const before = pe.drafts
pe.handleDraftPersist('f/x/n', { content: 'SELECT 2', writes: [] })
await flushMicrotasks()
expect(pe.drafts).not.toBe(before)
expect(pe.drafts.get('f/x/n')?.script.content).toBe('SELECT 2')
})
it('re-writes the drafts Map when the inferred writes actually change', async () => {
const pe = new PipelineEditorState()
pe.drafts = new Map([['f/x/n', draft('SELECT 1', undefined)]])
const before = pe.drafts
pe.handleDraftPersist('f/x/n', {
content: 'SELECT 1',
writes: [{ kind: 'resource' as AssetKind, path: 'f/x/out' }]
})
await flushMicrotasks()
expect(pe.drafts).not.toBe(before)
expect(pe.drafts.get('f/x/n')?.outputAssets).toEqual([{ kind: 'resource', path: 'f/x/out' }])
})
it('stays idempotent when outputAssets and inferred writes match (non-empty)', async () => {
const pe = new PipelineEditorState()
pe.drafts = new Map([
['f/x/n', draft('SELECT 1', [{ kind: 'resource' as AssetKind, path: 'f/x/out' }])]
])
const before = pe.drafts
pe.handleDraftPersist('f/x/n', {
content: 'SELECT 1',
writes: [{ kind: 'resource' as AssetKind, path: 'f/x/out' }]
})
await flushMicrotasks()
expect(pe.drafts).toBe(before)
})
})
describe('PipelineEditorState.handleDraftPersist — read capture', () => {
it('records an authoritative empty capture on an uncaptured entry (undefined → [])', async () => {
const pe = new PipelineEditorState()
pe.drafts = new Map([['f/x/n', draft('SELECT 1', undefined)]])
const before = pe.drafts
pe.handleDraftPersist('f/x/n', { content: 'SELECT 1', writes: [], reads: [] })
await flushMicrotasks()
// Must rewrite: undefined means "fall back to the session cache", [] means
// "reads nothing" — staying undefined would keep stale cached read edges
// alive after the pane closes.
expect(pe.drafts).not.toBe(before)
expect(pe.drafts.get('f/x/n')?.inputAssets).toEqual([])
})
it('is idempotent once the capture matches ([] vs reads: [])', async () => {
const pe = new PipelineEditorState()
const d = draft('SELECT 1', undefined)
d.inputAssets = []
pe.drafts = new Map([['f/x/n', d]])
const before = pe.drafts
pe.handleDraftPersist('f/x/n', { content: 'SELECT 1', writes: [], reads: [] })
await flushMicrotasks()
expect(pe.drafts).toBe(before)
})
it('is idempotent when a legacy caller omits reads', async () => {
const pe = new PipelineEditorState()
pe.drafts = new Map([['f/x/n', draft('SELECT 1', undefined)]])
const before = pe.drafts
pe.handleDraftPersist('f/x/n', { content: 'SELECT 1', writes: [] })
await flushMicrotasks()
expect(pe.drafts).toBe(before)
expect(pe.drafts.get('f/x/n')?.inputAssets).toBeUndefined()
})
})