Compare commits

..

13 Commits

Author SHA1 Message Date
Alexander Brown
0b06bc1372 Merge branch 'main' into test/standardize-test-utils 2026-04-10 19:40:57 -07:00
Alexander Brown
30c805658b Merge branch 'main' into test/standardize-test-utils 2026-04-08 16:42:07 -07:00
GitHub Action
dcbd653343 [automated] Apply ESLint and Oxfmt fixes 2026-04-07 01:00:31 +00:00
bymyself
e2301b630c fix: use render<C> generic and explicit RenderOptions type
Replace opaque Parameters<typeof render>[0/1] assertions with
render<C> generic call and RenderOptions<C> type annotation.

Addresses review feedback:
https://github.com/Comfy-Org/ComfyUI_frontend/pull/10628#discussion_r3011698548
https://github.com/Comfy-Org/ComfyUI_frontend/pull/10628#discussion_r3011696861
https://github.com/Comfy-Org/ComfyUI_frontend/pull/10628#discussion_r3011695091
2026-04-06 17:57:25 -07:00
bymyself
46cb9fdcfb fix: correct misleading JSDoc about stubs auto-application
renderWithDefaults only merges plugins and directives, not stubs.
Updated doc to accurately describe behavior.

Addresses review feedback:
https://github.com/Comfy-Org/ComfyUI_frontend/pull/10628#discussion_r3004203297
2026-04-06 17:57:25 -07:00
bymyself
f6aa48ecc6 fix: remove primitive UI stubs (Button, Dialog) from shared test-utils
Tests should import and register real PrimeVue components instead of
relying on centralized primitive stubs that hide integration behavior.

Addresses review feedback:
https://github.com/Comfy-Org/ComfyUI_frontend/pull/10628#discussion_r3004203300
2026-04-06 17:57:25 -07:00
Alexander Brown
409fdbd815 Merge branch 'main' into test/standardize-test-utils 2026-03-30 12:04:15 -07:00
GitHub Action
1f818ba529 [automated] Apply ESLint and Oxfmt fixes 2026-03-28 11:43:15 +00:00
bymyself
8680791f4a fix: remove unused emit declaration and eslint-disable directive 2026-03-28 04:40:10 -07:00
bymyself
c831562ec1 fix: suppress Vue ESLint rules for inline test component stubs 2026-03-28 03:39:29 -07:00
bymyself
35f79b7cd3 feat: add PrimeVue component stubs to test-utils (COMP-03) 2026-03-27 22:00:33 -07:00
GitHub Action
90a819bc87 [automated] Apply ESLint and Oxfmt fixes 2026-03-27 20:30:19 +00:00
bymyself
69239ca2d5 test: add standardized test-utils with Pinia + i18n defaults
Recreates src/utils/test-utils.ts (removed in #10471) as the canonical
shared render wrapper for VTL component tests.

Provides:
- renderWithDefaults(): auto-configures Pinia (stubActions: false),
  vue-i18n (English), and common directive stubs (tooltip)
- Optional userEvent instance (opt out via setupUser: false)
- Re-exports screen from @testing-library/vue

Eliminates duplicated createTestingPinia + createI18n boilerplate
across 97+ test files.

Part of: COMP-02 (standardize Pinia store test mocking patterns)
2026-03-27 13:27:06 -07:00
1508 changed files with 15548 additions and 118477 deletions

View File

@@ -1,86 +1,9 @@
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"if": "Bash(tsc *)",
"command": "echo 'Use `pnpm typecheck` instead of running tsc directly.' >&2 && exit 2"
},
{
"type": "command",
"if": "Bash(vue-tsc *)",
"command": "echo 'Use `pnpm typecheck` instead of running vue-tsc directly.' >&2 && exit 2"
},
{
"type": "command",
"if": "Bash(npx tsc *)",
"command": "echo 'Use `pnpm typecheck` instead of running tsc via npx.' >&2 && exit 2"
},
{
"type": "command",
"if": "Bash(pnpx tsc *)",
"command": "echo 'Use `pnpm typecheck` instead of running tsc via pnpx.' >&2 && exit 2"
},
{
"type": "command",
"if": "Bash(pnpm exec tsc *)",
"command": "echo 'Use `pnpm typecheck` instead of `pnpm exec tsc`.' >&2 && exit 2"
},
{
"type": "command",
"if": "Bash(npx vitest *)",
"command": "echo 'Use `pnpm test:unit` (or `pnpm test:unit -- <path>`) instead of npx vitest.' >&2 && exit 2"
},
{
"type": "command",
"if": "Bash(pnpx vitest *)",
"command": "echo 'Use `pnpm test:unit` (or `pnpm test:unit -- <path>`) instead of pnpx vitest.' >&2 && exit 2"
},
{
"type": "command",
"if": "Bash(npx eslint *)",
"command": "echo 'Use `pnpm lint` or `pnpm lint:fix` instead of npx eslint.' >&2 && exit 2"
},
{
"type": "command",
"if": "Bash(pnpx eslint *)",
"command": "echo 'Use `pnpm lint` or `pnpm lint:fix` instead of pnpx eslint.' >&2 && exit 2"
},
{
"type": "command",
"if": "Bash(npx prettier *)",
"command": "echo 'This project uses oxfmt, not prettier. Use `pnpm format` or `pnpm format:check`.' >&2 && exit 2"
},
{
"type": "command",
"if": "Bash(pnpx prettier *)",
"command": "echo 'This project uses oxfmt, not prettier. Use `pnpm format` or `pnpm format:check`.' >&2 && exit 2"
},
{
"type": "command",
"if": "Bash(npx oxlint *)",
"command": "echo 'Use `pnpm oxlint` instead of npx oxlint.' >&2 && exit 2"
},
{
"type": "command",
"if": "Bash(npx stylelint *)",
"command": "echo 'Use `pnpm stylelint` instead of npx stylelint.' >&2 && exit 2"
},
{
"type": "command",
"if": "Bash(npx knip *)",
"command": "echo 'Use `pnpm knip` instead of npx knip.' >&2 && exit 2"
},
{
"type": "command",
"if": "Bash(pnpx knip *)",
"command": "echo 'Use `pnpm knip` instead of pnpx knip.' >&2 && exit 2"
}
]
}
"permissions": {
"allow": [
"Bash(pnpx vitest run --testPathPattern=\"draftCacheV2.property\")",
"Bash(pnpx vitest run \"draftCacheV2.property\")",
"Bash(node -e \"const fc = require\\(''fast-check''\\); console.log\\(Object.keys\\(fc\\).filter\\(k => k.includes\\(''string''\\)\\).join\\('', ''\\)\\)\")"
]
}
}

View File

@@ -171,7 +171,7 @@ test('canvas text rendering with many nodes', async ({ comfyPage }) => {
| ----------------- | ----------------------------------------------------- |
| Perf test file | `browser_tests/tests/performance.spec.ts` |
| PerformanceHelper | `browser_tests/fixtures/helpers/PerformanceHelper.ts` |
| Perf reporter | `browser_tests/fixtures/utils/perfReporter.ts` |
| Perf reporter | `browser_tests/helpers/perfReporter.ts` |
| CI workflow | `.github/workflows/ci-perf-report.yaml` |
| Report generator | `scripts/perf-report.ts` |
| Stats utilities | `scripts/perf-stats.ts` |

View File

@@ -1,156 +0,0 @@
---
name: reviewing-unit-tests
description: Use when reviewing Vitest unit-test diffs in ComfyUI_frontend, especially new mocks, store tests, component tests, or bugfix regression tests.
---
# Reviewing Unit Tests for ComfyUI_frontend
## Overview
Review for behavior and current repo rules, not motion. Compare to authoritative rules, not prior diffs or legacy snippets.
## Review Workflow
1. Identify the test type: component, store, composable, util, or bugfix regression.
2. Name the behavior the test proves. If you cannot say it in one sentence, request changes.
3. Open the authoritative doc section before judging structure.
4. Scan the red flags below.
5. State the verdict first. Name the failure mode. Cite the doc or rule.
## Source of Truth / Precedence
When docs and examples conflict, use this order:
1. Explicit repo rules, lint rules, and note blocks.
2. [`docs/testing/vitest-patterns.md`](../../../docs/testing/vitest-patterns.md)
3. Rule sections in [`docs/testing/unit-testing.md`](../../../docs/testing/unit-testing.md), [`docs/testing/store-testing.md`](../../../docs/testing/store-testing.md), and [`docs/testing/component-testing.md`](../../../docs/testing/component-testing.md)
4. Example snippets
5. Prior diffs
Apply these repo-specific clarifications:
- [`docs/testing/component-testing.md`](../../../docs/testing/component-testing.md) starts with the authoritative rule: new component tests use `@testing-library/vue` with `@testing-library/user-event`. The `@vue/test-utils` snippets below it are legacy examples.
- [`docs/testing/store-testing.md`](../../../docs/testing/store-testing.md) still contains `as any` examples. Treat them as legacy snippets, not approval for new or edited test code.
- If docs conflict, prefer the stricter newer rule and call out the doc ambiguity. Do not approve through it.
- Motion != fix.
## 30-Second Red Flags
| If you see... | Failure mode | Default action |
| ----------------------------------------------------------------------------------------- | ------------------------------- | ------------------------------------------------------------- |
| New `@vue/test-utils` import in a new component test | legacy test API | Request changes |
| `vi.mock('vue-i18n', ...)` | mocked i18n | Request changes |
| `as any`, `@ts-expect-error`, `as Mock`, `as ReturnType<typeof vi.fn>`, `as unknown as X` | unnecessary cast or type escape | Request changes unless the author proves no safer type exists |
| `getXMock()`, renamed wrapper, or helper that only returns a mocked value | alias-by-renaming | Request changes |
| `beforeEach` recreates the return object for a module-mocked composable or service | shared mock setup drift | Request changes |
| Assertions only check defaults, mock plumbing, or CSS hooks | non-behavioral test | Request changes |
| Bugfix test has no proof it fails on pre-fix code | unproven regression | Request changes |
## Rationalization Table
| Excuse | Reality |
| ----------------------------------- | ------------------------------------------------------------------------------------------------------ |
| "I restructured the mocks" | If the indirection stayed, nothing improved. Flag `alias-by-renaming`. |
| "The docs do it" | Rule, note, and lint beat legacy snippet. Compare to the current rule, not the nearest example. |
| "TypeScript required the cast" | `vi.mocked()` usually narrows mock methods. Assertion-only references need no cast. |
| "Putting it in `beforeEach` is DRY" | Recreating module mock state in hooks hides singleton behavior and drifts from the documented pattern. |
| "It is only a nit" | Explicit repo-rule violations are never nits. |
| "No behavior changed, just cleanup" | Motion != fix. Ask what behavior got stronger. |
| "Mental revert is enough" | For bugfix tests, establish red on pre-fix code or ask the author to show it. |
## Mocking Rules
- Fail helpers that do not remove repeated setup, encode domain meaning, or simplify assertions. Barely earning the abstraction is not enough.
- For composables with reactive or singleton state, define stable mock state inside the `vi.mock()` factory. Access it per test via the composable itself. See [`docs/testing/unit-testing.md`](../../../docs/testing/unit-testing.md) "Mocking Composables with Reactive State".
- This does not ban local test data builders or per-test `vi.spyOn(...)`.
- Mock seams, not the project-owned module you are trying to exercise. For store tests, prefer real Pinia plus `createTestingPinia({ stubActions: false })` per [`docs/testing/vitest-patterns.md`](../../../docs/testing/vitest-patterns.md) and [`docs/testing/store-testing.md`](../../../docs/testing/store-testing.md).
### Alias-by-Renaming
```ts
// Before
const mockAdd = vi.hoisted(() => vi.fn())
// After: same indirection, new name
function getToastAddMock() {
return useToast().add
}
```
If the wrapper only renames or relays a mocked value, fail it. Inline the lookup at the call site or fetch the singleton mock via the documented pattern.
### `vi.mocked()` Scope
| Use case | `vi.mocked()` required? |
| --------------------------------------------------------------- | ----------------------- |
| `.mockReturnValue`, `.mockResolvedValue`, `.mockImplementation` | Yes |
| `.mock.calls`, `.mock.results` | Yes |
| `expect(fn).toHaveBeenCalled()` | No |
| `expect(fn).toHaveBeenCalledWith(...)` | No |
- Flag casts whenever `vi.mocked()` would narrow correctly.
- Do not add `vi.mocked()` around assertion-only references just for style.
### Reset Hygiene
- Flag per-mock `mockClear()` or `mockReset()` when `vi.clearAllMocks()` or `vi.resetAllMocks()` already runs in the relevant hook chain.
- Review for redundancy or broken state management. Do not bikeshed `clearAllMocks` vs `resetAllMocks` unless behavior depends on it.
### Third-Party Seams
- Distinguish trivial hooks from behavior-rich APIs.
- Mocking single-method third-party hooks like `primevue/usetoast` is usually acceptable.
- That exception does not justify mocking behavior-rich third-party modules.
### `vue-i18n`
- Never mock `vue-i18n` in component tests.
- Use real `createI18n` per [`docs/testing/vitest-patterns.md`](../../../docs/testing/vitest-patterns.md) and the shared [`testI18n`](../../../src/components/searchbox/v2/__test__/testUtils.ts) setup.
## Test-Body Rules
| Smell | Review bar |
| ----------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------- |
| Change-detector test | Reject. Default values alone prove nothing. |
| Mock-only assertion | Accept collaborator-call assertions only when the call is the meaningful external effect and the test also exercises the triggering behavior. |
| Non-behavioral assertion | Reject tests that only check classes, utility hooks, or styling internals. |
| New component test using `@vue/test-utils` | Request changes. Use `@testing-library/vue` plus `@testing-library/user-event`. |
| `any`, `as any`, or `@ts-expect-error` in new or edited test code | Request changes unless the author proves no safer type exists. Legacy doc snippets do not authorize it. |
## Bugfix Regression Proof
For `fix:` PRs or bugfix diffs:
1. Identify the production change that fixes the bug.
2. Verify the new test fails on pre-fix code, or ask the author to show it.
3. If the test passes on broken code, request changes.
A regression test that never proves red does not pin the bug.
## Review Output Rules
- State verdict before procedural questions.
- Do not lead with approval language like `LGTM, just one nit` or `approve and move on?`.
- Name the failure mode directly: `alias-by-renaming`, `unnecessary cast`, `mocked i18n`, `mock-only assertion`, `unproven regression`.
- Link the authoritative doc section in the review comment.
- If an explicit repo rule, lint rule, or authoritative doc note is violated, do not downgrade it to "minor deviation" or "nit".
## Quick Reference
| When you see... | Read this |
| ----------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| New `vi.mock(...)` for a composable | [`docs/testing/unit-testing.md`](../../../docs/testing/unit-testing.md) -> "Mocking Composables with Reactive State" |
| New store test or store mock | [`docs/testing/vitest-patterns.md`](../../../docs/testing/vitest-patterns.md) setup + [`docs/testing/store-testing.md`](../../../docs/testing/store-testing.md) |
| New component test | Top note in [`docs/testing/component-testing.md`](../../../docs/testing/component-testing.md) |
| `vue-i18n` in a component test | [`docs/testing/vitest-patterns.md`](../../../docs/testing/vitest-patterns.md) + [`src/components/searchbox/v2/__test__/testUtils.ts`](../../../src/components/searchbox/v2/__test__/testUtils.ts) |
| Cast around a mock | [`docs/guidance/typescript.md`](../../../docs/guidance/typescript.md) -> "Type Assertion Hierarchy" |
## Key Files to Read
| Purpose | Path |
| ------------------------------------ | ----------------------------------------------------------------------------------------------------------------- |
| Composable mocking patterns | [`docs/testing/unit-testing.md`](../../../docs/testing/unit-testing.md) |
| Store testing patterns | [`docs/testing/store-testing.md`](../../../docs/testing/store-testing.md) |
| Repo-wide Vitest setup defaults | [`docs/testing/vitest-patterns.md`](../../../docs/testing/vitest-patterns.md) |
| Component testing rule for new tests | [`docs/testing/component-testing.md`](../../../docs/testing/component-testing.md) |
| Real i18n setup | [`src/components/searchbox/v2/__test__/testUtils.ts`](../../../src/components/searchbox/v2/__test__/testUtils.ts) |

View File

@@ -1,12 +0,0 @@
root = true
[*]
indent_style = space
indent_size = 2
end_of_line = lf
charset = utf-8
trim_trailing_whitespace = true
insert_final_newline = true
[*.md]
trim_trailing_whitespace = false

View File

@@ -46,9 +46,3 @@ ALGOLIA_API_KEY=684d998c36b67a9a9fce8fc2d8860579
# SENTRY_ORG=comfy-org
# SENTRY_PROJECT=cloud-frontend-staging
# SENTRY_PROJECT_PROD= # prod project slug for sourcemap uploads
# Ashby (apps/website careers page build).
# Server-only; read inside the Astro build context. Do NOT prefix with PUBLIC_.
# When unset, the committed snapshot at apps/website/src/data/ashby-roles.snapshot.json is used.
# WEBSITE_ASHBY_API_KEY=
# WEBSITE_ASHBY_JOB_BOARD_NAME=comfy-org

View File

@@ -1,23 +0,0 @@
name: Ashby Pull
description: 'Refresh the apps/website Ashby roles snapshot from the Ashby job board API'
inputs:
api_key:
description: 'Ashby API key (WEBSITE_ASHBY_API_KEY).'
required: true
job_board_name:
description: 'Ashby job board name (WEBSITE_ASHBY_JOB_BOARD_NAME).'
required: true
runs:
using: 'composite'
steps:
# Note: this action assumes the frontend repo is checked out at the workspace root.
- name: Setup frontend
uses: ./.github/actions/setup-frontend
- name: Refresh Ashby snapshot
shell: bash
env:
WEBSITE_ASHBY_API_KEY: ${{ inputs.api_key }}
WEBSITE_ASHBY_JOB_BOARD_NAME: ${{ inputs.job_board_name }}
run: pnpm --filter @comfyorg/website ashby:refresh-snapshot

View File

@@ -1,87 +0,0 @@
# Outputs default to 'true' for non-pull_request events (push, merge_group):
# granular path filtering is a PR-only optimization. This avoids the silent
# skip footgun where a job gated on e.g. `app-website-changes == 'true'`
# would never run on push.
#
# Shared dependency files (root package.json, pnpm-lock.yaml,
# pnpm-workspace.yaml) are folded into every app-* and packages-changes
# output so a lockfile bump correctly invalidates each granular gate. They
# are NOT folded into docs-changes.
#
# Two paths-filter steps are needed because predicate-quantifier=every is
# required for the negated globs in `should-run` but breaks multi-pattern
# OR filters like `docs:` and `deps:`.
#
# Requires the caller to have checked out the repository.
name: 'Detect Path Changes'
description: >
Computes typed *-changes outputs and a back-compat should-run for
path-gated CI jobs.
outputs:
should-run:
description: 'Any file outside `apps/`, `docs/`, `.storybook/`, or `**/*.md` changed.'
value: ${{ github.event_name != 'pull_request' || steps.relevant.outputs.relevant == 'true' }}
app-website-changes:
description: 'Shared deps or `apps/website/**` changed.'
value: ${{ github.event_name != 'pull_request' || steps.filter.outputs.deps == 'true' || steps.filter.outputs.app_website == 'true' }}
app-desktop-changes:
description: 'Shared deps or `apps/desktop-ui/**` changed.'
value: ${{ github.event_name != 'pull_request' || steps.filter.outputs.deps == 'true' || steps.filter.outputs.app_desktop == 'true' }}
app-frontend-changes:
description: 'Shared deps or `src/**` changed.'
value: ${{ github.event_name != 'pull_request' || steps.filter.outputs.deps == 'true' || steps.filter.outputs.app_frontend == 'true' }}
packages-changes:
description: 'Shared deps or `packages/**` changed.'
value: ${{ github.event_name != 'pull_request' || steps.filter.outputs.deps == 'true' || steps.filter.outputs.packages == 'true' }}
storybook-changes:
description: 'Shared deps or `.storybook/**` changed.'
value: ${{ github.event_name != 'pull_request' || steps.filter.outputs.deps == 'true' || steps.filter.outputs.storybook == 'true' }}
docs-changes:
description: '`docs/**` or any `**/*.md` changed (deps NOT folded in).'
value: ${{ github.event_name != 'pull_request' || steps.filter.outputs.docs == 'true' }}
dependency-changes:
description: 'Root `package.json`, `pnpm-lock.yaml`, or `pnpm-workspace.yaml` changed.'
value: ${{ github.event_name != 'pull_request' || steps.filter.outputs.deps == 'true' }}
runs:
using: composite
steps:
- name: Filter typed changes
if: ${{ github.event_name == 'pull_request' }}
id: filter
uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3.0.2
with:
filters: |
app_website:
- 'apps/website/**'
app_desktop:
- 'apps/desktop-ui/**'
app_frontend:
- 'src/**'
packages:
- 'packages/**'
storybook:
- '.storybook/**'
docs:
- 'docs/**'
- '**/*.md'
deps:
- 'package.json'
- 'pnpm-lock.yaml'
- 'pnpm-workspace.yaml'
- name: Filter relevant changes
if: ${{ github.event_name == 'pull_request' }}
id: relevant
uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3.0.2
with:
predicate-quantifier: 'every'
filters: |
relevant:
- '**'
- '!apps/**'
- '!docs/**'
- '!.storybook/**'
- '!**/*.md'

View File

@@ -1,65 +0,0 @@
name: Find Workflow Run
description: Finds a workflow run for a given commit SHA and outputs its status and run ID.
inputs:
workflow-id:
description: The workflow filename (e.g., 'ci-size-data.yaml')
required: true
head-sha:
description: The commit SHA to find runs for
required: true
not-found-status:
description: Status to output when no run exists
required: false
default: pending
token:
description: GitHub token for API access
required: true
outputs:
status:
description: One of 'ready', 'pending', 'failed', or the not-found-status value
value: ${{ steps.find.outputs.status }}
run-id:
description: The workflow run ID (only set when status is 'ready')
value: ${{ steps.find.outputs.run-id }}
runs:
using: composite
steps:
- name: Find workflow run
id: find
uses: actions/github-script@v8
env:
WORKFLOW_ID: ${{ inputs.workflow-id }}
HEAD_SHA: ${{ inputs.head-sha }}
NOT_FOUND_STATUS: ${{ inputs.not-found-status }}
with:
github-token: ${{ inputs.token }}
script: |
const { data: runs } = await github.rest.actions.listWorkflowRuns({
owner: context.repo.owner,
repo: context.repo.repo,
workflow_id: process.env.WORKFLOW_ID,
head_sha: process.env.HEAD_SHA,
per_page: 1,
});
const run = runs.workflow_runs[0];
if (!run) {
core.setOutput('status', process.env.NOT_FOUND_STATUS);
return;
}
if (run.status !== 'completed') {
core.setOutput('status', 'pending');
return;
}
if (run.conclusion !== 'success') {
core.setOutput('status', 'failed');
return;
}
core.setOutput('status', 'ready');
core.setOutput('run-id', String(run.id));

View File

@@ -1,88 +0,0 @@
name: Resolve PR from workflow_run
description: >
Resolves the PR number from a workflow_run event using pull_requests[0]
with a listPullRequestsAssociatedWithCommit fallback.
Skips closed/merged PRs and stale runs (head SHA mismatch).
inputs:
token:
description: GitHub token for API calls
required: false
default: ${{ github.token }}
outputs:
skip:
description: "'true' when no open PR was found or the run is stale"
value: ${{ steps.resolve.outputs.skip }}
number:
description: The PR number (empty when skip is true)
value: ${{ steps.resolve.outputs.number }}
base:
description: The PR base branch (empty when skip is true)
value: ${{ steps.resolve.outputs.base }}
head-sha:
description: The PR head SHA (empty when skip is true)
value: ${{ steps.resolve.outputs.head-sha }}
runs:
using: composite
steps:
- name: Resolve PR
id: resolve
uses: actions/github-script@v8
with:
github-token: ${{ inputs.token }}
script: |
let pr = context.payload.workflow_run.pull_requests?.[0];
if (!pr) {
const { data: prs } = await github.rest.repos.listPullRequestsAssociatedWithCommit({
owner: context.repo.owner,
repo: context.repo.repo,
commit_sha: context.payload.workflow_run.head_sha,
});
pr = prs.find(p => p.state === 'open');
}
// Fork PRs: pull_requests is empty and commit SHA may not be in
// the base repo graph. Fall back to pulls.list with head filter.
if (!pr && context.payload.workflow_run.head_repository?.owner?.login) {
const { data: prs } = await github.rest.pulls.list({
owner: context.repo.owner,
repo: context.repo.repo,
state: 'open',
head: `${context.payload.workflow_run.head_repository.owner.login}:${context.payload.workflow_run.head_branch}`,
per_page: 1,
});
pr = prs.find(p => p.head.sha === context.payload.workflow_run.head_sha);
}
if (!pr) {
core.info('No open PR found for this workflow run — skipping.');
core.setOutput('skip', 'true');
return;
}
const { data: livePr } = await github.rest.pulls.get({
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: pr.number,
});
if (livePr.state !== 'open') {
core.info(`PR #${pr.number} is ${livePr.state} — skipping.`);
core.setOutput('skip', 'true');
return;
}
if (livePr.head.sha !== context.payload.workflow_run.head_sha) {
core.info(
`Stale run: workflow SHA ${context.payload.workflow_run.head_sha} != PR head ${livePr.head.sha}`
);
core.setOutput('skip', 'true');
return;
}
core.setOutput('base', livePr.base.ref);
core.setOutput('head-sha', livePr.head.sha);
core.setOutput('skip', 'false');
core.setOutput('number', String(pr.number));

View File

@@ -1,79 +0,0 @@
name: Upsert Comment Section
description: >
Manage a consolidated PR comment with independently-updatable sections.
All website CI workflows share the marker <!-- WEBSITE_CI_REPORT -->.
Valid section names: "e2e", "preview", "screenshot-update".
inputs:
pr-number:
description: PR number to comment on
required: true
section-name:
description: 'Section identifier: "e2e", "preview", or "screenshot-update"'
required: true
section-content:
description: Markdown content for this section
required: true
comment-marker:
description: Top-level HTML comment marker (must be <!-- WEBSITE_CI_REPORT --> for all callers)
required: true
token:
description: GitHub token with pull-requests write permission
required: true
runs:
using: composite
steps:
- uses: actions/github-script@v8
env:
INPUT_PR_NUMBER: ${{ inputs.pr-number }}
INPUT_SECTION_NAME: ${{ inputs.section-name }}
INPUT_SECTION_CONTENT: ${{ inputs.section-content }}
INPUT_COMMENT_MARKER: ${{ inputs.comment-marker }}
with:
github-token: ${{ inputs.token }}
script: |
const prNumber = Number(process.env.INPUT_PR_NUMBER)
const sectionName = process.env.INPUT_SECTION_NAME
const sectionContent = process.env.INPUT_SECTION_CONTENT
const commentMarker = process.env.INPUT_COMMENT_MARKER
const sectionStart = `<!-- section:${sectionName}:start -->`
const sectionEnd = `<!-- section:${sectionName}:end -->`
const sectionBlock = `${sectionStart}\n${sectionContent}\n${sectionEnd}`
// Escape special regex characters in delimiter strings
const escapeRegex = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
const comments = await github.paginate(
github.rest.issues.listComments,
{ ...context.repo, issue_number: prNumber }
)
const existing = comments.find(
(c) =>
c.user?.login === 'github-actions[bot]' &&
c.body?.includes(commentMarker)
)
if (!existing) {
return github.rest.issues.createComment({
...context.repo,
issue_number: prNumber,
body: `${commentMarker}\n${sectionBlock}`
})
}
const body = existing.body ?? ''
const sectionRegex = new RegExp(
`${escapeRegex(sectionStart)}[\\s\\S]*?${escapeRegex(sectionEnd)}`
)
const updated = sectionRegex.test(body)
? body.replace(sectionRegex, sectionBlock)
: body.trimEnd() + '\n\n' + sectionBlock
return github.rest.issues.updateComment({
...context.repo,
comment_id: existing.id,
body: updated
})

Binary file not shown.

Before

Width:  |  Height:  |  Size: 44 KiB

View File

@@ -12,30 +12,17 @@ permissions:
contents: read
jobs:
changes:
runs-on: ubuntu-latest
permissions:
contents: read
outputs:
should-run: ${{ steps.changes.outputs.should-run }}
steps:
- uses: actions/checkout@v6
- id: changes
uses: ./.github/actions/changes-filter
scan:
needs: changes
if: ${{ needs.changes.outputs.should-run == 'true' }}
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
- name: Install pnpm
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0
- name: Use Node.js
uses: actions/setup-node@v6
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
with:
node-version-file: '.nvmrc'
cache: 'pnpm'

View File

@@ -14,29 +14,16 @@ permissions:
contents: read
jobs:
changes:
runs-on: ubuntu-latest
permissions:
contents: read
outputs:
should-run: ${{ steps.changes.outputs.should-run }}
steps:
- uses: actions/checkout@v6
- id: changes
uses: ./.github/actions/changes-filter
validate-fonts:
needs: changes
if: ${{ needs.changes.outputs.should-run == 'true' }}
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
- name: Install pnpm
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0
- name: Use Node.js
uses: actions/setup-node@v6
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
with:
node-version-file: '.nvmrc'
cache: 'pnpm'
@@ -81,17 +68,15 @@ jobs:
echo '✅ No proprietary fonts found in dist'
validate-licenses:
needs: changes
if: ${{ needs.changes.outputs.should-run == 'true' }}
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
- name: Install pnpm
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0
- name: Use Node.js
uses: actions/setup-node@v6
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
with:
node-version-file: '.nvmrc'
cache: 'pnpm'

View File

@@ -3,8 +3,10 @@ name: 'CI: Performance Report'
on:
push:
branches: [main, core/*]
paths-ignore: ['**/*.md']
pull_request:
branches-ignore: [wip/*, draft/*, temp/*]
paths-ignore: ['**/*.md']
concurrency:
group: perf-${{ github.ref }}
@@ -14,24 +16,12 @@ permissions:
contents: read
jobs:
changes:
runs-on: ubuntu-latest
permissions:
contents: read
outputs:
should-run: ${{ steps.changes.outputs.should-run }}
steps:
- uses: actions/checkout@v6
- id: changes
uses: ./.github/actions/changes-filter
perf-tests:
needs: changes
if: ${{ needs.changes.outputs.should-run == 'true' && github.repository == 'Comfy-Org/ComfyUI_frontend' }}
if: github.repository == 'Comfy-Org/ComfyUI_frontend'
runs-on: ubuntu-latest
timeout-minutes: 30
container:
image: ghcr.io/comfy-org/comfyui-ci-container:0.0.17
image: ghcr.io/comfy-org/comfyui-ci-container:0.0.12
credentials:
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
@@ -68,6 +58,21 @@ jobs:
retention-days: 30
if-no-files-found: warn
- name: Save PR metadata
if: github.event_name == 'pull_request'
run: |
mkdir -p temp/perf-meta
echo "${{ github.event.number }}" > temp/perf-meta/number.txt
echo "${{ github.event.pull_request.base.ref }}" > temp/perf-meta/base.txt
echo "${{ github.event.pull_request.head.sha }}" > temp/perf-meta/head-sha.txt
- name: Upload PR metadata
if: github.event_name == 'pull_request'
uses: actions/upload-artifact@v6
with:
name: perf-meta
path: temp/perf-meta/
- name: Save perf baseline to perf-data branch
if: github.event_name == 'push' && github.ref == 'refs/heads/main' && steps.perf.outcome == 'success'
continue-on-error: true

View File

@@ -16,21 +16,8 @@ permissions:
contents: read
jobs:
changes:
if: github.repository == 'Comfy-Org/ComfyUI_frontend'
runs-on: ubuntu-latest
permissions:
contents: read
outputs:
should-run: ${{ steps.changes.outputs.should-run }}
steps:
- uses: actions/checkout@v6
- id: changes
uses: ./.github/actions/changes-filter
collect:
needs: changes
if: ${{ needs.changes.outputs.should-run == 'true' }}
if: github.repository == 'Comfy-Org/ComfyUI_frontend'
runs-on: ubuntu-latest
steps:
@@ -45,6 +32,13 @@ jobs:
- name: Collect size data
run: node scripts/size-collect.js
- name: Save PR metadata
if: ${{ github.event_name == 'pull_request' }}
run: |
echo ${{ github.event.number }} > ./temp/size/number.txt
echo ${{ github.base_ref }} > ./temp/size/base.txt
echo ${{ github.event.pull_request.head.sha }} > ./temp/size/head-sha.txt
- name: Upload size data
uses: actions/upload-artifact@v6
with:

View File

@@ -1,147 +0,0 @@
name: 'CI: E2E Coverage'
on:
workflow_run:
workflows: ['CI: Tests E2E']
types:
- completed
concurrency:
group: e2e-coverage-${{ github.event.workflow_run.head_sha }}
cancel-in-progress: true
permissions:
contents: read
jobs:
merge:
if: >
github.repository == 'Comfy-Org/ComfyUI_frontend' &&
github.event.workflow_run.conclusion == 'success'
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- name: Checkout repository
uses: actions/checkout@v6
- name: Setup frontend
uses: ./.github/actions/setup-frontend
- name: Download all shard coverage data
uses: dawidd6/action-download-artifact@0bd50d53a6d7fb5cb921e607957e9cc12b4ce392 # v12
with:
run_id: ${{ github.event.workflow_run.id }}
name: e2e-coverage-shard-.*
name_is_regexp: true
path: temp/coverage-shards
if_no_artifact_found: warn
- name: Install lcov
run: sudo apt-get install -y -qq lcov
- name: Merge shard coverage into single LCOV
run: |
mkdir -p coverage/playwright
LCOV_FILES=$(find temp/coverage-shards -name 'coverage.lcov' -type f)
if [ -z "$LCOV_FILES" ]; then
echo "No coverage.lcov files found"
touch coverage/playwright/coverage.lcov
exit 0
fi
ADD_ARGS=""
for f in $LCOV_FILES; do ADD_ARGS="$ADD_ARGS -a $f"; done
lcov $ADD_ARGS -o coverage/playwright/coverage.lcov
wc -l coverage/playwright/coverage.lcov
- name: Validate merged coverage
run: |
SHARD_COUNT=$(find temp/coverage-shards -name 'coverage.lcov' -type f | wc -l | tr -d ' ')
if [ "$SHARD_COUNT" -eq 0 ]; then
echo "::notice::No shard coverage files; upstream E2E was likely skipped."
exit 0
fi
MERGED_SF=$(grep -c '^SF:' coverage/playwright/coverage.lcov || echo 0)
MERGED_LH=$(awk -F: '/^LH:/{s+=$2}END{print s+0}' coverage/playwright/coverage.lcov)
MERGED_LF=$(awk -F: '/^LF:/{s+=$2}END{print s+0}' coverage/playwright/coverage.lcov)
echo "### Merged coverage" >> "$GITHUB_STEP_SUMMARY"
echo "- **$MERGED_SF** source files" >> "$GITHUB_STEP_SUMMARY"
echo "- **$MERGED_LH / $MERGED_LF** lines hit" >> "$GITHUB_STEP_SUMMARY"
echo "" >> "$GITHUB_STEP_SUMMARY"
echo "| Shard | Files | Lines Hit |" >> "$GITHUB_STEP_SUMMARY"
echo "|-------|-------|-----------|" >> "$GITHUB_STEP_SUMMARY"
for f in $(find temp/coverage-shards -name 'coverage.lcov' -type f | sort); do
SHARD=$(basename "$(dirname "$f")")
SHARD_SF=$(grep -c '^SF:' "$f" || echo 0)
SHARD_LH=$(awk -F: '/^LH:/{s+=$2}END{print s+0}' "$f")
echo "| $SHARD | $SHARD_SF | $SHARD_LH |" >> "$GITHUB_STEP_SUMMARY"
if [ "$MERGED_LH" -lt "$SHARD_LH" ]; then
echo "::error::Merged LH ($MERGED_LH) < shard LH ($SHARD_LH) in $SHARD — possible data loss"
fi
done
- name: Upload merged coverage data
if: always()
uses: actions/upload-artifact@v6
with:
name: e2e-coverage
path: coverage/playwright/
retention-days: 30
if-no-files-found: warn
- name: Upload E2E coverage to Codecov
if: always()
uses: codecov/codecov-action@1af58845a975a7985b0beb0cbe6fbbb71a41dbad # v5.5.3
with:
files: coverage/playwright/coverage.lcov
flags: e2e
token: ${{ secrets.CODECOV_TOKEN }}
fail_ci_if_error: false
- name: Generate HTML coverage report
run: |
if [ ! -s coverage/playwright/coverage.lcov ]; then
echo "No coverage data; generating placeholder report."
mkdir -p coverage/html
echo '<html><body><h1>No E2E coverage data available for this run.</h1></body></html>' > coverage/html/index.html
exit 0
fi
genhtml coverage/playwright/coverage.lcov \
-o coverage/html \
--title "ComfyUI E2E Coverage" \
--no-function-coverage \
--precision 1
- name: Upload HTML report artifact
uses: actions/upload-artifact@v6
with:
name: e2e-coverage-html
path: coverage/html/
retention-days: 30
deploy:
needs: merge
if: github.event.workflow_run.head_branch == 'main'
runs-on: ubuntu-latest
permissions:
pages: write
id-token: write
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
steps:
- name: Download HTML report
uses: actions/download-artifact@v7
with:
name: e2e-coverage-html
path: coverage/html
- name: Upload to GitHub Pages
uses: actions/upload-pages-artifact@56afc609e74202658d3ffba0e8f6dda462b719fa # v3.0.1
with:
path: coverage/html
- name: Deploy to GitHub Pages
id: deployment
uses: actions/deploy-pages@d6db90164ac5ed86f2b6aed7e0febac5b3c0c03e # v4.0.5

View File

@@ -6,10 +6,6 @@ on:
workflows: ['CI: Tests E2E']
types: [requested, completed]
concurrency:
group: ${{ github.workflow }}-${{ github.event.workflow_run.head_repository.full_name }}-${{ github.event.workflow_run.head_branch }}
cancel-in-progress: true
jobs:
deploy-and-comment-forked-pr:
runs-on: ubuntu-latest
@@ -34,33 +30,49 @@ jobs:
- name: Checkout repository
uses: actions/checkout@v6
- name: Resolve PR from workflow_run context
- name: Get PR Number
id: pr
uses: ./.github/actions/resolve-pr-from-workflow-run
uses: actions/github-script@v8
with:
script: |
const { data: prs } = await github.rest.pulls.list({
owner: context.repo.owner,
repo: context.repo.repo,
state: 'open',
});
const pr = prs.find(p => p.head.sha === context.payload.workflow_run.head_sha);
if (!pr) {
console.log('No PR found for SHA:', context.payload.workflow_run.head_sha);
return null;
}
console.log(`Found PR #${pr.number} from fork: ${context.payload.workflow_run.head_repository.full_name}`);
return pr.number;
- name: Handle Test Start
if: steps.pr.outputs.skip != 'true' && github.event.action == 'requested'
if: steps.pr.outputs.result != 'null' && github.event.action == 'requested'
env:
GITHUB_TOKEN: ${{ github.token }}
run: |
chmod +x scripts/cicd/pr-playwright-deploy-and-comment.sh
./scripts/cicd/pr-playwright-deploy-and-comment.sh \
"${{ steps.pr.outputs.number }}" \
"${{ steps.pr.outputs.result }}" \
"${{ github.event.workflow_run.head_branch }}" \
"starting"
- name: Download and Deploy Reports
if: steps.pr.outputs.skip != 'true' && github.event.action == 'completed'
uses: dawidd6/action-download-artifact@0bd50d53a6d7fb5cb921e607957e9cc12b4ce392 # v12
if: steps.pr.outputs.result != 'null' && github.event.action == 'completed'
uses: actions/download-artifact@v7
with:
run_id: ${{ github.event.workflow_run.id }}
name: playwright-report-.*
name_is_regexp: true
github-token: ${{ secrets.GITHUB_TOKEN }}
run-id: ${{ github.event.workflow_run.id }}
pattern: playwright-report-*
path: reports
if_no_artifact_found: warn
- name: Handle Test Completion
if: steps.pr.outputs.skip != 'true' && github.event.action == 'completed' && hashFiles('reports/**') != ''
if: steps.pr.outputs.result != 'null' && github.event.action == 'completed'
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
@@ -73,6 +85,6 @@ jobs:
chmod +x scripts/cicd/pr-playwright-deploy-and-comment.sh
./scripts/cicd/pr-playwright-deploy-and-comment.sh \
"${{ steps.pr.outputs.number }}" \
"${{ steps.pr.outputs.result }}" \
"${{ github.event.workflow_run.head_branch }}" \
"completed"

View File

@@ -4,8 +4,10 @@ name: 'CI: Tests E2E'
on:
push:
branches: [main, master, core/*, desktop/*]
paths-ignore: ['**/*.md']
pull_request:
branches-ignore: [wip/*, draft/*, temp/*]
paths-ignore: ['**/*.md']
merge_group:
workflow_dispatch:
@@ -14,20 +16,7 @@ concurrency:
cancel-in-progress: true
jobs:
changes:
runs-on: ubuntu-latest
permissions:
contents: read
outputs:
should-run: ${{ steps.changes.outputs.should-run }}
steps:
- uses: actions/checkout@v6
- id: changes
uses: ./.github/actions/changes-filter
setup:
needs: changes
if: ${{ needs.changes.outputs.should-run == 'true' }}
runs-on: ubuntu-latest
steps:
- name: Checkout repository
@@ -65,7 +54,7 @@ jobs:
runs-on: ubuntu-latest
timeout-minutes: 60
container:
image: ghcr.io/comfy-org/comfyui-ci-container:0.0.17
image: ghcr.io/comfy-org/comfyui-ci-container:0.0.16
credentials:
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
@@ -98,7 +87,6 @@ jobs:
run: pnpm exec playwright test --project=chromium --shard=${{ matrix.shardIndex }}/${{ matrix.shardTotal }} --reporter=blob
env:
PLAYWRIGHT_BLOB_OUTPUT_DIR: ./blob-report
COLLECT_COVERAGE: 'true'
- name: Upload blob report
uses: actions/upload-artifact@v6
@@ -108,22 +96,13 @@ jobs:
path: blob-report/
retention-days: 1
- name: Upload shard coverage data
if: always()
uses: actions/upload-artifact@v6
with:
name: e2e-coverage-shard-${{ matrix.shardIndex }}
path: coverage/playwright/
retention-days: 1
if-no-files-found: warn
playwright-tests:
# Ideally, each shard runs test in 6 minutes, but allow up to 15 minutes
timeout-minutes: 15
needs: setup
runs-on: ubuntu-latest
container:
image: ghcr.io/comfy-org/comfyui-ci-container:0.0.17
image: ghcr.io/comfy-org/comfyui-ci-container:0.0.16
credentials:
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
@@ -175,9 +154,9 @@ jobs:
# Merge sharded test reports (no container needed - only runs CLI)
merge-reports:
needs: [changes, playwright-tests-chromium-sharded]
needs: [playwright-tests-chromium-sharded]
runs-on: ubuntu-latest
if: ${{ !cancelled() && needs.changes.outputs.should-run == 'true' }}
if: ${{ !cancelled() }}
steps:
- name: Install pnpm
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0
@@ -206,38 +185,14 @@ jobs:
path: ./playwright-report/
retention-days: 30
# Gate job — single required check that passes whether the matrix ran or was
# skipped. Branch rulesets require this instead of the individual matrix-
# expanded check names so PRs with no e2e-relevant changes aren't stuck.
e2e-status:
if: ${{ always() }}
needs: [changes, playwright-tests-chromium-sharded, playwright-tests]
runs-on: ubuntu-latest
steps:
- name: Check E2E results
env:
SHOULD_RUN: ${{ needs.changes.outputs.should-run }}
SHARDED: ${{ needs.playwright-tests-chromium-sharded.result }}
BROWSERS: ${{ needs.playwright-tests.result }}
run: |
[[ "$SHOULD_RUN" != "true" ]] && echo "E2E skipped" && exit 0
[[ "$SHARDED" != "success" || "$BROWSERS" != "success" ]] && echo "E2E failed" && exit 1
echo "E2E passed"
#### BEGIN Deployment and commenting (non-forked PRs only)
# when using pull_request event, we have permission to comment directly
# if its a forked repo, we need to use workflow_run event in a separate workflow (pr-playwright-deploy.yaml)
# Post starting comment for non-forked PRs
comment-on-pr-start:
needs: changes
runs-on: ubuntu-latest
if: >-
${{
needs.changes.outputs.should-run == 'true' &&
github.event_name == 'pull_request' &&
github.event.pull_request.head.repo.fork == false
}}
if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == false
permissions:
pull-requests: write
steps:
@@ -256,15 +211,9 @@ jobs:
# Deploy and comment for non-forked PRs only
deploy-and-comment:
needs: [changes, playwright-tests, merge-reports]
needs: [playwright-tests, merge-reports]
runs-on: ubuntu-latest
if: >-
${{
always() &&
needs.changes.outputs.should-run == 'true' &&
github.event_name == 'pull_request' &&
github.event.pull_request.head.repo.fork == false
}}
if: always() && github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == false
permissions:
pull-requests: write
contents: read

View File

@@ -6,10 +6,6 @@ on:
workflows: ['CI: Tests Storybook']
types: [requested, completed]
concurrency:
group: ${{ github.workflow }}-${{ github.event.workflow_run.head_repository.full_name }}-${{ github.event.workflow_run.head_branch }}
cancel-in-progress: true
jobs:
deploy-and-comment-forked-pr:
runs-on: ubuntu-latest
@@ -34,23 +30,40 @@ jobs:
- name: Checkout repository
uses: actions/checkout@v6
- name: Resolve PR from workflow_run context
- name: Get PR Number
id: pr
uses: ./.github/actions/resolve-pr-from-workflow-run
uses: actions/github-script@v8
with:
script: |
const { data: prs } = await github.rest.pulls.list({
owner: context.repo.owner,
repo: context.repo.repo,
state: 'open',
});
const pr = prs.find(p => p.head.sha === context.payload.workflow_run.head_sha);
if (!pr) {
console.log('No PR found for SHA:', context.payload.workflow_run.head_sha);
return null;
}
console.log(`Found PR #${pr.number} from fork: ${context.payload.workflow_run.head_repository.full_name}`);
return pr.number;
- name: Handle Storybook Start
if: steps.pr.outputs.skip != 'true' && github.event.action == 'requested'
if: steps.pr.outputs.result != 'null' && github.event.action == 'requested'
env:
GITHUB_TOKEN: ${{ github.token }}
run: |
chmod +x scripts/cicd/pr-storybook-deploy-and-comment.sh
./scripts/cicd/pr-storybook-deploy-and-comment.sh \
"${{ steps.pr.outputs.number }}" \
"${{ steps.pr.outputs.result }}" \
"${{ github.event.workflow_run.head_branch }}" \
"starting"
- name: Download and Deploy Storybook
if: steps.pr.outputs.skip != 'true' && github.event.action == 'completed' && github.event.workflow_run.conclusion == 'success'
if: steps.pr.outputs.result != 'null' && github.event.action == 'completed' && github.event.workflow_run.conclusion == 'success'
uses: actions/download-artifact@v7
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
@@ -59,7 +72,7 @@ jobs:
path: storybook-static
- name: Handle Storybook Completion
if: steps.pr.outputs.skip != 'true' && github.event.action == 'completed'
if: steps.pr.outputs.result != 'null' && github.event.action == 'completed'
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
@@ -69,6 +82,6 @@ jobs:
run: |
chmod +x scripts/cicd/pr-storybook-deploy-and-comment.sh
./scripts/cicd/pr-storybook-deploy-and-comment.sh \
"${{ steps.pr.outputs.number }}" \
"${{ steps.pr.outputs.result }}" \
"${{ github.event.workflow_run.head_branch }}" \
"completed"

View File

@@ -8,29 +8,10 @@ on:
branches: [main]
jobs:
changes:
runs-on: ubuntu-latest
permissions:
contents: read
outputs:
storybook-changes: ${{ steps.changes.outputs.storybook-changes }}
app-frontend-changes: ${{ steps.changes.outputs.app-frontend-changes }}
packages-changes: ${{ steps.changes.outputs.packages-changes }}
steps:
- uses: actions/checkout@v6
- id: changes
uses: ./.github/actions/changes-filter
# Post starting comment for non-forked PRs
comment-on-pr-start:
needs: changes
runs-on: ubuntu-latest
if: |
github.event_name == 'pull_request'
&& github.event.pull_request.head.repo.fork == false
&& (needs.changes.outputs.storybook-changes == 'true'
|| needs.changes.outputs.app-frontend-changes == 'true'
|| needs.changes.outputs.packages-changes == 'true')
if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == false
permissions:
pull-requests: write
steps:
@@ -49,13 +30,8 @@ jobs:
# Build Storybook for all PRs (free Cloudflare deployment)
storybook-build:
needs: changes
runs-on: ubuntu-latest
if: |
github.event_name == 'pull_request'
&& (needs.changes.outputs.storybook-changes == 'true'
|| needs.changes.outputs.app-frontend-changes == 'true'
|| needs.changes.outputs.packages-changes == 'true')
if: github.event_name == 'pull_request'
outputs:
conclusion: ${{ steps.job-status.outputs.conclusion }}
workflow-url: ${{ steps.workflow-url.outputs.url }}
@@ -91,15 +67,8 @@ jobs:
# Chromatic deployment only for version-bump-* branches or manual triggers
chromatic-deployment:
needs: changes
runs-on: ubuntu-latest
if: |
github.event_name == 'workflow_dispatch'
|| (github.event_name == 'pull_request'
&& startsWith(github.head_ref, 'version-bump-')
&& (needs.changes.outputs.storybook-changes == 'true'
|| needs.changes.outputs.app-frontend-changes == 'true'
|| needs.changes.outputs.packages-changes == 'true'))
if: github.event_name == 'workflow_dispatch' || (github.event_name == 'pull_request' && startsWith(github.head_ref, 'version-bump-'))
outputs:
conclusion: ${{ steps.job-status.outputs.conclusion }}
workflow-url: ${{ steps.workflow-url.outputs.url }}
@@ -138,15 +107,9 @@ jobs:
# Deploy and comment for non-forked PRs only
deploy-and-comment:
needs: [changes, storybook-build]
needs: [storybook-build]
runs-on: ubuntu-latest
if: |
always()
&& github.event_name == 'pull_request'
&& github.event.pull_request.head.repo.fork == false
&& (needs.changes.outputs.storybook-changes == 'true'
|| needs.changes.outputs.app-frontend-changes == 'true'
|| needs.changes.outputs.packages-changes == 'true')
if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == false && always()
permissions:
pull-requests: write
contents: read

View File

@@ -4,8 +4,10 @@ name: 'CI: Tests Unit'
on:
push:
branches: [main, master, dev*, core/*, desktop/*]
paths-ignore: ['**/*.md']
pull_request:
branches-ignore: [wip/*, draft/*, temp/*]
paths-ignore: ['**/*.md']
merge_group:
concurrency:
@@ -13,20 +15,7 @@ concurrency:
cancel-in-progress: true
jobs:
changes:
runs-on: ubuntu-latest
permissions:
contents: read
outputs:
should-run: ${{ steps.changes.outputs.should-run }}
steps:
- uses: actions/checkout@v6
- id: changes
uses: ./.github/actions/changes-filter
test:
needs: changes
if: ${{ needs.changes.outputs.should-run == 'true' }}
runs-on: ubuntu-latest
steps:
@@ -38,20 +27,10 @@ jobs:
- name: Run Vitest tests with coverage
run: pnpm test:coverage
- name: Upload unit coverage artifact
if: always() && github.event_name == 'push'
uses: actions/upload-artifact@v6
with:
name: unit-coverage
path: coverage/lcov.info
retention-days: 30
if-no-files-found: warn
- name: Upload coverage to Codecov
if: always()
uses: codecov/codecov-action@1af58845a975a7985b0beb0cbe6fbbb71a41dbad # v5.5.3
with:
files: coverage/lcov.info
flags: unit
token: ${{ secrets.CODECOV_TOKEN }}
fail_ci_if_error: false

View File

@@ -1,164 +0,0 @@
---
name: 'CI: Vercel Website Preview'
on:
pull_request:
types: [opened, synchronize, reopened]
paths:
- 'apps/website/**'
- 'packages/design-system/**'
- 'packages/tailwind-utils/**'
push:
branches: [main]
paths:
- 'apps/website/**'
- 'packages/design-system/**'
- 'packages/tailwind-utils/**'
env:
VERCEL_ORG_ID: ${{ secrets.VERCEL_WEBSITE_ORG_ID }}
VERCEL_PROJECT_ID: ${{ secrets.VERCEL_WEBSITE_PROJECT_ID }}
VERCEL_TOKEN: ${{ secrets.VERCEL_WEBSITE_TOKEN }}
VERCEL_SCOPE: comfyui
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
deploy-preview:
if: github.event_name == 'pull_request'
runs-on: ubuntu-latest
permissions:
contents: read
env:
ALIAS_HOST: comfy-website-preview-pr-${{ github.event.pull_request.number }}.vercel.app
steps:
- name: Checkout repository
uses: actions/checkout@v6
- name: Install pnpm
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0
- name: Setup Node
uses: actions/setup-node@v6
with:
node-version-file: '.nvmrc'
- name: Install Vercel CLI
run: npm install --global vercel@latest
- name: Pull Vercel environment information
run: vercel pull --yes --environment=preview
- name: Build project artifacts
env:
WEBSITE_ASHBY_API_KEY: ${{ secrets.WEBSITE_ASHBY_API_KEY }}
WEBSITE_ASHBY_JOB_BOARD_NAME: ${{ secrets.WEBSITE_ASHBY_JOB_BOARD_NAME }}
run: vercel build
- name: Fetch head commit metadata
id: head-commit
uses: actions/github-script@v8
with:
script: |
const { data } = await github.rest.repos.getCommit({
owner: context.repo.owner,
repo: context.repo.repo,
ref: context.payload.pull_request.head.sha,
})
const author = data.author?.login || data.commit.author?.name || ''
const message = (data.commit.message || '').split('\n', 1)[0]
core.setOutput('author', author)
core.setOutput('message', message)
- name: Deploy project artifacts to Vercel
id: deploy
env:
GIT_COMMIT_REF: ${{ github.event.pull_request.head.ref }}
GIT_COMMIT_SHA: ${{ github.event.pull_request.head.sha }}
GIT_AUTHOR_LOGIN: ${{ steps.head-commit.outputs.author }}
GIT_COMMIT_MESSAGE: ${{ steps.head-commit.outputs.message }}
GIT_PR_ID: ${{ github.event.pull_request.number }}
GIT_REPO: ${{ github.repository }}
run: |
URL=$(vercel deploy --prebuilt \
--meta githubCommitRef="$GIT_COMMIT_REF" \
--meta githubCommitSha="$GIT_COMMIT_SHA" \
--meta githubCommitAuthorLogin="$GIT_AUTHOR_LOGIN" \
--meta githubCommitMessage="$GIT_COMMIT_MESSAGE" \
--meta githubPrId="$GIT_PR_ID" \
--meta githubRepo="$GIT_REPO")
echo "url=$URL" >> "$GITHUB_OUTPUT"
- name: Alias deployment to stable PR hostname
id: alias-set
continue-on-error: true
env:
DEPLOY_URL: ${{ steps.deploy.outputs.url }}
run: |
vercel alias set "$DEPLOY_URL" "$ALIAS_HOST" --scope="$VERCEL_SCOPE"
- name: Publish preview outputs
env:
DEPLOY_URL: ${{ steps.deploy.outputs.url }}
ALIAS_OK: ${{ steps.alias-set.outcome == 'success' }}
run: |
if [[ "$ALIAS_OK" == "true" ]]; then
STABLE_URL="https://$ALIAS_HOST"
else
STABLE_URL="$DEPLOY_URL"
fi
mkdir -p temp/vercel-preview
echo "$DEPLOY_URL" > temp/vercel-preview/url.txt
echo "$STABLE_URL" > temp/vercel-preview/stable-url.txt
{
echo "**Preview:** $STABLE_URL"
if [[ "$ALIAS_OK" == "true" ]]; then
echo "**This commit:** $DEPLOY_URL"
else
echo "_Stable alias update failed — URL reflects this commit only._"
fi
} >> "$GITHUB_STEP_SUMMARY"
- name: Upload preview metadata
uses: actions/upload-artifact@v6
with:
name: vercel-preview
path: temp/vercel-preview
deploy-production:
if: github.event_name == 'push'
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v6
- name: Install pnpm
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0
- name: Setup Node
uses: actions/setup-node@v6
with:
node-version-file: '.nvmrc'
- name: Install Vercel CLI
run: npm install --global vercel@latest
- name: Pull Vercel environment information
run: vercel pull --yes --environment=production
- name: Build project artifacts
env:
WEBSITE_ASHBY_API_KEY: ${{ secrets.WEBSITE_ASHBY_API_KEY }}
WEBSITE_ASHBY_JOB_BOARD_NAME: ${{ secrets.WEBSITE_ASHBY_JOB_BOARD_NAME }}
run: vercel build --prod
- name: Deploy project artifacts to Vercel
id: deploy
run: |
URL=$(vercel deploy --prebuilt --prod)
echo "url=$URL" >> "$GITHUB_OUTPUT"
- name: Add deployment URL to summary
run: echo "**Production:** ${{ steps.deploy.outputs.url }}" >> "$GITHUB_STEP_SUMMARY"

View File

@@ -4,29 +4,23 @@ name: 'CI: Website Build'
on:
push:
branches: [main, master, website/*]
paths:
- 'apps/website/**'
- 'packages/design-system/**'
- 'pnpm-lock.yaml'
pull_request:
branches-ignore: [wip/*, draft/*, temp/*]
paths:
- 'apps/website/**'
- 'packages/design-system/**'
- 'pnpm-lock.yaml'
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
changes:
runs-on: ubuntu-latest
permissions:
contents: read
outputs:
app-website-changes: ${{ steps.changes.outputs.app-website-changes }}
packages-changes: ${{ steps.changes.outputs.packages-changes }}
steps:
- uses: actions/checkout@v6
- id: changes
uses: ./.github/actions/changes-filter
build:
needs: changes
if: ${{ needs.changes.outputs.app-website-changes == 'true' || needs.changes.outputs.packages-changes == 'true' }}
runs-on: ubuntu-latest
steps:
@@ -36,7 +30,4 @@ jobs:
uses: ./.github/actions/setup-frontend
- name: Build website
env:
WEBSITE_ASHBY_API_KEY: ${{ secrets.WEBSITE_ASHBY_API_KEY }}
WEBSITE_ASHBY_JOB_BOARD_NAME: ${{ secrets.WEBSITE_ASHBY_JOB_BOARD_NAME }}
run: pnpm --filter @comfyorg/website build

View File

@@ -1,270 +0,0 @@
name: 'CI: Website E2E'
on:
push:
branches: [main]
pull_request:
branches-ignore: [wip/*, draft/*, temp/*]
concurrency:
group: ${{ github.workflow }}-${{ github.repository }}-${{ github.head_ref || github.ref }}
cancel-in-progress: true
jobs:
changes:
runs-on: ubuntu-latest
permissions:
contents: read
outputs:
app-website-changes: ${{ steps.changes.outputs.app-website-changes }}
packages-changes: ${{ steps.changes.outputs.packages-changes }}
steps:
- uses: actions/checkout@v6
- id: changes
uses: ./.github/actions/changes-filter
website-e2e:
needs: changes
if: ${{ needs.changes.outputs.app-website-changes == 'true' || needs.changes.outputs.packages-changes == 'true' }}
runs-on: ubuntu-latest
container:
image: mcr.microsoft.com/playwright:v1.58.1-noble
timeout-minutes: 15
permissions:
contents: read
outputs:
test-outcome: ${{ steps.tests.outcome }}
report-url: ${{ steps.deploy.outputs.url }}
screenshot-failures: ${{ steps.failures.outputs.screenshot }}
other-failures: ${{ steps.failures.outputs.other }}
# Evaluated at job level (not from a step) — static expression.
is-pr: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == false }}
steps:
- uses: actions/checkout@v6
- name: Install pnpm
run: corepack enable && corepack prepare
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Build website
env:
WEBSITE_GITHUB_STARS_OVERRIDE: 110000
run: pnpm --filter @comfyorg/website build
- name: Run Playwright tests
id: tests
run: pnpm --filter @comfyorg/website test:e2e
- name: Upload test report
uses: actions/upload-artifact@v6
if: ${{ !cancelled() }}
with:
name: website-playwright-report
path: apps/website/playwright-report/
retention-days: 30
- name: Deploy report to Cloudflare
id: deploy
if: always() && !cancelled()
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
HEAD_REF: ${{ github.head_ref || github.ref_name }}
run: |
BRANCH=$(echo "$HEAD_REF" | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9-]/-/g;s/--*/-/g;s/^-\|-$//g')
DEPLOY_OK=false
for i in 1 2 3; do
echo "Deployment attempt $i of 3..."
OUTPUT=$(npx wrangler@^4.0.0 pages deploy apps/website/playwright-report \
--project-name=comfyui-website-e2e \
--branch="$BRANCH" 2>&1) && { DEPLOY_OK=true; break; } || echo "$OUTPUT"
[ $i -lt 3 ] && sleep 10
done
echo "$OUTPUT"
if [ "$DEPLOY_OK" != "true" ]; then
echo "::error::All 3 deployment attempts failed"
exit 1
fi
URL=$(echo "$OUTPUT" | grep -oE 'https://[a-zA-Z0-9.-]+\.pages\.dev\S*' | head -1)
echo "url=${URL}" >> $GITHUB_OUTPUT
- name: Categorize failures
id: failures
if: always() && !cancelled() && steps.tests.outcome != 'success'
uses: actions/github-script@v8
with:
script: |
const fs = require('fs')
const report = JSON.parse(fs.readFileSync('apps/website/results.json', 'utf8'))
function isFailed(t) { return t.status === 'unexpected' || t.status === 'flaky' }
function isVisual(spec) {
return spec.file?.includes('visual') ||
spec.tests?.some(t => t.results?.some(r => r.error?.message?.includes('toHaveScreenshot')))
}
function specsOf(suite) {
return [
...(suite.specs || []),
...(suite.suites || []).flatMap(specsOf)
]
}
// True: Visual
// False: Other
const failed = specsOf(report)
.flatMap(spec => (spec.tests || [])
.filter(isFailed)
.map(() => isVisual(spec)))
const screenshotFailures = failed.filter(Boolean).length
core.setOutput('screenshot', screenshotFailures)
core.setOutput('other', failed.length - screenshotFailures)
- name: Write job summary
if: always() && !cancelled()
uses: actions/github-script@v8
env:
TEST_OUTCOME: ${{ steps.tests.outcome }}
REPORT_URL: ${{ steps.deploy.outputs.url }}
SCREENSHOT_FAILURES: ${{ steps.failures.outputs.screenshot }}
OTHER_FAILURES: ${{ steps.failures.outputs.other }}
with:
script: |
const passed = process.env.TEST_OUTCOME === 'success'
const reportUrl = process.env.REPORT_URL
const screenshotFailures = parseInt(process.env.SCREENSHOT_FAILURES) || 0
const otherFailures = parseInt(process.env.OTHER_FAILURES) || 0
const lines = ['## 🌐 Website E2E', '']
if (passed) {
lines.push('> [!TIP]', '> All tests passed.')
} else {
lines.push('> [!CAUTION]', '> Some tests failed.')
}
const rows = [
['Status', passed ? '✅ Passed' : '❌ Failed'],
['Report', reportUrl ? `[View Report](${reportUrl})` : '_unavailable_']
]
if (!passed) {
rows.push(
['Screenshot diffs', String(screenshotFailures)],
['Other failures', String(otherFailures)]
)
}
lines.push(
'',
'| | |',
'|---|---|',
...rows.map(([k, v]) => `| **${k}** | ${v} |`)
)
await core.summary.addRaw(lines.join('\n')).write()
post-starting-comment:
# Safe to comment from pull_request trigger: fork PRs are excluded by the guard below.
# This avoids a ci-*/pr-* workflow_run split for a comment that must appear immediately.
needs: changes
if: |
github.event_name == 'pull_request'
&& github.event.pull_request.head.repo.fork == false
&& (needs.changes.outputs.app-website-changes == 'true' || needs.changes.outputs.packages-changes == 'true')
runs-on: ubuntu-latest
permissions:
pull-requests: write
contents: read
concurrency:
group: website-pr-comment-${{ github.event.pull_request.number }}
cancel-in-progress: false
steps:
- uses: actions/checkout@v6
- uses: ./.github/actions/upsert-comment-section
with:
pr-number: ${{ github.event.pull_request.number }}
section-name: e2e
comment-marker: '<!-- WEBSITE_CI_REPORT -->'
token: ${{ secrets.GITHUB_TOKEN }}
section-content: |-
## 🌐 Website E2E
<!-- WEBSITE_E2E_STATUS -->
> [!NOTE]
> Tests are running… [View workflow run](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }})
post-result-comment:
needs: website-e2e
if: always() && !cancelled() && needs.website-e2e.outputs.is-pr == 'true'
runs-on: ubuntu-latest
permissions:
pull-requests: write
contents: read
concurrency:
group: website-pr-comment-${{ github.event.pull_request.number }}
cancel-in-progress: false
steps:
- uses: actions/checkout@v6
- name: Build e2e section content
id: content
uses: actions/github-script@v8
env:
TEST_OUTCOME: ${{ needs.website-e2e.outputs.test-outcome }}
REPORT_URL: ${{ needs.website-e2e.outputs.report-url }}
SCREENSHOT_FAILURES: ${{ needs.website-e2e.outputs.screenshot-failures }}
OTHER_FAILURES: ${{ needs.website-e2e.outputs.other-failures }}
with:
script: |
const passed = process.env.TEST_OUTCOME === 'success'
const reportUrl = process.env.REPORT_URL
const screenshotFailures = parseInt(process.env.SCREENSHOT_FAILURES) || 0
const otherFailures = parseInt(process.env.OTHER_FAILURES) || 0
const lines = ['## 🌐 Website E2E', '<!-- WEBSITE_E2E_STATUS -->', '']
if (passed) {
lines.push('> [!TIP]', '> All tests passed.')
} else {
lines.push('> [!CAUTION]', '> Some tests failed.')
}
const rows = [
['Status', passed ? '✅ Passed' : '❌ Failed'],
['Report', reportUrl ? `[View Report](${reportUrl})` : '_unavailable_']
]
if (!passed) {
rows.push(
['Screenshot diffs', String(screenshotFailures)],
['Other failures', String(otherFailures)]
)
}
lines.push(
'',
'| | |',
'|---|---|',
...rows.map(([k, v]) => `| **${k}** | ${v} |`)
)
if (screenshotFailures > 0) {
const s = screenshotFailures === 1 ? '' : 's'
lines.push('', `- [ ] Update website screenshots (${screenshotFailures} screenshot diff${s})`)
}
if (otherFailures > 0) {
lines.push(
'',
'> [!WARNING]',
`> ${otherFailures} non-screenshot failure${otherFailures === 1 ? '' : 's'} — these require manual review.`
)
}
core.setOutput('section-content', lines.join('\n'))
- uses: ./.github/actions/upsert-comment-section
with:
pr-number: ${{ github.event.pull_request.number }}
section-name: e2e
comment-marker: '<!-- WEBSITE_CI_REPORT -->'
token: ${{ secrets.GITHUB_TOKEN }}
section-content: ${{ steps.content.outputs.section-content }}

View File

@@ -1,149 +0,0 @@
name: 'Coverage: Slack Notification'
on:
workflow_run:
workflows: ['CI: Tests Unit']
branches: [main]
types:
- completed
permissions:
contents: read
actions: read
pull-requests: read
jobs:
notify:
if: >
github.repository == 'Comfy-Org/ComfyUI_frontend' &&
github.event.workflow_run.conclusion == 'success' &&
github.event.workflow_run.event == 'push'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- name: Setup frontend
uses: ./.github/actions/setup-frontend
- name: Download current unit coverage
uses: dawidd6/action-download-artifact@0bd50d53a6d7fb5cb921e607957e9cc12b4ce392 # v12
with:
run_id: ${{ github.event.workflow_run.id }}
name: unit-coverage
path: coverage
- name: Download previous unit coverage baseline
continue-on-error: true
uses: dawidd6/action-download-artifact@0bd50d53a6d7fb5cb921e607957e9cc12b4ce392 # v12
with:
branch: main
workflow: coverage-slack-notify.yaml
name: unit-coverage-baseline
path: temp/coverage-baseline
if_no_artifact_found: warn
- name: Download latest E2E coverage
continue-on-error: true
uses: dawidd6/action-download-artifact@0bd50d53a6d7fb5cb921e607957e9cc12b4ce392 # v12
with:
branch: main
workflow: ci-tests-e2e-coverage.yaml
name: e2e-coverage
path: temp/e2e-coverage
if_no_artifact_found: warn
- name: Download previous E2E coverage baseline
continue-on-error: true
uses: dawidd6/action-download-artifact@0bd50d53a6d7fb5cb921e607957e9cc12b4ce392 # v12
with:
branch: main
workflow: coverage-slack-notify.yaml
name: e2e-coverage-baseline
path: temp/e2e-coverage-baseline
if_no_artifact_found: warn
- name: Resolve merged PR metadata
id: pr-meta
uses: actions/github-script@v8
with:
script: |
const sha = context.payload.workflow_run.head_sha;
const { data: commit } = await github.rest.repos.getCommit({
owner: context.repo.owner,
repo: context.repo.repo,
ref: sha,
});
const message = commit.commit.message ?? '';
const firstLine = message.split('\n')[0];
const match = firstLine.match(/\(#(\d+)\)\s*$/);
if (!match) {
core.setOutput('skip', 'true');
core.info('No PR number found in commit message — skipping.');
return;
}
const prNumber = match[1];
const { data: pr } = await github.rest.pulls.get({
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: Number(prNumber),
});
core.setOutput('skip', 'false');
core.setOutput('number', prNumber);
core.setOutput('url', pr.html_url);
core.setOutput('author', pr.user.login);
- name: Generate Slack notification
if: steps.pr-meta.outputs.skip != 'true'
id: slack-payload
env:
PR_URL: ${{ steps.pr-meta.outputs.url }}
PR_NUMBER: ${{ steps.pr-meta.outputs.number }}
PR_AUTHOR: ${{ steps.pr-meta.outputs.author }}
run: |
PAYLOAD=$(pnpm exec tsx scripts/coverage-slack-notify.ts \
--pr-url="$PR_URL" \
--pr-number="$PR_NUMBER" \
--author="$PR_AUTHOR")
if [ -n "$PAYLOAD" ]; then
echo "has_payload=true" >> "$GITHUB_OUTPUT"
DELIM="SLACK_PAYLOAD_$(date +%s)"
echo "payload<<$DELIM" >> "$GITHUB_OUTPUT"
printf '%s\n' "$PAYLOAD" >> "$GITHUB_OUTPUT"
echo "$DELIM" >> "$GITHUB_OUTPUT"
else
echo "has_payload=false" >> "$GITHUB_OUTPUT"
fi
- name: Post to Slack
if: steps.slack-payload.outputs.has_payload == 'true'
continue-on-error: true
env:
SLACK_PAYLOAD: ${{ steps.slack-payload.outputs.payload }}
SLACK_BOT_TOKEN: ${{ secrets.SLACK_BOT_TOKEN }}
run: |
# Channel: #p-deprecated-frontend-automated-testing
BODY=$(echo "$SLACK_PAYLOAD" | jq --arg ch "C0AP09LKRDZ" '. + {channel: $ch}')
curl -sf -X POST \
-H "Authorization: Bearer $SLACK_BOT_TOKEN" \
-H "Content-Type: application/json" \
-d "$BODY" \
-o /dev/null \
https://slack.com/api/chat.postMessage
- name: Save unit coverage baseline
if: always() && hashFiles('coverage/lcov.info') != ''
uses: actions/upload-artifact@v6
with:
name: unit-coverage-baseline
path: coverage/lcov.info
retention-days: 90
if-no-files-found: warn
- name: Save E2E coverage baseline
if: always() && hashFiles('temp/e2e-coverage/coverage.lcov') != ''
uses: actions/upload-artifact@v6
with:
name: e2e-coverage-baseline
path: temp/e2e-coverage/coverage.lcov
retention-days: 90
if-no-files-found: warn

View File

@@ -2,7 +2,7 @@ name: 'PR: Unified Report'
on:
workflow_run:
workflows: ['CI: Size Data', 'CI: Performance Report', 'CI: E2E Coverage']
workflows: ['CI: Size Data', 'CI: Performance Report']
types:
- completed
@@ -30,25 +30,110 @@ jobs:
- name: Resolve PR from workflow_run context
id: pr-meta
uses: ./.github/actions/resolve-pr-from-workflow-run
uses: actions/github-script@v8
with:
script: |
let pr = context.payload.workflow_run.pull_requests?.[0];
if (!pr) {
const { data: prs } = await github.rest.repos.listPullRequestsAssociatedWithCommit({
owner: context.repo.owner,
repo: context.repo.repo,
commit_sha: context.payload.workflow_run.head_sha,
});
pr = prs.find(p => p.state === 'open');
}
- name: Find size workflow run
if (!pr) {
core.info('No open PR found for this workflow run — skipping.');
core.setOutput('skip', 'true');
return;
}
// Verify the workflow_run head SHA matches the current PR head
const { data: livePr } = await github.rest.pulls.get({
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: pr.number,
});
if (livePr.head.sha !== context.payload.workflow_run.head_sha) {
core.info(`Stale run: workflow SHA ${context.payload.workflow_run.head_sha} != PR head ${livePr.head.sha}`);
core.setOutput('skip', 'true');
return;
}
core.setOutput('skip', 'false');
core.setOutput('number', String(pr.number));
core.setOutput('base', livePr.base.ref);
core.setOutput('head-sha', livePr.head.sha);
- name: Find size workflow run for this commit
if: steps.pr-meta.outputs.skip != 'true'
id: find-size
uses: ./.github/actions/find-workflow-run
uses: actions/github-script@v8
with:
workflow-id: ci-size-data.yaml
head-sha: ${{ steps.pr-meta.outputs.head-sha }}
token: ${{ secrets.GITHUB_TOKEN }}
script: |
const headSha = '${{ steps.pr-meta.outputs.head-sha }}';
const { data: runs } = await github.rest.actions.listWorkflowRuns({
owner: context.repo.owner,
repo: context.repo.repo,
workflow_id: 'ci-size-data.yaml',
head_sha: headSha,
per_page: 1,
});
- name: Find perf workflow run
const run = runs.workflow_runs[0];
if (!run) {
core.setOutput('status', 'pending');
return;
}
if (run.status !== 'completed') {
core.setOutput('status', 'pending');
return;
}
if (run.conclusion !== 'success') {
core.setOutput('status', 'failed');
return;
}
core.setOutput('status', 'ready');
core.setOutput('run-id', String(run.id));
- name: Find perf workflow run for this commit
if: steps.pr-meta.outputs.skip != 'true'
id: find-perf
uses: ./.github/actions/find-workflow-run
uses: actions/github-script@v8
with:
workflow-id: ci-perf-report.yaml
head-sha: ${{ steps.pr-meta.outputs.head-sha }}
token: ${{ secrets.GITHUB_TOKEN }}
script: |
const headSha = '${{ steps.pr-meta.outputs.head-sha }}';
const { data: runs } = await github.rest.actions.listWorkflowRuns({
owner: context.repo.owner,
repo: context.repo.repo,
workflow_id: 'ci-perf-report.yaml',
head_sha: headSha,
per_page: 1,
});
const run = runs.workflow_runs[0];
if (!run) {
core.setOutput('status', 'pending');
return;
}
if (run.status !== 'completed') {
core.setOutput('status', 'pending');
return;
}
if (run.conclusion !== 'success') {
core.setOutput('status', 'failed');
return;
}
core.setOutput('status', 'ready');
core.setOutput('run-id', String(run.id));
- name: Download size data (current)
if: steps.pr-meta.outputs.skip != 'true' && steps.find-size.outputs.status == 'ready'
@@ -69,25 +154,6 @@ jobs:
path: temp/size-prev
if_no_artifact_found: warn
- name: Find coverage workflow run
if: steps.pr-meta.outputs.skip != 'true'
id: find-coverage
uses: ./.github/actions/find-workflow-run
with:
workflow-id: ci-tests-e2e-coverage.yaml
head-sha: ${{ steps.pr-meta.outputs.head-sha }}
not-found-status: skip
token: ${{ secrets.GITHUB_TOKEN }}
- name: Download coverage data
if: steps.pr-meta.outputs.skip != 'true' && steps.find-coverage.outputs.status == 'ready'
uses: dawidd6/action-download-artifact@0bd50d53a6d7fb5cb921e607957e9cc12b4ce392 # v12
with:
name: e2e-coverage
run_id: ${{ steps.find-coverage.outputs.run-id }}
path: temp/coverage
if_no_artifact_found: warn
- name: Download perf metrics (current)
if: steps.pr-meta.outputs.skip != 'true' && steps.find-perf.outputs.status == 'ready'
uses: dawidd6/action-download-artifact@0bd50d53a6d7fb5cb921e607957e9cc12b4ce392 # v12
@@ -123,10 +189,9 @@ jobs:
- name: Generate unified report
if: steps.pr-meta.outputs.skip != 'true'
run: >
pnpm exec tsx scripts/unified-report.ts
node scripts/unified-report.js
--size-status=${{ steps.find-size.outputs.status }}
--perf-status=${{ steps.find-perf.outputs.status }}
--coverage-status=${{ steps.find-coverage.outputs.status }}
> pr-report.md
- name: Remove legacy separate comments

View File

@@ -77,7 +77,7 @@ jobs:
needs: setup
runs-on: ubuntu-latest
container:
image: ghcr.io/comfy-org/comfyui-ci-container:0.0.17
image: ghcr.io/comfy-org/comfyui-ci-container:0.0.16
credentials:
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}

View File

@@ -1,245 +0,0 @@
name: 'PR: Update Website Screenshots'
on:
pull_request:
types: [labeled]
issue_comment:
types: [created, edited]
concurrency:
group: ${{ github.workflow }}-${{ github.event.issue.number || github.event.pull_request.number || github.ref }}
cancel-in-progress: false
jobs:
update-screenshots:
runs-on: ubuntu-latest
container:
image: mcr.microsoft.com/playwright:v1.58.1-noble
timeout-minutes: 15
permissions:
contents: write
issues: write
pull-requests: read
# Trigger: (1) label, (2) /slash-command, or (3) checkbox in E2E status comment
# ⚠️ This condition is duplicated on `post-starting-comment` — keep them in sync.
if: >
( github.event_name == 'pull_request' &&
github.event.label.name == 'Update Website Screenshots' ) ||
( github.event.issue.pull_request &&
github.event_name == 'issue_comment' &&
(
github.event.comment.author_association == 'OWNER' ||
github.event.comment.author_association == 'MEMBER' ||
github.event.comment.author_association == 'COLLABORATOR'
) &&
startsWith(github.event.comment.body, '/update-website-screenshots') ) ||
( github.event.issue.pull_request &&
github.event_name == 'issue_comment' &&
github.event.comment.user.login == 'github-actions[bot]' &&
github.actor != 'github-actions[bot]' &&
contains(github.event.comment.body, '<!-- WEBSITE_E2E_STATUS -->') &&
contains(github.event.comment.body, '- [x] Update website screenshots') )
outputs:
pr-number: ${{ steps.pr-info.outputs.pr-number }}
update-outcome: ${{ steps.update-screenshots.outcome }}
has-changes: ${{ steps.commit.outputs.has-changes }}
changed-count: ${{ steps.commit.outputs.changed-count }}
steps:
- name: Verify sender permissions
if: >
github.event_name == 'issue_comment' &&
contains(github.event.comment.body, '<!-- WEBSITE_E2E_STATUS -->')
uses: actions/github-script@v8
with:
script: |
const { data } = await github.rest.repos.getCollaboratorPermissionLevel({
...context.repo,
username: context.actor
})
if (!['admin', 'write'].includes(data.permission)) {
core.setFailed(`User ${context.actor} does not have write access`)
}
- name: Get PR info
id: pr-info
uses: actions/github-script@v8
env:
PR_NUMBER: ${{ github.event.number || github.event.issue.number }}
with:
script: |
const prNumber = Number(process.env.PR_NUMBER)
const { data: pr } = await github.rest.pulls.get({
...context.repo,
pull_number: prNumber
})
core.setOutput('pr-number', prNumber)
core.setOutput('branch', pr.head.ref)
- uses: actions/checkout@v6
with:
ref: ${{ steps.pr-info.outputs.branch }}
token: ${{ secrets.PR_GH_TOKEN }}
- name: Install pnpm
run: corepack enable && corepack prepare
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Build website
env:
WEBSITE_GITHUB_STARS_OVERRIDE: 110000
run: pnpm --filter @comfyorg/website build
- name: Update screenshots
id: update-screenshots
run: pnpm --filter @comfyorg/website test:visual:update
continue-on-error: true
- name: Commit updated screenshots
id: commit
if: steps.update-screenshots.outcome == 'success'
run: |
git config --global --add safe.directory "$(pwd)"
git config --global user.name 'github-actions'
git config --global user.email 'github-actions@github.com'
CHANGED=$(git status --porcelain=v1 --untracked-files=all -- apps/website/e2e/ | wc -l)
echo "changed-count=${CHANGED}" >> $GITHUB_OUTPUT
if [ "$CHANGED" -eq 0 ]; then
echo "No screenshot changes to commit"
echo "has-changes=false" >> $GITHUB_OUTPUT
exit 0
fi
echo "has-changes=true" >> $GITHUB_OUTPUT
git add apps/website/e2e/
git commit -m "[automated] Update website screenshot expectations"
git push origin ${{ steps.pr-info.outputs.branch }}
- name: Upload test report
uses: actions/upload-artifact@v6
if: ${{ !cancelled() }}
with:
name: website-screenshot-update-report
path: apps/website/playwright-report/
retention-days: 14
- name: Remove label
if: always() && github.event_name == 'pull_request'
uses: actions/github-script@v8
env:
PR_NUMBER: ${{ steps.pr-info.outputs.pr-number }}
with:
script: |
try {
await github.rest.issues.removeLabel({
...context.repo,
issue_number: Number(process.env.PR_NUMBER),
name: 'Update Website Screenshots'
})
} catch (e) {
if (e.status !== 404) {
throw e
}
core.info('Label "Update Website Screenshots" was already removed')
}
post-starting-comment:
# Runs in parallel with update-screenshots to show "in progress" immediately.
# ⚠️ This condition is duplicated from `update-screenshots` — keep them in sync.
if: >
( github.event_name == 'pull_request' &&
github.event.label.name == 'Update Website Screenshots' ) ||
( github.event.issue.pull_request &&
github.event_name == 'issue_comment' &&
(
github.event.comment.author_association == 'OWNER' ||
github.event.comment.author_association == 'MEMBER' ||
github.event.comment.author_association == 'COLLABORATOR'
) &&
startsWith(github.event.comment.body, '/update-website-screenshots') ) ||
( github.event.issue.pull_request &&
github.event_name == 'issue_comment' &&
github.event.comment.user.login == 'github-actions[bot]' &&
github.actor != 'github-actions[bot]' &&
contains(github.event.comment.body, '<!-- WEBSITE_E2E_STATUS -->') &&
contains(github.event.comment.body, '- [x] Update website screenshots') )
runs-on: ubuntu-latest
permissions:
pull-requests: write
contents: read
concurrency:
group: website-pr-comment-${{ github.event.number || github.event.issue.number }}
cancel-in-progress: false
steps:
- uses: actions/checkout@v6
- uses: ./.github/actions/upsert-comment-section
with:
pr-number: ${{ github.event.number || github.event.issue.number }}
section-name: screenshot-update
comment-marker: '<!-- WEBSITE_CI_REPORT -->'
token: ${{ secrets.GITHUB_TOKEN }}
section-content: |-
## 📸 Screenshot Update
> [!NOTE]
> Updating screenshots… [View workflow run](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }})
post-result-comment:
needs: update-screenshots
if: always() && !cancelled() && needs.update-screenshots.result != 'skipped'
runs-on: ubuntu-latest
permissions:
pull-requests: write
contents: read
concurrency:
group: website-pr-comment-${{ needs.update-screenshots.outputs.pr-number }}
cancel-in-progress: false
steps:
- uses: actions/checkout@v6
- name: Build screenshot-update section content
id: content
uses: actions/github-script@v8
env:
UPDATE_OUTCOME: ${{ needs.update-screenshots.outputs.update-outcome }}
HAS_CHANGES: ${{ needs.update-screenshots.outputs.has-changes }}
CHANGED_COUNT: ${{ needs.update-screenshots.outputs.changed-count }}
with:
script: |
const outcome = process.env.UPDATE_OUTCOME
const hasChanges = process.env.HAS_CHANGES === 'true'
const changedCount = parseInt(process.env.CHANGED_COUNT) || 0
const runUrl = `${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`
const lines = ['## 📸 Screenshot Update', '']
if (outcome !== 'success') {
lines.push(
'> [!CAUTION]',
`> Screenshot update failed. [View workflow run](${runUrl})`
)
} else if (!hasChanges) {
lines.push(
'> [!TIP]',
'> All screenshots are already up to date.'
)
} else {
const s = changedCount === 1 ? '' : 's'
lines.push(
'> [!TIP]',
`> Updated ${changedCount} screenshot${s} and pushed to the branch.`
)
}
core.setOutput('section-content', lines.join('\n'))
- uses: ./.github/actions/upsert-comment-section
with:
pr-number: ${{ needs.update-screenshots.outputs.pr-number }}
section-name: screenshot-update
comment-marker: '<!-- WEBSITE_CI_REPORT -->'
token: ${{ secrets.GITHUB_TOKEN }}
section-content: ${{ steps.content.outputs.section-content }}

View File

@@ -1,70 +0,0 @@
---
name: 'PR: Vercel Website Preview'
on:
workflow_run:
workflows: ['CI: Vercel Website Preview']
types:
- completed
concurrency:
group: ${{ github.workflow }}-${{ github.event.workflow_run.head_repository.full_name }}-${{ github.event.workflow_run.head_branch }}
cancel-in-progress: true
jobs:
comment:
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: write
actions: read
# Uses head_branch as proxy for PR number (unavailable at job-level in workflow_run).
# Preview and E2E comment writes are NOT mutually serialized — the race window is
# small and self-healing on next push.
concurrency:
group: website-pr-comment-${{ github.event.workflow_run.head_branch }}
cancel-in-progress: false
if: >
github.repository == 'Comfy-Org/ComfyUI_frontend' &&
github.event.workflow_run.event == 'pull_request' &&
github.event.workflow_run.conclusion == 'success'
steps:
- uses: actions/checkout@v6
- name: Download preview metadata
uses: dawidd6/action-download-artifact@0bd50d53a6d7fb5cb921e607957e9cc12b4ce392 # v12
with:
name: vercel-preview
run_id: ${{ github.event.workflow_run.id }}
path: temp/vercel-preview
- name: Resolve PR number from workflow_run context
id: pr-meta
uses: ./.github/actions/resolve-pr-from-workflow-run
- name: Read preview URLs
if: steps.pr-meta.outputs.skip != 'true'
id: urls
run: |
echo "stable-url=$(cat temp/vercel-preview/stable-url.txt)" >> "$GITHUB_OUTPUT"
echo "unique-url=$(cat temp/vercel-preview/url.txt)" >> "$GITHUB_OUTPUT"
echo "short-sha=${HEAD_SHA:0:7}" >> "$GITHUB_OUTPUT"
env:
HEAD_SHA: ${{ github.event.workflow_run.head_sha }}
- name: Post preview comment
if: steps.pr-meta.outputs.skip != 'true'
uses: ./.github/actions/upsert-comment-section
with:
pr-number: ${{ steps.pr-meta.outputs.number }}
section-name: preview
comment-marker: '<!-- WEBSITE_CI_REPORT -->'
token: ${{ secrets.GITHUB_TOKEN }}
section-content: |-
## 🔗 Website Preview
**Website Preview:** ${{ steps.urls.outputs.stable-url }}
<sub>This commit: ${{ steps.urls.outputs.unique-url }}</sub>
<sub>Last updated: ${{ github.event.workflow_run.updated_at }} for `${{ steps.urls.outputs.short-sha }}`</sub>

View File

@@ -1,23 +1,14 @@
# Release workflow for ComfyUI frontend: version bump → PyPI publish → ComfyUI PR.
# Runs on a bi-weekly schedule for minor releases, or manually for patch/hotfix releases.
name: 'Release: ComfyUI'
# Automated bi-weekly workflow to bump ComfyUI frontend RC releases
name: 'Release: Bi-weekly ComfyUI'
on:
# Bi-weekly schedule: Monday at 20:00 UTC
# Schedule for Monday at 12:00 PM PST (20:00 UTC)
schedule:
- cron: '0 20 * * 1'
# Manual trigger for both on-demand minor and patch/hotfix releases
# Allow manual triggering (bypasses bi-weekly check)
workflow_dispatch:
inputs:
release_type:
description: 'minor = next minor version (bi-weekly cadence), patch = hotfix for current production version'
required: true
default: 'minor'
type: choice
options:
- minor
- patch
comfyui_fork:
description: 'ComfyUI fork to use for PR (e.g., Comfy-Org/ComfyUI)'
required: false
@@ -50,11 +41,10 @@ jobs:
- name: Summary
run: |
echo "## Release Check" >> $GITHUB_STEP_SUMMARY
echo "## Bi-weekly Check" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "- Is release week: ${{ steps.check.outputs.is_release_week }}" >> $GITHUB_STEP_SUMMARY
echo "- Manual trigger: ${{ github.event_name == 'workflow_dispatch' }}" >> $GITHUB_STEP_SUMMARY
echo "- Release type: ${{ inputs.release_type || 'minor (scheduled)' }}" >> $GITHUB_STEP_SUMMARY
resolve-version:
needs: check-release-week
@@ -86,8 +76,6 @@ jobs:
- name: Install pnpm
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0
with:
package_json_file: frontend/package.json
- name: Setup Node.js
uses: actions/setup-node@v6
@@ -101,8 +89,6 @@ jobs:
- name: Resolve release information
id: resolve
working-directory: frontend
env:
RELEASE_TYPE: ${{ inputs.release_type || 'minor' }}
run: |
set -euo pipefail

View File

@@ -1,59 +0,0 @@
# Description: Manual workflow to refresh the apps/website Ashby roles snapshot
# and open a PR. Merging the PR triggers the existing Vercel website production
# deploy via ci-vercel-website-preview.yaml.
name: 'Release: Website'
on:
workflow_dispatch:
concurrency:
group: release-website
cancel-in-progress: true
jobs:
refresh-snapshot:
if: github.repository == 'Comfy-Org/ComfyUI_frontend'
runs-on: ubuntu-latest
permissions:
contents: write
pull-requests: write
steps:
- name: Checkout repository
uses: actions/checkout@v6
with:
ref: main
persist-credentials: false
- name: Refresh Ashby snapshot
uses: ./.github/actions/ashby-pull
with:
api_key: ${{ secrets.WEBSITE_ASHBY_API_KEY }}
job_board_name: ${{ secrets.WEBSITE_ASHBY_JOB_BOARD_NAME }}
- name: Create Pull Request
uses: peter-evans/create-pull-request@c0f553fe549906ede9cf27b5156039d195d2ece0 # v8.1.0
with:
token: ${{ secrets.PR_GH_TOKEN }}
commit-message: 'chore(website): refresh Ashby roles snapshot'
title: 'chore(website): refresh Ashby roles snapshot'
body: |
Automated refresh of `apps/website/src/data/ashby-roles.snapshot.json`
from the Ashby job board API.
**Flow:**
1. `Release: Website` workflow ran (manual trigger).
2. This PR opens with the regenerated snapshot.
3. `CI: Vercel Website Preview` deploys a preview for review.
4. Merging to `main` triggers the production Vercel deploy.
The snapshot fallback in `apps/website/src/utils/ashby.ts` remains
intact: builds without `WEBSITE_ASHBY_API_KEY` continue to use the
committed snapshot.
Triggered by workflow run `${{ github.run_id }}`.
branch: chore/refresh-ashby-snapshot-${{ github.run_id }}
base: main
labels: |
Release:Website
delete-branch: true

View File

@@ -1,13 +1,5 @@
#!/usr/bin/env bash
# Skip in CI: the canonical knip check runs in ci-lint-format on every
# PR, and bot workflows (e.g. i18n-update-core) populate ComfyUI/ via
# setup-comfyui-server, which contaminates knip's project glob with the
# devtools copy under custom_nodes and produces false-positive failures.
if [ -n "${CI-}" ]; then
exit 0
fi
# Run Knip with cache via package script
pnpm knip 1>&2

View File

@@ -150,7 +150,7 @@
"playwright/no-element-handle": "error",
"playwright/no-eval": "error",
"playwright/no-focused-test": "error",
"playwright/no-force-option": "error",
"playwright/no-force-option": "off",
"playwright/no-networkidle": "error",
"playwright/no-page-pause": "error",
"playwright/no-skipped-test": "error",

View File

@@ -67,7 +67,6 @@
"ignoreFiles": [
"node_modules/**",
"dist/**",
"**/dist/**",
"playwright-report/**",
"public/**",
"src/lib/litegraph/**"

View File

@@ -1,12 +1,16 @@
{
"recommendations": [
"antfu.vite",
"austenc.tailwind-docs",
"bradlc.vscode-tailwindcss",
"davidanson.vscode-markdownlint",
"dbaeumer.vscode-eslint",
"donjayamanne.githistory",
"eamodio.gitlens",
"github.vscode-github-actions",
"github.vscode-pull-request-github",
"hbenl.vscode-test-explorer",
"kisstkondoros.vscode-codemetrics",
"lokalise.i18n-ally",
"ms-playwright.playwright",
"oxc.oxc-vscode",

View File

@@ -44,7 +44,6 @@ This project uses **pnpm**. Always prefer scripts defined in `package.json` (e.g
## Build, Test, and Development Commands
- `pnpm dev`: Start Vite dev server.
- `pnpm dev:cloud`: Dev server connected to cloud backend (testcloud.comfy.org)
- `pnpm dev:electron`: Dev server with Electron API mocks
- `pnpm build`: Type-check then production build to `dist/`
- `pnpm preview`: Preview the production build locally
@@ -312,7 +311,7 @@ When referencing Comfy-Org repos:
- Instead use a semantic value from the `style.css` theme
- e.g. `bg-node-component-surface`
- NEVER use `:class="[]"` to merge class names
- Always use `import { cn } from '@comfyorg/tailwind-utils'`
- Always use `import { cn } from '@/utils/tailwindUtil'`
- e.g. `<div :class="cn('text-node-component-header-icon', hasError && 'text-danger')" />`
- Use `cn()` inline in the template when feasible instead of creating a `computed` to hold the value
- NEVER use `!important` or the `!` important prefix for tailwind classes

View File

@@ -5,14 +5,12 @@
"scripts": {
"lint": "nx run @comfyorg/desktop-ui:lint",
"typecheck": "nx run @comfyorg/desktop-ui:typecheck",
"test:unit": "vitest run --config vitest.config.mts",
"storybook": "storybook dev -p 6007",
"build-storybook": "storybook build -o dist/storybook"
},
"dependencies": {
"@comfyorg/comfyui-electron-types": "catalog:",
"@comfyorg/shared-frontend-utils": "workspace:*",
"@comfyorg/tailwind-utils": "workspace:*",
"@primevue/core": "catalog:",
"@primevue/themes": "catalog:",
"@vueuse/core": "catalog:",

View File

@@ -20,15 +20,15 @@
}
.p-button-danger {
background-color: var(--color-coral-700);
background-color: var(--color-coral-red-600);
}
.p-button-danger:hover {
background-color: var(--color-coral-600);
background-color: var(--color-coral-red-500);
}
.p-button-danger:active {
background-color: var(--color-coral-500);
background-color: var(--color-coral-red-400);
}
.task-div .p-card {

View File

@@ -32,7 +32,7 @@ import { useI18n } from 'vue-i18n'
import { useTerminal } from '@/composables/bottomPanelTabs/useTerminal'
import { electronAPI, isElectron } from '@/utils/envUtil'
import { cn } from '@comfyorg/tailwind-utils'
import { cn } from '@/utils/tailwindUtil'
const { t } = useI18n()

View File

@@ -1,97 +0,0 @@
import { render, screen } from '@testing-library/vue'
import PrimeVue from 'primevue/config'
import { describe, expect, it } from 'vitest'
import { createI18n } from 'vue-i18n'
import StartupDisplay from '@/components/common/StartupDisplay.vue'
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: { en: { g: { logoAlt: 'ComfyUI' } } }
})
const ProgressBarStub = {
props: ['mode', 'value', 'showValue'],
template:
'<div data-testid="progress-bar" :data-mode="mode" :data-value="value" />'
}
function renderDisplay(
props: {
progressPercentage?: number
title?: string
statusText?: string
hideProgress?: boolean
fullScreen?: boolean
} = {}
) {
return render(StartupDisplay, {
props,
global: {
plugins: [[PrimeVue, { unstyled: true }], i18n],
stubs: { ProgressBar: ProgressBarStub }
}
})
}
describe('StartupDisplay', () => {
describe('progressMode', () => {
it('renders indeterminate mode when progressPercentage is undefined', () => {
renderDisplay()
expect(screen.getByTestId('progress-bar').dataset.mode).toBe(
'indeterminate'
)
})
it('renders determinate mode when progressPercentage is provided', () => {
renderDisplay({ progressPercentage: 50 })
expect(screen.getByTestId('progress-bar').dataset.mode).toBe(
'determinate'
)
})
it('passes progressPercentage as value to the progress bar', () => {
renderDisplay({ progressPercentage: 75 })
expect(screen.getByTestId('progress-bar').dataset.value).toBe('75')
})
})
describe('hideProgress', () => {
it('hides the progress bar when hideProgress is true', () => {
renderDisplay({ hideProgress: true })
expect(screen.queryByTestId('progress-bar')).toBeNull()
})
it('shows the progress bar by default', () => {
renderDisplay()
expect(screen.getByTestId('progress-bar')).toBeDefined()
})
})
describe('title', () => {
it('renders the title text when provided', () => {
renderDisplay({ title: 'Loading...' })
expect(screen.getByText('Loading...')).toBeDefined()
})
it('does not render h1 when title is not provided', () => {
renderDisplay()
expect(screen.queryByRole('heading', { level: 1 })).toBeNull()
})
})
describe('statusText', () => {
it('renders statusText with data-testid attribute', () => {
renderDisplay({ statusText: 'Starting server' })
expect(screen.getByTestId('startup-status-text').textContent).toContain(
'Starting server'
)
})
it('does not render statusText element when not provided', () => {
renderDisplay()
expect(screen.queryByTestId('startup-status-text')).toBeNull()
})
})
})

View File

@@ -1,208 +0,0 @@
import { render, screen, waitFor } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import PrimeVue from 'primevue/config'
import { describe, expect, it, vi, beforeEach } from 'vitest'
vi.mock('@comfyorg/shared-frontend-utils/networkUtil', () => ({
checkUrlReachable: vi.fn()
}))
import { checkUrlReachable } from '@comfyorg/shared-frontend-utils/networkUtil'
import UrlInput from '@/components/common/UrlInput.vue'
import { ValidationState } from '@/utils/validationUtil'
const InputTextStub = {
props: ['modelValue', 'invalid'],
emits: ['update:modelValue', 'blur'],
template: `<input
data-testid="url-input"
:value="modelValue"
:data-invalid="invalid"
@input="$emit('update:modelValue', $event.target.value)"
@blur="$emit('blur')"
/>`
}
const InputIconStub = {
template: '<span data-testid="input-icon" />'
}
const IconFieldStub = {
template: '<div><slot /></div>'
}
function renderUrlInput(
modelValue = '',
validateUrlFn?: (url: string) => Promise<boolean>
) {
return render(UrlInput, {
props: { modelValue, ...(validateUrlFn ? { validateUrlFn } : {}) },
global: {
plugins: [[PrimeVue, { unstyled: true }]],
stubs: {
InputText: InputTextStub,
InputIcon: InputIconStub,
IconField: IconFieldStub
}
}
})
}
describe('UrlInput', () => {
beforeEach(() => {
vi.resetAllMocks()
})
describe('initial validation on mount', () => {
it('stays IDLE when modelValue is empty on mount', async () => {
renderUrlInput('')
await waitFor(() => {
expect(screen.getByTestId('url-input').dataset.invalid).toBe('false')
})
})
it('sets VALID state when modelValue is a reachable URL on mount', async () => {
vi.mocked(checkUrlReachable).mockResolvedValue(true)
renderUrlInput('https://example.com')
await waitFor(() => {
expect(screen.getByTestId('url-input').dataset.invalid).toBe('false')
})
})
it('sets INVALID state when URL is not reachable on mount', async () => {
vi.mocked(checkUrlReachable).mockResolvedValue(false)
renderUrlInput('https://unreachable.example')
await waitFor(() => {
expect(screen.getByTestId('url-input').dataset.invalid).toBe('true')
})
})
})
describe('input handling', () => {
it('resets validation state to IDLE on user input', async () => {
vi.mocked(checkUrlReachable).mockResolvedValue(false)
renderUrlInput('https://bad.example')
await waitFor(() => {
expect(screen.getByTestId('url-input').dataset.invalid).toBe('true')
})
const user = userEvent.setup()
await user.type(screen.getByTestId('url-input'), 'x')
expect(screen.getByTestId('url-input').dataset.invalid).toBe('false')
})
it('strips whitespace from typed input', async () => {
const onUpdate = vi.fn()
render(UrlInput, {
props: {
modelValue: '',
'onUpdate:modelValue': onUpdate
},
global: {
plugins: [[PrimeVue, { unstyled: true }]],
stubs: {
InputText: InputTextStub,
InputIcon: InputIconStub,
IconField: IconFieldStub
}
}
})
const user = userEvent.setup()
const input = screen.getByTestId('url-input')
await user.type(input, 'htt ps')
expect((input as HTMLInputElement).value).not.toContain(' ')
})
})
describe('blur handling', () => {
it('emits update:modelValue on blur', async () => {
const onUpdate = vi.fn()
render(UrlInput, {
props: {
modelValue: 'https://example.com',
'onUpdate:modelValue': onUpdate
},
global: {
plugins: [[PrimeVue, { unstyled: true }]],
stubs: {
InputText: InputTextStub,
InputIcon: InputIconStub,
IconField: IconFieldStub
}
}
})
const user = userEvent.setup()
await user.click(screen.getByTestId('url-input'))
await user.tab()
expect(onUpdate).toHaveBeenCalled()
})
it('normalizes URL on blur', async () => {
const onUpdate = vi.fn()
render(UrlInput, {
props: {
modelValue: 'https://example.com',
'onUpdate:modelValue': onUpdate
},
global: {
plugins: [[PrimeVue, { unstyled: true }]],
stubs: {
InputText: InputTextStub,
InputIcon: InputIconStub,
IconField: IconFieldStub
}
}
})
const user = userEvent.setup()
await user.click(screen.getByTestId('url-input'))
await user.tab()
const emittedUrl = onUpdate.mock.calls[0]?.[0]
expect(emittedUrl).toBe('https://example.com/')
})
})
describe('custom validateUrlFn', () => {
it('uses custom validateUrlFn when provided', async () => {
const customValidator = vi.fn().mockResolvedValue(true)
renderUrlInput('https://custom.example', customValidator)
await waitFor(() => {
expect(customValidator).toHaveBeenCalledWith('https://custom.example')
})
expect(checkUrlReachable).not.toHaveBeenCalled()
})
})
describe('state-change emission', () => {
it('emits state-change when validation state changes', async () => {
const onStateChange = vi.fn()
vi.mocked(checkUrlReachable).mockResolvedValue(true)
render(UrlInput, {
props: {
modelValue: 'https://example.com',
'onState-change': onStateChange
},
global: {
plugins: [[PrimeVue, { unstyled: true }]],
stubs: {
InputText: InputTextStub,
InputIcon: InputIconStub,
IconField: IconFieldStub
}
}
})
await waitFor(() => {
expect(onStateChange).toHaveBeenCalledWith(ValidationState.VALID)
})
})
})
})

View File

@@ -1,112 +0,0 @@
import { render, screen } from '@testing-library/vue'
import PrimeVue from 'primevue/config'
import { describe, expect, it, vi } from 'vitest'
import { createI18n } from 'vue-i18n'
vi.mock('@/utils/envUtil', () => ({
electronAPI: vi.fn(() => ({
getPlatform: vi.fn().mockReturnValue('win32')
}))
}))
vi.mock('@/i18n', () => ({
t: (key: string) => key,
te: () => false,
st: (_key: string, fallback: string) => fallback
}))
import type { TorchDeviceType } from '@comfyorg/comfyui-electron-types'
import GpuPicker from '@/components/install/GpuPicker.vue'
const i18n = createI18n({
legacy: false,
locale: 'en',
missingWarn: false,
fallbackWarn: false,
messages: { en: {} }
})
const HardwareOptionStub = {
props: ['imagePath', 'placeholderText', 'subtitle', 'selected'],
emits: ['click'],
template:
'<button :data-testid="placeholderText" :data-selected="selected" @click="$emit(\'click\')" >{{ placeholderText }}</button>'
}
function renderPicker(device: TorchDeviceType | null = null) {
return render(GpuPicker, {
props: { device },
global: {
plugins: [[PrimeVue, { unstyled: true }], i18n],
stubs: {
HardwareOption: HardwareOptionStub,
Tag: {
props: ['value'],
template: '<span data-testid="recommended-tag">{{ value }}</span>'
}
}
}
})
}
describe('GpuPicker', () => {
describe('recommended badge', () => {
it('shows recommended badge for nvidia', () => {
renderPicker('nvidia')
expect(screen.getByTestId('recommended-tag')).toBeVisible()
})
it('shows recommended badge for amd', () => {
renderPicker('amd')
expect(screen.getByTestId('recommended-tag')).toBeVisible()
})
it('does not show recommended badge for cpu', () => {
renderPicker('cpu')
expect(screen.getByTestId('recommended-tag')).not.toBeVisible()
})
it('does not show recommended badge for unsupported', () => {
renderPicker('unsupported')
expect(screen.getByTestId('recommended-tag')).not.toBeVisible()
})
it('does not show recommended badge when no device is selected', () => {
renderPicker(null)
expect(screen.getByTestId('recommended-tag')).not.toBeVisible()
})
})
describe('selection state', () => {
it('marks nvidia as selected when device is nvidia', () => {
renderPicker('nvidia')
expect(screen.getByTestId('NVIDIA').dataset.selected).toBe('true')
})
it('marks cpu as selected when device is cpu', () => {
renderPicker('cpu')
expect(screen.getByTestId('CPU').dataset.selected).toBe('true')
})
it('marks unsupported as selected when device is unsupported', () => {
renderPicker('unsupported')
expect(screen.getByTestId('Manual Install').dataset.selected).toBe('true')
})
it('no option is selected when device is null', () => {
renderPicker(null)
expect(screen.getByTestId('CPU').dataset.selected).toBe('false')
expect(screen.getByTestId('NVIDIA').dataset.selected).toBe('false')
})
})
describe('gpu options on non-darwin platform', () => {
it('shows NVIDIA, AMD, CPU, and Manual Install options', () => {
renderPicker(null)
expect(screen.getByTestId('NVIDIA')).toBeDefined()
expect(screen.getByTestId('AMD')).toBeDefined()
expect(screen.getByTestId('CPU')).toBeDefined()
expect(screen.getByTestId('Manual Install')).toBeDefined()
})
})
})

View File

@@ -4,7 +4,7 @@
<button
:class="
cn(
'hardware-option flex h-[190px] w-[170px] flex-col items-center rounded-3xl border-4 bg-neutral-900/70 p-5 transition-all duration-200',
'hardware-option w-[170px] h-[190px] p-5 flex flex-col items-center rounded-3xl transition-all duration-200 bg-neutral-900/70 border-4',
selected ? 'border-solid border-brand-yellow' : 'border-transparent'
)
"
@@ -12,13 +12,13 @@
>
<!-- Icon/Logo Area - Rounded square container -->
<div
class="icon-container flex h-[110px] w-[110px] shrink-0 items-center justify-center overflow-hidden rounded-2xl bg-neutral-800"
class="icon-container w-[110px] h-[110px] shrink-0 rounded-2xl bg-neutral-800 flex items-center justify-center overflow-hidden"
>
<img
v-if="imagePath"
:src="imagePath"
:alt="placeholderText"
class="size-full object-cover"
class="w-full h-full object-cover"
style="object-position: 57% center"
draggable="false"
/>
@@ -28,7 +28,7 @@
</div>
<!-- Text Content -->
<div v-if="subtitle" class="mt-4 text-center">
<div v-if="subtitle" class="text-center mt-4">
<div class="text-sm text-neutral-500">{{ subtitle }}</div>
</div>
</button>
@@ -36,7 +36,7 @@
</template>
<script setup lang="ts">
import { cn } from '@comfyorg/tailwind-utils'
import { cn } from '@/utils/tailwindUtil'
interface Props {
imagePath?: string

View File

@@ -1,223 +0,0 @@
import { render, screen, waitFor } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import PrimeVue from 'primevue/config'
import { describe, expect, it, vi, beforeEach } from 'vitest'
import { createI18n } from 'vue-i18n'
const mockValidateComfyUISource = vi.fn()
const mockShowDirectoryPicker = vi.fn()
vi.mock('@/utils/envUtil', () => ({
electronAPI: vi.fn(() => ({
validateComfyUISource: mockValidateComfyUISource,
showDirectoryPicker: mockShowDirectoryPicker
}))
}))
import MigrationPicker from '@/components/install/MigrationPicker.vue'
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: {
en: {
install: {
migrationSourcePathDescription: 'Source path description',
migrationOptional: 'Migration is optional',
selectItemsToMigrate: 'Select items to migrate',
pathValidationFailed: 'Validation failed',
failedToSelectDirectory: 'Failed to select directory',
locationPicker: {
migrationPathPlaceholder: 'Enter path'
}
}
}
}
})
const InputTextStub = {
props: ['modelValue', 'invalid'],
emits: ['update:modelValue'],
template: `<input
data-testid="source-input"
:value="modelValue"
@input="$emit('update:modelValue', $event.target.value)"
/>`
}
const CheckboxStub = {
props: ['modelValue', 'inputId', 'binary'],
emits: ['update:modelValue', 'click'],
template: `<input
type="checkbox"
:data-testid="'checkbox-' + inputId"
:checked="modelValue"
@change="$emit('update:modelValue', $event.target.checked)"
@click.stop="$emit('click')"
/>`
}
function renderPicker(sourcePath = '', migrationItemIds: string[] = []) {
return render(MigrationPicker, {
props: { sourcePath, migrationItemIds },
global: {
plugins: [[PrimeVue, { unstyled: true }], i18n],
stubs: {
InputText: InputTextStub,
Checkbox: CheckboxStub,
Button: { template: '<button data-testid="browse-btn" />' },
Message: {
props: ['severity'],
template: '<div data-testid="error-msg"><slot /></div>'
}
}
}
})
}
describe('MigrationPicker', () => {
beforeEach(() => {
vi.resetAllMocks()
})
describe('isValidSource', () => {
it('hides migration options when source path is empty', () => {
renderPicker('')
expect(screen.queryByText('Select items to migrate')).toBeNull()
})
it('shows migration options when source path is valid', async () => {
mockValidateComfyUISource.mockResolvedValue({ isValid: true })
const { rerender } = renderPicker('')
await rerender({ sourcePath: '/valid/path' })
await waitFor(() => {
expect(screen.getByText('Select items to migrate')).toBeDefined()
})
})
it('shows optional message when no valid source', () => {
renderPicker('')
expect(screen.getByText('Migration is optional')).toBeDefined()
})
})
describe('validateSource', () => {
it('clears error when source path becomes empty', async () => {
mockValidateComfyUISource.mockResolvedValue({
isValid: false,
error: 'Not found'
})
const user = userEvent.setup()
renderPicker()
await user.type(screen.getByTestId('source-input'), '/bad/path')
await waitFor(() => {
expect(screen.getByTestId('error-msg')).toBeDefined()
})
await user.clear(screen.getByTestId('source-input'))
await waitFor(() => {
expect(screen.queryByTestId('error-msg')).toBeNull()
})
})
it('shows error message when validation fails', async () => {
mockValidateComfyUISource.mockResolvedValue({
isValid: false,
error: 'Path not found'
})
const user = userEvent.setup()
renderPicker()
await user.type(screen.getByTestId('source-input'), '/bad/path')
await waitFor(() => {
expect(screen.getByTestId('error-msg')).toBeDefined()
})
})
it('shows no error when validation passes', async () => {
mockValidateComfyUISource.mockResolvedValue({ isValid: true })
const user = userEvent.setup()
renderPicker()
await user.type(screen.getByTestId('source-input'), '/valid/path')
await waitFor(() => {
expect(screen.queryByTestId('error-msg')).toBeNull()
})
})
})
describe('migrationItemIds watchEffect', () => {
it('emits all item IDs by default (all items start selected)', async () => {
const onUpdate = vi.fn()
render(MigrationPicker, {
props: {
sourcePath: '',
migrationItemIds: [],
'onUpdate:migrationItemIds': onUpdate
},
global: {
plugins: [[PrimeVue, { unstyled: true }], i18n],
stubs: {
InputText: InputTextStub,
Checkbox: CheckboxStub,
Button: { template: '<button />' },
Message: { template: '<div />' }
}
}
})
await waitFor(() => {
expect(onUpdate).toHaveBeenCalled()
const emittedIds = onUpdate.mock.calls[0][0]
expect(Array.isArray(emittedIds)).toBe(true)
expect(emittedIds.length).toBeGreaterThan(0)
})
})
})
describe('browse path', () => {
it('opens directory picker on browse click', async () => {
mockShowDirectoryPicker.mockResolvedValue(null)
renderPicker()
const user = userEvent.setup()
await user.click(screen.getByTestId('browse-btn'))
expect(mockShowDirectoryPicker).toHaveBeenCalledOnce()
})
it('updates source path when directory is selected', async () => {
mockShowDirectoryPicker.mockResolvedValue('/selected/path')
mockValidateComfyUISource.mockResolvedValue({ isValid: true })
const onUpdate = vi.fn()
render(MigrationPicker, {
props: {
sourcePath: '',
'onUpdate:sourcePath': onUpdate
},
global: {
plugins: [[PrimeVue, { unstyled: true }], i18n],
stubs: {
InputText: InputTextStub,
Checkbox: CheckboxStub,
Button: { template: '<button data-testid="browse-btn" />' },
Message: { template: '<div />' }
}
}
})
const user = userEvent.setup()
await user.click(screen.getByTestId('browse-btn'))
await waitFor(() => {
expect(onUpdate).toHaveBeenCalledWith('/selected/path')
})
})
})
})

View File

@@ -1,85 +0,0 @@
import { render, screen } from '@testing-library/vue'
import PrimeVue from 'primevue/config'
import { describe, expect, it, vi } from 'vitest'
import { defineComponent } from 'vue'
vi.mock('@/i18n', () => ({
t: (key: string) => key
}))
import StatusTag from '@/components/maintenance/StatusTag.vue'
const TagStub = defineComponent({
name: 'Tag',
props: {
icon: String,
severity: String,
value: String
},
template: `<span data-testid="tag" :data-icon="icon" :data-severity="severity" :data-value="value">{{ value }}</span>`
})
function renderStatusTag(props: { error: boolean; refreshing?: boolean }) {
return render(StatusTag, {
props,
global: {
plugins: [[PrimeVue, { unstyled: true }]],
stubs: { Tag: TagStub }
}
})
}
describe('StatusTag', () => {
describe('refreshing state', () => {
it('shows info severity when refreshing', () => {
renderStatusTag({ error: false, refreshing: true })
expect(screen.getByTestId('tag').dataset.severity).toBe('info')
})
it('shows refreshing translation key when refreshing', () => {
renderStatusTag({ error: false, refreshing: true })
expect(screen.getByTestId('tag').dataset.value).toBe(
'maintenance.refreshing'
)
})
it('shows question icon when refreshing', () => {
renderStatusTag({ error: false, refreshing: true })
expect(screen.getByTestId('tag').dataset.icon).toBeDefined()
})
})
describe('error state', () => {
it('shows danger severity when error is true', () => {
renderStatusTag({ error: true })
expect(screen.getByTestId('tag').dataset.severity).toBe('danger')
})
it('shows error translation key when error is true', () => {
renderStatusTag({ error: true })
expect(screen.getByTestId('tag').dataset.value).toBe('g.error')
})
})
describe('OK state', () => {
it('shows success severity when not refreshing and not error', () => {
renderStatusTag({ error: false })
expect(screen.getByTestId('tag').dataset.severity).toBe('success')
})
it('shows OK translation key when not refreshing and not error', () => {
renderStatusTag({ error: false })
expect(screen.getByTestId('tag').dataset.value).toBe('maintenance.OK')
})
})
describe('precedence', () => {
it('shows refreshing state when both refreshing and error are true', () => {
renderStatusTag({ error: true, refreshing: true })
expect(screen.getByTestId('tag').dataset.severity).toBe('info')
expect(screen.getByTestId('tag').dataset.value).toBe(
'maintenance.refreshing'
)
})
})
})

View File

@@ -1,89 +0,0 @@
import { render, screen } from '@testing-library/vue'
import PrimeVue from 'primevue/config'
import { describe, expect, it, vi } from 'vitest'
vi.mock('@/utils/envUtil', () => ({
electronAPI: vi.fn(() => ({
Validation: { validateInstallation: vi.fn() }
}))
}))
vi.mock('@/constants/desktopMaintenanceTasks', () => ({
DESKTOP_MAINTENANCE_TASKS: []
}))
vi.mock('@/utils/refUtil', () => ({
useMinLoadingDurationRef: (source: { value: boolean }) => source
}))
const mockGetRunner = vi.fn()
vi.mock('@/stores/maintenanceTaskStore', () => ({
useMaintenanceTaskStore: vi.fn(() => ({
getRunner: mockGetRunner
}))
}))
import type { MaintenanceTask } from '@/types/desktop/maintenanceTypes'
import TaskCard from '@/components/maintenance/TaskCard.vue'
const baseTask: MaintenanceTask = {
id: 'testTask',
name: 'Test Task',
shortDescription: 'Short description',
errorDescription: 'Error occurred',
execute: vi.fn().mockResolvedValue(true)
}
const cardStubs = {
Card: {
template: '<div data-testid="card"><slot name="content"></slot></div>'
},
Button: { template: '<button />' }
}
function renderCard(
state: 'OK' | 'error' | 'warning' | 'skipped',
task: MaintenanceTask = baseTask
) {
mockGetRunner.mockReturnValue({
state,
executing: false,
refreshing: false,
resolved: false
})
return render(TaskCard, {
props: { task },
global: {
plugins: [[PrimeVue, { unstyled: true }]],
stubs: cardStubs
}
})
}
describe('TaskCard', () => {
describe('description computed', () => {
it('shows errorDescription when task state is error', () => {
renderCard('error')
expect(screen.getByText('Error occurred')).toBeDefined()
})
it('shows shortDescription when task state is OK', () => {
renderCard('OK')
expect(screen.getByText('Short description')).toBeDefined()
})
it('shows shortDescription when task state is warning', () => {
renderCard('warning')
expect(screen.getByText('Short description')).toBeDefined()
})
it('falls back to shortDescription when errorDescription is absent and state is error', () => {
const taskWithoutErrorDesc: MaintenanceTask = {
...baseTask,
errorDescription: undefined
}
renderCard('error', taskWithoutErrorDesc)
expect(screen.getByText('Short description')).toBeDefined()
})
})
})

View File

@@ -64,7 +64,7 @@ import { computed } from 'vue'
import { useMaintenanceTaskStore } from '@/stores/maintenanceTaskStore'
import type { MaintenanceTask } from '@/types/desktop/maintenanceTypes'
import { cn } from '@comfyorg/tailwind-utils'
import { cn } from '@/utils/tailwindUtil'
import { useMinLoadingDurationRef } from '@/utils/refUtil'
const taskStore = useMaintenanceTaskStore()

View File

@@ -1,97 +0,0 @@
import { render, screen } from '@testing-library/vue'
import PrimeVue from 'primevue/config'
import { describe, expect, it, vi } from 'vitest'
vi.mock('@/utils/envUtil', () => ({
electronAPI: vi.fn(() => ({
Validation: { validateInstallation: vi.fn() }
}))
}))
vi.mock('@/constants/desktopMaintenanceTasks', () => ({
DESKTOP_MAINTENANCE_TASKS: []
}))
vi.mock('@/utils/refUtil', () => ({
useMinLoadingDurationRef: (source: { value: boolean }) => source
}))
vi.mock('@/i18n', () => ({
t: (key: string) => key
}))
const mockGetRunner = vi.fn()
vi.mock('@/stores/maintenanceTaskStore', () => ({
useMaintenanceTaskStore: vi.fn(() => ({
getRunner: mockGetRunner
}))
}))
import type { MaintenanceTask } from '@/types/desktop/maintenanceTypes'
import TaskListItem from '@/components/maintenance/TaskListItem.vue'
const baseTask: MaintenanceTask = {
id: 'testTask',
name: 'Test Task',
button: { text: 'Fix', icon: 'pi pi-check' },
execute: vi.fn().mockResolvedValue(true)
}
const ButtonStub = {
props: ['severity', 'label', 'icon', 'loading'],
template:
'<button :data-severity="severity" :data-label="label" :data-testid="label ? \'action-button\' : \'icon-button\'" />'
}
function renderItem(state: 'OK' | 'error' | 'warning' | 'skipped') {
mockGetRunner.mockReturnValue({
state,
executing: false,
refreshing: false,
resolved: false
})
return render(TaskListItem, {
props: { task: baseTask },
global: {
plugins: [[PrimeVue, { unstyled: true }]],
stubs: {
Button: ButtonStub,
Popover: { template: '<div />' },
TaskListStatusIcon: { template: '<span />' }
}
}
})
}
describe('TaskListItem', () => {
describe('severity computed', () => {
it('uses primary severity for error state', () => {
renderItem('error')
expect(screen.getByTestId('action-button').dataset.severity).toBe(
'primary'
)
})
it('uses primary severity for warning state', () => {
renderItem('warning')
expect(screen.getByTestId('action-button').dataset.severity).toBe(
'primary'
)
})
it('uses secondary severity for OK state', () => {
renderItem('OK')
expect(screen.getByTestId('action-button').dataset.severity).toBe(
'secondary'
)
})
it('uses secondary severity for skipped state', () => {
renderItem('skipped')
expect(screen.getByTestId('action-button').dataset.severity).toBe(
'secondary'
)
})
})
})

View File

@@ -76,7 +76,7 @@ const executeTask = async (task: MaintenanceTask) => {
message = t('maintenance.error.taskFailed')
} catch (error) {
message = error instanceof Error ? error.message : undefined
message = (error as Error)?.message
}
toast.add({

View File

@@ -1,44 +0,0 @@
import { render, screen } from '@testing-library/vue'
import PrimeVue from 'primevue/config'
import { describe, expect, it, vi } from 'vitest'
vi.mock('@/i18n', () => ({
t: (key: string) => key
}))
import TaskListStatusIcon from '@/components/maintenance/TaskListStatusIcon.vue'
type TaskState = 'warning' | 'error' | 'resolved' | 'OK' | 'skipped' | undefined
function renderIcon(state: TaskState, loading?: boolean) {
return render(TaskListStatusIcon, {
props: { state, loading },
global: {
plugins: [[PrimeVue, { unstyled: true }]],
stubs: {
ProgressSpinner: {
template: '<div data-testid="spinner" />'
}
}
}
})
}
describe('TaskListStatusIcon', () => {
describe('loading / no state', () => {
it('renders spinner when state is undefined', () => {
renderIcon(undefined)
expect(screen.getByTestId('spinner')).toBeDefined()
})
it('renders spinner when loading is true', () => {
renderIcon('OK', true)
expect(screen.getByTestId('spinner')).toBeDefined()
})
it('hides spinner when state is defined and not loading', () => {
renderIcon('OK', false)
expect(screen.queryByTestId('spinner')).toBeNull()
})
})
})

View File

@@ -1,124 +0,0 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { ref } from 'vue'
const { mockTerminal, MockTerminal, mockFitAddon, MockFitAddon } = vi.hoisted(
() => {
const mockTerminal = {
loadAddon: vi.fn(),
attachCustomKeyEventHandler: vi.fn(),
open: vi.fn(),
dispose: vi.fn(),
hasSelection: vi.fn<[], boolean>(),
resize: vi.fn(),
cols: 80,
rows: 24
}
const MockTerminal = vi.fn(function () {
return mockTerminal
})
const mockFitAddon = {
proposeDimensions: vi.fn().mockReturnValue({ cols: 80, rows: 24 })
}
const MockFitAddon = vi.fn(function () {
return mockFitAddon
})
return { mockTerminal, MockTerminal, mockFitAddon, MockFitAddon }
}
)
vi.mock('@xterm/xterm', () => ({ Terminal: MockTerminal }))
vi.mock('@xterm/addon-fit', () => ({ FitAddon: MockFitAddon }))
vi.mock('@xterm/xterm/css/xterm.css', () => ({}))
import { withSetup } from '@/test/withSetup'
import { useTerminal } from '@/composables/bottomPanelTabs/useTerminal'
function getKeyHandler(): (event: KeyboardEvent) => boolean {
return mockTerminal.attachCustomKeyEventHandler.mock.calls[0][0]
}
describe('useTerminal key event handler', () => {
beforeEach(() => {
vi.clearAllMocks()
mockTerminal.hasSelection.mockReturnValue(false)
const element = ref<HTMLElement | undefined>(undefined)
withSetup(() => useTerminal(element))
})
it('allows browser to handle copy when text is selected (Ctrl+C)', () => {
mockTerminal.hasSelection.mockReturnValue(true)
const event = {
type: 'keydown',
ctrlKey: true,
metaKey: false,
key: 'c'
} as KeyboardEvent
expect(getKeyHandler()(event)).toBe(false)
})
it('allows browser to handle copy when text is selected (Meta+C)', () => {
mockTerminal.hasSelection.mockReturnValue(true)
const event = {
type: 'keydown',
ctrlKey: false,
metaKey: true,
key: 'c'
} as KeyboardEvent
expect(getKeyHandler()(event)).toBe(false)
})
it('does not pass copy to browser when no text is selected', () => {
mockTerminal.hasSelection.mockReturnValue(false)
const event = {
type: 'keydown',
ctrlKey: true,
metaKey: false,
key: 'c'
} as KeyboardEvent
expect(getKeyHandler()(event)).toBe(true)
})
it('allows browser to handle paste (Ctrl+V)', () => {
const event = {
type: 'keydown',
ctrlKey: true,
metaKey: false,
key: 'v'
} as KeyboardEvent
expect(getKeyHandler()(event)).toBe(false)
})
it('allows browser to handle paste (Meta+V)', () => {
const event = {
type: 'keydown',
ctrlKey: false,
metaKey: true,
key: 'v'
} as KeyboardEvent
expect(getKeyHandler()(event)).toBe(false)
})
it('does not intercept non-keydown events', () => {
mockTerminal.hasSelection.mockReturnValue(true)
const event = {
type: 'keyup',
ctrlKey: true,
metaKey: false,
key: 'c'
} as KeyboardEvent
expect(getKeyHandler()(event)).toBe(true)
})
it('passes through unrelated key combinations', () => {
const event = {
type: 'keydown',
ctrlKey: false,
metaKey: false,
key: 'Enter'
} as KeyboardEvent
expect(getKeyHandler()(event)).toBe(true)
})
})

View File

@@ -1,48 +0,0 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const { mockSerialize, MockSerializeAddon } = vi.hoisted(() => {
const mockSerialize = vi.fn<[], string>()
const MockSerializeAddon = vi.fn(function () {
return { serialize: mockSerialize }
})
return { mockSerialize, MockSerializeAddon }
})
vi.mock('@xterm/xterm', () => ({
Terminal: vi.fn(function () {
return { loadAddon: vi.fn(), dispose: vi.fn(), write: vi.fn() }
})
}))
vi.mock('@xterm/addon-serialize', () => ({
SerializeAddon: MockSerializeAddon
}))
import type { Terminal } from '@xterm/xterm'
import { withSetup } from '@/test/withSetup'
import { useTerminalBuffer } from '@/composables/bottomPanelTabs/useTerminalBuffer'
describe('useTerminalBuffer', () => {
beforeEach(() => {
vi.clearAllMocks()
mockSerialize.mockReturnValue('')
})
describe('copyTo', () => {
it('writes serialized buffer content to the destination terminal', () => {
mockSerialize.mockReturnValue('hello world')
const { copyTo } = withSetup(() => useTerminalBuffer())
const mockWrite = vi.fn()
copyTo({ write: mockWrite } as Pick<Terminal, 'write'>)
expect(mockWrite).toHaveBeenCalledWith('hello world')
})
it('writes empty string when buffer is empty', () => {
mockSerialize.mockReturnValue('')
const { copyTo } = withSetup(() => useTerminalBuffer())
const mockWrite = vi.fn()
copyTo({ write: mockWrite } as Pick<Terminal, 'write'>)
expect(mockWrite).toHaveBeenCalledWith('')
})
})
})

View File

@@ -1,50 +0,0 @@
import { describe, expect, it } from 'vitest'
import { DESKTOP_DIALOGS, getDialog } from '@/constants/desktopDialogs'
describe('getDialog', () => {
it('returns the matching dialog for a valid ID', () => {
const result = getDialog('reinstallVenv')
expect(result.id).toBe('reinstallVenv')
expect(result.title).toBe(DESKTOP_DIALOGS.reinstallVenv.title)
expect(result.message).toBe(DESKTOP_DIALOGS.reinstallVenv.message)
})
it('returns invalidDialog for an unknown string ID', () => {
const result = getDialog('unknownDialog')
expect(result.id).toBe('invalidDialog')
})
it('returns invalidDialog when given an array of strings', () => {
const result = getDialog(['reinstallVenv', 'other'])
expect(result.id).toBe('invalidDialog')
})
it('returns invalidDialog for empty string', () => {
const result = getDialog('')
expect(result.id).toBe('invalidDialog')
})
it('returns a deep clone — mutations do not affect the original', () => {
const result = getDialog('reinstallVenv')
const originalFirstLabel = DESKTOP_DIALOGS.reinstallVenv.buttons[0].label
result.buttons[0].label = 'Mutated'
expect(DESKTOP_DIALOGS.reinstallVenv.buttons[0].label).toBe(
originalFirstLabel
)
})
it('every button has a returnValue', () => {
for (const id of Object.keys(DESKTOP_DIALOGS)) {
const result = getDialog(id)
for (const button of result.buttons) {
expect(button.returnValue).toBeDefined()
}
}
})
it('invalidDialog has a close/cancel button', () => {
const result = getDialog('invalidDialog')
expect(result.buttons.some((b) => b.action === 'cancel')).toBe(true)
})
})

View File

@@ -1,75 +0,0 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const { mockElectron } = vi.hoisted(() => ({
mockElectron: {
setBasePath: vi.fn(),
reinstall: vi.fn<[], Promise<void>>().mockResolvedValue(undefined),
uv: {
installRequirements: vi.fn<[], Promise<void>>(),
clearCache: vi.fn<[], Promise<void>>().mockResolvedValue(undefined),
resetVenv: vi.fn<[], Promise<void>>().mockResolvedValue(undefined)
}
}
}))
vi.mock('@/utils/envUtil', () => ({
electronAPI: vi.fn(() => mockElectron)
}))
import { DESKTOP_MAINTENANCE_TASKS } from '@/constants/desktopMaintenanceTasks'
function findTask(id: string) {
const task = DESKTOP_MAINTENANCE_TASKS.find((t) => t.id === id)
if (!task) throw new Error(`Task not found: ${id}`)
return task
}
describe('desktopMaintenanceTasks', () => {
beforeEach(() => {
vi.resetAllMocks()
vi.spyOn(window, 'open').mockReturnValue(null)
mockElectron.reinstall.mockResolvedValue(undefined)
mockElectron.uv.clearCache.mockResolvedValue(undefined)
mockElectron.uv.resetVenv.mockResolvedValue(undefined)
})
describe('pythonPackages', () => {
it('returns true when installation succeeds', async () => {
mockElectron.uv.installRequirements.mockResolvedValue(undefined)
expect(await findTask('pythonPackages').execute()).toBe(true)
})
it('returns false when installation throws', async () => {
mockElectron.uv.installRequirements.mockRejectedValue(
new Error('install failed')
)
expect(await findTask('pythonPackages').execute()).toBe(false)
})
})
describe('URL-opening tasks', () => {
it('git execute opens the git download page', () => {
findTask('git').execute()
expect(window.open).toHaveBeenCalledWith(
'https://git-scm.com/downloads/',
'_blank'
)
})
it('uv execute opens the uv installation page', () => {
findTask('uv').execute()
expect(window.open).toHaveBeenCalledWith(
'https://docs.astral.sh/uv/getting-started/installation/',
'_blank'
)
})
it('vcRedist execute opens the VC++ redistributable download', () => {
findTask('vcRedist').execute()
expect(window.open).toHaveBeenCalledWith(
'https://aka.ms/vs/17/release/vc_redist.x64.exe',
'_blank'
)
})
})
})

View File

@@ -1,288 +0,0 @@
import { createTestingPinia } from '@pinia/testing'
import type { InstallValidation } from '@comfyorg/comfyui-electron-types'
import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
const { mockElectron, testTasks } = vi.hoisted(() => {
const terminalTaskExecute = vi.fn().mockResolvedValue(true)
const basicTaskExecute = vi.fn().mockResolvedValue(true)
return {
mockElectron: {
Validation: {
validateInstallation: vi.fn()
}
},
testTasks: [
{
id: 'basicTask',
name: 'Basic Task',
execute: basicTaskExecute
},
{
id: 'terminalTask',
name: 'Terminal Task',
execute: terminalTaskExecute,
usesTerminal: true,
isInstallationFix: true
}
]
}
})
vi.mock('@/utils/envUtil', () => ({
electronAPI: vi.fn(() => mockElectron)
}))
vi.mock('@/constants/desktopMaintenanceTasks', () => ({
DESKTOP_MAINTENANCE_TASKS: testTasks
}))
import { useMaintenanceTaskStore } from '@/stores/maintenanceTaskStore'
import type { MaintenanceTask } from '@/types/desktop/maintenanceTypes'
type PartialInstallValidation = Partial<InstallValidation> &
Record<string, unknown>
function makeUpdate(
overrides: PartialInstallValidation = {}
): InstallValidation {
return {
inProgress: false,
installState: 'installed',
...overrides
} as InstallValidation
}
function createStore() {
setActivePinia(createTestingPinia({ stubActions: false }))
return useMaintenanceTaskStore()
}
describe('useMaintenanceTaskStore', () => {
let store: ReturnType<typeof useMaintenanceTaskStore>
const [basicTask, terminalTask] = testTasks as MaintenanceTask[]
beforeEach(() => {
vi.resetAllMocks()
store = createStore()
})
describe('processUpdate', () => {
it('sets isRefreshing to true during in-progress update', () => {
store.processUpdate(makeUpdate({ inProgress: true }))
expect(store.isRefreshing).toBe(true)
})
it('sets isRefreshing to false when update is complete', () => {
store.processUpdate(makeUpdate({ inProgress: false, basicTask: 'OK' }))
expect(store.isRefreshing).toBe(false)
})
it('updates runner state for tasks present in the final update', () => {
store.processUpdate(makeUpdate({ basicTask: 'error' }))
expect(store.getRunner(basicTask).state).toBe('error')
})
it('sets task state to warning from update', () => {
store.processUpdate(makeUpdate({ basicTask: 'warning' }))
expect(store.getRunner(basicTask).state).toBe('warning')
})
it('marks runners as refreshing when task id is absent from in-progress update', () => {
store.processUpdate(makeUpdate({ inProgress: true }))
expect(store.getRunner(basicTask).refreshing).toBe(true)
})
it('marks task as skipped when absent from final update', () => {
store.processUpdate(makeUpdate({ inProgress: false }))
expect(store.getRunner(basicTask).state).toBe('skipped')
})
it('clears refreshing flag after final update', () => {
store.processUpdate(makeUpdate({ inProgress: true }))
store.processUpdate(makeUpdate({ inProgress: false }))
expect(store.getRunner(basicTask).refreshing).toBe(false)
})
it('stores lastUpdate and exposes unsafeBasePath', () => {
store.processUpdate(makeUpdate({ unsafeBasePath: true }))
expect(store.unsafeBasePath).toBe(true)
})
it('exposes unsafeBasePathReason from the update', () => {
store.processUpdate(
makeUpdate({ unsafeBasePath: true, unsafeBasePathReason: 'oneDrive' })
)
expect(store.unsafeBasePathReason).toBe('oneDrive')
})
})
describe('anyErrors', () => {
it('returns true when any task has error state', () => {
store.processUpdate(makeUpdate({ basicTask: 'error' }))
expect(store.anyErrors).toBe(true)
})
it('returns false when all tasks are OK', () => {
store.processUpdate(makeUpdate({ basicTask: 'OK', terminalTask: 'OK' }))
expect(store.anyErrors).toBe(false)
})
it('returns false when all tasks are warning', () => {
store.processUpdate(
makeUpdate({ basicTask: 'warning', terminalTask: 'warning' })
)
expect(store.anyErrors).toBe(false)
})
})
describe('runner state transitions', () => {
it('marks runner as resolved when transitioning from error to OK', () => {
store.processUpdate(makeUpdate({ basicTask: 'error' }))
store.processUpdate(makeUpdate({ basicTask: 'OK' }))
expect(store.getRunner(basicTask).resolved).toBe(true)
})
it('does not mark resolved for warning to OK transition', () => {
store.processUpdate(makeUpdate({ basicTask: 'warning' }))
store.processUpdate(makeUpdate({ basicTask: 'OK' }))
expect(store.getRunner(basicTask).resolved).toBeFalsy()
})
it('clears resolved flag when task returns to error', () => {
store.processUpdate(makeUpdate({ basicTask: 'error' }))
store.processUpdate(makeUpdate({ basicTask: 'OK' }))
store.processUpdate(makeUpdate({ basicTask: 'error' }))
expect(store.getRunner(basicTask).resolved).toBeFalsy()
})
})
describe('clearResolved', () => {
it('clears resolved flags on all runners', () => {
store.processUpdate(makeUpdate({ basicTask: 'error' }))
store.processUpdate(makeUpdate({ basicTask: 'OK' }))
expect(store.getRunner(basicTask).resolved).toBe(true)
store.clearResolved()
expect(store.getRunner(basicTask).resolved).toBeFalsy()
})
})
describe('execute', () => {
it('returns true when task execution succeeds', async () => {
vi.mocked(basicTask.execute).mockResolvedValue(true)
const result = await store.execute(basicTask)
expect(result).toBe(true)
})
it('returns false when task execution fails', async () => {
vi.mocked(basicTask.execute).mockResolvedValue(false)
const result = await store.execute(basicTask)
expect(result).toBe(false)
})
it('calls refreshDesktopTasks after successful installation-fix task', async () => {
vi.mocked(terminalTask.execute).mockResolvedValue(true)
await store.execute(terminalTask)
expect(
mockElectron.Validation.validateInstallation
).toHaveBeenCalledOnce()
})
it('does not call refreshDesktopTasks when task is not an installation fix', async () => {
vi.mocked(basicTask.execute).mockResolvedValue(true)
await store.execute(basicTask)
expect(
mockElectron.Validation.validateInstallation
).not.toHaveBeenCalled()
})
it('does not call refreshDesktopTasks when installation-fix task fails', async () => {
vi.mocked(terminalTask.execute).mockResolvedValue(false)
await store.execute(terminalTask)
expect(
mockElectron.Validation.validateInstallation
).not.toHaveBeenCalled()
})
it('sets runner executing to true during task execution', async () => {
let resolveTask!: (value: boolean) => void
vi.mocked(basicTask.execute).mockReturnValue(
new Promise<boolean>((resolve) => {
resolveTask = resolve
})
)
const executePromise = store.execute(basicTask)
expect(store.getRunner(basicTask).executing).toBe(true)
resolveTask(true)
await executePromise
expect(store.getRunner(basicTask).executing).toBe(false)
})
it('clears executing flag when task throws', async () => {
vi.mocked(basicTask.execute).mockRejectedValue(new Error('fail'))
await expect(store.execute(basicTask)).rejects.toThrow('fail')
expect(store.getRunner(basicTask).executing).toBe(false)
})
it('sets runner error message when task throws', async () => {
vi.mocked(basicTask.execute).mockRejectedValue(
new Error('something broke')
)
await expect(store.execute(basicTask)).rejects.toThrow()
expect(store.getRunner(basicTask).error).toBe('something broke')
})
it('clears runner error on successful execution', async () => {
vi.mocked(basicTask.execute).mockRejectedValue(new Error('fail'))
await expect(store.execute(basicTask)).rejects.toThrow()
vi.mocked(basicTask.execute).mockResolvedValue(true)
await store.execute(basicTask)
expect(store.getRunner(basicTask).error).toBeUndefined()
})
})
describe('isRunningTerminalCommand', () => {
it('returns true while a terminal task is executing', async () => {
let resolveTask!: (value: boolean) => void
vi.mocked(terminalTask.execute).mockReturnValue(
new Promise<boolean>((resolve) => {
resolveTask = resolve
})
)
const executePromise = store.execute(terminalTask)
expect(store.isRunningTerminalCommand).toBe(true)
resolveTask(true)
await executePromise
expect(store.isRunningTerminalCommand).toBe(false)
})
it('returns false when no terminal tasks are executing', () => {
expect(store.isRunningTerminalCommand).toBe(false)
})
})
describe('isRunningInstallationFix', () => {
it('returns true while an installation-fix task is executing', async () => {
let resolveTask!: (value: boolean) => void
vi.mocked(terminalTask.execute).mockReturnValue(
new Promise<boolean>((resolve) => {
resolveTask = resolve
})
)
const executePromise = store.execute(terminalTask)
expect(store.isRunningInstallationFix).toBe(true)
resolveTask(true)
await executePromise
expect(store.isRunningInstallationFix).toBe(false)
})
})
})

View File

@@ -66,7 +66,7 @@ class MaintenanceTaskRunner {
this.error = undefined
return true
} catch (error) {
this.error = error instanceof Error ? error.message : String(error)
this.error = (error as Error)?.message
throw error
} finally {
this.executing = false

View File

@@ -1 +0,0 @@
import '@testing-library/jest-dom/vitest'

View File

@@ -1,16 +0,0 @@
import { render } from '@testing-library/vue'
import { defineComponent } from 'vue'
export function withSetup<T>(composable: () => T): T {
let result!: T
render(
defineComponent({
setup() {
result = composable()
return {}
},
template: '<div />'
})
)
return result
}

View File

@@ -1,52 +0,0 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const { mockElectron } = vi.hoisted(() => ({
mockElectron: {
NetWork: {
canAccessUrl: vi.fn<[url: string], Promise<boolean>>()
}
}
}))
vi.mock('@/utils/envUtil', () => ({
electronAPI: vi.fn(() => mockElectron)
}))
import { checkMirrorReachable } from '@/utils/electronMirrorCheck'
describe('checkMirrorReachable', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('returns false for an invalid URL without calling canAccessUrl', async () => {
const result = await checkMirrorReachable('not-a-url')
expect(result).toBe(false)
expect(mockElectron.NetWork.canAccessUrl).not.toHaveBeenCalled()
})
it('returns false when canAccessUrl returns false', async () => {
mockElectron.NetWork.canAccessUrl.mockResolvedValue(false)
const result = await checkMirrorReachable('https://example.com')
expect(result).toBe(false)
})
it('returns true when URL is valid and canAccessUrl returns true', async () => {
mockElectron.NetWork.canAccessUrl.mockResolvedValue(true)
const result = await checkMirrorReachable('https://example.com')
expect(result).toBe(true)
})
it('passes the mirror URL to canAccessUrl', async () => {
const url = 'https://pypi.org/simple/'
mockElectron.NetWork.canAccessUrl.mockResolvedValue(true)
await checkMirrorReachable(url)
expect(mockElectron.NetWork.canAccessUrl).toHaveBeenCalledWith(url)
})
it('returns false for empty string', async () => {
const result = await checkMirrorReachable('')
expect(result).toBe(false)
expect(mockElectron.NetWork.canAccessUrl).not.toHaveBeenCalled()
})
})

View File

@@ -1,72 +0,0 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { isElectron, isNativeWindow } from '@/utils/envUtil'
describe('isElectron', () => {
afterEach(() => {
vi.unstubAllGlobals()
})
it('returns true when window.electronAPI is an object', () => {
vi.stubGlobal('window', { ...window, electronAPI: {} })
expect(isElectron()).toBe(true)
})
it('returns false when window.electronAPI is undefined', () => {
vi.stubGlobal('window', { ...window, electronAPI: undefined })
expect(isElectron()).toBe(false)
})
it('returns false when window.electronAPI is absent', () => {
const copy = { ...window } as Record<string, unknown>
delete copy['electronAPI']
vi.stubGlobal('window', copy)
expect(isElectron()).toBe(false)
})
})
describe('isNativeWindow', () => {
afterEach(() => {
vi.unstubAllGlobals()
})
it('returns true when Electron and windowControlsOverlay.visible is true', () => {
vi.stubGlobal('window', {
...window,
electronAPI: {},
navigator: {
...window.navigator,
windowControlsOverlay: { visible: true }
}
})
expect(isNativeWindow()).toBe(true)
})
it('returns false when not in Electron', () => {
const copy = { ...window } as Record<string, unknown>
delete copy['electronAPI']
vi.stubGlobal('window', copy)
expect(isNativeWindow()).toBe(false)
})
it('returns false when windowControlsOverlay.visible is false', () => {
vi.stubGlobal('window', {
...window,
electronAPI: {},
navigator: {
...window.navigator,
windowControlsOverlay: { visible: false }
}
})
expect(isNativeWindow()).toBe(false)
})
it('returns false when windowControlsOverlay is absent', () => {
vi.stubGlobal('window', {
...window,
electronAPI: {},
navigator: { ...window.navigator, windowControlsOverlay: undefined }
})
expect(isNativeWindow()).toBe(false)
})
})

View File

@@ -1,102 +0,0 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { computed, nextTick, ref } from 'vue'
import { withSetup } from '@/test/withSetup'
import { useMinLoadingDurationRef } from '@/utils/refUtil'
describe('useMinLoadingDurationRef', () => {
beforeEach(() => {
vi.useFakeTimers()
})
afterEach(() => {
vi.useRealTimers()
})
it('reflects false when source is initially false', () => {
const source = ref(false)
const result = withSetup(() => useMinLoadingDurationRef(source))
expect(result.value).toBe(false)
})
it('reflects true when source is initially true', () => {
const source = ref(true)
const result = withSetup(() => useMinLoadingDurationRef(source))
expect(result.value).toBe(true)
})
it('becomes true immediately when source transitions to true', async () => {
const source = ref(false)
const result = withSetup(() => useMinLoadingDurationRef(source))
source.value = true
await nextTick()
expect(result.value).toBe(true)
})
it('stays true within minDuration after source returns to false', async () => {
const source = ref(false)
const result = withSetup(() => useMinLoadingDurationRef(source, 250))
source.value = true
await nextTick()
source.value = false
await nextTick()
vi.advanceTimersByTime(100)
await nextTick()
expect(result.value).toBe(true)
})
it('becomes false after minDuration has elapsed', async () => {
const source = ref(false)
const result = withSetup(() => useMinLoadingDurationRef(source, 250))
source.value = true
await nextTick()
source.value = false
await nextTick()
vi.advanceTimersByTime(250)
await nextTick()
expect(result.value).toBe(false)
})
it('remains true while source is true even after minDuration elapses', async () => {
const source = ref(false)
const result = withSetup(() => useMinLoadingDurationRef(source, 250))
source.value = true
await nextTick()
vi.advanceTimersByTime(500)
await nextTick()
expect(result.value).toBe(true)
})
it('works with a computed ref as input', async () => {
const raw = ref(false)
const source = computed(() => raw.value)
const result = withSetup(() => useMinLoadingDurationRef(source))
raw.value = true
await nextTick()
expect(result.value).toBe(true)
})
it('uses 250ms as default minDuration', async () => {
const source = ref(false)
const result = withSetup(() => useMinLoadingDurationRef(source))
source.value = true
await nextTick()
source.value = false
await nextTick()
vi.advanceTimersByTime(249)
await nextTick()
expect(result.value).toBe(true)
vi.advanceTimersByTime(1)
await nextTick()
expect(result.value).toBe(false)
})
})

View File

@@ -0,0 +1 @@
export { cn } from '@comfyorg/tailwind-utils'

View File

@@ -13,8 +13,7 @@
"src/**/*.ts",
"src/**/*.vue",
"src/**/*.d.ts",
"vite.config.mts",
"vitest.config.mts"
"vite.config.mts"
],
"references": []
}

View File

@@ -1,22 +0,0 @@
import vue from '@vitejs/plugin-vue'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import { defineConfig } from 'vitest/config'
const projectRoot = fileURLToPath(new URL('.', import.meta.url))
export default defineConfig({
plugins: [vue()],
resolve: {
alias: {
'@': path.resolve(projectRoot, 'src'),
'@frontend-locales': path.resolve(projectRoot, '../../src/locales')
}
},
test: {
globals: true,
environment: 'happy-dom',
include: ['src/**/*.{test,spec}.{ts,tsx}'],
setupFiles: ['./src/test/setup.ts']
}
})

View File

@@ -1,8 +1,2 @@
dist/
.astro/
test-results/
playwright-report/
results.json
# Platform-specific Playwright snapshots (CI runs Linux)
*-win32.png

View File

@@ -1,148 +0,0 @@
# @comfyorg/website
Marketing/brand website built with Astro + Vue.
## Ashby careers integration
`/careers` and `/zh-CN/careers` are rendered from Ashby's public job board
API at build time. Data flow:
1. `src/pages/careers.astro` awaits `fetchRolesForBuild()` during the
Astro build.
2. `src/utils/ashby.ts` calls
`GET https://api.ashbyhq.com/posting-api/job-board/{board}?includeCompensation=false`,
validates the envelope and each posting with Zod
(`src/utils/ashby.schema.ts`), and maps to the domain type in
`src/data/roles.ts`.
3. On any failure (network, HTTP 4xx/5xx, envelope schema drift),
the fetcher falls back to the committed JSON snapshot at
`src/data/ashby-roles.snapshot.json`.
4. `src/utils/ashby.ci.ts` emits GitHub Actions annotations and a
`$GITHUB_STEP_SUMMARY` block so stale fetches are visible on green
builds.
### Required environment variables
Both are build-time only. Never prefix with `PUBLIC_` (Astro would
inline that into the client bundle).
| Name | Purpose | Default (when unset) |
| ------------------------------ | --------------------------- | --------------------------------- |
| `WEBSITE_ASHBY_API_KEY` | Ashby API key (Basic auth) | Build uses the committed snapshot |
| `WEBSITE_ASHBY_JOB_BOARD_NAME` | Ashby public job board slug | Build uses the committed snapshot |
### CI wiring (manual step — required)
This repo's `.github/workflows/*.yaml` changes cannot be pushed by a
GitHub App. A maintainer must apply the following edits **once**:
**`.github/workflows/ci-website-build.yaml`** — pass the env into the
build step and run the unit tests before it:
```yaml
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- name: Setup frontend
uses: ./.github/actions/setup-frontend
- name: Run website unit tests
run: pnpm --filter @comfyorg/website test:unit
- name: Build website
env:
WEBSITE_ASHBY_API_KEY: ${{ secrets.WEBSITE_ASHBY_API_KEY }}
WEBSITE_ASHBY_JOB_BOARD_NAME: ${{ vars.WEBSITE_ASHBY_JOB_BOARD_NAME || 'comfy-org' }}
run: pnpm --filter @comfyorg/website build
- name: Verify API key is not leaked into build output
env:
WEBSITE_ASHBY_API_KEY: ${{ secrets.WEBSITE_ASHBY_API_KEY }}
run: |
set +x
if [ -z "${WEBSITE_ASHBY_API_KEY:-}" ]; then
echo "Secret not available in this run; skipping leak check."
exit 0
fi
# grep -rlF prints only file paths (never match content).
MATCHES=$(grep -rlF --exclude-dir=node_modules --null \
-e "$WEBSITE_ASHBY_API_KEY" apps/website/dist/ 2>/dev/null \
| tr '\0' '\n' || true)
if [ -n "$MATCHES" ]; then
echo "::error title=Ashby API key leaked into build output::$MATCHES"
exit 1
fi
```
**`.github/workflows/ci-vercel-website-preview.yaml`** — add the
two env vars to the top-level `env:` block so `vercel build` (both
`deploy-preview` and `deploy-production` jobs) sees them:
```yaml
env:
VERCEL_ORG_ID: ${{ secrets.VERCEL_WEBSITE_ORG_ID }}
VERCEL_PROJECT_ID: ${{ secrets.VERCEL_WEBSITE_PROJECT_ID }}
VERCEL_TOKEN: ${{ secrets.VERCEL_WEBSITE_TOKEN }}
VERCEL_SCOPE: comfyui
WEBSITE_ASHBY_API_KEY: ${{ secrets.WEBSITE_ASHBY_API_KEY }}
WEBSITE_ASHBY_JOB_BOARD_NAME: ${{ vars.WEBSITE_ASHBY_JOB_BOARD_NAME || 'comfy-org' }}
```
The secret must also be added to the Vercel project environment
(`vercel env add WEBSITE_ASHBY_API_KEY …` or via the Vercel UI) so
that `vercel build` in the preview job has access to it.
Fork PRs do not exercise this path: `ci-vercel-website-preview.yaml`
receives an empty `VERCEL_TOKEN` for forks and fails at `vercel pull`
before the build runs. Fork-safe PR interactions (the preview-URL
comment) are handled by `pr-vercel-website-preview.yaml`.
### Refreshing the snapshot
When a maintainer wants to update the committed snapshot (e.g. after
onboarding/offboarding roles):
```bash
WEBSITE_ASHBY_API_KEY=WEBSITE_ASHBY_JOB_BOARD_NAME=comfy-org \
pnpm --filter @comfyorg/website ashby:refresh-snapshot
git commit apps/website/src/data/ashby-roles.snapshot.json
```
The script exits non-zero on any non-fresh outcome so stale/empty
snapshots can't be accidentally committed.
## HubSpot contact form
The contact page uses HubSpot's hosted form embed for the interest form:
```html
<script
src="https://js-na2.hsforms.net/forms/embed/developer/244637579.js"
defer
></script>
<div
class="hs-form-html"
data-region="na2"
data-form-id="94e05eab-1373-47f7-ab5e-d84f9e6aa262"
data-portal-id="244637579"
></div>
```
The localized `/zh-CN/contact` page uses the same portal and script with form
ID `6885750c-02ef-4aa2-ba0d-213be9cccf93`.
This keeps submission handling, validation, anti-spam updates, and field
configuration in HubSpot. The local implementation in
`src/components/contact/HubspotFormEmbed.vue` only loads the hosted script and
renders the documented embed container.
## Scripts
- `pnpm dev` — Astro dev server
- `pnpm build` — production build to `dist/`
- `pnpm typecheck``astro check`
- `pnpm test:unit` — Vitest unit tests
- `pnpm test:e2e` — Playwright E2E tests (requires `pnpm build` first)
- `pnpm ashby:refresh-snapshot` — refresh the committed careers snapshot

View File

@@ -6,25 +6,14 @@ import tailwindcss from '@tailwindcss/vite'
export default defineConfig({
site: 'https://comfy.org',
output: 'static',
prefetch: { prefetchAll: true },
redirects: {
'/cloud/enterprise-case-studies/comfyui-at-architectural-scale-how-moment-factory-reimagined-3d-projection-mapping':
'/customers/moment-factory/',
'/cloud/enterprise-case-studies/how-series-entertainment-rebuilt-game-and-video-production-with-comfyui':
'/customers/series-entertainment/'
},
build: {
assets: '_website'
},
devToolbar: { enabled: !process.env.NO_TOOLBAR },
integrations: [vue(), sitemap()],
vite: {
plugins: [tailwindcss()],
server: {
watch: {
ignored: ['**/playwright-report/**']
}
}
plugins: [tailwindcss()]
},
build: {
assetsPrefix: process.env.VERCEL_URL
? `https://${process.env.VERCEL_URL}`
: undefined
},
i18n: {
locales: ['en', 'zh-CN'],

Binary file not shown.

Before

Width:  |  Height:  |  Size: 43 B

View File

@@ -1,57 +0,0 @@
import { expect } from '@playwright/test'
import { test } from './fixtures/blockExternalMedia'
test.describe('Careers page @smoke', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/careers')
})
test('has correct title', async ({ page }) => {
await expect(page).toHaveTitle('Careers — Comfy')
})
test('Roles section heading is visible', async ({ page }) => {
await expect(
page.getByRole('heading', { name: 'Roles', level: 2 })
).toBeVisible()
})
test('renders at least one role from the snapshot', async ({ page }) => {
const roles = page.getByTestId('careers-role-link')
await expect(roles.first()).toBeVisible()
expect(await roles.count()).toBeGreaterThan(0)
})
test('each role links to jobs.ashbyhq.com', async ({ page }) => {
const roles = page.getByTestId('careers-role-link')
const count = await roles.count()
for (let i = 0; i < count; i++) {
const href = await roles.nth(i).getAttribute('href')
expect(href).toMatch(/^https:\/\/jobs\.ashbyhq\.com\//)
}
})
test('ENGINEERING category filter narrows the role list', async ({
page
}) => {
const allCount = await page.getByTestId('careers-role-link').count()
await page.getByRole('button', { name: 'ENGINEERING', exact: true }).click()
const engineeringLocator = page.getByTestId('careers-role-link')
await expect(engineeringLocator.first()).toBeVisible()
const engineeringCount = await engineeringLocator.count()
expect(engineeringCount).toBeLessThanOrEqual(allCount)
expect(engineeringCount).toBeGreaterThan(0)
})
})
test.describe('Careers page (zh-CN) @smoke', () => {
test('renders localized heading and roles', async ({ page }) => {
await page.goto('/zh-CN/careers')
await expect(page).toHaveTitle('招聘 — Comfy')
await expect(
page.getByRole('heading', { name: '职位', level: 2 })
).toBeVisible()
await expect(page.getByTestId('careers-role-link').first()).toBeVisible()
})
})

View File

@@ -1,143 +0,0 @@
import { expect } from '@playwright/test'
import { test } from './fixtures/blockExternalMedia'
test.describe('Cloud page @smoke', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/cloud')
})
test('has correct title', async ({ page }) => {
await expect(page).toHaveTitle('Comfy Cloud — AI in the Cloud')
})
test('HeroSection heading and subtitle are visible', async ({ page }) => {
await expect(
page.getByRole('heading', { name: /The full power of/i, level: 1 })
).toBeVisible()
await expect(
page.getByText(/The easiest way to start with ComfyUI/)
).toBeVisible()
})
test('HeroSection has CTA button linking to cloud', async ({ page }) => {
const cta = page.getByRole('link', { name: /TRY COMFY CLOUD FOR FREE/i })
await expect(cta).toBeVisible()
})
test('ReasonSection heading and reasons are visible', async ({ page }) => {
await expect(
page.getByRole('heading', { name: /Why.*professionals.*choose/i })
).toBeVisible()
for (const title of [
'Powerful GPUs',
'All models',
'More control',
'Community workflows'
]) {
await expect(page.getByText(title).first()).toBeVisible()
}
})
test('AIModelsSection heading and 5 model cards are visible', async ({
page
}) => {
const heading = page.getByRole('heading', { name: /leading AI models/i })
await expect(heading).toBeVisible()
const section = heading.locator('xpath=ancestor::section')
const grid = section.locator('.grid')
const modelCards = grid.locator('a[href="https://comfy.org/workflows"]')
await expect(modelCards).toHaveCount(5)
})
test('AIModelsSection CTA links to workflows', async ({ page }) => {
const cta = page.getByRole('link', {
name: /EXPLORE WORKFLOWS/i
})
await expect(cta.first()).toBeVisible()
await expect(cta.first()).toHaveAttribute(
'href',
'https://comfy.org/workflows'
)
})
test('AudienceSection heading and cards are visible', async ({ page }) => {
await expect(page.getByText(/creators/i).first()).toBeVisible()
for (const label of ['CREATORS', 'TEAMS & STUDIOS']) {
await expect(page.getByText(label).first()).toBeVisible()
}
})
test('PricingSection heading and CTA are visible', async ({ page }) => {
await expect(page.getByText(/Simple, credit-based pricing/)).toBeVisible()
const cta = page.getByRole('link', { name: /SEE PRICING PLANS/i })
await expect(cta).toBeVisible()
await expect(cta).toHaveAttribute('href', '/cloud/pricing')
})
test('ProductCardsSection has 3 product cards', async ({ page }) => {
const section = page.locator('section', {
has: page.getByRole('heading', { name: /The AI creation/ })
})
const cards = section.locator('a[href]')
await expect(cards).toHaveCount(3)
})
test('FAQSection heading is visible with 15 items', async ({ page }) => {
await expect(page.getByRole('heading', { name: /FAQ/i })).toBeVisible()
const faqButtons = page.locator('button[aria-controls^="faq-panel-"]')
await expect(faqButtons).toHaveCount(15)
})
})
test.describe('Cloud FAQ accordion @interaction', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/cloud')
})
test('all FAQs are collapsed by default', async ({ page }) => {
await expect(
page.getByText(/Comfy Cloud is a version of ComfyUI/i)
).toBeHidden()
})
test('clicking a collapsed FAQ expands it', async ({ page }) => {
const firstQuestion = page.getByRole('button', {
name: /What is Comfy Cloud/i
})
await firstQuestion.scrollIntoViewIfNeeded()
// Gate: wait for Vue hydration to bind aria-expanded
await expect(firstQuestion).toHaveAttribute('aria-expanded', 'false')
await firstQuestion.click()
await expect(
page.getByText(/Comfy Cloud is a version of ComfyUI/i)
).toBeVisible()
})
test('clicking an expanded FAQ collapses it again', async ({ page }) => {
const firstQuestion = page.getByRole('button', {
name: /What is Comfy Cloud/i
})
await firstQuestion.scrollIntoViewIfNeeded()
// Gate: wait for Vue hydration to bind aria-expanded
await expect(firstQuestion).toHaveAttribute('aria-expanded', 'false')
await firstQuestion.click()
await expect(firstQuestion).toHaveAttribute('aria-expanded', 'true')
await expect(
page.getByText(/Comfy Cloud is a version of ComfyUI/i)
).toBeVisible()
await firstQuestion.click()
await expect(firstQuestion).toHaveAttribute('aria-expanded', 'false')
await expect(
page.getByText(/Comfy Cloud is a version of ComfyUI/i)
).toBeHidden()
})
})

View File

@@ -1,196 +0,0 @@
import { devices, expect } from '@playwright/test'
import { test } from './fixtures/blockExternalMedia'
const WINDOWS_UA =
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36'
test.describe('Download page @smoke', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/download')
})
test('has correct title', async ({ page }) => {
await expect(page).toHaveTitle('Download Comfy — Run AI Locally')
})
test('CloudBannerSection is visible with cloud link', async ({ page }) => {
const link = page.getByRole('link', { name: /TRY COMFY CLOUD/i })
await expect(link).toBeVisible()
await expect(link).toHaveAttribute('href', 'https://cloud.comfy.org')
})
test('HeroSection heading and subtitle are visible', async ({ page }) => {
await expect(
page.getByRole('heading', { name: /Run on your hardware/i, level: 1 })
).toBeVisible()
await expect(page.getByText(/The full ComfyUI engine/)).toBeVisible()
})
test('HeroSection has download and GitHub buttons', async ({ browser }) => {
const context = await browser.newContext({ userAgent: WINDOWS_UA })
const page = await context.newPage()
await page.goto('/download')
const hero = page.locator('section', {
has: page.getByRole('heading', {
name: /Run on your hardware/i,
level: 1
})
})
const downloadBtn = hero.getByRole('link', { name: /DOWNLOAD LOCAL/i })
await expect(downloadBtn).toBeVisible()
await expect(downloadBtn).toHaveAttribute('target', '_blank')
const githubBtn = hero.getByRole('link', { name: /INSTALL FROM GITHUB/i })
await expect(githubBtn).toBeVisible()
await expect(githubBtn).toHaveAttribute(
'href',
'https://github.com/Comfy-Org/ComfyUI'
)
await context.close()
})
test('ReasonSection heading and reasons are visible', async ({ page }) => {
await expect(
page.getByRole('heading', { name: /Why.*professionals.*choose/i })
).toBeVisible()
for (const title of [
'Unlimited',
'Any model',
'Your machine',
'Free. Open Source'
]) {
await expect(page.getByText(title).first()).toBeVisible()
}
})
test('EcoSystemSection heading is visible', async ({ page }) => {
await expect(page.getByText(/An ecosystem that moves faster/)).toBeVisible()
})
test('ProductCardsSection has 3 product cards', async ({ page }) => {
const section = page.locator('section', {
has: page.getByRole('heading', { name: /The AI creation/ })
})
const cards = section.locator('a[href]')
await expect(cards).toHaveCount(3)
})
test('ProductCardsSection links to cloud, api, enterprise', async ({
page
}) => {
const section = page.locator('section', {
has: page.getByRole('heading', { name: /The AI creation/ })
})
for (const href of ['/cloud', '/api', '/cloud/enterprise']) {
await expect(section.locator(`a[href="${href}"]`)).toBeVisible()
}
})
test('FAQSection heading is visible with 8 items', async ({ page }) => {
await expect(page.getByRole('heading', { name: /FAQ/i })).toBeVisible()
const faqButtons = page.locator('button[aria-controls^="faq-panel-"]')
await expect(faqButtons).toHaveCount(8)
})
})
test.describe('FAQ accordion @interaction', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/download')
})
test('all FAQs are collapsed by default', async ({ page }) => {
await expect(
page.getByText(/A dedicated GPU is strongly recommended/i)
).toBeHidden()
await expect(page.getByText(/ComfyUI is lightweight/i)).toBeHidden()
})
test('clicking a collapsed FAQ expands it', async ({ page }) => {
const firstQuestion = page.getByRole('button', {
name: /Do I need a GPU/i
})
await firstQuestion.scrollIntoViewIfNeeded()
// Gate: wait for Vue hydration to bind aria-expanded
await expect(firstQuestion).toHaveAttribute('aria-expanded', 'false')
await firstQuestion.click()
await expect(
page.getByText(/A dedicated GPU is strongly recommended/i)
).toBeVisible()
})
test('clicking an expanded FAQ collapses it again', async ({ page }) => {
const firstQuestion = page.getByRole('button', {
name: /Do I need a GPU/i
})
await firstQuestion.scrollIntoViewIfNeeded()
// Gate: wait for Vue hydration to bind aria-expanded
await expect(firstQuestion).toHaveAttribute('aria-expanded', 'false')
await firstQuestion.click()
await expect(firstQuestion).toHaveAttribute('aria-expanded', 'true')
await expect(
page.getByText(/A dedicated GPU is strongly recommended/i)
).toBeVisible()
await firstQuestion.click()
await expect(firstQuestion).toHaveAttribute('aria-expanded', 'false')
await expect(
page.getByText(/A dedicated GPU is strongly recommended/i)
).toBeHidden()
})
})
test.describe('Download page mobile @mobile', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/download')
})
test('CloudBannerSection is visible', async ({ page }) => {
await expect(page.getByText(/Need more power/)).toBeVisible()
})
test('HeroSection heading is visible', async ({ page }) => {
await expect(
page.getByRole('heading', { name: /Run on your hardware/i, level: 1 })
).toBeVisible()
})
test('download buttons are stacked vertically', async ({ browser }) => {
const context = await browser.newContext({
...devices['Pixel 5'],
userAgent: WINDOWS_UA
})
const page = await context.newPage()
await page.goto('/download')
const hero = page.locator('section', {
has: page.getByRole('heading', {
name: /Run on your hardware/i,
level: 1
})
})
const downloadBtn = hero.getByRole('link', { name: /DOWNLOAD LOCAL/i })
const githubBtn = hero.getByRole('link', { name: /INSTALL FROM GITHUB/i })
await expect(downloadBtn).toBeVisible()
await expect(githubBtn).toBeVisible()
await expect
.poll(async () => {
const downloadBox = await downloadBtn.boundingBox()
const githubBox = await githubBtn.boundingBox()
if (!downloadBox || !githubBox) return false
return githubBox.y > downloadBox.y
})
.toBe(true)
await context.close()
})
})

View File

@@ -1,47 +0,0 @@
import { fileURLToPath } from 'node:url'
import type { Route } from '@playwright/test'
import { test as base } from '@playwright/test'
function assetPath(relativePath: string) {
return fileURLToPath(new URL(relativePath, import.meta.url))
}
const IMAGE_PLACEHOLDER = assetPath('../assets/placeholder-1x1.webp')
const VIDEO_PLACEHOLDER = assetPath('../assets/placeholder.webm')
const ANALYTICS_PATTERN = '**/va.vercel-scripts.com/**' as const
const MEDIA_PATTERN =
/^https:\/\/media\.comfy\.org\/.*\.(webp|webm|mp4|png|jpg|jpeg|vtt)(\?.*)?$/i
const VIDEO_PATTERN = /\.(webm|mp4)(\?|$)/i
const SUBTITLE_PATTERN = /\.vtt(\?|$)/i
function blockAnalytics(route: Route) {
return route.abort('blockedbyclient')
}
async function fulfillMedia(route: Route) {
const url = route.request().url()
if (VIDEO_PATTERN.test(url))
return route.fulfill({ path: VIDEO_PLACEHOLDER, status: 200 })
if (SUBTITLE_PATTERN.test(url))
return route.fulfill({
status: 200,
contentType: 'text/vtt',
body: 'WEBVTT\n'
})
await route.fulfill({ path: IMAGE_PLACEHOLDER, status: 200 })
}
export const test = base.extend<{ blockExternalMedia: void }>({
blockExternalMedia: [
async ({ page }, use) => {
await page.route(ANALYTICS_PATTERN, blockAnalytics)
await page.route(MEDIA_PATTERN, fulfillMedia)
await use()
},
{ auto: true }
]
})

View File

@@ -1,224 +0,0 @@
import { fileURLToPath } from 'node:url'
import { expect } from '@playwright/test'
import { test } from './fixtures/blockExternalMedia'
const caseStudyVideoPath = fileURLToPath(
new URL(
'../../../public/assets/images/cloud-subscription.webm',
import.meta.url
)
)
test.describe('Homepage @smoke', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/')
})
test('has correct title', async ({ page }) => {
await expect(page).toHaveTitle('Comfy — Professional Control of Visual AI')
})
test('HeroSection heading is visible', async ({ page }) => {
await expect(
page.getByRole('heading', { name: /Professional Control/i, level: 1 })
).toBeVisible()
})
test('SocialProofBar logos are visible', async ({ page }) => {
await expect(
page.locator('img[src*="/icons/clients/"]').first()
).toBeVisible()
})
test('ProductShowcase section is visible', async ({ page }) => {
await expect(page.getByText('HOW', { exact: true }).first()).toBeVisible()
await expect(
page.getByText(/Connect models, processing steps, and outputs/)
).toBeVisible()
})
test('UseCaseSection is visible', async ({ page }) => {
await expect(
page.getByText('Industries that create with ComfyUI')
).toBeVisible()
})
test('GetStartedSection with heading is visible', async ({ page }) => {
await expect(
page.getByRole('heading', { name: 'Get started in minutes' })
).toBeVisible()
})
test('ProductCardsSection has 4 product cards', async ({ page }) => {
const section = page.locator('section', {
has: page.getByRole('heading', { name: /The AI creation/ })
})
const cards = section.locator('a[href]')
await expect(cards).toHaveCount(4)
})
test('CaseStudySpotlight section is visible', async ({ page }) => {
const section = page.locator('section', {
has: page.getByText('Customer Stories')
})
await expect(section).toBeVisible()
await expect(
section.getByRole('heading', { name: /See Comfy/i })
).toBeVisible()
})
test('CaseStudySpotlight CTA sizes to its content, not the column', async ({
page
}) => {
const contentColumn = page.getByTestId('case-study-content')
const cta = contentColumn.getByRole('link', {
name: /see all case studies/i
})
await cta.scrollIntoViewIfNeeded()
await expect(cta).toBeVisible()
const [columnBox, ctaBox] = await Promise.all([
contentColumn.boundingBox(),
cta.boundingBox()
])
expect(columnBox).not.toBeNull()
expect(ctaBox).not.toBeNull()
expect(ctaBox!.width).toBeLessThan(columnBox!.width * 0.7)
})
test('CaseStudySpotlight CTA has breathing room above it on mobile @mobile', async ({
page
}) => {
const contentColumn = page.getByTestId('case-study-content')
const subheading = contentColumn.getByText(
/Videos & case studies from teams/i
)
const cta = contentColumn.getByRole('link', {
name: /see all case studies/i
})
await cta.scrollIntoViewIfNeeded()
const [subBox, ctaBox] = await Promise.all([
subheading.boundingBox(),
cta.boundingBox()
])
expect(subBox).not.toBeNull()
expect(ctaBox).not.toBeNull()
expect(ctaBox!.y - (subBox!.y + subBox!.height)).toBeGreaterThanOrEqual(24)
})
test('BuildWhatSection is visible', async ({ page }) => {
// "DOESN'T EXIST" is the actual badge text rendered in the Build What section
await expect(page.getByText("DOESN'T EXIST")).toBeVisible()
})
})
test.describe('Product showcase accordion @interaction', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/')
})
test('first feature is active by default', async ({ page }) => {
await expect(
page.getByText(/Build powerful AI pipelines by connecting nodes/).first()
).toBeVisible()
})
test('clicking inactive feature expands it and collapses previous', async ({
page
}) => {
const secondFeature = page
.getByRole('button', { name: /App mode/i })
.first()
await secondFeature.scrollIntoViewIfNeeded()
await secondFeature.click()
await expect(
secondFeature.getByText(/If you are new to ComfyUI/)
).toBeVisible()
const firstFeature = page
.getByRole('button', { name: /Full Control with Nodes/i })
.first()
await expect(firstFeature).not.toHaveClass(/bg-primary-comfy-yellow/)
await expect(secondFeature).toHaveClass(/bg-primary-comfy-yellow/)
})
})
test.describe('Video player @interaction', () => {
test.beforeEach(async ({ page }) => {
await page.route(
'https://media.comfy.org/website/customers/blackmath/video.webm',
(route) =>
route.fulfill({
contentType: 'video/webm',
path: caseStudyVideoPath
})
)
await page.goto('/')
})
test('clicking play advances playback', async ({ page }) => {
const section = page.locator('section', {
has: page.getByText('Customer Stories')
})
const video = section.locator('video')
await expect
.poll(
async () =>
video.evaluate((element: HTMLVideoElement) => element.duration),
{ timeout: 15_000 }
)
.toBeGreaterThan(0)
await section.getByRole('button', { name: 'Play' }).click()
await expect
.poll(async () =>
video.evaluate((element: HTMLVideoElement) => element.currentTime)
)
.toBeGreaterThan(0)
})
})
test.describe('Product cards links @smoke', () => {
test('cards have correct hrefs', async ({ page }) => {
await page.goto('/')
const section = page.locator('section', {
has: page.getByRole('heading', { name: /The AI creation/ })
})
for (const href of ['/download', '/cloud', '/api', '/cloud/enterprise']) {
await expect(section.locator(`a[href="${href}"]`)).toBeVisible()
}
})
})
test.describe('Get started section links @smoke', () => {
test('has download and cloud links', async ({ page }) => {
await page.goto('/')
const section = page.locator('section', {
has: page.getByRole('heading', { name: 'Get started in minutes' })
})
const downloadLink = section.getByRole('link', { name: 'Download Local' })
await expect(downloadLink).toBeVisible()
await expect(downloadLink).toHaveAttribute('href', '/download')
const cloudLink = section.getByRole('link', { name: 'Launch Cloud' })
await expect(cloudLink).toBeVisible()
await expect(cloudLink).toHaveAttribute('href', 'https://cloud.comfy.org')
})
})

View File

@@ -1,163 +0,0 @@
import { expect } from '@playwright/test'
import { test } from './fixtures/blockExternalMedia'
test.describe('Desktop navigation @smoke', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/')
})
test('logo links to homepage', async ({ page }) => {
const logo = page.getByRole('link', { name: 'Comfy home' })
await expect(logo).toBeVisible()
await expect(logo).toHaveAttribute('href', '/')
})
test('has all top-level nav items', async ({ page }) => {
const nav = page.getByRole('navigation', { name: 'Main navigation' })
const desktopLinks = nav.getByTestId('desktop-nav-links')
for (const label of [
'PRODUCTS',
'PRICING',
'COMMUNITY',
'RESOURCES',
'COMPANY'
]) {
await expect(desktopLinks.getByText(label).first()).toBeVisible()
}
})
test('CTA buttons are visible', async ({ page }) => {
const nav = page.getByRole('navigation', { name: 'Main navigation' })
const desktopCTA = nav.getByTestId('desktop-nav-cta')
await expect(
desktopCTA.getByRole('link', { name: 'DOWNLOAD LOCAL' })
).toBeVisible()
await expect(
desktopCTA.getByRole('link', { name: 'LAUNCH CLOUD' })
).toBeVisible()
})
})
test.describe('Desktop dropdown @interaction', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/')
})
test('hovering PRODUCTS shows dropdown items', async ({ page }) => {
const nav = page.getByRole('navigation', { name: 'Main navigation' })
const desktopLinks = nav.getByTestId('desktop-nav-links')
const productsButton = desktopLinks.getByRole('button', {
name: /PRODUCTS/i
})
await productsButton.hover()
const dropdown = productsButton.locator('..').getByTestId('nav-dropdown')
for (const item of [
'Comfy Local',
'Comfy Cloud',
'Comfy API',
'Comfy Enterprise'
]) {
await expect(dropdown.getByText(item)).toBeVisible()
}
})
test('moving mouse away closes dropdown', async ({ page }) => {
const nav = page.getByRole('navigation', { name: 'Main navigation' })
const desktopLinks = nav.getByTestId('desktop-nav-links')
await desktopLinks.getByRole('button', { name: /PRODUCTS/i }).hover()
const comfyLocal = nav.getByRole('link', { name: 'Comfy Local' }).first()
await expect(comfyLocal).toBeVisible()
await page.locator('main').hover()
await expect(comfyLocal).toBeHidden()
})
test('Escape key closes dropdown', async ({ page }) => {
const nav = page.getByRole('navigation', { name: 'Main navigation' })
const desktopLinks = nav.getByTestId('desktop-nav-links')
await desktopLinks.getByRole('button', { name: /PRODUCTS/i }).hover()
const comfyLocal = nav.getByRole('link', { name: 'Comfy Local' }).first()
await expect(comfyLocal).toBeVisible()
await page.keyboard.press('Escape')
await expect(comfyLocal).toBeHidden()
})
})
test.describe('Mobile menu @mobile', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/')
})
test('hamburger button is visible', async ({ page }) => {
await expect(
page.getByRole('button', { name: 'Toggle menu' })
).toBeVisible()
})
test('clicking hamburger opens mobile menu with nav items', async ({
page
}) => {
await page.getByRole('button', { name: 'Toggle menu' }).click()
const menu = page.locator('#site-mobile-menu')
await expect(menu).toBeVisible()
for (const label of ['PRODUCTS', 'PRICING', 'COMMUNITY']) {
await expect(menu.getByText(label).first()).toBeVisible()
}
})
test('clicking section with subitems drills down and back works', async ({
page
}) => {
await page.getByRole('button', { name: 'Toggle menu' }).click()
const menu = page.locator('#site-mobile-menu')
await menu.getByText('PRODUCTS').first().click()
await expect(menu.getByText('Comfy Local')).toBeVisible()
await expect(menu.getByText('Comfy Cloud')).toBeVisible()
await menu.getByRole('button', { name: /BACK/i }).click()
await expect(menu.getByText('PRODUCTS').first()).toBeVisible()
})
test('CTA buttons visible in mobile menu', async ({ page }) => {
await page.getByRole('button', { name: 'Toggle menu' }).click()
const menu = page.locator('#site-mobile-menu')
await expect(
menu.getByRole('link', { name: 'DOWNLOAD LOCAL' })
).toBeVisible()
await expect(menu.getByRole('link', { name: 'LAUNCH CLOUD' })).toBeVisible()
})
})
test.describe('Footer @smoke', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/')
})
test('footer is visible with link sections', async ({ page }) => {
const footer = page.locator('footer')
await expect(footer).toBeVisible()
for (const heading of ['Products', 'Resources', 'Company']) {
await expect(
footer.getByRole('heading', { name: heading }).first()
).toBeVisible()
}
})
test('copyright text is visible', async ({ page }) => {
await expect(
page.locator('footer').getByText(/© \d{4} Comfy Org/)
).toBeVisible()
})
})

View File

@@ -1,50 +0,0 @@
import { expect } from '@playwright/test'
import { test } from './fixtures/blockExternalMedia'
test.describe('Desktop layout @smoke', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/')
})
test('navigation links visible and hamburger hidden', async ({ page }) => {
const nav = page.getByRole('navigation', { name: 'Main navigation' })
const desktopLinks = nav.getByTestId('desktop-nav-links')
await expect(desktopLinks.getByText('PRODUCTS').first()).toBeVisible()
await expect(desktopLinks.getByText('PRICING').first()).toBeVisible()
await expect(page.getByRole('button', { name: 'Toggle menu' })).toBeHidden()
})
test('product cards in grid layout', async ({ page }) => {
const section = page.locator('section', {
has: page.getByRole('heading', { name: /The AI creation/ })
})
const cards = section.locator('a[href]')
await expect(cards).toHaveCount(4)
const firstBox = await cards.nth(0).boundingBox()
const secondBox = await cards.nth(1).boundingBox()
expect(firstBox, 'first card bounding box').not.toBeNull()
expect(secondBox, 'second card bounding box').not.toBeNull()
expect(firstBox!.y).toBeCloseTo(secondBox!.y, 0)
})
})
test.describe('Mobile layout @mobile', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/')
})
test('hamburger visible and desktop nav hidden', async ({ page }) => {
await expect(
page.getByRole('button', { name: 'Toggle menu' })
).toBeVisible()
})
test('SocialProofBar shows two marquee rows on mobile', async ({ page }) => {
const mobileContainer = page.getByTestId('social-proof-mobile')
await expect(mobileContainer).toBeVisible()
})
})

View File

@@ -1,6 +0,0 @@
export const VIEWPORTS = [
{ name: '1-sm', width: 393, height: 851 },
{ name: '2-md', width: 768, height: 1024 },
{ name: '3-lg', width: 1280, height: 800 },
{ name: '4-xl', width: 1536, height: 864 }
] as const

View File

@@ -1,145 +0,0 @@
import type { Page } from '@playwright/test'
import { expect } from '@playwright/test'
import { test } from './fixtures/blockExternalMedia'
import { VIEWPORTS } from './viewports'
test.describe.configure({ timeout: 60_000 })
const SMALL_VIEWPORTS = VIEWPORTS.filter(
(v) => v.name === '1-sm' || v.name === '2-md'
)
async function assertNoOverflow(page: Page) {
await expect
.poll(
() =>
page.evaluate(
() =>
document.documentElement.scrollWidth >
document.documentElement.clientWidth
),
{ message: 'page has horizontal overflow', timeout: 5_000 }
)
.toBe(false)
}
async function navigateAndSettle(page: Page, url: string) {
await page.goto(url)
await page.waitForLoadState('networkidle')
}
test.describe('Home', { tag: '@visual' }, () => {
for (const vp of VIEWPORTS) {
test.describe(vp.name, () => {
test.beforeEach(async ({ page }) => {
await page.setViewportSize({ width: vp.width, height: vp.height })
await navigateAndSettle(page, '/')
})
test('product-cards screenshot', async ({ page }) => {
const section = page.locator('section', {
has: page.getByRole('heading', { name: /The AI creation/i })
})
await expect(section).toBeVisible()
await section.scrollIntoViewIfNeeded()
await expect(page).toHaveScreenshot(`home-product-cards-${vp.name}.png`)
})
test('get-started screenshot', async ({ page }) => {
const section = page.locator('section', {
has: page.getByRole('heading', { name: /Get started/i })
})
await expect(section).toBeVisible()
await section.scrollIntoViewIfNeeded()
await expect(page).toHaveScreenshot(`home-get-started-${vp.name}.png`)
})
})
}
})
test.describe('Pricing', { tag: '@visual' }, () => {
for (const vp of VIEWPORTS) {
test(`pricing-tiers-${vp.name}`, async ({ page }) => {
await page.setViewportSize({ width: vp.width, height: vp.height })
await navigateAndSettle(page, '/cloud/pricing')
await assertNoOverflow(page)
const section = page.locator('section', {
has: page.getByRole('heading', { name: /Pricing/i })
})
await expect(section).toBeVisible()
await section.scrollIntoViewIfNeeded()
await expect(page).toHaveScreenshot(`pricing-tiers-${vp.name}.png`)
})
}
})
test.describe('Contact', { tag: '@visual' }, () => {
for (const vp of SMALL_VIEWPORTS) {
test(`form-${vp.name}`, async ({ page }) => {
await page.setViewportSize({ width: vp.width, height: vp.height })
await navigateAndSettle(page, '/contact')
const section = page.locator('section', {
has: page.getByRole('heading', { name: /Create powerful workflows/i })
})
await expect(section).toBeVisible()
await section.scrollIntoViewIfNeeded()
await expect(page).toHaveScreenshot(`contact-form-${vp.name}.png`)
})
}
})
test.describe('Gallery', { tag: '@visual' }, () => {
for (const vp of SMALL_VIEWPORTS) {
test(`gallery-grid-${vp.name}`, async ({ page }) => {
await page.setViewportSize({ width: vp.width, height: vp.height })
await navigateAndSettle(page, '/gallery')
const section = page.getByTestId('gallery-grid')
await expect(section).toBeVisible()
await section.scrollIntoViewIfNeeded()
await expect(page).toHaveScreenshot(`gallery-grid-${vp.name}.png`)
})
}
})
test.describe('About', { tag: '@visual' }, () => {
for (const vp of SMALL_VIEWPORTS) {
test(`hero-${vp.name}`, async ({ page }) => {
await page.setViewportSize({ width: vp.width, height: vp.height })
await navigateAndSettle(page, '/about')
const hero = page.locator('section', {
has: page.getByRole('heading', { name: /Build the tools/i })
})
await expect(hero).toBeVisible()
await hero.scrollIntoViewIfNeeded()
await expect(page).toHaveScreenshot(`about-hero-${vp.name}.png`)
})
}
})
test.describe('Overflow guards', { tag: '@visual' }, () => {
const pages = [
'/',
'/cloud',
'/cloud/pricing',
'/contact',
'/download',
'/gallery',
'/about',
'/careers'
]
for (const url of pages) {
for (const vp of VIEWPORTS) {
test(`${url} ${vp.name} no overflow`, async ({ page }) => {
await page.setViewportSize({ width: vp.width, height: vp.height })
await page.goto(url)
await assertNoOverflow(page)
})
}
}
})

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 85 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 96 KiB

View File

@@ -5,41 +5,21 @@
"type": "module",
"scripts": {
"dev": "astro dev",
"dev:no-toolbar": "cross-env NO_TOOLBAR=1 astro dev",
"build": "astro build",
"preview": "astro preview",
"typecheck": "astro check",
"test:unit": "vitest run",
"test:coverage": "vitest run --coverage",
"test:e2e": "playwright test",
"test:e2e:local": "cross-env PLAYWRIGHT_LOCAL=1 playwright test",
"test:visual": "playwright test --project visual",
"test:visual:update": "playwright test --project visual --update-snapshots",
"ashby:refresh-snapshot": "tsx ./scripts/refresh-ashby-snapshot.ts"
"preview": "astro preview"
},
"dependencies": {
"@astrojs/sitemap": "catalog:",
"@comfyorg/design-system": "workspace:*",
"@comfyorg/tailwind-utils": "workspace:*",
"@vercel/analytics": "catalog:",
"@vueuse/core": "catalog:",
"cva": "catalog:",
"gsap": "catalog:",
"lenis": "catalog:",
"posthog-js": "catalog:",
"vue": "catalog:",
"zod": "catalog:"
"vue": "catalog:"
},
"devDependencies": {
"@astrojs/check": "catalog:",
"@astrojs/vue": "catalog:",
"@playwright/test": "catalog:",
"@tailwindcss/vite": "catalog:",
"astro": "catalog:",
"tailwindcss": "catalog:",
"tsx": "catalog:",
"typescript": "catalog:",
"vitest": "catalog:"
"typescript": "catalog:"
},
"nx": {
"tags": [
@@ -95,32 +75,6 @@
"cwd": "apps/website",
"command": "astro check"
}
},
"test:unit": {
"executor": "nx:run-commands",
"cache": true,
"options": {
"cwd": "apps/website",
"command": "vitest run"
}
},
"test:coverage": {
"executor": "nx:run-commands",
"cache": true,
"options": {
"cwd": "apps/website",
"command": "vitest run --coverage"
}
},
"test:e2e": {
"executor": "nx:run-commands",
"dependsOn": [
"build"
],
"options": {
"cwd": "apps/website",
"command": "playwright test"
}
}
}
}

View File

@@ -1,57 +0,0 @@
import type { PlaywrightTestConfig } from '@playwright/test'
import { defineConfig, devices } from '@playwright/test'
const maybeLocalOptions: PlaywrightTestConfig = process.env.PLAYWRIGHT_LOCAL
? {
timeout: 30_000,
retries: 0,
workers: 1,
use: {
baseURL: 'http://localhost:4321',
trace: 'on',
video: 'on'
}
}
: {
retries: process.env.CI ? 2 : 0,
use: {
baseURL: 'http://localhost:4321',
trace: 'on-first-retry'
}
}
export default defineConfig({
testDir: './e2e',
fullyParallel: true,
forbidOnly: !!process.env.CI,
reporter: process.env.CI
? [['html'], ['json', { outputFile: 'results.json' }]]
: 'html',
expect: {
toHaveScreenshot: { maxDiffPixels: 50 }
},
...maybeLocalOptions,
webServer: {
command: 'pnpm preview',
port: 4321,
reuseExistingServer: !process.env.CI
},
projects: [
{
name: 'desktop',
use: { ...devices['Desktop Chrome'] },
grepInvert: /@mobile|@visual/
},
{
name: 'mobile',
use: { ...devices['Pixel 5'] },
grep: /@mobile/
},
{
name: 'visual',
use: { ...devices['Desktop Chrome'] },
grep: /@visual/,
fullyParallel: false
}
]
})

Some files were not shown because too many files have changed in this diff Show More