Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
87 changes: 87 additions & 0 deletions .github/workflows/ci.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,17 @@ on:
- "v[0-9]+.[0-9]+.[0-9]+"
- "v[0-9]+.[0-9]+.[0-9]+-*"
paths:
- "api/**"
- "*.go"
- "**/*.go"
- "cmd/**"
- "go.mod"
- "go.sum"
- "internal/**"
- "Makefile"
- "proto/**"
- "scripts/e2e/**"
- "ui/**"
- ".github/workflows/ci.yaml"
pull_request:
# Keep this workflow directly rerunnable from PR-only maintenance commits.
Expand All @@ -24,13 +28,17 @@ on:
- synchronize # new commits are pushed to the branch that the PR is based on
- ready_for_review # PR is ready for review
paths:
- "api/**"
- "*.go"
- "**/*.go"
- "cmd/**"
- "go.mod"
- "go.sum"
- "internal/**"
- "Makefile"
- "proto/**"
- "scripts/e2e/**"
- "ui/**"
- ".github/workflows/ci.yaml"
workflow_dispatch:

Expand All @@ -47,6 +55,7 @@ jobs:
runs-on: ubuntu-latest
outputs:
go: ${{ steps.filter.outputs.go }}
e2e: ${{ steps.filter.outputs.e2e }}
steps:
- name: Check out code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
Expand All @@ -59,6 +68,7 @@ jobs:
run: |
if [[ "${GITHUB_EVENT_NAME}" == "workflow_dispatch" ]]; then
echo "go=true" >> "${GITHUB_OUTPUT}"
echo "e2e=true" >> "${GITHUB_OUTPUT}"
exit 0
fi

Expand All @@ -71,28 +81,38 @@ jobs:

if [[ -z "${base_sha}" || "${base_sha}" == "0000000000000000000000000000000000000000" ]]; then
echo "go=true" >> "${GITHUB_OUTPUT}"
echo "e2e=true" >> "${GITHUB_OUTPUT}"
exit 0
fi

if ! git cat-file -e "${base_sha}^{commit}" 2>/dev/null; then
echo "Base commit ${base_sha} is not available locally; running CI conservatively."
echo "go=true" >> "${GITHUB_OUTPUT}"
echo "e2e=true" >> "${GITHUB_OUTPUT}"
exit 0
fi

changed_files="$(git diff --name-only "${base_sha}" "${GITHUB_SHA}")"
printf '%s\n' "${changed_files}"

go_changed=false
e2e_changed=false
while IFS= read -r file; do
case "${file}" in
*.go|go.mod|go.sum|Makefile|.github/workflows/ci.yaml)
go_changed=true
;;
esac

case "${file}" in
ui/*)
e2e_changed=true
;;
esac
done <<< "${changed_files}"

echo "go=${go_changed}" >> "${GITHUB_OUTPUT}"
echo "e2e=${e2e_changed}" >> "${GITHUB_OUTPUT}"

# Lint Go code
golint:
Expand Down Expand Up @@ -231,6 +251,73 @@ jobs:
env:
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}

e2e:
name: E2E (${{ matrix.shard }}/${{ matrix.shard_total }})
needs:
- changes
- golint
- test-ubuntu
- test-windows
if: >-
always() &&
needs.changes.outputs.e2e == 'true' &&
(needs.golint.result == 'success' || needs.golint.result == 'skipped') &&
(needs.test-ubuntu.result == 'success' || needs.test-ubuntu.result == 'skipped') &&
(needs.test-windows.result == 'success' || needs.test-windows.result == 'skipped')
runs-on: ubuntu-latest
timeout-minutes: 35
strategy:
fail-fast: false
matrix:
include:
- shard: 1
shard_total: 3
- shard: 2
shard_total: 3
- shard: 3
shard_total: 3
concurrency:
group: ci-e2e-${{ github.event.pull_request.number || github.ref }}-shard-${{ matrix.shard }}
cancel-in-progress: true
steps:
- name: Check out code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2

- name: Set up Go
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
with:
go-version: ${{ env.GO_VERSION }}

- name: Setup pnpm
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0
with:
version: 10

- name: Setup Node.js
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
with:
node-version: ${{ env.NODE_VERSION }}
cache: "pnpm"
cache-dependency-path: ui/pnpm-lock.yaml

- name: Install frontend dependencies
working-directory: ui
run: pnpm install --frozen-lockfile

- name: Install Playwright Chromium
working-directory: ui
run: pnpm exec playwright install --with-deps chromium

- name: Run browser E2E tests
env:
CI: "true"
DAGU_LICENSE_PRIVKEY_B64: ${{ secrets.DAGU_LICENSE_PRIVKEY_B64 }}
PLAYWRIGHT_FULLY_PARALLEL: "true"
PLAYWRIGHT_TEST_ARGS: --shard=${{ matrix.shard }}/${{ matrix.shard_total }}
run: |
make test-e2e-build
make test-e2e-run

test-windows:
name: Test on windows-latest (${{ matrix.suite_index }}/${{ matrix.suite_total }})
needs: changes
Expand Down
26 changes: 24 additions & 2 deletions conformance/harness/runner.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@ type Result struct {
stderr string
}

const defaultCommandTimeout = 30 * time.Second

// NewRunner creates an isolated project seeded with package-local testdata.
func NewRunner(t *testing.T) *Runner {
t.Helper()
Expand Down Expand Up @@ -60,7 +62,8 @@ func (r *Runner) RunWithEnv(env []string, args ...string) *Result {
func (r *Runner) run(extraEnv []string, args ...string) *Result {
r.t.Helper()

ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
timeout := commandTimeout(r.t)
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()

var stdout bytes.Buffer
Expand All @@ -75,7 +78,8 @@ func (r *Runner) run(extraEnv []string, args ...string) *Result {
err := cmd.Run()
if ctx.Err() != nil {
r.t.Fatalf(
"dagu command timed out: dagu %s\nstdout:\n%s\nstderr:\n%s",
"dagu command timed out after %s: dagu %s\nstdout:\n%s\nstderr:\n%s",
timeout,
strings.Join(args, " "),
stdout.String(),
stderr.String(),
Expand Down Expand Up @@ -287,6 +291,24 @@ func (r *Result) ExpectStderrNotContains(parts ...string) {
}
}

func commandTimeout(t *testing.T) time.Duration {
t.Helper()

raw := os.Getenv("DAGU_CONFORMANCE_COMMAND_TIMEOUT")
if raw == "" {
return defaultCommandTimeout
}

timeout, err := time.ParseDuration(raw)
if err != nil {
t.Fatalf("invalid DAGU_CONFORMANCE_COMMAND_TIMEOUT %q: %v", raw, err)
}
if timeout <= 0 {
t.Fatalf("invalid DAGU_CONFORMANCE_COMMAND_TIMEOUT %q: must be positive", raw)
}
return timeout
}

func daguBinary(t *testing.T) string {
t.Helper()

Expand Down
24 changes: 24 additions & 0 deletions internal/service/frontend/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -1112,6 +1112,9 @@ func (srv *Server) setupAssetRoutes(r *chi.Mux, basePath string) {
func cacheControlForAsset(assetPath string) string {
base := path.Base(assetPath)
lowerBase := strings.ToLower(base)
if hasContentHashSuffix(lowerBase, ".worker.js") {
return "max-age=31536000, immutable"
}
if strings.HasSuffix(lowerBase, ".bundle.js") && !strings.EqualFold(base, "bundle.js") {
return "max-age=31536000, immutable"
}
Expand All @@ -1121,6 +1124,27 @@ func cacheControlForAsset(assetPath string) string {
return "max-age=86400"
}

func hasContentHashSuffix(base, suffix string) bool {
if !strings.HasSuffix(base, suffix) {
return false
}
stem := strings.TrimSuffix(base, suffix)
hashStart := strings.LastIndex(stem, ".")
if hashStart < 0 {
return false
}
hash := stem[hashStart+1:]
if len(hash) != 16 {
return false
}
for _, char := range hash {
if (char < '0' || char > '9') && (char < 'a' || char > 'f') {
return false
}
}
return true
}

func (srv *Server) setupOIDCRoutes(r *chi.Mux, basePath string) {
if srv.builtinOIDCCfg == nil {
return
Expand Down
15 changes: 15 additions & 0 deletions internal/service/frontend/server_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -242,6 +242,21 @@ func TestCacheControlForAssetCachesContentHashedJavaScriptChunks(t *testing.T) {
)
}

func TestCacheControlForAssetCachesContentHashedJavaScriptWorkers(t *testing.T) {
t.Parallel()

assert.Equal(
t,
"max-age=31536000, immutable",
cacheControlForAsset("/assets/yaml.a1b2c3d4e5f6a1b2.worker.js"),
)
assert.Equal(
t,
"no-cache, no-store, must-revalidate",
cacheControlForAsset("/assets/yaml.worker.js"),
)
}

func TestCacheControlForAssetCachesNonJavaScriptAssets(t *testing.T) {
t.Parallel()

Expand Down
4 changes: 4 additions & 0 deletions specs/003-value-resolution.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,10 @@

Partially implemented.

This spec includes target conformance behavior.
Current product behavior covers only rows whose owning namespace or field spec is implemented.
Rows owned by a spec marked `Not implemented` in the spec index are target behavior until that owning spec is implemented.

## Scope

Dagu field evaluation defines what Dagu does to workflow YAML text before the owning field uses it.
Expand Down
12 changes: 10 additions & 2 deletions ui/e2e/dag-crud.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,12 @@ function dagDefinitionsEntry(page: Page, dagName: string) {
.first();
}

function localScopedURL(baseURL: string, path: string) {
const url = new URL(path, baseURL);
url.searchParams.set('remoteNode', 'local');
return url.toString();
}

test.describe('DAG CRUD operations', () => {
test.beforeEach(async ({ page }) => {
const stack = await loadStack();
Expand Down Expand Up @@ -76,7 +82,9 @@ steps:
await dialog.getByLabel('DAG Name').fill(newName);
await dialog.getByRole('button', { name: 'Rename' }).click();

await expect(page).toHaveURL(new RegExp(`/dags/${newName}$`));
await expect(page).toHaveURL(
localScopedURL(stack.local.baseURL, `/dags/${encodeURIComponent(newName)}`)
);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
});

test('deletes a DAG from the UI', async ({ page, request }) => {
Expand Down Expand Up @@ -107,7 +115,7 @@ steps:
page.once('dialog', (d) => d.accept());
await page.getByRole('button', { name: 'Delete', exact: true }).click();

await expect(page).toHaveURL(/\/dags$/);
await expect(page).toHaveURL(localScopedURL(stack.local.baseURL, '/dags'));

// Verify DAG is gone via API
const response = await request.get(
Expand Down
10 changes: 10 additions & 0 deletions ui/src/__tests__/webpack.prod.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,10 @@ const webpackProdConfigSource = readFileSync(
resolve(__dirname, '../../webpack.prod.js'),
'utf8'
);
const webpackCommonConfigSource = readFileSync(
resolve(__dirname, '../../webpack.common.js'),
'utf8'
);

describe('webpack production assets', () => {
it('uses stable entry and content-hashed lazy chunk filenames', () => {
Expand All @@ -18,4 +22,10 @@ describe('webpack production assets', () => {
);
expect(webpackProdConfigSource).not.toContain('bundle.js?v=0.0.0');
});

it('uses content-hashed Monaco worker filenames', () => {
expect(webpackCommonConfigSource).toContain(
"filename: '[name].[contenthash:16].worker.js'"
);
});
});
40 changes: 40 additions & 0 deletions ui/src/contexts/RemoteNodeContext.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
// Copyright (C) 2026 Yota Hamada
// SPDX-License-Identifier: GPL-3.0-or-later

import React, { createContext, useContext } from 'react';
import { AppBarContext } from './AppBarContext';

const RemoteNodeContext = createContext<string | undefined>(undefined);

function normalizeRemoteNode(value?: string): string | undefined {
const normalized = value?.trim();
return normalized ? normalized : undefined;
}

type RemoteNodeProviderProps = {
remoteNode?: string;
children: React.ReactNode;
};

export function RemoteNodeProvider({
remoteNode,
children,
}: RemoteNodeProviderProps) {
const value = normalizeRemoteNode(remoteNode);
return (
<RemoteNodeContext.Provider value={value}>
{children}
</RemoteNodeContext.Provider>
);
}

export function useRemoteNode(override?: string): string {
const scopedRemoteNode = useContext(RemoteNodeContext);
const appBarContext = useContext(AppBarContext);
return (
normalizeRemoteNode(override) ||
normalizeRemoteNode(scopedRemoteNode) ||
normalizeRemoteNode(appBarContext.selectedRemoteNode) ||
'local'
);
}
Loading