From d5846361f81a3ef5545329a176c93ded8e01ad57 Mon Sep 17 00:00:00 2001 From: SCcagg5 Date: Sat, 31 Oct 2026 11:36:22 +0100 Subject: [PATCH 1/8] test(ui): add YAML fixtures for the document model tests (#3385) * Yaml fixtures of the document model tests --- .../__tests__/fixtures/anchors-aliases.yaml | 25 ++++++++ .../doc/__tests__/fixtures/block-scalars.yaml | 30 +++++++++ .../__tests__/fixtures/browser-flow-maps.yaml | 32 ++++++++++ .../__tests__/fixtures/build-comments.yaml | 61 +++++++++++++++++++ .../doc/__tests__/fixtures/chain-router.yaml | 10 +++ .../fixtures/comments-everywhere.yaml | 28 +++++++++ .../doc/__tests__/fixtures/config-cli.yaml | 8 +++ .../fixtures/demo-agent-release.yaml | 57 +++++++++++++++++ .../__tests__/fixtures/demo-graph-etl.yaml | 52 ++++++++++++++++ .../fixtures/embedded-custom-executor.yaml | 7 +++ .../__tests__/fixtures/embedded-local.yaml | 11 ++++ .../doc/__tests__/fixtures/empty-file.yaml | 0 .../__tests__/fixtures/flow-collections.yaml | 23 +++++++ .../__tests__/fixtures/flow-maps-unicode.yaml | 18 ++++++ .../__tests__/fixtures/folded-line-break.yaml | 8 +++ .../fixtures/foreach-string-items.yaml | 21 +++++++ .../doc/__tests__/fixtures/handler-env.yaml | 13 ++++ .../__tests__/fixtures/handlers-chain.yaml | 20 ++++++ .../doc/__tests__/fixtures/harness-acp.yaml | 13 ++++ .../doc/__tests__/fixtures/invalid-yaml.yaml | 4 ++ .../doc/__tests__/fixtures/long-lines.yaml | 25 ++++++++ .../fixtures/map-steps-forbidden.yaml | 3 + .../doc/__tests__/fixtures/map-steps.yaml | 20 ++++++ .../fixtures/multidoc-comments-markers.yaml | 20 ++++++ .../fixtures/multidoc-empty-between.yaml | 9 +++ .../__tests__/fixtures/multidoc-subdag.yaml | 14 +++++ .../fixtures/multidoc-unnamed-later.yaml | 7 +++ .../__tests__/fixtures/nested-array-run.yaml | 6 ++ .../fixtures/nested-parallel-groups.yaml | 18 ++++++ .../__tests__/fixtures/odd-indentation.yaml | 9 +++ .../fixtures/quoted-keys-and-values.yaml | 17 ++++++ .../fixtures/router-numeric-band.yaml | 11 ++++ .../__tests__/fixtures/router-regex-keys.yaml | 13 ++++ .../doc/__tests__/fixtures/run-context.yaml | 38 ++++++++++++ .../__tests__/fixtures/scheduler-cron.yaml | 5 ++ .../fixtures/script-utf8-multiline.yaml | 9 +++ .../__tests__/fixtures/shorthand-steps.yaml | 9 +++ .../fixtures/spec-harness-comments.yaml | 16 +++++ .../fixtures/spec-outputs-heredoc.yaml | 15 +++++ .../fixtures/spec-subdag-parallel.yaml | 17 ++++++ .../__tests__/fixtures/spec-xlsx-convert.yaml | 9 +++ .../__tests__/fixtures/unicode-comments.yaml | 10 +++ .../doc/__tests__/fixtures/xlsx-japanese.yaml | 42 +++++++++++++ 43 files changed, 783 insertions(+) create mode 100644 ui/src/features/visual-editor/doc/__tests__/fixtures/anchors-aliases.yaml create mode 100644 ui/src/features/visual-editor/doc/__tests__/fixtures/block-scalars.yaml create mode 100644 ui/src/features/visual-editor/doc/__tests__/fixtures/browser-flow-maps.yaml create mode 100644 ui/src/features/visual-editor/doc/__tests__/fixtures/build-comments.yaml create mode 100644 ui/src/features/visual-editor/doc/__tests__/fixtures/chain-router.yaml create mode 100644 ui/src/features/visual-editor/doc/__tests__/fixtures/comments-everywhere.yaml create mode 100644 ui/src/features/visual-editor/doc/__tests__/fixtures/config-cli.yaml create mode 100644 ui/src/features/visual-editor/doc/__tests__/fixtures/demo-agent-release.yaml create mode 100644 ui/src/features/visual-editor/doc/__tests__/fixtures/demo-graph-etl.yaml create mode 100644 ui/src/features/visual-editor/doc/__tests__/fixtures/embedded-custom-executor.yaml create mode 100644 ui/src/features/visual-editor/doc/__tests__/fixtures/embedded-local.yaml create mode 100644 ui/src/features/visual-editor/doc/__tests__/fixtures/empty-file.yaml create mode 100644 ui/src/features/visual-editor/doc/__tests__/fixtures/flow-collections.yaml create mode 100644 ui/src/features/visual-editor/doc/__tests__/fixtures/flow-maps-unicode.yaml create mode 100644 ui/src/features/visual-editor/doc/__tests__/fixtures/folded-line-break.yaml create mode 100644 ui/src/features/visual-editor/doc/__tests__/fixtures/foreach-string-items.yaml create mode 100644 ui/src/features/visual-editor/doc/__tests__/fixtures/handler-env.yaml create mode 100644 ui/src/features/visual-editor/doc/__tests__/fixtures/handlers-chain.yaml create mode 100644 ui/src/features/visual-editor/doc/__tests__/fixtures/harness-acp.yaml create mode 100644 ui/src/features/visual-editor/doc/__tests__/fixtures/invalid-yaml.yaml create mode 100644 ui/src/features/visual-editor/doc/__tests__/fixtures/long-lines.yaml create mode 100644 ui/src/features/visual-editor/doc/__tests__/fixtures/map-steps-forbidden.yaml create mode 100644 ui/src/features/visual-editor/doc/__tests__/fixtures/map-steps.yaml create mode 100644 ui/src/features/visual-editor/doc/__tests__/fixtures/multidoc-comments-markers.yaml create mode 100644 ui/src/features/visual-editor/doc/__tests__/fixtures/multidoc-empty-between.yaml create mode 100644 ui/src/features/visual-editor/doc/__tests__/fixtures/multidoc-subdag.yaml create mode 100644 ui/src/features/visual-editor/doc/__tests__/fixtures/multidoc-unnamed-later.yaml create mode 100644 ui/src/features/visual-editor/doc/__tests__/fixtures/nested-array-run.yaml create mode 100644 ui/src/features/visual-editor/doc/__tests__/fixtures/nested-parallel-groups.yaml create mode 100644 ui/src/features/visual-editor/doc/__tests__/fixtures/odd-indentation.yaml create mode 100644 ui/src/features/visual-editor/doc/__tests__/fixtures/quoted-keys-and-values.yaml create mode 100644 ui/src/features/visual-editor/doc/__tests__/fixtures/router-numeric-band.yaml create mode 100644 ui/src/features/visual-editor/doc/__tests__/fixtures/router-regex-keys.yaml create mode 100644 ui/src/features/visual-editor/doc/__tests__/fixtures/run-context.yaml create mode 100644 ui/src/features/visual-editor/doc/__tests__/fixtures/scheduler-cron.yaml create mode 100644 ui/src/features/visual-editor/doc/__tests__/fixtures/script-utf8-multiline.yaml create mode 100644 ui/src/features/visual-editor/doc/__tests__/fixtures/shorthand-steps.yaml create mode 100644 ui/src/features/visual-editor/doc/__tests__/fixtures/spec-harness-comments.yaml create mode 100644 ui/src/features/visual-editor/doc/__tests__/fixtures/spec-outputs-heredoc.yaml create mode 100644 ui/src/features/visual-editor/doc/__tests__/fixtures/spec-subdag-parallel.yaml create mode 100644 ui/src/features/visual-editor/doc/__tests__/fixtures/spec-xlsx-convert.yaml create mode 100644 ui/src/features/visual-editor/doc/__tests__/fixtures/unicode-comments.yaml create mode 100644 ui/src/features/visual-editor/doc/__tests__/fixtures/xlsx-japanese.yaml diff --git a/ui/src/features/visual-editor/doc/__tests__/fixtures/anchors-aliases.yaml b/ui/src/features/visual-editor/doc/__tests__/fixtures/anchors-aliases.yaml new file mode 100644 index 0000000000..9af713b0fb --- /dev/null +++ b/ui/src/features/visual-editor/doc/__tests__/fixtures/anchors-aliases.yaml @@ -0,0 +1,25 @@ +# Shared defaults through anchors and aliases. +name: anchors +env: + - COMMON: &common_env + RETRIES: 3 + REGION: eu-west-1 +defaults: &defaults + timeout_sec: 30 + retry_policy: &retry + limit: 2 + interval_sec: 5 +steps: + - name: first + run: echo first + <<: *defaults + - name: second + depends: [first] + run: echo second + retry_policy: *retry # same policy as above + env: *common_env + - name: third + depends: + - second + run: echo third + <<: *defaults diff --git a/ui/src/features/visual-editor/doc/__tests__/fixtures/block-scalars.yaml b/ui/src/features/visual-editor/doc/__tests__/fixtures/block-scalars.yaml new file mode 100644 index 0000000000..4e274323dd --- /dev/null +++ b/ui/src/features/visual-editor/doc/__tests__/fixtures/block-scalars.yaml @@ -0,0 +1,30 @@ +name: block-scalars +description: >- + A folded scalar that + strips the final line break. +steps: + - name: literal + run: | + echo "line one" + echo "indented line" + + echo "after a blank line" + - name: keep + run: |+ + keeps trailing blank lines + + - name: strip + run: |- + no trailing line break + - name: folded + run: > + echo this folded text + is one line + + and a second line + - name: explicit indent + run: |2 + two extra spaces + base + - name: last + run: echo done diff --git a/ui/src/features/visual-editor/doc/__tests__/fixtures/browser-flow-maps.yaml b/ui/src/features/visual-editor/doc/__tests__/fixtures/browser-flow-maps.yaml new file mode 100644 index 0000000000..c6519c30f3 --- /dev/null +++ b/ui/src/features/visual-editor/doc/__tests__/fixtures/browser-flow-maps.yaml @@ -0,0 +1,32 @@ +working_dir: . +env: + - SHOP_URL: ${SHOP_URL} + - LLM_BASE_URL: ${LLM_BASE_URL} +llm: + provider: local + model: test-model + base_url: ${LLM_BASE_URL} +steps: + - id: checkout + action: browser.run + with: + url: ${SHOP_URL} + browser: + screenshots: final + do: + - act: Click the Place order button + when: The page shows 'Cart has 2 items' + - expect: {text: "Order confirmed"} + - extract: + instruction: The order number + schema: + type: object + properties: + order_number: + type: string + - screenshot: confirmation + - id: record + depends: checkout + run: | + echo "${steps.checkout.outputs.order_number}" > order.out + ls "$DAG_RUN_ARTIFACTS_DIR/browser/checkout" > shots.out diff --git a/ui/src/features/visual-editor/doc/__tests__/fixtures/build-comments.yaml b/ui/src/features/visual-editor/doc/__tests__/fixtures/build-comments.yaml new file mode 100644 index 0000000000..1bd29ce969 --- /dev/null +++ b/ui/src/features/visual-editor/doc/__tests__/fixtures/build-comments.yaml @@ -0,0 +1,61 @@ +# Copyright (C) 2026 Yota Hamada +# SPDX-License-Identifier: GPL-3.0-or-later + +type: build +working_dir: . +shell: [sh] + +steps: + - id: build + name: build + inputs: + - name: source + path: source.txt + outputs: + - name: intermediate + path: intermediate.txt + preconditions: + - condition: test -f "${inputs.source}" + script: | + count=$(cat build-count.txt 2>/dev/null || printf 0) + printf '%s\n' "$((count + 1))" > build-count.txt + if [ -f fail.txt ]; then + printf 'broken\n' > "${outputs.intermediate}" + exit 1 + fi + cat "${inputs.source}" > "${outputs.intermediate}" + + - id: consume + name: consume + inputs: + - name: intermediate + path: intermediate.txt + outputs: + - name: result + path: result.txt + env: + INPUT_PATH: ${inputs.intermediate} + OUTPUT_PATH: ${outputs.result} + script: | + count=$(cat consume-count.txt 2>/dev/null || printf 0) + printf '%s\n' "$((count + 1))" > consume-count.txt + cat "$INPUT_PATH" > "$OUTPUT_PATH" + + - id: controlled + name: controlled + depends: build + inputs: + - name: source + path: source.txt + outputs: + - name: result + path: controlled.txt + script: | + count=$(cat controlled-count.txt 2>/dev/null || printf 0) + printf '%s\n' "$((count + 1))" > controlled-count.txt + cat "${inputs.source}" > "${outputs.result}" + + - name: always + script: | + count=$(cat always-count.txt 2>/dev/null || printf 0) + printf '%s\n' "$((count + 1))" > always-count.txt diff --git a/ui/src/features/visual-editor/doc/__tests__/fixtures/chain-router.yaml b/ui/src/features/visual-editor/doc/__tests__/fixtures/chain-router.yaml new file mode 100644 index 0000000000..b823eaf196 --- /dev/null +++ b/ui/src/features/visual-editor/doc/__tests__/fixtures/chain-router.yaml @@ -0,0 +1,10 @@ +type: chain +steps: + - name: pick + action: router.route + with: + value: "a" + routes: + a: [t] + - name: t + run: echo hi diff --git a/ui/src/features/visual-editor/doc/__tests__/fixtures/comments-everywhere.yaml b/ui/src/features/visual-editor/doc/__tests__/fixtures/comments-everywhere.yaml new file mode 100644 index 0000000000..4ec4e912b8 --- /dev/null +++ b/ui/src/features/visual-editor/doc/__tests__/fixtures/comments-everywhere.yaml @@ -0,0 +1,28 @@ +#!/usr/bin/env dagu +# Leading comment + +# Second block of comments +name: comments # trailing on name + +# before params +params: + # inside params + - NAME: world # trailing on item + # after the item + +steps: + # before step one + - name: one # trailing one + # inside step one, before run + run: echo one # trailing run + + # inside step one, after a blank line + # between steps (less indented) + - name: two + depends: [one] # trailing depends + run: echo two + # last comment of step two +# comment at column 0 inside steps + - name: three + run: echo three +# final comment diff --git a/ui/src/features/visual-editor/doc/__tests__/fixtures/config-cli.yaml b/ui/src/features/visual-editor/doc/__tests__/fixtures/config-cli.yaml new file mode 100644 index 0000000000..317010f68a --- /dev/null +++ b/ui/src/features/visual-editor/doc/__tests__/fixtures/config-cli.yaml @@ -0,0 +1,8 @@ +port: 54321 +headless: true +dags: /tmp/.dagu_test +scheduler: + port: 0 +coordinator: + host: 127.0.0.1 + port: 9876 diff --git a/ui/src/features/visual-editor/doc/__tests__/fixtures/demo-agent-release.yaml b/ui/src/features/visual-editor/doc/__tests__/fixtures/demo-agent-release.yaml new file mode 100644 index 0000000000..1c6b08dcbc --- /dev/null +++ b/ui/src/features/visual-editor/doc/__tests__/fixtures/demo-agent-release.yaml @@ -0,0 +1,57 @@ +# Agent: the agent chooses which step to run until every task is done. +type: agent +description: Ship a small release. The agent plans, builds, checks, asks for approval and publishes. +llm: + provider: anthropic + model: claude-opus-5 + max_tool_iterations: 30 + system: | + You ship a small release. Plan first, then build, then check the result. + Ask a person to approve before you publish. Never publish without approval. +params: + - VERSION: 1.4.0 +steps: + - id: plan + name: plan + description: Write the plan of the release. + run: echo "plan for ${params.VERSION}" + + - id: build + name: build + description: Build the release artifacts. + run: echo "building ${params.VERSION}" + + - id: check + name: check + description: Check that the artifacts exist and report their size. + run: echo "artifacts 3, size 12 MB" + + - id: approve + name: approve + description: Ask a person to approve the release before it goes out. + action: human.task + with: + prompt: Approve the release ${params.VERSION}? + form: + type: object + properties: + approved: + type: boolean + note: + type: string + required: [approved] + + - id: publish + name: publish + description: Publish the release. Run it only after the approval. + run: echo "published ${params.VERSION}" + +tasks: + - name: planned + description: Finished when the plan of the release exists. + - name: built + description: Finished when the build ran and the check passed. + - name: approved + description: Finished when a person approved the release. + - name: published + description: Finished when the release was published after the approval. diff --git a/ui/src/features/visual-editor/doc/__tests__/fixtures/demo-graph-etl.yaml b/ui/src/features/visual-editor/doc/__tests__/fixtures/demo-graph-etl.yaml new file mode 100644 index 0000000000..577112e820 --- /dev/null +++ b/ui/src/features/visual-editor/doc/__tests__/fixtures/demo-graph-etl.yaml @@ -0,0 +1,52 @@ +# Graph: two extractions run in parallel, then merge, validate and load. +description: Extract orders and users in parallel, merge them, validate the result and load it. + +schedule: + - 0 9 * * * + - 0 9 * * 1-5 + +params: + - TARGET: staging +type: graph + +steps: + - id: extract_orders + name: extract orders + run: echo '{"orders":42}' + output: + orders: + from: stdout + decode: json + + - id: extract_users + name: extract users + run: echo '{"users":7}' + output: + users: + from: stdout + decode: json + + - id: merge + name: merge + run: echo "merging ${steps.extract_orders.outputs.orders} and ${steps.extract_users.outputs.users}" + depends: [extract_orders, extract_users] + + - id: merge_3 + name: merge_3 + run: echo "merging ${steps.extract_orders.outputs.orders} and ${steps.extract_users.outputs.users}" + depends: [extract_orders, extract_users] + + - id: merge_2 + name: merge_2 + run: echo "merging ${steps.extract_orders.outputs.orders} and ${steps.extract_users.outputs.users}" + depends: [extract_orders, extract_users] + + - id: validate + name: validate + run: echo "validating for ${params.TARGET}" + depends: [merge] + + - id: load + name: load + run: echo "loaded into ${params.TARGET}" + depends: [validate] diff --git a/ui/src/features/visual-editor/doc/__tests__/fixtures/embedded-custom-executor.yaml b/ui/src/features/visual-editor/doc/__tests__/fixtures/embedded-custom-executor.yaml new file mode 100644 index 0000000000..8c3779ea4b --- /dev/null +++ b/ui/src/features/visual-editor/doc/__tests__/fixtures/embedded-custom-executor.yaml @@ -0,0 +1,7 @@ +name: embedded-custom-executor-example +type: graph +steps: + - name: call-go-code + action: embedded_echo + with: + message: domain operation from DAG YAML diff --git a/ui/src/features/visual-editor/doc/__tests__/fixtures/embedded-local.yaml b/ui/src/features/visual-editor/doc/__tests__/fixtures/embedded-local.yaml new file mode 100644 index 0000000000..b2e4dda3a2 --- /dev/null +++ b/ui/src/features/visual-editor/doc/__tests__/fixtures/embedded-local.yaml @@ -0,0 +1,11 @@ +name: embedded-local-example +type: graph +params: + - TARGET +steps: + - name: greet + # Dagu resolves ${TARGET} from run parameters before the shell sees it. + run: echo "hello ${TARGET}" + - name: finish + run: echo "local embedded run complete" + depends: [greet] diff --git a/ui/src/features/visual-editor/doc/__tests__/fixtures/empty-file.yaml b/ui/src/features/visual-editor/doc/__tests__/fixtures/empty-file.yaml new file mode 100644 index 0000000000..e69de29bb2 diff --git a/ui/src/features/visual-editor/doc/__tests__/fixtures/flow-collections.yaml b/ui/src/features/visual-editor/doc/__tests__/fixtures/flow-collections.yaml new file mode 100644 index 0000000000..49f6adbe69 --- /dev/null +++ b/ui/src/features/visual-editor/doc/__tests__/fixtures/flow-collections.yaml @@ -0,0 +1,23 @@ +name: flow-collections +params: [ALPHA, BETA] +labels: {team: ops, tier: {name: gold, rank: 1}} +steps: + - {name: inline, run: "echo inline", depends: []} + - name: spaced + depends: [ inline ] + with: { method: GET, url: "https://example.com/?a=1&b=2" } + - name: long flow + run: echo long + env: [FIRST=1, SECOND=2, THIRD=3, FOURTH=4, FIFTH=5, SIXTH=6, SEVENTH=7, EIGHTH=8, NINTH=9, TENTH=10] + - name: multiline flow + run: echo multi + with: + items: [ + one, + two, # second + three + ] + options: { + a: 1, + b: 2 + } diff --git a/ui/src/features/visual-editor/doc/__tests__/fixtures/flow-maps-unicode.yaml b/ui/src/features/visual-editor/doc/__tests__/fixtures/flow-maps-unicode.yaml new file mode 100644 index 0000000000..7584c20fe1 --- /dev/null +++ b/ui/src/features/visual-editor/doc/__tests__/fixtures/flow-maps-unicode.yaml @@ -0,0 +1,18 @@ +working_dir: . +steps: + - id: run + action: computer.run + with: + ai: never + idle: "0" + do: + - launch: + command: powershell.exe + args: [-NoProfile, -ExecutionPolicy, Bypass, -File, elements.ps1] + - wait: 3s + - act: + instruction: Save the form + recorded: + windows: + - click: {app: powershell, window: "Dagu elements test", role: button, name: 保存, id: saveButton} + - expect: {text: 保存しました, within: 10s} diff --git a/ui/src/features/visual-editor/doc/__tests__/fixtures/folded-line-break.yaml b/ui/src/features/visual-editor/doc/__tests__/fixtures/folded-line-break.yaml new file mode 100644 index 0000000000..4e9c9140dc --- /dev/null +++ b/ui/src/features/visual-editor/doc/__tests__/fixtures/folded-line-break.yaml @@ -0,0 +1,8 @@ +working_dir: . +env: + - COMMAND_TEXT: |- + printf 'line-break-ran\n' > line-break-ran.txt + printf 'second\n' >> line-break-ran.txt +steps: + - id: line_break + run: ${env.COMMAND_TEXT} diff --git a/ui/src/features/visual-editor/doc/__tests__/fixtures/foreach-string-items.yaml b/ui/src/features/visual-editor/doc/__tests__/fixtures/foreach-string-items.yaml new file mode 100644 index 0000000000..17c07f2856 --- /dev/null +++ b/ui/src/features/visual-editor/doc/__tests__/fixtures/foreach-string-items.yaml @@ -0,0 +1,21 @@ +params: + - name: episodes + default: '["alpha","beta"]' +working_dir: . +steps: + - name: each + foreach: + items: ${params.episodes} + max_concurrent: 2 + steps: + - id: summarize + run: | + printf '%s\n' '${foreach.item}' + output: BODY_RESULT + collect: + markdown: ${summarize.output} + output: FOREACH_RESULTS + - name: consume + depends: each + run: | + printf '%s\n' "$FOREACH_RESULTS" > foreach-string-results.txt diff --git a/ui/src/features/visual-editor/doc/__tests__/fixtures/handler-env.yaml b/ui/src/features/visual-editor/doc/__tests__/fixtures/handler-env.yaml new file mode 100644 index 0000000000..df99f088da --- /dev/null +++ b/ui/src/features/visual-editor/doc/__tests__/fixtures/handler-env.yaml @@ -0,0 +1,13 @@ +params: + - name: environment + description: Runtime environment. +working_dir: . +handler_on: + success: + env: + HANDLER_VALUE: "${params.environment}" + run: | + printf '%s\n' "$HANDLER_VALUE" > handler-env.txt +steps: + - name: ok + run: "true" diff --git a/ui/src/features/visual-editor/doc/__tests__/fixtures/handlers-chain.yaml b/ui/src/features/visual-editor/doc/__tests__/fixtures/handlers-chain.yaml new file mode 100644 index 0000000000..2531d2727c --- /dev/null +++ b/ui/src/features/visual-editor/doc/__tests__/fixtures/handlers-chain.yaml @@ -0,0 +1,20 @@ +type: chain +name: release +handler_on: + init: + run: echo init + success: + run: echo ok + failure: + # Notify on failure. + run: ./notify.sh + abort: + run: echo aborted + exit: + run: echo exit +steps: + - name: build + run: make build + - name: test + run: make test + - make deploy diff --git a/ui/src/features/visual-editor/doc/__tests__/fixtures/harness-acp.yaml b/ui/src/features/visual-editor/doc/__tests__/fixtures/harness-acp.yaml new file mode 100644 index 0000000000..06436f162b --- /dev/null +++ b/ui/src/features/visual-editor/doc/__tests__/fixtures/harness-acp.yaml @@ -0,0 +1,13 @@ +# Install and authenticate an ACP adapter, such as @agentclientprotocol/codex-acp. +harnesses: + coding-agent: + protocol: acp + binary: codex-acp + +steps: + - id: review + action: harness.run + with: + provider: coding-agent + prompt: Review the code in this directory and summarize its modules without modifying files. + output: REVIEW diff --git a/ui/src/features/visual-editor/doc/__tests__/fixtures/invalid-yaml.yaml b/ui/src/features/visual-editor/doc/__tests__/fixtures/invalid-yaml.yaml new file mode 100644 index 0000000000..0b5bf88ddb --- /dev/null +++ b/ui/src/features/visual-editor/doc/__tests__/fixtures/invalid-yaml.yaml @@ -0,0 +1,4 @@ +steps: + - name: should-not-run + run: echo executed > executed.txt + bad: [ diff --git a/ui/src/features/visual-editor/doc/__tests__/fixtures/long-lines.yaml b/ui/src/features/visual-editor/doc/__tests__/fixtures/long-lines.yaml new file mode 100644 index 0000000000..16d853a29e --- /dev/null +++ b/ui/src/features/visual-editor/doc/__tests__/fixtures/long-lines.yaml @@ -0,0 +1,25 @@ +# A messy workflow with very long lines. A long comment is never reflowed: alpha0 beta1 gamma2 delta3 epsilon4 zeta5 eta6 theta7 iota8 kappa9 lambda10 omega11 alpha12 beta13 gamma14 delta15 epsilon16 zeta17 eta18 theta19 iota20 kappa21 lambda22 omega23 alpha24 beta25 gamma26 delta27 epsilon28 zeta29 +steps: + - name: fetch + description: alpha0 beta1 gamma2 delta3 epsilon4 zeta5 eta6 theta7 iota8 kappa9 lambda10 omega11 alpha12 beta13 gamma14 delta15 epsilon16 zeta17 eta18 theta19 iota20 kappa21 lambda22 omega23 alpha24 beta25 gamma26 delta27 epsilon28 zeta29 + run: curl --fail --silent --show-error --location --header Accept=json --output /tmp/out.json https://example.com/api/v1/items?page=1 + output: ITEMS + - name: report + run: echo "items from ${steps.fetch.outputs.ITEMS} and ${params.TARGET} for the release of ${env.VERSION} on the host ${env.HOSTNAME} in ten words" + depends: [fetch, a_long_name_one, a_long_name_two, a_long_name_three, a_long_name_four, a_long_name_five] + with: {first: 1, second: two, third: three three three three, fourth: four four four, fifth: five five five five five} + - name: quoted + description: "Quoted text with \"inner quotes\" and a tab\t and unicode éè 你好 😀 alpha0 beta1 gamma2 delta3 epsilon4 zeta5 eta6 theta7 iota8 kappa9 lambda10 omega11 alpha12 beta13 gamma14 delta15 epsilon16 zeta17 eta18 theta19" + run: 'echo it''s quoted and long alpha0 beta1 gamma2 delta3 epsilon4 zeta5 eta6 theta7 iota8 kappa9 lambda10 omega11 alpha12 beta13 gamma14 delta15 epsilon16 zeta17 eta18 theta19' + - name: url + description: https://example.com/very-long-path-segment/very-long-path-segment/very-long-path-segment/very-long-path-segment/very-long-path-segment/very-long-path-segment/very-long-path-segment/very-long-path-segment/end + run: echo xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx + - name: script + script: | + echo one + echo alpha0 beta1 gamma2 delta3 epsilon4 zeta5 eta6 theta7 iota8 kappa9 lambda10 omega11 alpha12 beta13 gamma14 delta15 epsilon16 zeta17 eta18 theta19 iota20 kappa21 lambda22 omega23 alpha24 + shell: bash +name: messy-long +description: alpha0 beta1 gamma2 delta3 epsilon4 zeta5 eta6 theta7 iota8 kappa9 lambda10 omega11 alpha12 beta13 gamma14 delta15 epsilon16 zeta17 eta18 theta19 iota20 kappa21 lambda22 omega23 alpha24 beta25 gamma26 delta27 epsilon28 zeta29 eta30 theta31 iota32 kappa33 lambda34 omega35 alpha36 beta37 gamma38 delta39 +labels: [one, two] +params: [TARGET=tttttttttttttttttttttttttttttt, VERSION=1.0, P0=value0, P1=value1, P2=value2, P3=value3, P4=value4, P5=value5, P6=value6, P7=value7] diff --git a/ui/src/features/visual-editor/doc/__tests__/fixtures/map-steps-forbidden.yaml b/ui/src/features/visual-editor/doc/__tests__/fixtures/map-steps-forbidden.yaml new file mode 100644 index 0000000000..770dbf2d4d --- /dev/null +++ b/ui/src/features/visual-editor/doc/__tests__/fixtures/map-steps-forbidden.yaml @@ -0,0 +1,3 @@ +steps: + build: + run: echo executed > executed.txt diff --git a/ui/src/features/visual-editor/doc/__tests__/fixtures/map-steps.yaml b/ui/src/features/visual-editor/doc/__tests__/fixtures/map-steps.yaml new file mode 100644 index 0000000000..8c897a6e0c --- /dev/null +++ b/ui/src/features/visual-editor/doc/__tests__/fixtures/map-steps.yaml @@ -0,0 +1,20 @@ +# Map-form steps: the key is the step name. +name: map-form +steps: + # Entry point. + fetch: + run: curl -sSf https://example.com/data.csv -o data.csv # download + + parse: + depends: [fetch] + run: wc -l data.csv + env: + - LC_ALL: C + + "publish-all": + depends: + - parse + action: http.request + with: + method: POST + url: https://example.com/publish diff --git a/ui/src/features/visual-editor/doc/__tests__/fixtures/multidoc-comments-markers.yaml b/ui/src/features/visual-editor/doc/__tests__/fixtures/multidoc-comments-markers.yaml new file mode 100644 index 0000000000..9bf4703912 --- /dev/null +++ b/ui/src/features/visual-editor/doc/__tests__/fixtures/multidoc-comments-markers.yaml @@ -0,0 +1,20 @@ +# First document +name: parent +steps: + - name: call + action: dag.run + with: + dag: child-a + +# comment before the marker +--- # first child +name: child-a +steps: + - name: work + run: echo a +... +--- +name: child-b # last document +steps: + - name: work + run: echo b diff --git a/ui/src/features/visual-editor/doc/__tests__/fixtures/multidoc-empty-between.yaml b/ui/src/features/visual-editor/doc/__tests__/fixtures/multidoc-empty-between.yaml new file mode 100644 index 0000000000..25ef01d432 --- /dev/null +++ b/ui/src/features/visual-editor/doc/__tests__/fixtures/multidoc-empty-between.yaml @@ -0,0 +1,9 @@ +steps: + - name: first + run: echo executed > executed.txt +--- +--- +name: child +steps: + - name: second + run: echo executed > executed.txt diff --git a/ui/src/features/visual-editor/doc/__tests__/fixtures/multidoc-subdag.yaml b/ui/src/features/visual-editor/doc/__tests__/fixtures/multidoc-subdag.yaml new file mode 100644 index 0000000000..1e6f7aaf5d --- /dev/null +++ b/ui/src/features/visual-editor/doc/__tests__/fixtures/multidoc-subdag.yaml @@ -0,0 +1,14 @@ +working_dir: . +steps: + - id: call_child + action: dag.run + with: + dag: depth_child + +--- + +name: depth_child +working_dir: . +steps: + - id: work + run: printf 'child ran\n' > child.out diff --git a/ui/src/features/visual-editor/doc/__tests__/fixtures/multidoc-unnamed-later.yaml b/ui/src/features/visual-editor/doc/__tests__/fixtures/multidoc-unnamed-later.yaml new file mode 100644 index 0000000000..8bac309687 --- /dev/null +++ b/ui/src/features/visual-editor/doc/__tests__/fixtures/multidoc-unnamed-later.yaml @@ -0,0 +1,7 @@ +steps: + - name: first + run: echo executed > executed.txt +--- +steps: + - name: second + run: echo executed > executed.txt diff --git a/ui/src/features/visual-editor/doc/__tests__/fixtures/nested-array-run.yaml b/ui/src/features/visual-editor/doc/__tests__/fixtures/nested-array-run.yaml new file mode 100644 index 0000000000..273767cd20 --- /dev/null +++ b/ui/src/features/visual-editor/doc/__tests__/fixtures/nested-array-run.yaml @@ -0,0 +1,6 @@ +working_dir: . +steps: + - id: bad + run: + - echo one + - [echo, two] diff --git a/ui/src/features/visual-editor/doc/__tests__/fixtures/nested-parallel-groups.yaml b/ui/src/features/visual-editor/doc/__tests__/fixtures/nested-parallel-groups.yaml new file mode 100644 index 0000000000..ba39931a4b --- /dev/null +++ b/ui/src/features/visual-editor/doc/__tests__/fixtures/nested-parallel-groups.yaml @@ -0,0 +1,18 @@ +name: ci # parallel groups are nested arrays +steps: + - name: prepare + run: ./prepare.sh + + # Group 1: quality gates. + - - name: lint + run: make lint + - name: unit + run: make test + # trailing comment inside the group item + - echo shorthand inside the group + + # Group 2 has one item only. + - - name: package + run: make package + - name: report + run: ./report.sh diff --git a/ui/src/features/visual-editor/doc/__tests__/fixtures/odd-indentation.yaml b/ui/src/features/visual-editor/doc/__tests__/fixtures/odd-indentation.yaml new file mode 100644 index 0000000000..6d99b1c569 --- /dev/null +++ b/ui/src/features/visual-editor/doc/__tests__/fixtures/odd-indentation.yaml @@ -0,0 +1,9 @@ +name: odd # extra spaces after the colon +steps: +- name: a + run: echo a +- name: b + depends: [a] + run: echo b + env: + - K: v diff --git a/ui/src/features/visual-editor/doc/__tests__/fixtures/quoted-keys-and-values.yaml b/ui/src/features/visual-editor/doc/__tests__/fixtures/quoted-keys-and-values.yaml new file mode 100644 index 0000000000..1c0d497ade --- /dev/null +++ b/ui/src/features/visual-editor/doc/__tests__/fixtures/quoted-keys-and-values.yaml @@ -0,0 +1,17 @@ +name: 'quoted' +"steps": + - 'name': "first" + "run": 'echo ''single'' and "double"' + - name: second + run: "echo \"escaped\" \t tab \\ backslash" + env: + "KEY WITH SPACE": "value" + 'x': yes + ? complex key + : complex value + 0x1F: !!str 0x1F + empty: + nulls: ~ + description: "multi + line quoted" + timeout_sec: 0030 diff --git a/ui/src/features/visual-editor/doc/__tests__/fixtures/router-numeric-band.yaml b/ui/src/features/visual-editor/doc/__tests__/fixtures/router-numeric-band.yaml new file mode 100644 index 0000000000..996e6b1794 --- /dev/null +++ b/ui/src/features/visual-editor/doc/__tests__/fixtures/router-numeric-band.yaml @@ -0,0 +1,11 @@ +working_dir: . +steps: + - name: pick + action: router.route + with: + value: "0.95" + routes: + "num:<0.1": [review] + "num:>0.9": [review] + - name: review + run: echo review > review.out diff --git a/ui/src/features/visual-editor/doc/__tests__/fixtures/router-regex-keys.yaml b/ui/src/features/visual-editor/doc/__tests__/fixtures/router-regex-keys.yaml new file mode 100644 index 0000000000..470bddc6ae --- /dev/null +++ b/ui/src/features/visual-editor/doc/__tests__/fixtures/router-regex-keys.yaml @@ -0,0 +1,13 @@ +working_dir: . +steps: + - name: pick + action: router.route + with: + value: "404" + routes: + "re:^5\\d\\d$": [server_error] + "re:^4\\d\\d$": [client_error] + - name: server_error + run: echo 5xx > server_error.out + - name: client_error + run: echo 4xx > client_error.out diff --git a/ui/src/features/visual-editor/doc/__tests__/fixtures/run-context.yaml b/ui/src/features/visual-editor/doc/__tests__/fixtures/run-context.yaml new file mode 100644 index 0000000000..2c37bf8b02 --- /dev/null +++ b/ui/src/features/visual-editor/doc/__tests__/fixtures/run-context.yaml @@ -0,0 +1,38 @@ +name: builtin_runtime_context +working_dir: . +env: + - RUN_ENV=${context.run.id} +steps: + - id: capture + run: | + case '${context.attempt.started_at}' in + ????-??-??T??:??:??Z) ;; + *) printf 'bad started_at=%s\n' '${context.attempt.started_at}' >&2; exit 1 ;; + esac + if [ -z '${context.attempt.id}' ]; then + printf 'attempt id missing\n' >&2 + exit 1 + fi + if [ '${context.attempt.id}' = '${context.run.id}' ]; then + printf 'attempt matched run id\n' >&2 + exit 1 + fi + test -f '${context.paths.log_file}' + test -d '${context.paths.work_dir}' + printf 'dag=%s\n' '${context.dag.name}' > run-context.txt + printf 'run=%s\n' '${context.run.id}' >> run-context.txt + printf 'root_name=%s\n' '${context.run.root_name}' >> run-context.txt + printf 'root_id=%s\n' '${context.run.root_id}' >> run-context.txt + printf 'root_env=%s\n' "$RUN_ENV" >> run-context.txt + printf 'trigger=%s\n' '${context.trigger.type}' >> run-context.txt + printf 'scheduled=%s\n' '${context.run.scheduled_at}' >> run-context.txt + printf 'started=%s\n' '${context.attempt.started_at}' >> run-context.txt + printf 'attempt=%s\n' '${context.attempt.id}' >> run-context.txt + printf 'step_id=%s\n' '${context.step.id}' >> run-context.txt + printf 'step_name=%s\n' '${context.step.name}' >> run-context.txt + printf 'env_run=%s\n' "$DAG_RUN_ID" >> run-context.txt + printf 'env_step=%s\n' "$DAG_RUN_STEP_NAME" >> run-context.txt + printf 'stdout=%s\n' '${context.paths.step_stdout_file}' >> run-context.txt + printf 'stderr=%s\n' '${context.paths.step_stderr_file}' >> run-context.txt + printf 'output=%s\n' '${context.paths.step_output_file}' >> run-context.txt + printf 'paths_ready=yes\n' >> run-context.txt diff --git a/ui/src/features/visual-editor/doc/__tests__/fixtures/scheduler-cron.yaml b/ui/src/features/visual-editor/doc/__tests__/fixtures/scheduler-cron.yaml new file mode 100644 index 0000000000..10d4df52d2 --- /dev/null +++ b/ui/src/features/visual-editor/doc/__tests__/fixtures/scheduler-cron.yaml @@ -0,0 +1,5 @@ +name: cron-test +schedule: "*/1 * * * *" +steps: + - name: test-step + run: echo "hello" diff --git a/ui/src/features/visual-editor/doc/__tests__/fixtures/script-utf8-multiline.yaml b/ui/src/features/visual-editor/doc/__tests__/fixtures/script-utf8-multiline.yaml new file mode 100644 index 0000000000..965cdecda6 --- /dev/null +++ b/ui/src/features/visual-editor/doc/__tests__/fixtures/script-utf8-multiline.yaml @@ -0,0 +1,9 @@ +working_dir: . +steps: + - id: pwsh_utf8_multiline + run: | + Write-Output "first" + $text = '台帳に伝票を3件入力(未保存)' + [System.IO.File]::WriteAllText((Join-Path (Get-Location) 'pwsh-utf8-multiline.txt'), $text, (New-Object System.Text.UTF8Encoding $false)) + with: + shell: pwsh diff --git a/ui/src/features/visual-editor/doc/__tests__/fixtures/shorthand-steps.yaml b/ui/src/features/visual-editor/doc/__tests__/fixtures/shorthand-steps.yaml new file mode 100644 index 0000000000..2a45273b14 --- /dev/null +++ b/ui/src/features/visual-editor/doc/__tests__/fixtures/shorthand-steps.yaml @@ -0,0 +1,9 @@ +name: shorthand +steps: + - echo one + - echo two # shorthand step + - name: three + run: echo three + - ./script.sh --flag "quoted arg" + - 'echo single quoted' + - "echo double quoted" diff --git a/ui/src/features/visual-editor/doc/__tests__/fixtures/spec-harness-comments.yaml b/ui/src/features/visual-editor/doc/__tests__/fixtures/spec-harness-comments.yaml new file mode 100644 index 0000000000..4a4c184131 --- /dev/null +++ b/ui/src/features/visual-editor/doc/__tests__/fixtures/spec-harness-comments.yaml @@ -0,0 +1,16 @@ +harnesses: + remote-agent: + protocol: acp + transport: http # websocket for ws:// or wss:// endpoints + url: https://acp.example.com/acp # wss://acp.example.com/acp for secure WebSocket + cwd: /srv/project + # ca_file: /path/to/private-ca.pem + headers: + Authorization: Bearer ${ACP_TOKEN} +steps: + - action: harness.run + timeout_sec: 120 + with: + provider: remote-agent + prompt: Review the project without modifying files. + output: REVIEW diff --git a/ui/src/features/visual-editor/doc/__tests__/fixtures/spec-outputs-heredoc.yaml b/ui/src/features/visual-editor/doc/__tests__/fixtures/spec-outputs-heredoc.yaml new file mode 100644 index 0000000000..ad25a5ff98 --- /dev/null +++ b/ui/src/features/visual-editor/doc/__tests__/fixtures/spec-outputs-heredoc.yaml @@ -0,0 +1,15 @@ +steps: + - id: inspect + run: | + cat >> "$DAGU_OUTPUT_FILE" <<'EOF' + metadata< result.txt + pwd > pwd.txt diff --git a/ui/src/features/visual-editor/doc/__tests__/fixtures/spec-xlsx-convert.yaml b/ui/src/features/visual-editor/doc/__tests__/fixtures/spec-xlsx-convert.yaml new file mode 100644 index 0000000000..3ff5efe89d --- /dev/null +++ b/ui/src/features/visual-editor/doc/__tests__/fixtures/spec-xlsx-convert.yaml @@ -0,0 +1,9 @@ +steps: + - id: export + action: xlsx.convert + with: + path: orders.xlsx + sheet: Orders + output: out/orders.csv + encoding: shift_jis + columns: [品名, 数量, 金額] diff --git a/ui/src/features/visual-editor/doc/__tests__/fixtures/unicode-comments.yaml b/ui/src/features/visual-editor/doc/__tests__/fixtures/unicode-comments.yaml new file mode 100644 index 0000000000..5845309fa6 --- /dev/null +++ b/ui/src/features/visual-editor/doc/__tests__/fixtures/unicode-comments.yaml @@ -0,0 +1,10 @@ +# Commentaire en français : à é ü ñ ç +# 日本語のコメント 😀 (surrogate pair) +name: unicode +steps: + - name: 日本語の手順 # nom non ASCII + run: echo "héllo wörld 🌍" + - name: emoji + run: echo 😀 + env: + GREETING: こんにちは diff --git a/ui/src/features/visual-editor/doc/__tests__/fixtures/xlsx-japanese.yaml b/ui/src/features/visual-editor/doc/__tests__/fixtures/xlsx-japanese.yaml new file mode 100644 index 0000000000..6e52d37536 --- /dev/null +++ b/ui/src/features/visual-editor/doc/__tests__/fixtures/xlsx-japanese.yaml @@ -0,0 +1,42 @@ +name: quote-extract-japanese +working_dir: . +env: + - LLM_BASE_URL: ${LLM_BASE_URL} +llm: + provider: local + model: test-model + base_url: ${LLM_BASE_URL} +steps: + - id: form + action: xlsx.write + with: + path: quote.xlsx + header: false + style: none + rows: + - ["御見積書", null] + - ["見積番号", "Q-2026-001"] + - ["納期", "令和8年10月15日"] + - ["合計金額", "¥123,000"] + - ["納品日時", "令和8年10月15日 午後2時30分"] + - ["検収", "済"] + - id: fields + depends: form + action: xlsx.extract + with: + path: quote.xlsx + instruction: A supplier quote. Find the quote number, the delivery date, and the total amount. + schema: + type: object + properties: + quote_no: {type: string, description: 見積番号} + delivery: {type: string, format: date, description: 納期} + total: {type: number, description: 合計金額} + delivered: {type: string, format: date-time, description: 納品日時} + accepted: {type: boolean, description: 検収} + - id: out + depends: fields + action: file.write + with: + path: out.json + content: '{"quote_no": "${steps.fields.outputs.quote_no}", "delivery": "${steps.fields.outputs.delivery}", "total": ${steps.fields.outputs.total}, "delivered": "${steps.fields.outputs.delivered}", "accepted": ${steps.fields.outputs.accepted}, "cells": ${steps.fields.outputs.cells}, "source": "${steps.fields.outputs.source}"}' From 980d79a3775294bfa68483a43aa6485652a94372 Mon Sep 17 00:00:00 2001 From: SCcagg5 Date: Wed, 28 Oct 2026 12:39:10 +0100 Subject: [PATCH 2/8] UI: components, lib, dags/components --- ui/src/components/ui/__tests__/badge.test.tsx | 22 + .../ui/__tests__/copy-button.test.tsx | 72 +++ .../ui/__tests__/icon-button.test.tsx | 154 +++++ ui/src/components/ui/__tests__/kbd.test.tsx | 15 + .../ui/__tests__/repeat-summary.test.ts | 154 +++++ .../ui/__tests__/status-chip.test.tsx | 13 + .../ui/__tests__/status-icon.test.tsx | 59 ++ ui/src/components/ui/badge.tsx | 9 +- ui/src/components/ui/compact-count.tsx | 34 ++ ui/src/components/ui/copy-button.tsx | 54 ++ ui/src/components/ui/dialog.tsx | 97 +++- ui/src/components/ui/icon-button.tsx | 129 +++++ ui/src/components/ui/kbd.tsx | 24 + ui/src/components/ui/matrix-text.tsx | 57 +- ui/src/components/ui/refresh-button.tsx | 21 +- ui/src/components/ui/relative-time.tsx | 102 +++- ui/src/components/ui/repeat-summary.ts | 188 ++++++ ui/src/components/ui/retry-count.tsx | 31 + ui/src/components/ui/spinner.tsx | 31 + ui/src/components/ui/status-chip.tsx | 18 +- ui/src/components/ui/status-icon.tsx | 93 +++ ui/src/components/ui/ticker.tsx | 11 +- ui/src/components/ui/tooltip.tsx | 42 +- ui/src/contexts/LayoutChromeContext.tsx | 102 ++++ ui/src/contexts/RightPanelContext.tsx | 199 +++++++ .../__tests__/RightPanelContext.test.tsx | 200 +++++++ .../components/common/RunMetaLine.tsx | 535 +++++++++++++++--- .../common/__tests__/RunMetaLine.test.tsx | 340 ++++++++++- .../dag-run-details/DAGRunHeader.tsx | 325 ++++++----- .../__tests__/DAGRunHeader.test.tsx | 132 ++++- .../hooks/__tests__/useExactDAGRuns.test.tsx | 380 ------------- .../dag-runs/lib/__tests__/dagRunUrls.test.ts | 120 +++- ui/src/features/dag-runs/lib/dagRunUrls.ts | 183 +++++- .../dags/components/common/DAGPagination.tsx | 2 +- .../dags/components/common/NodeStatusChip.tsx | 20 +- .../common/__tests__/PushBackHistory.test.tsx | 2 +- .../__tests__/DAGStatusOverview.test.tsx | 2 +- .../__tests__/RunProgressModal.test.tsx | 6 +- .../components/step-details/PanelShell.tsx | 171 ++++++ .../components/step-details/StepDetails.tsx | 34 +- .../step-details/StepDetailsDrawer.tsx | 393 +++---------- .../step-details/StepSideDrawer.tsx | 457 +++++++++++++++ .../foreach/ForeachItemDetail.tsx | 12 +- .../foreach/ForeachItemsSection.tsx | 9 +- .../inspector/__tests__/realSchema.ts | 22 + .../dock/tabs/__tests__/leafStubs.tsx | 41 ++ ui/src/hooks/__tests__/useClock.test.tsx | 110 ++++ ui/src/hooks/useClock.ts | 131 +++++ ui/src/hooks/useCopyFeedback.ts | 13 +- ui/src/lib/__tests__/dagRunTiming.test.ts | 85 ++- .../lib/__tests__/formatCompactCount.test.ts | 64 +++ ui/src/lib/__tests__/formatStepSpan.test.ts | 89 +++ ui/src/lib/__tests__/pageVisibility.test.ts | 49 ++ ui/src/lib/__tests__/runDisplayStatus.test.ts | 142 +++++ ui/src/lib/__tests__/statusPalette.test.ts | 146 +++++ ui/src/lib/clipboard.ts | 16 + ui/src/lib/dagRunTiming.ts | 113 +++- ui/src/lib/formatCompactCount.ts | 46 ++ ui/src/lib/formatStepSpan.ts | 54 ++ ui/src/lib/hatchPattern.ts | 11 + ui/src/lib/monaco-yaml.ts | 21 + ui/src/lib/pageVisibility.ts | 27 + ui/src/lib/runDisplayStatus.ts | 113 ++++ ui/src/lib/shortRunId.ts | 13 + ui/src/lib/statusPalette.ts | 126 +++++ ui/src/lib/syntax-colors.ts | 30 + ui/src/lib/utils.ts | 4 + ui/src/styles/global.css | 79 +++ ui/src/test/setup.ts | 14 + ui/src/testing/__tests__/importGraph.test.ts | 127 +++++ ui/src/testing/importGraph.ts | 213 +++++++ ui/src/testing/jsxLiterals.ts | 184 ++++++ 72 files changed, 5984 insertions(+), 1153 deletions(-) create mode 100644 ui/src/components/ui/__tests__/badge.test.tsx create mode 100644 ui/src/components/ui/__tests__/copy-button.test.tsx create mode 100644 ui/src/components/ui/__tests__/icon-button.test.tsx create mode 100644 ui/src/components/ui/__tests__/kbd.test.tsx create mode 100644 ui/src/components/ui/__tests__/repeat-summary.test.ts create mode 100644 ui/src/components/ui/__tests__/status-icon.test.tsx create mode 100644 ui/src/components/ui/compact-count.tsx create mode 100644 ui/src/components/ui/copy-button.tsx create mode 100644 ui/src/components/ui/icon-button.tsx create mode 100644 ui/src/components/ui/kbd.tsx create mode 100644 ui/src/components/ui/repeat-summary.ts create mode 100644 ui/src/components/ui/retry-count.tsx create mode 100644 ui/src/components/ui/spinner.tsx create mode 100644 ui/src/components/ui/status-icon.tsx create mode 100644 ui/src/contexts/LayoutChromeContext.tsx create mode 100644 ui/src/contexts/RightPanelContext.tsx create mode 100644 ui/src/contexts/__tests__/RightPanelContext.test.tsx delete mode 100644 ui/src/features/dag-runs/hooks/__tests__/useExactDAGRuns.test.tsx create mode 100644 ui/src/features/dags/components/step-details/PanelShell.tsx create mode 100644 ui/src/features/dags/components/step-details/StepSideDrawer.tsx create mode 100644 ui/src/features/visual-editor/inspector/__tests__/realSchema.ts create mode 100644 ui/src/features/workflow-workspace/dock/tabs/__tests__/leafStubs.tsx create mode 100644 ui/src/hooks/__tests__/useClock.test.tsx create mode 100644 ui/src/hooks/useClock.ts create mode 100644 ui/src/lib/__tests__/formatCompactCount.test.ts create mode 100644 ui/src/lib/__tests__/formatStepSpan.test.ts create mode 100644 ui/src/lib/__tests__/pageVisibility.test.ts create mode 100644 ui/src/lib/__tests__/runDisplayStatus.test.ts create mode 100644 ui/src/lib/__tests__/statusPalette.test.ts create mode 100644 ui/src/lib/formatCompactCount.ts create mode 100644 ui/src/lib/formatStepSpan.ts create mode 100644 ui/src/lib/hatchPattern.ts create mode 100644 ui/src/lib/monaco-yaml.ts create mode 100644 ui/src/lib/pageVisibility.ts create mode 100644 ui/src/lib/runDisplayStatus.ts create mode 100644 ui/src/lib/shortRunId.ts create mode 100644 ui/src/lib/statusPalette.ts create mode 100644 ui/src/lib/syntax-colors.ts create mode 100644 ui/src/testing/__tests__/importGraph.test.ts create mode 100644 ui/src/testing/importGraph.ts create mode 100644 ui/src/testing/jsxLiterals.ts diff --git a/ui/src/components/ui/__tests__/badge.test.tsx b/ui/src/components/ui/__tests__/badge.test.tsx new file mode 100644 index 0000000000..dc113b855e --- /dev/null +++ b/ui/src/components/ui/__tests__/badge.test.tsx @@ -0,0 +1,22 @@ +// Copyright (C) 2026 Yota Hamada +// SPDX-License-Identifier: GPL-3.0-or-later + +import { render, screen } from '@testing-library/react'; +import { describe, expect, it } from 'vitest'; +import { Badge } from '../badge'; + +describe('Badge', () => { + it('has the default size', () => { + render(Default); + expect(screen.getByText('Default')).toHaveClass('h-5', 'px-1.5'); + }); + + it('has a small size for dense lists', () => { + render(Small); + const badge = screen.getByText('Small'); + expect(badge).toHaveClass('h-4', 'px-1', 'text-[10px]', 'leading-4'); + expect(badge).not.toHaveClass('h-5'); + expect(badge).not.toHaveClass('px-1.5'); + expect(badge).not.toHaveClass('text-[11px]'); + }); +}); diff --git a/ui/src/components/ui/__tests__/copy-button.test.tsx b/ui/src/components/ui/__tests__/copy-button.test.tsx new file mode 100644 index 0000000000..1cc9d5f559 --- /dev/null +++ b/ui/src/components/ui/__tests__/copy-button.test.tsx @@ -0,0 +1,72 @@ +// Copyright (C) 2026 Yota Hamada +// SPDX-License-Identifier: GPL-3.0-or-later + +import { act, fireEvent, render, screen } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { CopyButton } from '@/components/ui/copy-button'; + +const copyText = vi.hoisted(() => vi.fn()); +vi.mock('@/lib/clipboard', () => ({ copyText })); + +async function click(name: string) { + await act(async () => { + fireEvent.click(screen.getByRole('button', { name })); + }); +} + +describe('CopyButton', () => { + beforeEach(() => { + vi.useFakeTimers(); + copyText.mockReset(); + copyText.mockResolvedValue(true); + }); + afterEach(() => { + vi.useRealTimers(); + }); + + it('copies, shows Check for 2 s, then returns', async () => { + render(); + const idle = screen.getByRole('button', { name: 'Copy id' }); + const idleIcon = idle.innerHTML; + + await click('Copy id'); + expect(copyText).toHaveBeenCalledWith('abc'); + + const copied = screen.getByRole('button', { name: 'Copied' }); + expect(copied.innerHTML).not.toBe(idleIcon); + expect(copied.querySelector('svg')).toHaveClass('text-success'); + expect(copied.className).toBe(idle.className); + + await act(async () => { + vi.advanceTimersByTime(1999); + }); + expect(screen.getByRole('button', { name: 'Copied' })).toBeInTheDocument(); + + await act(async () => { + vi.advanceTimersByTime(1); + }); + const back = screen.getByRole('button', { name: 'Copy id' }); + expect(back.innerHTML).toBe(idleIcon); + }); + + it('uses the copied label that the caller gives', async () => { + render(); + await click('Copy'); + expect(screen.getByRole('button', { name: 'Done' })).toBeInTheDocument(); + }); + + it('reads an async text function when clicked', async () => { + const text = vi.fn(async () => 'later'); + render(); + expect(text).not.toHaveBeenCalled(); + await click('Copy'); + expect(copyText).toHaveBeenCalledWith('later'); + }); + + it('stays idle when the copy fails', async () => { + copyText.mockResolvedValue(false); + render(); + await click('Copy'); + expect(screen.getByRole('button', { name: 'Copy' })).toBeInTheDocument(); + }); +}); diff --git a/ui/src/components/ui/__tests__/icon-button.test.tsx b/ui/src/components/ui/__tests__/icon-button.test.tsx new file mode 100644 index 0000000000..5a64bb367a --- /dev/null +++ b/ui/src/components/ui/__tests__/icon-button.test.tsx @@ -0,0 +1,154 @@ +// Copyright (C) 2026 Yota Hamada +// SPDX-License-Identifier: GPL-3.0-or-later + +import { render, screen, within } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { Play } from 'lucide-react'; +import { describe, expect, it, vi } from 'vitest'; +import { IconButton } from '@/components/ui/icon-button'; +import { Spinner } from '@/components/ui/spinner'; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from '@/components/ui/tooltip'; + +function sizeClasses(el: HTMLElement): string[] { + return el.className.split(/\s+/).filter((c) => /^size-|^h-|^w-/.test(c)); +} + +describe('IconButton', () => { + it('keeps the same size classes when idle, busy and disabled', () => { + render( +
+ + + +
+ ); + const idle = sizeClasses(screen.getByRole('button', { name: 'Idle' })); + expect(idle).toContain('size-6'); + expect(sizeClasses(screen.getByRole('button', { name: 'Busy' }))).toEqual( + idle + ); + expect(sizeClasses(screen.getByRole('button', { name: 'Off' }))).toEqual( + idle + ); + }); + + it('uses 28 px for md and puts a spinner in the icon slot', () => { + render(); + const button = screen.getByRole('button', { name: 'Run' }); + expect(button).toHaveClass('size-7'); + expect(button).toHaveAttribute('aria-busy', 'true'); + expect(button.querySelectorAll('svg')).toHaveLength(1); + expect(button.querySelector('svg')).toHaveClass('animate-spin'); + }); + + it('ignores clicks while busy', async () => { + const onClick = vi.fn(); + render(); + await userEvent.click(screen.getByRole('button', { name: 'Run' })); + expect(onClick).not.toHaveBeenCalled(); + }); + + it('sets aria-pressed and a pressed background', () => { + render( +
+ + + +
+ ); + const on = screen.getByRole('button', { name: 'On' }); + expect(on).toHaveAttribute('aria-pressed', 'true'); + expect(on).toHaveClass('bg-muted'); + expect(screen.getByRole('button', { name: 'Off' })).toHaveAttribute( + 'aria-pressed', + 'false' + ); + expect(screen.getByRole('button', { name: 'Plain' })).not.toHaveAttribute( + 'aria-pressed' + ); + }); + + it('shows the label, shortcut and description in the tooltip', async () => { + render( + + ); + await userEvent.hover(screen.getByRole('button', { name: 'Run step' })); + const tip = await screen.findByRole('tooltip'); + expect(tip).toHaveTextContent('Run step'); + expect(tip).toHaveTextContent('Ctrl+R'); + expect(tip).toHaveTextContent('Runs this step alone'); + }); + + it('shows the disabled reason in the tooltip', async () => { + const onClick = vi.fn(); + render( + + ); + const button = screen.getByRole('button', { name: 'Run step' }); + expect(button).toBeDisabled(); + // The wrapper takes the pointer event that the disabled button cannot. + await userEvent.hover(button.parentElement as HTMLElement); + const tip = await screen.findByRole('tooltip'); + expect(tip).toHaveTextContent('Save the workflow first'); + expect(onClick).not.toHaveBeenCalled(); + }); +}); + +// A second Radix tooltip that opens closes the first one and its content: +// the button of a tooltip keeps working, and the title says its name. +describe('IconButton in a tooltip', () => { + it('stays clickable and names itself with a title', async () => { + const user = userEvent.setup(); + const onClick = vi.fn(); + render( + + + + host + + + + + + + ); + await user.hover(screen.getByTestId('host')); + const tip = await screen.findByRole('tooltip'); + const button = within(tip).getByRole('button', { name: 'Copy it' }); + expect(button).toHaveAttribute('title', 'Copy it'); + + await user.click(button); + + expect(onClick).toHaveBeenCalledTimes(1); + expect(screen.getAllByRole('tooltip')).toHaveLength(1); + }); +}); + +describe('Spinner', () => { + it('is hidden from assistive technology without a label', () => { + const { container } = render(); + const svg = container.querySelector('svg'); + expect(svg).toHaveAttribute('aria-hidden', 'true'); + expect(svg).toHaveClass('size-3.5', 'animate-spin'); + expect(screen.queryByRole('status')).toBeNull(); + }); + + it('is a named status with a label', () => { + render(); + expect(screen.getByRole('status', { name: 'Loading' })).toBeInTheDocument(); + }); +}); diff --git a/ui/src/components/ui/__tests__/kbd.test.tsx b/ui/src/components/ui/__tests__/kbd.test.tsx new file mode 100644 index 0000000000..148286f99e --- /dev/null +++ b/ui/src/components/ui/__tests__/kbd.test.tsx @@ -0,0 +1,15 @@ +// Copyright (C) 2026 Yota Hamada +// SPDX-License-Identifier: GPL-3.0-or-later + +import { render, screen } from '@testing-library/react'; +import { describe, expect, it } from 'vitest'; +import { Kbd } from '@/components/ui/kbd'; + +describe('Kbd', () => { + it('renders a kbd element with the key text and the shared box', () => { + render(Ctrl/Cmd+C); + const key = screen.getByText('Ctrl/Cmd+C'); + expect(key.tagName).toBe('KBD'); + expect(key).toHaveClass('rounded', 'border', 'bg-muted', 'ml-4'); + }); +}); diff --git a/ui/src/components/ui/__tests__/repeat-summary.test.ts b/ui/src/components/ui/__tests__/repeat-summary.test.ts new file mode 100644 index 0000000000..d2287bb680 --- /dev/null +++ b/ui/src/components/ui/__tests__/repeat-summary.test.ts @@ -0,0 +1,154 @@ +// Copyright (C) 2026 Yota Hamada +// SPDX-License-Identifier: GPL-3.0-or-later + +import { describe, expect, it } from 'vitest'; +import { + readRepeatPolicy, + repeatIteration, + repeatSummary, + type RepeatPolicyView, +} from '../repeat-summary'; + +// The English catalog: the key is the text. +const ts = (source: string, values?: Record) => + source.replace(/\{(\w+)\}/g, (_, key: string) => String(values?.[key])); + +function summary(raw: unknown): string { + const policy = readRepeatPolicy(raw); + if (!policy) throw new Error('no policy'); + return repeatSummary(policy, ts); +} + +describe('readRepeatPolicy', () => { + it('reads the YAML form', () => { + expect( + readRepeatPolicy({ + repeat: 'until', + interval_sec: 30, + limit: 5, + condition: '${STATE}', + expected: 'ready', + exit_code: [0, 2], + }) + ).toEqual({ + mode: 'until', + intervalSec: 30, + limit: 5, + condition: '${STATE} = ready', + exitCodes: [0, 2], + }); + }); + + it('reads the API form', () => { + expect( + readRepeatPolicy({ + repeat: 'while', + interval: 10, + limit: 3, + condition: { condition: 'x', expected: 'y' }, + exitCode: [1], + backoff: 2, + }) + ).toEqual({ + mode: 'while', + intervalSec: 10, + limit: 3, + condition: 'x = y', + exitCodes: [1], + backoff: true, + }); + }); + + it('reads the boolean and unknown forms as while', () => { + expect(readRepeatPolicy({ repeat: true })?.mode).toBe('while'); + expect(readRepeatPolicy({ repeat: 'sometimes' })?.mode).toBe('while'); + }); + + it('infers the mode as the server does when repeat is absent', () => { + expect(readRepeatPolicy({ condition: 'a', expected: 'b' })?.mode).toBe( + 'until' + ); + expect(readRepeatPolicy({ condition: 'a' })?.mode).toBe('while'); + expect(readRepeatPolicy({ exit_code: [1] })?.mode).toBe('while'); + }); + + it('gives null for a step that does not repeat', () => { + expect(readRepeatPolicy(undefined)).toBeNull(); + expect(readRepeatPolicy('while')).toBeNull(); + expect(readRepeatPolicy({})).toBeNull(); + expect(readRepeatPolicy({ interval_sec: 5 })).toBeNull(); + }); + + it('keeps only fixed whole numbers for the interval and the limit', () => { + expect( + readRepeatPolicy({ repeat: 'while', interval_sec: '15', limit: '${N}' }) + ).toEqual({ mode: 'while', intervalSec: 15 }); + expect( + readRepeatPolicy({ repeat: 'while', interval_sec: 0, limit: -1 }) + ).toEqual({ mode: 'while' }); + }); +}); + +describe('repeatSummary', () => { + it('writes an unconditional while with an interval and no limit', () => { + expect(summary({ repeat: 'while', interval_sec: 10 })).toBe( + 'repeat while exit 0 · every 10s · no limit' + ); + }); + + it('writes until with a condition, an interval and a limit', () => { + expect( + summary({ + repeat: 'until', + interval_sec: 30, + limit: 5, + condition: 'ready', + }) + ).toBe('repeat until "ready" · every 30s · max 5'); + }); + + it('writes the exit codes', () => { + expect(summary({ repeat: 'while', exit_code: [1, 2] })).toBe( + 'repeat while exit 1, 2 · no limit' + ); + }); + + it('writes no interval when the policy has none', () => { + expect(summary({ repeat: 'until', limit: 2 })).toBe( + 'repeat until exit 0 · max 2' + ); + }); + + it('reads the boolean form as while', () => { + expect(summary({ repeat: true, interval_sec: 120 })).toBe( + 'repeat while exit 0 · every 2m · no limit' + ); + }); + + it('cuts a long condition to 24 characters', () => { + const text = summary({ + repeat: 'while', + condition: 'abcdefghijklmnopqrstuvwxyz0123456789', + }); + expect(text).toContain('"abcdefghijklmnopqrstuvw…"'); + }); +}); + +describe('repeatIteration', () => { + const limited: RepeatPolicyView = { mode: 'while', limit: 5 }; + const open: RepeatPolicyView = { mode: 'while' }; + + it('counts the running iteration after the finished ones', () => { + expect(repeatIteration(limited, 2, true, ts)).toBe('iteration 3 of 5'); + expect(repeatIteration(open, 2, true, ts)).toBe('iteration 3'); + }); + + it('counts the finished ones for a step that stopped', () => { + expect(repeatIteration(limited, 3, false, ts)).toBe('iteration 3 of 5'); + }); + + it('gives nothing before the first run', () => { + expect(repeatIteration(limited, 0, false, ts)).toBeNull(); + expect(repeatIteration(limited, 0, true, ts)).toBe('iteration 1 of 5'); + }); +}); diff --git a/ui/src/components/ui/__tests__/status-chip.test.tsx b/ui/src/components/ui/__tests__/status-chip.test.tsx index 15237f6429..0ae40bd088 100644 --- a/ui/src/components/ui/__tests__/status-chip.test.tsx +++ b/ui/src/components/ui/__tests__/status-chip.test.tsx @@ -27,4 +27,17 @@ describe('StatusChip', () => { expect(screen.getByText('exit 3')).toBeVisible(); }); + + it('reads Healthy in the success token for a healthy run', () => { + render( + + {StatusLabel.running} + + ); + + const chip = screen.getByText('Healthy').closest('[data-status]'); + expect(chip).toHaveAttribute('data-status', String(Status.Running)); + expect(chip).toHaveClass('status-success'); + expect(screen.queryByText('Running')).toBeNull(); + }); }); diff --git a/ui/src/components/ui/__tests__/status-icon.test.tsx b/ui/src/components/ui/__tests__/status-icon.test.tsx new file mode 100644 index 0000000000..41b8fe585d --- /dev/null +++ b/ui/src/components/ui/__tests__/status-icon.test.tsx @@ -0,0 +1,59 @@ +import { render, screen } from '@testing-library/react'; +import { describe, expect, it } from 'vitest'; +import { Status } from '@/api/v1/schema'; +import StatusIcon from '../status-icon'; + +describe('StatusIcon', () => { + it.each([ + [Status.NotStarted, 'Not started'], + [Status.Running, 'Running'], + [Status.Failed, 'Failed'], + [Status.Aborted, 'Aborted'], + [Status.Success, 'Succeeded'], + [Status.Queued, 'Queued'], + [Status.PartialSuccess, 'Partially succeeded'], + [Status.Waiting, 'Waiting'], + [Status.Rejected, 'Rejected'], + ])('names status %i with its text', (status, text) => { + render(); + + const icon = screen.getByRole('img', { name: text }); + expect(icon).toHaveAttribute('title', text); + expect(icon).not.toHaveTextContent(/\S/); + }); + + it('spins only while the run is running', () => { + const { container, rerender } = render( + + ); + expect(container.querySelector('svg')).toHaveClass('animate-spin'); + + rerender(); + expect(container.querySelector('svg')).not.toHaveClass('animate-spin'); + }); + + it('renders nothing without a status', () => { + const { container } = render(); + expect(container).toBeEmptyDOMElement(); + }); + + it('is a calm heart named Healthy for a run that never ends', () => { + const { container } = render( + + ); + const icon = screen.getByRole('img', { name: 'Healthy' }); + expect(icon).toHaveAttribute('title', 'Healthy'); + expect(icon).toHaveAttribute('data-status', String(Status.Running)); + expect(icon.style.color).toBe('var(--status-success)'); + const svg = container.querySelector('svg'); + expect(svg).toHaveClass('motion-safe:animate-pulse'); + expect(svg).not.toHaveClass('animate-spin'); + }); + + it('gives no native title when a tooltip of the parent speaks', () => { + render(); + expect(screen.getByRole('img', { name: 'Failed' })).not.toHaveAttribute( + 'title' + ); + }); +}); diff --git a/ui/src/components/ui/badge.tsx b/ui/src/components/ui/badge.tsx index cb48e9aff7..2080794c57 100644 --- a/ui/src/components/ui/badge.tsx +++ b/ui/src/components/ui/badge.tsx @@ -27,9 +27,15 @@ const badgeVariants = cva( 'bg-destructive/10 text-[color-mix(in_srgb,var(--destructive)_85%,#000)] border-transparent dark:bg-destructive/15 dark:text-[color-mix(in_srgb,var(--destructive)_90%,#fff)]', cancelled: 'bg-muted text-muted-foreground border-transparent', }, + size: { + default: '', + // Smaller chip for dense lists. It follows the base classes. + sm: 'h-4 px-1 text-[10px] leading-4', + }, }, defaultVariants: { variant: 'default', + size: 'default', }, } ); @@ -37,6 +43,7 @@ const badgeVariants = cva( function Badge({ className, variant, + size, asChild = false, ...props }: React.ComponentProps<'span'> & @@ -46,7 +53,7 @@ function Badge({ return ( ); diff --git a/ui/src/components/ui/compact-count.tsx b/ui/src/components/ui/compact-count.tsx new file mode 100644 index 0000000000..b983a1d385 --- /dev/null +++ b/ui/src/components/ui/compact-count.tsx @@ -0,0 +1,34 @@ +// Copyright (C) 2026 Yota Hamada +// SPDX-License-Identifier: GPL-3.0-or-later + +import type { ReactElement } from 'react'; +import { useI18n } from '@/i18n/I18nProvider'; +import { formatCompactCount, formatExactCount } from '@/lib/formatCompactCount'; + +type Props = { + /** The count of executions. */ + value: number; + className?: string; +}; + +/** + * A count of executions in its compact form (`1.2k`), with the whole count, + * in the separators of the language, as its title and accessible name. Every + * place that prints a count of executions without the repeat badge uses it. + */ +export function CompactCount({ value, className }: Props): ReactElement { + const { locale } = useI18n(); + const exact = formatExactCount(value, locale); + return ( + + {formatCompactCount(value)} + + ); +} + +export default CompactCount; diff --git a/ui/src/components/ui/copy-button.tsx b/ui/src/components/ui/copy-button.tsx new file mode 100644 index 0000000000..14c843afb8 --- /dev/null +++ b/ui/src/components/ui/copy-button.tsx @@ -0,0 +1,54 @@ +// Copyright (C) 2026 Yota Hamada +// SPDX-License-Identifier: GPL-3.0-or-later + +import { Check, Copy, type LucideIcon } from 'lucide-react'; + +import { IconButton, type IconButtonProps } from '@/components/ui/icon-button'; +import { useCopyFeedback } from '@/hooks/useCopyFeedback'; +import { useI18n } from '@/i18n/I18nProvider'; + +export interface CopyButtonProps + extends Omit< + IconButtonProps, + 'icon' | 'label' | 'onClick' | 'pressed' | 'iconClassName' + > { + /** The text, or a function that returns it when the user clicks. */ + text: string | (() => string | Promise); + label: string; + /** Name shown for 2000 ms after the copy. Defaults to "Copied". */ + copiedLabel?: string; + /** Defaults to Copy. */ + icon?: LucideIcon; + /** Called once each time the text reached the clipboard. */ + onCopied?: () => void; +} + +/** + * An icon-only copy action. On success the icon becomes a Check in the + * success colour, and the name becomes the copied label, for 2000 ms. + */ +export function CopyButton({ + text, + label, + copiedLabel, + icon = Copy, + onCopied, + ...props +}: CopyButtonProps) { + const { ts } = useI18n(); + const { copied, copy } = useCopyFeedback(2000, onCopied); + + const onClick = async () => { + await copy(typeof text === 'function' ? await text() : text); + }; + + return ( + void onClick()} + /> + ); +} diff --git a/ui/src/components/ui/dialog.tsx b/ui/src/components/ui/dialog.tsx index 98812efc74..2a9cdcc931 100644 --- a/ui/src/components/ui/dialog.tsx +++ b/ui/src/components/ui/dialog.tsx @@ -15,6 +15,33 @@ const DialogPortal = DialogPrimitive.Portal; const DialogClose = DialogPrimitive.Close; +/** + * The layer of a dialog. 'default' is z-50. 'raised' is z-[80], the Z.dialog + * layer of the workspace, above the node view (z-[60]) and the menus. + */ +type DialogLayer = 'default' | 'raised'; + +const RAISED_LAYER = 'z-[80]'; + +const DialogLayerContext = React.createContext('default'); + +/** Every dialog below this provider takes the layer, unless it sets its own. */ +const DialogLayerProvider = DialogLayerContext.Provider; + +/** + * The z-index class for a popup (select, menu, tooltip) that renders in a + * portal. Inside a raised dialog or workspace it must sit above the dialog + * overlay; elsewhere it keeps its own layer. + */ +function useRaisedLayerClass(): string | undefined { + return useIsRaisedLayer() ? RAISED_LAYER : undefined; +} + +/** True below a raised layer provider, for drawers that sit under dialogs. */ +function useIsRaisedLayer(): boolean { + return React.useContext(DialogLayerContext) === 'raised'; +} + const DialogOverlay = React.forwardRef< React.ElementRef, React.ComponentPropsWithoutRef @@ -35,34 +62,52 @@ const DialogContent = React.forwardRef< React.ComponentPropsWithoutRef & { hideCloseButton?: boolean; fullscreen?: boolean; + /** Defaults to the layer of the nearest DialogLayerProvider. */ + layer?: DialogLayer; } >( ( - { className, children, hideCloseButton, fullscreen = false, ...props }, + { + className, + children, + hideCloseButton, + fullscreen = false, + layer, + ...props + }, ref - ) => ( - - - - {children} - {!hideCloseButton && ( - - - - - )} - - - ) + ) => { + const inherited = React.useContext(DialogLayerContext); + const raised = (layer ?? inherited) === 'raised'; + return ( + + + + + {children} + + {!hideCloseButton && ( + + + + + + + )} + + + ); + } ); DialogContent.displayName = DialogPrimitive.Content.displayName; @@ -118,6 +163,7 @@ const DialogDescription = React.forwardRef< )); DialogDescription.displayName = DialogPrimitive.Description.displayName; +export type { DialogLayer }; export { Dialog, DialogClose, @@ -125,8 +171,11 @@ export { DialogDescription, DialogFooter, DialogHeader, + DialogLayerProvider, DialogOverlay, DialogPortal, DialogTitle, DialogTrigger, + useIsRaisedLayer, + useRaisedLayerClass, }; diff --git a/ui/src/components/ui/icon-button.tsx b/ui/src/components/ui/icon-button.tsx new file mode 100644 index 0000000000..e2e016dbdd --- /dev/null +++ b/ui/src/components/ui/icon-button.tsx @@ -0,0 +1,129 @@ +// Copyright (C) 2026 Yota Hamada +// SPDX-License-Identifier: GPL-3.0-or-later + +import type { LucideIcon } from 'lucide-react'; +import * as React from 'react'; + +import { Button } from '@/components/ui/button'; +import { Kbd } from '@/components/ui/kbd'; +import { Spinner } from '@/components/ui/spinner'; +import { + Tooltip, + TooltipContent, + TooltipTrigger, + useInsideTooltip, +} from '@/components/ui/tooltip'; +import { cn, FOCUS_RING } from '@/lib/utils'; + +export interface IconButtonProps + extends Omit, 'children' | 'aria-label'> { + /** The name of the action: its accessible name and the tooltip title. */ + label: string; + icon: LucideIcon; + /** sm is 24 px (node toolbars and rows); md is 28 px (elsewhere). */ + size?: 'sm' | 'md'; + tone?: 'default' | 'destructive'; + /** The button is a toggle: set aria-pressed and a pressed background. */ + pressed?: boolean; + /** Show a spinner in the icon slot and ignore clicks. The box is unchanged. */ + busy?: boolean; + /** Disables the button and shows this text in the tooltip. */ + disabledReason?: string; + /** The key text that the action has, for example from canvasShortcut(). */ + shortcut?: string; + /** A second tooltip line. */ + description?: string; + /** Extra classes for the icon, for example a status colour. */ + iconClassName?: string; + tooltipSide?: React.ComponentProps['side']; +} + +const SIZE_CLASS = { + sm: 'size-6 [&_svg]:size-3.5', + md: 'size-7 [&_svg]:size-4', +} as const; + +/** + * An icon-only action. The name is in the tooltip and in aria-label, also + * while the button is disabled or busy. + */ +export function IconButton({ + label, + icon: Icon, + size = 'md', + tone = 'default', + pressed, + busy = false, + disabledReason, + shortcut, + description, + iconClassName, + tooltipSide = 'bottom', + disabled, + className, + onClick, + type = 'button', + ...buttonProps +}: IconButtonProps) { + const insideTooltip = useInsideTooltip(); + const isDisabled = Boolean(disabled || disabledReason); + const button = ( + + ); + + // A tooltip in a tooltip would close the outer one: the title says the name. + if (insideTooltip) return button; + + return ( + + + {/* A disabled button takes no pointer or focus event: its wrapper does. */} + {isDisabled ? ( + + {button} + + ) : ( + button + )} + + +
+ + {label} + {shortcut && {shortcut}} + + {description && ( + {description} + )} + {disabledReason && ( + {disabledReason} + )} +
+
+
+ ); +} diff --git a/ui/src/components/ui/kbd.tsx b/ui/src/components/ui/kbd.tsx new file mode 100644 index 0000000000..129bbeb1cc --- /dev/null +++ b/ui/src/components/ui/kbd.tsx @@ -0,0 +1,24 @@ +// Copyright (C) 2026 Yota Hamada +// SPDX-License-Identifier: GPL-3.0-or-later + +import type { ComponentProps, ReactElement } from 'react'; +import { cn } from '@/lib/utils'; + +/** + * The text of a key, in a small box. Menus and the key help use this one + * look for every key hint. + */ +export function Kbd({ + className, + ...props +}: ComponentProps<'kbd'>): ReactElement { + return ( + + ); +} diff --git a/ui/src/components/ui/matrix-text.tsx b/ui/src/components/ui/matrix-text.tsx index e614ef7294..e6712d973c 100644 --- a/ui/src/components/ui/matrix-text.tsx +++ b/ui/src/components/ui/matrix-text.tsx @@ -1,5 +1,6 @@ -import React, { useEffect, useRef, useState } from 'react'; +import React, { useSyncExternalStore } from 'react'; +import { useClock } from '@/hooks/useClock'; import { cn } from '@/lib/utils'; type MatrixTextProps = { @@ -8,38 +9,34 @@ type MatrixTextProps = { glowSpeed?: number; }; -function MatrixText({ text, className, glowSpeed = 175 }: MatrixTextProps) { - const [glowPosition, setGlowPosition] = useState(0); - const intervalRef = useRef(null); - const prefersReducedMotion = useRef(false); - const textChars = text.split(''); - - useEffect(() => { - const mediaQuery = window.matchMedia('(prefers-reduced-motion: reduce)'); - prefersReducedMotion.current = mediaQuery.matches; +const REDUCED_MOTION = '(prefers-reduced-motion: reduce)'; - const handler = (event: MediaQueryListEvent) => { - prefersReducedMotion.current = event.matches; - }; - mediaQuery.addEventListener('change', handler); - return () => mediaQuery.removeEventListener('change', handler); - }, []); - - useEffect(() => { - if (prefersReducedMotion.current || textChars.length === 0) { - return; - } +function subscribeReducedMotion(onChange: () => void): () => void { + const query = window.matchMedia(REDUCED_MOTION); + query.addEventListener('change', onChange); + return () => query.removeEventListener('change', onChange); +} - intervalRef.current = setInterval(() => { - setGlowPosition((prev) => (prev + 1) % textChars.length); - }, glowSpeed); +function prefersReducedMotion(): boolean { + return window.matchMedia(REDUCED_MOTION).matches; +} - return () => { - if (intervalRef.current) { - clearInterval(intervalRef.current); - } - }; - }, [glowSpeed, textChars.length]); +function MatrixText({ text, className, glowSpeed = 175 }: MatrixTextProps) { + const reducedMotion = useSyncExternalStore( + subscribeReducedMotion, + prefersReducedMotion, + () => false + ); + const textChars = text.split(''); + // The glow reads the shared clock: all chips glow in step, on one timer, + // and none runs while the tab is hidden. + const glowPosition = useClock( + reducedMotion || textChars.length === 0 ? null : glowSpeed, + (now) => + reducedMotion || textChars.length === 0 + ? 0 + : Math.floor(now / glowSpeed) % textChars.length + ); return ( void | Promise; className?: string; disabled?: boolean; + /** The caller is loading the data: show the spinner and ignore clicks. */ + isLoading?: boolean; + /** A tooltip that explains what the button reloads; the name stays Refresh. */ + description?: string; } export const RefreshButton: React.FC = ({ onRefresh, className, disabled = false, + isLoading = false, + description, }) => { const { t } = useI18n(); const [isRefreshing, setIsRefreshing] = useState(false); + const busy = isRefreshing || isLoading; const handleRefresh = async () => { setIsRefreshing(true); @@ -32,11 +39,17 @@ export const RefreshButton: React.FC = ({ ); }; diff --git a/ui/src/components/ui/relative-time.tsx b/ui/src/components/ui/relative-time.tsx index 824b422208..613917fc8a 100644 --- a/ui/src/components/ui/relative-time.tsx +++ b/ui/src/components/ui/relative-time.tsx @@ -2,8 +2,11 @@ // SPDX-License-Identifier: GPL-3.0-or-later import React from 'react'; +import { formatAbsoluteTime } from '@/lib/dagRunTiming'; import dayjs from '@/lib/dayjs'; +import { useClock } from '@/hooks/useClock'; import { useI18n } from '@/i18n/I18nProvider'; +import { cn } from '@/lib/utils'; type Props = { /** Timestamp string parsable by dayjs; '-' and empty are treated as absent. */ @@ -12,18 +15,53 @@ type Props = { fallback?: string; /** Single-unit form ("5s", "3m", "2h", "4d") instead of "3 minutes ago". */ compact?: boolean; + /** + * With `compact`, a lapse of 5 s or more reads "3m ago" instead of "3m", and + * rounds to the nearest unit as the long form does, so that both agree. + */ + ago?: boolean; + /** + * With `compact`, keeps the width of the widest text of the unit that shows + * now ("99m ago"), so that a text that ticks moves nothing next to it. + */ + stable?: boolean; /** Tooltip text; defaults to the parsed timestamp formatted as an absolute time. */ absolute?: string | null; className?: string; }; -function formatCompact(diffSeconds: number, now: string): string { +interface CompactUnits { + s: string; + m: string; + h: string; + d: string; +} + +// ja and zh-CN write the unit as a word ("5時間前"), not as a Latin letter. +const EN_UNITS: CompactUnits = { s: 's', m: 'm', h: 'h', d: 'd' }; +const COMPACT_UNITS: Record = { + 'zh-CN': { s: '秒', m: '分钟', h: '小时', d: '天' }, + ja: { s: '秒', m: '分', h: '時間', d: '日' }, +}; + +function formatCompact( + diffSeconds: number, + now: string, + round: (value: number) => number, + units: CompactUnits +): string { const seconds = Math.abs(diffSeconds); if (seconds < 5) return now; - if (seconds < 60) return `${Math.floor(seconds)}s`; - if (seconds < 3600) return `${Math.floor(seconds / 60)}m`; - if (seconds < 86400) return `${Math.floor(seconds / 3600)}h`; - return `${Math.floor(seconds / 86400)}d`; + if (seconds < 60) return `${Math.floor(seconds)}${units.s}`; + // Rounding up must not give "60m" or "24h": the next unit takes over. + if (round(seconds / 60) < 60) return `${round(seconds / 60)}${units.m}`; + if (round(seconds / 3600) < 24) return `${round(seconds / 3600)}${units.h}`; + return `${round(seconds / 86400)}${units.d}`; +} + +// dayjs zh-cn writes "5 小时前"; Chinese puts no space between number and unit. +function longForm(text: string): string { + return text.replace(/(\d)\s+(?=[\u4e00-\u9fff])/g, '$1'); } /** @@ -34,35 +72,57 @@ export function RelativeTime({ timestamp, fallback = '-', compact = false, + ago = false, + stable = false, absolute, className, }: Props) { - const { locale, t } = useI18n(); + const { locale, t, ts } = useI18n(); const parsed = timestamp && timestamp !== '-' ? dayjs(timestamp) : null; const isValid = !!parsed && parsed.isValid(); - const [, setTick] = React.useState(0); - - React.useEffect(() => { - if (!isValid) return; - const interval = setInterval( - () => setTick((tick) => tick + 1), - compact ? 1000 : 15000 - ); - return () => clearInterval(interval); - }, [isValid, compact]); + const now = t('time.now'); + const units = COMPACT_UNITS[locale] ?? EN_UNITS; + // The text that reads the clock: the component renders only when it changes. + const text = useClock(isValid ? 1000 : null, (nowMs) => { + if (!parsed || !isValid) return ''; + return compact + ? formatCompact( + dayjs(nowMs).diff(parsed, 'second'), + now, + ago ? Math.round : Math.floor, + units + ) + : longForm( + parsed + .locale(locale === 'zh-CN' ? 'zh-cn' : locale) + .from(dayjs(nowMs)) + ); + }); if (!parsed || !isValid) { return {fallback}; } - const label = compact - ? formatCompact(dayjs().diff(parsed, 'second'), t('time.now')) - : parsed.locale(locale === 'zh-CN' ? 'zh-cn' : locale).fromNow(); + const lapse = compact ? text : undefined; + const label = lapse + ? ago && lapse !== now + ? ts('{time} ago', { time: lapse }) + : lapse + : text; + + // The widest text of the same unit has two digits: "99m", "99m ago". + const widest = + stable && lapse && lapse !== now + ? ago + ? ts('{time} ago', { time: lapse.replace(/\d+/, '99') }) + : lapse.replace(/\d+/, '99') + : null; return ( {label} diff --git a/ui/src/components/ui/repeat-summary.ts b/ui/src/components/ui/repeat-summary.ts new file mode 100644 index 0000000000..aed94e6b79 --- /dev/null +++ b/ui/src/components/ui/repeat-summary.ts @@ -0,0 +1,188 @@ +// Copyright (C) 2026 Yota Hamada +// SPDX-License-Identifier: GPL-3.0-or-later + +import { formatDurationMs } from '@/lib/formatDuration'; + +/** The translator of static messages, as useI18n gives it. */ +export type Translate = ( + source: string, + values?: Record +) => string; + +/** The repeat policy of a step, in one shape for the YAML and for the API. */ +export interface RepeatPolicyView { + mode: 'while' | 'until'; + /** Seconds between two runs, when the policy sets a fixed number. */ + intervalSec?: number; + /** The maximum number of runs, when the policy sets a fixed number. */ + limit?: number; + /** The condition, with ` = expected` appended when the policy has one. */ + condition?: string; + exitCodes?: number[]; + /** True when the interval grows after each run. */ + backoff?: boolean; +} + +/** The longest condition that a summary shows; the title keeps all of it. */ +export const REPEAT_CONDITION_MAX = 24; + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +/** A positive whole number, written as a number or as digits; else undefined. */ +function count(value: unknown): number | undefined { + const n = + typeof value === 'string' && /^\s*\d+\s*$/.test(value) + ? Number(value) + : value; + return typeof n === 'number' && Number.isInteger(n) && n > 0 ? n : undefined; +} + +function text(value: unknown): string { + return typeof value === 'string' || typeof value === 'number' + ? String(value).trim() + : ''; +} + +function conditionOf( + condition: unknown, + expected: unknown +): string | undefined { + // The API gives {condition, expected}; the YAML gives two strings. + const source = isRecord(condition) ? condition.condition : condition; + const wanted = isRecord(condition) ? condition.expected : expected; + const base = text(source); + if (!base) return undefined; + const want = text(wanted); + return want ? `${base} = ${want}` : base; +} + +function exitCodesOf(value: unknown): number[] | undefined { + if (!Array.isArray(value)) return undefined; + const codes = value.filter( + (code): code is number => typeof code === 'number' && Number.isInteger(code) + ); + return codes.length > 0 ? codes : undefined; +} + +/** + * Reads the repeat policy of a step: the `repeat_policy` block of the YAML + * (`interval_sec`, `exit_code`) or the `repeatPolicy` of the API (`interval`, + * `exitCode`). `repeat: until` is until; every other value of `repeat`, such + * as the boolean of old files, is while. Without `repeat`, the server infers + * the mode, and so does this function. Returns null when the step does not + * repeat. + */ +export function readRepeatPolicy(raw: unknown): RepeatPolicyView | null { + if (!isRecord(raw)) return null; + const condition = conditionOf(raw.condition, raw.expected); + const exitCodes = exitCodesOf(raw.exit_code ?? raw.exitCode); + let mode: RepeatPolicyView['mode'] | null = null; + const repeat = raw.repeat; + if (repeat === 'until') { + mode = 'until'; + } else if (repeat === 'while' || repeat === true) { + mode = 'while'; + } else if (repeat !== undefined && repeat !== null && repeat !== false) { + // An unknown form reads as while. + mode = 'while'; + } else if ( + condition !== undefined && + text(isRecord(raw.condition) ? raw.condition.expected : raw.expected) + ) { + mode = 'until'; + } else if (condition !== undefined || exitCodes !== undefined) { + mode = 'while'; + } + if (mode === null) return null; + const view: RepeatPolicyView = { mode }; + const intervalSec = count( + raw.interval_sec ?? raw.intervalSec ?? raw.interval + ); + if (intervalSec !== undefined) view.intervalSec = intervalSec; + const limit = count(raw.limit); + if (limit !== undefined) view.limit = limit; + if (condition !== undefined) view.condition = condition; + if (exitCodes !== undefined) view.exitCodes = exitCodes; + if (raw.backoff !== undefined && raw.backoff !== false && raw.backoff !== 0) { + view.backoff = true; + } + return view; +} + +/** The condition cut to REPEAT_CONDITION_MAX characters, with an ellipsis. */ +export function shortCondition(condition: string): string { + return condition.length > REPEAT_CONDITION_MAX + ? `${condition.slice(0, REPEAT_CONDITION_MAX - 1)}…` + : condition; +} + +/** "10s", "2m": the interval as the other durations of the UI read. */ +export function repeatInterval(seconds: number): string { + return formatDurationMs(seconds * 1000); +} + +/** + * The text of the repeat rule: what the step waits for, as in + * `repeat while exit 0` or `repeat until "ready = yes"`. A policy with + * neither condition nor exit codes repeats while, or until, the step exits 0. + */ +export function repeatRule(policy: RepeatPolicyView, ts: Translate): string { + const until = policy.mode === 'until'; + if (policy.condition !== undefined) { + return ts( + until ? 'repeat until "{condition}"' : 'repeat while "{condition}"', + { + condition: shortCondition(policy.condition), + } + ); + } + return ts(until ? 'repeat until exit {codes}' : 'repeat while exit {codes}', { + codes: (policy.exitCodes ?? [0]).join(', '), + }); +} + +/** + * The one line that describes a repeat policy, for the card tooltip, the run + * list and the step facts: `repeat while exit 0 · every 10s · no limit` or + * `repeat until "ready = yes" · every 30s · max 5`. + */ +export function repeatSummary(policy: RepeatPolicyView, ts: Translate): string { + const parts = [repeatRule(policy, ts)]; + if (policy.intervalSec !== undefined) { + parts.push( + ts('every {interval}', { interval: repeatInterval(policy.intervalSec) }) + ); + } + parts.push( + policy.limit !== undefined + ? ts('max {limit}', { limit: policy.limit }) + : ts('no limit') + ); + return parts.join(' · '); +} + +/** + * The iteration of a step in a run: `iteration 3 of 5` with a limit, + * `iteration 3` without. `done` is the number of finished runs. A step that + * runs is in the next iteration. Returns null before the first run. `format` + * writes the number: the badge gives the whole count with the separators of + * the language. + */ +export function repeatIteration( + policy: RepeatPolicyView, + done: number, + running: boolean, + ts: Translate, + format: (n: number) => string = String +): string | null { + const iteration = running ? done + 1 : done; + if (iteration <= 0) return null; + return policy.limit !== undefined + ? ts('iteration {n} of {limit}', { + n: format(iteration), + limit: policy.limit, + }) + : ts('iteration {n}', { n: format(iteration) }); +} diff --git a/ui/src/components/ui/retry-count.tsx b/ui/src/components/ui/retry-count.tsx new file mode 100644 index 0000000000..965631e2f6 --- /dev/null +++ b/ui/src/components/ui/retry-count.tsx @@ -0,0 +1,31 @@ +// Copyright (C) 2026 Yota Hamada +// SPDX-License-Identifier: GPL-3.0-or-later + +import { RotateCcw } from 'lucide-react'; +import type { ReactElement } from 'react'; +import { Badge } from '@/components/ui/badge'; +import { useI18n } from '@/i18n/I18nProvider'; + +/** + * How many times a step was retried: a small warning badge with the count. + * Every surface that shows a retry count renders this one badge. + */ +export function RetryCount({ count }: { count: number }): ReactElement { + const { ts } = useI18n(); + const label = ts('Retried {count}×', { count }); + return ( + + + ); +} + +export default RetryCount; diff --git a/ui/src/components/ui/spinner.tsx b/ui/src/components/ui/spinner.tsx new file mode 100644 index 0000000000..829965610d --- /dev/null +++ b/ui/src/components/ui/spinner.tsx @@ -0,0 +1,31 @@ +// Copyright (C) 2026 Yota Hamada +// SPDX-License-Identifier: GPL-3.0-or-later + +import { Loader2 } from 'lucide-react'; + +import { cn } from '@/lib/utils'; + +export interface SpinnerProps { + className?: string; + /** Accessible name. Without it the spinner is decorative and hidden. */ + label?: string; +} + +/** + * The one busy indicator. Put it in the slot of the icon that it replaces, so + * the control keeps its size. + */ +export function Spinner({ className, label }: SpinnerProps) { + const icon = ( +