diff --git a/ui/src/lib/editor/JsonEditor.svelte b/ui/src/lib/editor/JsonEditor.svelte index 6ac0609..9ed833c 100644 --- a/ui/src/lib/editor/JsonEditor.svelte +++ b/ui/src/lib/editor/JsonEditor.svelte @@ -60,6 +60,7 @@ guides: { indentation: false }, automaticLayout: true, scrollBeyondLastLine: false, + wordWrap: 'on', fontSize: 13, tabSize: 2, fixedOverflowWidgets: true, diff --git a/ui/src/lib/pages/JobPage.svelte b/ui/src/lib/pages/JobPage.svelte index 68e9e58..8c4f9eb 100644 --- a/ui/src/lib/pages/JobPage.svelte +++ b/ui/src/lib/pages/JobPage.svelte @@ -23,6 +23,7 @@ } from '../runstate' import { stepProgress } from '../progress' import FlowView from '../editor/FlowView.svelte' + import JsonEditor from '../editor/JsonEditor.svelte' import CopyButton from '../CopyButton.svelte' import DownloadLink from '../DownloadLink.svelte' import { notify } from '../toast' @@ -39,6 +40,13 @@ // a workflow that pins its seed to a literal or names none at all - // neither can be handed a different one, so the button stays away. let seedVariable = $state(null) + // Whether `definition` is the realized copy the run itself wrote (every + // mutable input pinned) or the definition as submitted - the run predates + // run tracking, or its run directory is gone. Named beside the JSON view. + let realized = $state(false) + // The JSON view of that definition - off until asked, since the flow graph + // already answers "what did this run do" for most readers + let showJson = $state(false) let events = $state([]) let error = $state('') // arrival clocks for pipeline_step events, for the ETA estimate @@ -51,6 +59,8 @@ events = [] definition = null seedVariable = null + realized = false + showJson = false // Under the flat output layout two runs write the same file names, so // a map keyed by name would show the last job's recipe for this one fileMeta = {} @@ -64,6 +74,7 @@ if (stopped) return definition = result.definition seedVariable = result.seed_variable + realized = result.realized }) .catch(() => { /* no definition on file - the graph just does not appear */ @@ -440,7 +451,27 @@ {#if definition}
-

Workflow

+
+

Workflow

+ {realized ? 'realized' : 'as submitted'} + + +
+ {#if showJson} +
+ +
+ {/if}
{/if} @@ -618,9 +658,21 @@ .flowsection { margin-bottom: 1rem; } - .flowsection h2 { + .flowhead { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.4rem 1rem; margin-bottom: var(--space-2); } + .flowhead h2 { + margin: 0; + } + /* Which copy the definition is - what the engine resolves, so mono */ + .jsonmark { + font-family: var(--font-mono); + font-size: var(--t-xs); + } .warnings { color: var(--warn); } diff --git a/ui/src/lib/pages/JobPage.test.ts b/ui/src/lib/pages/JobPage.test.ts index eb0d654..fe39904 100644 --- a/ui/src/lib/pages/JobPage.test.ts +++ b/ui/src/lib/pages/JobPage.test.ts @@ -1,4 +1,4 @@ -import { cleanup, render, screen, waitFor } from '@testing-library/svelte' +import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/svelte' import { afterEach, expect, it, vi } from 'vitest' import JobPage from './JobPage.svelte' import { api } from '../api' @@ -13,9 +13,17 @@ const stream = vi.hoisted(() => ({ const metadata = vi.hoisted(() => ({ byFile: {} as Record>, })) -// The definition the job ran, for the flow view and the unsaved reasons +// The definition the job ran, for the flow view and the unsaved reasons, +// and whether it is the realized copy or the definition as submitted const ran = vi.hoisted(() => ({ definition: null as Record | null, + realized: true, +})) + +// Monaco cannot boot in jsdom; the page's JSON view is tested through this +// stub, which renders the value it was handed as plain text +vi.mock('../editor/JsonEditor.svelte', async () => ({ + default: (await import('./JsonEditorStub.svelte')).default, })) vi.mock('../api', () => ({ @@ -23,7 +31,11 @@ vi.mock('../api', () => ({ api: { getJob: vi.fn(() => Promise.resolve(detail.job)), getJobWorkflow: vi.fn(() => - Promise.resolve({ definition: ran.definition, seed_variable: null }), + Promise.resolve({ + definition: ran.definition, + realized: ran.realized, + seed_variable: null, + }), ), galleryMetadata: vi.fn((name: string) => Promise.resolve({ @@ -67,6 +79,7 @@ afterEach(() => { stream.onEvent = null metadata.byFile = {} ran.definition = null + ran.realized = true vi.mocked(api.galleryMetadata).mockClear() }) @@ -351,3 +364,23 @@ it('says nothing about an acknowledgement a run did not carry', async () => { await waitFor(() => expect(screen.getByText('j1')).toBeTruthy()) expect(screen.queryByText(/acknowledged/)).toBeNull() }) + +it('offers the workflow JSON behind a toggle, labelled as the realized copy', async () => { + ran.definition = { steps: [{ name: 'base', pipeline: {} }] } + render(JobPage, { jobId: 'j1' }) + await waitFor(() => expect(screen.getByText('realized')).toBeTruthy()) + // The JSON is for the curious, not the default view + expect(screen.queryByTestId('json-editor')).toBeNull() + await fireEvent.click(screen.getByRole('button', { name: 'show JSON' })) + const shown = await screen.findByTestId('json-editor') + // What the viewer is handed is the definition the page fetched + expect(shown.textContent).toContain('"name": "base"') + expect(screen.getByRole('button', { name: 'hide JSON' })).toBeTruthy() +}) + +it('labels a definition with no realized copy on file as submitted', async () => { + ran.definition = { steps: [{ name: 'base', pipeline: {} }] } + ran.realized = false + render(JobPage, { jobId: 'j1' }) + await waitFor(() => expect(screen.getByText('as submitted')).toBeTruthy()) +}) diff --git a/ui/src/lib/pages/JsonEditorStub.svelte b/ui/src/lib/pages/JsonEditorStub.svelte new file mode 100644 index 0000000..b023d69 --- /dev/null +++ b/ui/src/lib/pages/JsonEditorStub.svelte @@ -0,0 +1,8 @@ + + +
{value}
\ No newline at end of file