mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-07-17 01:07:56 +00:00
Compare commits
9 Commits
codex/fron
...
codex/veri
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c622ccab76 | ||
|
|
84379232da | ||
|
|
24407cf69a | ||
|
|
216878e488 | ||
|
|
3e2c120e13 | ||
|
|
48be912767 | ||
|
|
608de229e1 | ||
|
|
9e11b49e14 | ||
|
|
427e9a20c4 |
@@ -22,29 +22,6 @@ reviews:
|
||||
docstrings:
|
||||
mode: 'off'
|
||||
custom_checks:
|
||||
- name: Reproduction and contribution evidence
|
||||
mode: error
|
||||
instructions: |
|
||||
Use only PR metadata already available in the review context: the PR
|
||||
description, changed-file list relative to the base, and diff content.
|
||||
Do not run shell commands or follow links to infer missing evidence.
|
||||
|
||||
Fail when the PR description does not contain concrete reproduction or
|
||||
validation steps that another reviewer can follow.
|
||||
|
||||
The PR must select exactly one evidence type:
|
||||
1. Visual: require either distinct Before and After screenshot links, or
|
||||
one screencast link that demonstrates the before and after behavior.
|
||||
2. Non-visual: allow this only when the diff has no user-visible behavior
|
||||
or presentation change. Require an `N/A:` rationale plus a concrete
|
||||
test command and result or relevant log evidence.
|
||||
|
||||
Fail placeholders, generic statements such as `done` or `tested`, a
|
||||
screenshot used as both Before and After, and Non-visual selections for
|
||||
changes to rendered UI, styling, interaction, or user-visible states.
|
||||
Pass otherwise. The repository metadata workflow validates field shape;
|
||||
this check validates whether the supplied evidence matches the diff.
|
||||
|
||||
- name: End-to-end regression coverage for fixes
|
||||
mode: error
|
||||
instructions: |
|
||||
@@ -86,14 +63,3 @@ reviews:
|
||||
Pass if none of these patterns are found in the diff.
|
||||
|
||||
When warning, reference the specific ADR by number and link to `docs/adr/` for context. Frame findings as directional guidance since ADR 0003 and 0008 are in Proposed status.
|
||||
|
||||
path_instructions:
|
||||
- path: '**/*.test.ts'
|
||||
instructions: |
|
||||
Treat `.agents/checks/test-quality.md`, `docs/testing/README.md`, and `docs/guidance/vitest.md` as required review context for every changed Vitest test file.
|
||||
- path: 'src/lib/litegraph/**/*.test.ts'
|
||||
instructions: |
|
||||
Treat `.agents/checks/test-quality.md`, `docs/testing/README.md`, `docs/guidance/vitest.md`, and `docs/testing/litegraph-testing.md` as required review context for every changed litegraph Vitest test file.
|
||||
- path: '{browser_tests,apps/website/e2e}/**/*.spec.ts'
|
||||
instructions: |
|
||||
Treat `.agents/checks/test-quality.md`, `docs/testing/README.md`, and `docs/guidance/playwright.md` as required review context for every changed Playwright test file.
|
||||
|
||||
31
.github/ISSUE_TEMPLATE/bug-report.yaml
vendored
31
.github/ISSUE_TEMPLATE/bug-report.yaml
vendored
@@ -39,37 +39,6 @@ body:
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: dropdown
|
||||
id: evidence-type
|
||||
attributes:
|
||||
label: Evidence type
|
||||
description: Choose the evidence that supports this report.
|
||||
options:
|
||||
- Visual
|
||||
- Non-visual
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: evidence
|
||||
attributes:
|
||||
label: Evidence
|
||||
description: >-
|
||||
Visual reports require distinct before and after links or one screencast.
|
||||
For non-visual reports, explain why visuals do not apply and include test
|
||||
or log output.
|
||||
placeholder: |
|
||||
Visual:
|
||||
Before: Drag in a screenshot.
|
||||
After: Drag in a screenshot.
|
||||
Or Screencast: Drag in one recording that shows both states.
|
||||
|
||||
Non-visual:
|
||||
N/A: Explain why visual evidence does not apply.
|
||||
Test/log evidence: Paste the command and result, logs, or a link.
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: dropdown
|
||||
id: severity
|
||||
attributes:
|
||||
|
||||
40
.github/ISSUE_TEMPLATE/feature-request.yaml
vendored
40
.github/ISSUE_TEMPLATE/feature-request.yaml
vendored
@@ -29,46 +29,6 @@ body:
|
||||
- While collaborating with team members...
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: example
|
||||
attributes:
|
||||
label: Concrete example or steps
|
||||
description: Show the exact workflow or steps where the problem occurs.
|
||||
placeholder: |
|
||||
1. Open a workflow with...
|
||||
2. Try to...
|
||||
3. Observe...
|
||||
validations:
|
||||
required: true
|
||||
- type: dropdown
|
||||
id: evidence-type
|
||||
attributes:
|
||||
label: Evidence type
|
||||
description: Choose the evidence that supports this request.
|
||||
options:
|
||||
- Visual
|
||||
- Non-visual
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: evidence
|
||||
attributes:
|
||||
label: Evidence
|
||||
description: >-
|
||||
Visual requests require distinct before and after links or one screencast.
|
||||
For non-visual requests, explain why visuals do not apply and include test
|
||||
or log output.
|
||||
placeholder: |
|
||||
Visual:
|
||||
Before: Drag in a screenshot.
|
||||
After: Drag in a screenshot.
|
||||
Or Screencast: Drag in one recording that shows both states.
|
||||
|
||||
Non-visual:
|
||||
N/A: Explain why visual evidence does not apply.
|
||||
Test/log evidence: Paste the command and result, logs, or a link.
|
||||
validations:
|
||||
required: true
|
||||
- type: dropdown
|
||||
id: frequency
|
||||
attributes:
|
||||
|
||||
@@ -8,6 +8,10 @@ inputs:
|
||||
head-sha:
|
||||
description: The commit SHA to find runs for
|
||||
required: true
|
||||
event:
|
||||
description: Optional workflow event to match
|
||||
required: false
|
||||
default: ''
|
||||
not-found-status:
|
||||
description: Status to output when no run exists
|
||||
required: false
|
||||
@@ -33,6 +37,7 @@ runs:
|
||||
env:
|
||||
WORKFLOW_ID: ${{ inputs.workflow-id }}
|
||||
HEAD_SHA: ${{ inputs.head-sha }}
|
||||
EVENT_NAME: ${{ inputs.event }}
|
||||
NOT_FOUND_STATUS: ${{ inputs.not-found-status }}
|
||||
with:
|
||||
github-token: ${{ inputs.token }}
|
||||
@@ -42,6 +47,7 @@ runs:
|
||||
repo: context.repo.repo,
|
||||
workflow_id: process.env.WORKFLOW_ID,
|
||||
head_sha: process.env.HEAD_SHA,
|
||||
event: process.env.EVENT_NAME || undefined,
|
||||
per_page: 1,
|
||||
});
|
||||
|
||||
|
||||
33
.github/pull_request_template.md
vendored
33
.github/pull_request_template.md
vendored
@@ -15,35 +15,6 @@
|
||||
<!-- If this PR fixes an issue, uncomment and update the line below -->
|
||||
<!-- Fixes #ISSUE_NUMBER -->
|
||||
|
||||
## Reproduction or validation steps
|
||||
## Screenshots (if applicable)
|
||||
|
||||
<!-- Required. List the exact steps a reviewer can follow. -->
|
||||
|
||||
1.
|
||||
|
||||
## Evidence type
|
||||
|
||||
<!-- Required. Check exactly one. -->
|
||||
|
||||
- [ ] Visual
|
||||
- [ ] Non-visual
|
||||
|
||||
## Before
|
||||
|
||||
<!-- Visual: add a screenshot link. Leave blank when using a screencast. -->
|
||||
|
||||
## After
|
||||
|
||||
<!-- Visual: add a screenshot link. Leave blank when using a screencast. -->
|
||||
|
||||
## Screencast
|
||||
|
||||
<!-- Visual alternative: add one link that demonstrates before and after. -->
|
||||
|
||||
## Non-visual rationale
|
||||
|
||||
<!-- Non-visual only. Start with "N/A:" and explain why visuals do not apply. -->
|
||||
|
||||
## Test or log evidence
|
||||
|
||||
<!-- Non-visual only. Paste the command and result, logs, or a link. -->
|
||||
<!-- Add screenshots or video recording to help explain your changes -->
|
||||
|
||||
@@ -41,20 +41,6 @@ jobs:
|
||||
title: '[chore] Update electron-types to ${{ steps.get-version.outputs.NEW_VERSION }}'
|
||||
body: |
|
||||
Automated update of desktop API types to version ${{ steps.get-version.outputs.NEW_VERSION }}.
|
||||
|
||||
## Reproduction or validation steps
|
||||
1. Review the package and lockfile changes for the expected version.
|
||||
2. Confirm the desktop API type checks pass in CI.
|
||||
|
||||
## Evidence type
|
||||
- [ ] Visual
|
||||
- [x] Non-visual
|
||||
|
||||
## Non-visual rationale
|
||||
N/A: This automated dependency update has no visual behavior.
|
||||
|
||||
## Test or log evidence
|
||||
`pnpm install --workspace-root @comfyorg/comfyui-electron-types@latest` passed in workflow run `${{ github.run_id }}`.
|
||||
branch: update-electron-types-${{ steps.get-version.outputs.NEW_VERSION }}
|
||||
base: main
|
||||
labels: |
|
||||
|
||||
@@ -98,20 +98,6 @@ jobs:
|
||||
- Generated on: ${{ github.event.repository.updated_at }}
|
||||
|
||||
These types are automatically generated using openapi-typescript.
|
||||
|
||||
## Reproduction or validation steps
|
||||
1. Review the generated type diff against the referenced Manager commit.
|
||||
2. Confirm the generated type lint and repository checks pass in CI.
|
||||
|
||||
## Evidence type
|
||||
- [ ] Visual
|
||||
- [x] Non-visual
|
||||
|
||||
## Non-visual rationale
|
||||
N/A: This automated generated-type update has no visual behavior.
|
||||
|
||||
## Test or log evidence
|
||||
`pnpm lint:fix:no-cache -- ./src/types/generatedManagerTypes.ts` passed in workflow run `${{ github.run_id }}`.
|
||||
branch: update-manager-types-${{ steps.manager-info.outputs.commit }}
|
||||
base: ${{ inputs.target_branch }}
|
||||
labels: Manager
|
||||
|
||||
117
.github/workflows/ci-issue-evidence.yaml
vendored
117
.github/workflows/ci-issue-evidence.yaml
vendored
@@ -1,117 +0,0 @@
|
||||
# Description: Labels and reports issues blocked by missing evidence or a Comfy human owner.
|
||||
name: 'CI: Issue Evidence'
|
||||
|
||||
on:
|
||||
issues:
|
||||
types: [opened, edited, reopened, assigned, unassigned]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
issues: write
|
||||
|
||||
concurrency:
|
||||
group: issue-evidence-${{ github.event.issue.number }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
issue-evidence:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout default-branch validator
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
ref: ${{ github.workflow_sha }}
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version-file: '.nvmrc'
|
||||
|
||||
- name: Validate issue evidence
|
||||
id: evidence
|
||||
continue-on-error: true
|
||||
run: node scripts/cicd/validate-evidence.ts issue "$GITHUB_EVENT_PATH"
|
||||
|
||||
- name: Validate human maintainer assignment
|
||||
id: maintainer
|
||||
continue-on-error: true
|
||||
env:
|
||||
COMFY_ORG_MEMBERS_READ_TOKEN: ${{ secrets.COMFY_ORG_MEMBERS_READ_TOKEN }}
|
||||
run: node scripts/cicd/validate-maintainer.ts "$GITHUB_EVENT_PATH"
|
||||
|
||||
- name: Update blocked labels
|
||||
if: always()
|
||||
uses: actions/github-script@v8
|
||||
env:
|
||||
EVIDENCE_VALID: ${{ steps.evidence.outputs.valid }}
|
||||
MAINTAINER_VALID: ${{ steps.maintainer.outputs.valid }}
|
||||
with:
|
||||
script: |
|
||||
const issue_number = context.issue.number
|
||||
const { owner, repo } = context.repo
|
||||
const policies = [
|
||||
{
|
||||
description: 'Blocked until required reproduction and evidence details are added',
|
||||
name: 'blocked: needs-evidence',
|
||||
valid: process.env.EVIDENCE_VALID === 'true'
|
||||
},
|
||||
{
|
||||
description: 'Blocked until an active human comfy_frontend_devs member is assigned',
|
||||
name: 'blocked: needs-maintainer',
|
||||
valid: process.env.MAINTAINER_VALID === 'true'
|
||||
}
|
||||
]
|
||||
|
||||
for (const policy of policies) {
|
||||
if (policy.valid) {
|
||||
try {
|
||||
await github.rest.issues.removeLabel({
|
||||
owner,
|
||||
repo,
|
||||
issue_number,
|
||||
name: policy.name
|
||||
})
|
||||
} catch (error) {
|
||||
if (error.status !== 404) throw error
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
try {
|
||||
await github.rest.issues.getLabel({
|
||||
owner,
|
||||
repo,
|
||||
name: policy.name
|
||||
})
|
||||
} catch (error) {
|
||||
if (error.status !== 404) throw error
|
||||
await github.rest.issues.createLabel({
|
||||
owner,
|
||||
repo,
|
||||
name: policy.name,
|
||||
color: 'B60205',
|
||||
description: policy.description
|
||||
}).catch((createError) => {
|
||||
const alreadyExists =
|
||||
createError.status === 422 &&
|
||||
createError.response?.data?.errors?.some(
|
||||
(detail) => detail.code === 'already_exists'
|
||||
)
|
||||
if (!alreadyExists) throw createError
|
||||
})
|
||||
}
|
||||
|
||||
await github.rest.issues.addLabels({
|
||||
owner,
|
||||
repo,
|
||||
issue_number,
|
||||
labels: [policy.name]
|
||||
})
|
||||
}
|
||||
|
||||
- name: Fail blocked contribution
|
||||
if: >-
|
||||
always() &&
|
||||
(steps.evidence.outputs.valid != 'true' ||
|
||||
steps.maintainer.outputs.valid != 'true')
|
||||
run: exit 1
|
||||
67
.github/workflows/ci-pr-evidence.yaml
vendored
67
.github/workflows/ci-pr-evidence.yaml
vendored
@@ -1,67 +0,0 @@
|
||||
# Description: Enforces PR evidence and a Comfy human owner with trusted base-branch code.
|
||||
name: 'CI: PR Evidence'
|
||||
|
||||
on:
|
||||
pull_request_target:
|
||||
types:
|
||||
[
|
||||
opened,
|
||||
edited,
|
||||
synchronize,
|
||||
reopened,
|
||||
ready_for_review,
|
||||
assigned,
|
||||
unassigned
|
||||
]
|
||||
merge_group:
|
||||
types: [checks_requested]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
|
||||
concurrency:
|
||||
group: pr-evidence-${{ github.event.pull_request.number || github.event.merge_group.head_sha }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
pr-evidence:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout trusted validator
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
ref: ${{ github.workflow_sha }}
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version-file: '.nvmrc'
|
||||
|
||||
- name: Validate human maintainer assignment
|
||||
id: maintainer
|
||||
if: github.event_name == 'pull_request_target'
|
||||
continue-on-error: true
|
||||
env:
|
||||
COMFY_ORG_MEMBERS_READ_TOKEN: ${{ secrets.COMFY_ORG_MEMBERS_READ_TOKEN }}
|
||||
run: node scripts/cicd/validate-maintainer.ts "$GITHUB_EVENT_PATH"
|
||||
|
||||
- name: Validate pull request evidence
|
||||
id: evidence
|
||||
if: github.event_name == 'pull_request_target'
|
||||
continue-on-error: true
|
||||
run: node scripts/cicd/validate-evidence.ts pr "$GITHUB_EVENT_PATH"
|
||||
|
||||
- name: Enforce contribution policy
|
||||
if: >-
|
||||
github.event_name == 'pull_request_target' &&
|
||||
(steps.maintainer.outputs.valid != 'true' ||
|
||||
steps.evidence.outputs.valid != 'true')
|
||||
run: exit 1
|
||||
|
||||
- name: Validate current merge group metadata
|
||||
if: github.event_name == 'merge_group'
|
||||
env:
|
||||
COMFY_ORG_MEMBERS_READ_TOKEN: ${{ secrets.COMFY_ORG_MEMBERS_READ_TOKEN }}
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
run: node scripts/cicd/validate-merge-group.ts "$GITHUB_EVENT_PATH"
|
||||
88
.github/workflows/ci-tests-unit.yaml
vendored
88
.github/workflows/ci-tests-unit.yaml
vendored
@@ -38,6 +38,18 @@ jobs:
|
||||
- name: Run Vitest tests with coverage
|
||||
run: pnpm test:coverage
|
||||
|
||||
- name: Generate critical unit coverage artifact
|
||||
run: pnpm coverage:critical:extract
|
||||
|
||||
- name: Upload critical unit coverage artifact
|
||||
if: github.event_name != 'merge_group'
|
||||
uses: actions/upload-artifact@v6
|
||||
with:
|
||||
name: critical-unit-coverage-${{ github.sha }}
|
||||
path: coverage/critical-unit-coverage.json
|
||||
retention-days: 30
|
||||
if-no-files-found: error
|
||||
|
||||
- name: Upload unit coverage artifact
|
||||
if: always() && github.event_name == 'push'
|
||||
uses: actions/upload-artifact@v6
|
||||
@@ -55,3 +67,79 @@ jobs:
|
||||
flags: unit
|
||||
token: ${{ secrets.CODECOV_TOKEN }}
|
||||
fail_ci_if_error: false
|
||||
|
||||
critical-unit-regression:
|
||||
needs: test
|
||||
if: ${{ github.event_name == 'pull_request' && needs.test.result == 'success' }}
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
actions: read
|
||||
contents: read
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Setup frontend
|
||||
uses: ./.github/actions/setup-frontend
|
||||
|
||||
- name: Download head critical unit coverage
|
||||
uses: actions/download-artifact@v7
|
||||
with:
|
||||
name: critical-unit-coverage-${{ github.sha }}
|
||||
path: temp/head-critical-coverage
|
||||
|
||||
- name: Find base unit coverage run
|
||||
id: find-base-unit
|
||||
uses: ./.github/actions/find-workflow-run
|
||||
with:
|
||||
workflow-id: ci-tests-unit.yaml
|
||||
head-sha: ${{ github.event.pull_request.base.sha }}
|
||||
event: push
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Require base unit coverage run
|
||||
if: steps.find-base-unit.outputs.status != 'ready'
|
||||
run: |
|
||||
echo "Critical unit baseline artifact unavailable for base ${{ github.event.pull_request.base.sha }}." >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "Status: ${{ steps.find-base-unit.outputs.status }}" >> "$GITHUB_STEP_SUMMARY"
|
||||
exit 1
|
||||
|
||||
- name: Download base critical unit coverage
|
||||
continue-on-error: true
|
||||
uses: dawidd6/action-download-artifact@0bd50d53a6d7fb5cb921e607957e9cc12b4ce392 # v12
|
||||
with:
|
||||
name: critical-unit-coverage-${{ github.event.pull_request.base.sha }}
|
||||
run_id: ${{ steps.find-base-unit.outputs.run-id }}
|
||||
path: temp/base-critical-coverage
|
||||
if_no_artifact_found: warn
|
||||
|
||||
- name: Download base unit LCOV fallback
|
||||
continue-on-error: true
|
||||
uses: dawidd6/action-download-artifact@0bd50d53a6d7fb5cb921e607957e9cc12b4ce392 # v12
|
||||
with:
|
||||
name: unit-coverage
|
||||
run_id: ${{ steps.find-base-unit.outputs.run-id }}
|
||||
path: temp/base-unit-coverage
|
||||
if_no_artifact_found: warn
|
||||
|
||||
- name: Prepare base critical unit coverage fallback
|
||||
run: |
|
||||
if [ -f temp/base-critical-coverage/critical-unit-coverage.json ]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [ ! -f temp/base-unit-coverage/lcov.info ]; then
|
||||
echo "Critical unit baseline artifact missing for base ${{ github.event.pull_request.base.sha }}." >> "$GITHUB_STEP_SUMMARY"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
pnpm coverage:critical:extract \
|
||||
--input temp/base-unit-coverage/lcov.info \
|
||||
--output temp/base-critical-coverage/critical-unit-coverage.json \
|
||||
--sha=${{ github.event.pull_request.base.sha }}
|
||||
|
||||
- name: Compare critical unit coverage
|
||||
run: >
|
||||
pnpm coverage:critical:compare
|
||||
--base temp/base-critical-coverage/critical-unit-coverage.json
|
||||
--head temp/head-critical-coverage/critical-unit-coverage.json
|
||||
|
||||
3
.github/workflows/ci-website-build.yaml
vendored
3
.github/workflows/ci-website-build.yaml
vendored
@@ -40,6 +40,3 @@ jobs:
|
||||
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
|
||||
|
||||
- name: Validate JSON-LD structured data
|
||||
run: pnpm --filter @comfyorg/website validate:jsonld
|
||||
|
||||
4
.github/workflows/cla.yml
vendored
4
.github/workflows/cla.yml
vendored
@@ -38,11 +38,9 @@ jobs:
|
||||
PR_NUMBER: ${{ github.event.pull_request.number || github.event.issue.number }}
|
||||
PR_AUTHOR: ${{ github.event.pull_request.user.login || github.event.issue.user.login }}
|
||||
BASE_ALLOWLIST: action@github.com,actions-user,ampagent,claude,comfy-pr-bot,GitHub Action,github-actions,github-actions[bot],Glary Bot,Glary-Bot,*[bot]
|
||||
# For each commit emit the GitHub login when the author/committer email resolves to a GitHub account
|
||||
# otherwise fall back to the raw git name.
|
||||
run: |
|
||||
others=$(gh api "repos/${{ github.repository }}/pulls/${PR_NUMBER}/commits" --paginate \
|
||||
--jq '.[] | (.author.login // .commit.author.name // empty), (.committer.login // .commit.committer.name // empty)' \
|
||||
--jq '.[] | (.author.login // empty), (.committer.login // empty)' \
|
||||
| sort -u | grep -vix "${PR_AUTHOR}" | paste -sd, -)
|
||||
if [ -n "$others" ]; then
|
||||
echo "allowlist=${BASE_ALLOWLIST},${others}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
14
.github/workflows/i18n-update-nodes.yaml
vendored
14
.github/workflows/i18n-update-nodes.yaml
vendored
@@ -49,20 +49,6 @@ jobs:
|
||||
Automated PR to update locales for node definitions
|
||||
|
||||
This PR was created automatically by the frontend update workflow.
|
||||
|
||||
## Reproduction or validation steps
|
||||
1. Review the locale diff for the expected node-definition strings.
|
||||
2. Confirm locale generation and repository checks pass in CI.
|
||||
|
||||
## Evidence type
|
||||
- [ ] Visual
|
||||
- [x] Non-visual
|
||||
|
||||
## Non-visual rationale
|
||||
N/A: This automated locale data update has no visual behavior.
|
||||
|
||||
## Test or log evidence
|
||||
`pnpm locale` passed in workflow run `${{ github.run_id }}`.
|
||||
branch: update-locales-node-defs-${{ github.event.inputs.trigger_type }}-${{ github.run_id }}
|
||||
base: main
|
||||
labels: dependencies
|
||||
|
||||
91
.github/workflows/pr-backport.yaml
vendored
91
.github/workflows/pr-backport.yaml
vendored
@@ -278,49 +278,32 @@ jobs:
|
||||
continue
|
||||
fi
|
||||
|
||||
# Create backport branch. A failure here (e.g. dirty state left
|
||||
# by a prior target) must not abort the loop and skip remaining
|
||||
# targets, so fall back to a clean checkout and record the error.
|
||||
if ! git checkout -B "${BACKPORT_BRANCH}" "origin/${TARGET_BRANCH}"; then
|
||||
echo "::error::Failed to create branch ${BACKPORT_BRANCH} for ${TARGET_BRANCH}"
|
||||
FAILED="${FAILED}${TARGET_BRANCH}:branch-create-failed "
|
||||
git checkout main || git checkout -f main
|
||||
echo "::endgroup::"
|
||||
continue
|
||||
fi
|
||||
# Create backport branch
|
||||
git checkout -b "${BACKPORT_BRANCH}" "origin/${TARGET_BRANCH}"
|
||||
|
||||
# Try cherry-pick
|
||||
if git cherry-pick "${MERGE_COMMIT}"; then
|
||||
if [ "$REMOTE_BACKPORT_EXISTS" = true ]; then
|
||||
PUSH_CMD=(git push --force-with-lease origin "${BACKPORT_BRANCH}")
|
||||
git push --force-with-lease origin "${BACKPORT_BRANCH}"
|
||||
else
|
||||
PUSH_CMD=(git push origin "${BACKPORT_BRANCH}")
|
||||
git push origin "${BACKPORT_BRANCH}"
|
||||
fi
|
||||
|
||||
# A push failure for one target must not abort the loop and
|
||||
# prevent remaining targets from being attempted.
|
||||
if "${PUSH_CMD[@]}"; then
|
||||
echo "${BACKPORT_BRANCH}" >> "$CREATED_BRANCHES_FILE"
|
||||
SUCCESS="${SUCCESS}${TARGET_BRANCH}:${BACKPORT_BRANCH} "
|
||||
echo "Successfully created backport branch: ${BACKPORT_BRANCH}"
|
||||
else
|
||||
echo "::error::Failed to push ${BACKPORT_BRANCH} for ${TARGET_BRANCH}"
|
||||
FAILED="${FAILED}${TARGET_BRANCH}:push-failed "
|
||||
fi
|
||||
|
||||
echo "${BACKPORT_BRANCH}" >> "$CREATED_BRANCHES_FILE"
|
||||
SUCCESS="${SUCCESS}${TARGET_BRANCH}:${BACKPORT_BRANCH} "
|
||||
echo "Successfully created backport branch: ${BACKPORT_BRANCH}"
|
||||
# Return to main (keep the branch, we need it for PR)
|
||||
git checkout main || git checkout -f main
|
||||
git checkout main
|
||||
else
|
||||
# Get conflict info
|
||||
CONFLICTS=$(git diff --name-only --diff-filter=U | tr '\n' ',')
|
||||
git cherry-pick --abort || true
|
||||
git cherry-pick --abort
|
||||
|
||||
echo "::error::Cherry-pick failed due to conflicts"
|
||||
FAILED="${FAILED}${TARGET_BRANCH}:conflicts:${CONFLICTS} "
|
||||
|
||||
# Clean up the failed branch
|
||||
git checkout main || git checkout -f main
|
||||
git branch -D "${BACKPORT_BRANCH}" || true
|
||||
git checkout main
|
||||
git branch -D "${BACKPORT_BRANCH}"
|
||||
fi
|
||||
|
||||
echo "::endgroup::"
|
||||
@@ -348,34 +331,22 @@ jobs:
|
||||
run: |
|
||||
# Get PR data for manual triggers
|
||||
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
|
||||
PR_DATA=$(gh pr view ${{ inputs.pr_number }} --json title,author,body)
|
||||
PR_DATA=$(gh pr view ${{ inputs.pr_number }} --json title,author)
|
||||
PR_TITLE=$(echo "$PR_DATA" | jq -r '.title')
|
||||
PR_AUTHOR=$(echo "$PR_DATA" | jq -r '.author.login')
|
||||
PR_SOURCE_BODY=$(echo "$PR_DATA" | jq -r '.body // ""')
|
||||
else
|
||||
PR_TITLE=$(jq -r '.pull_request.title' "$GITHUB_EVENT_PATH")
|
||||
PR_AUTHOR=$(jq -r '.pull_request.user.login' "$GITHUB_EVENT_PATH")
|
||||
PR_SOURCE_BODY=$(jq -r '.pull_request.body // ""' "$GITHUB_EVENT_PATH")
|
||||
fi
|
||||
|
||||
for backport in ${{ steps.backport.outputs.success }}; do
|
||||
IFS=':' read -r target branch <<< "${backport}"
|
||||
|
||||
PR_BODY=$(cat <<EOF
|
||||
Backport of #${PR_NUMBER} to \`${target}\`.
|
||||
|
||||
Automatically created by the backport workflow.
|
||||
|
||||
## Source pull request evidence
|
||||
${PR_SOURCE_BODY}
|
||||
EOF
|
||||
)
|
||||
|
||||
if PR_URL=$(gh pr create \
|
||||
--base "${target}" \
|
||||
--head "${branch}" \
|
||||
--title "[backport ${target}] ${PR_TITLE}" \
|
||||
--body "${PR_BODY}" \
|
||||
--body "Backport of #${PR_NUMBER} to \`${target}\`"$'\n\n'"Automatically created by backport workflow." \
|
||||
--label "backport" 2>&1); then
|
||||
|
||||
# Extract PR number from URL
|
||||
@@ -413,10 +384,6 @@ jobs:
|
||||
|
||||
**Reason:** Merge conflicts detected during cherry-pick of `${MERGE_COMMIT_SHORT}`
|
||||
|
||||
The auto-backport could not be completed automatically. Please backport
|
||||
manually onto branch `${BACKPORT_BRANCH}` (from `origin/${target}`) and
|
||||
open a PR to `${target}`.
|
||||
|
||||
<details>
|
||||
<summary>📄 Conflicting files</summary>
|
||||
|
||||
@@ -449,37 +416,19 @@ jobs:
|
||||
MERGE_COMMIT=$(jq -r '.pull_request.merge_commit_sha' "$GITHUB_EVENT_PATH")
|
||||
fi
|
||||
|
||||
# Post a comment without letting a single failed `gh pr comment` (e.g.
|
||||
# a locked issue, as happened for PR #13359, or a transient API error)
|
||||
# abort the step under `set -e` and swallow the remaining failures.
|
||||
post_comment() {
|
||||
local body="$1"
|
||||
local context="$2"
|
||||
if ! gh pr comment "${PR_NUMBER}" --body "${body}"; then
|
||||
echo "::warning::Could not comment on PR #${PR_NUMBER} about ${context}. Manual backport required."
|
||||
fi
|
||||
}
|
||||
|
||||
for failure in ${{ steps.backport.outputs.failed }}; do
|
||||
IFS=':' read -r target reason conflicts <<< "${failure}"
|
||||
|
||||
SAFE_TARGET=$(echo "$target" | tr '/' '-')
|
||||
BACKPORT_BRANCH="backport-${PR_NUMBER}-to-${SAFE_TARGET}"
|
||||
|
||||
if [ "${reason}" = "branch-missing" ]; then
|
||||
post_comment "@${PR_AUTHOR} Backport failed: Branch \`${target}\` does not exist" "missing branch ${target}"
|
||||
gh pr comment "${PR_NUMBER}" --body "@${PR_AUTHOR} Backport failed: Branch \`${target}\` does not exist"
|
||||
|
||||
elif [ "${reason}" = "already-exists" ]; then
|
||||
post_comment "@${PR_AUTHOR} Commit \`${MERGE_COMMIT}\` already exists on branch \`${target}\`. No backport needed." "already-backported ${target}"
|
||||
|
||||
elif [ "${reason}" = "branch-create-failed" ]; then
|
||||
gh pr comment "${PR_NUMBER}" --body "@${PR_AUTHOR} Backport to \`${target}\` failed: could not create the backport branch. Please retry or backport manually."
|
||||
|
||||
elif [ "${reason}" = "push-failed" ]; then
|
||||
gh pr comment "${PR_NUMBER}" --body "@${PR_AUTHOR} Backport to \`${target}\` cherry-picked cleanly but the push failed. Please retry or push the backport branch manually."
|
||||
gh pr comment "${PR_NUMBER}" --body "@${PR_AUTHOR} Commit \`${MERGE_COMMIT}\` already exists on branch \`${target}\`. No backport needed."
|
||||
|
||||
elif [ "${reason}" = "conflicts" ]; then
|
||||
CONFLICTS_INLINE=$(echo "${conflicts}" | tr ',' ' ')
|
||||
SAFE_TARGET=$(echo "$target" | tr '/' '-')
|
||||
BACKPORT_BRANCH="backport-${PR_NUMBER}-to-${SAFE_TARGET}"
|
||||
PR_URL="${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/pull/${PR_NUMBER}"
|
||||
|
||||
export PR_NUMBER PR_URL MERGE_COMMIT target BACKPORT_BRANCH CONFLICTS_INLINE
|
||||
@@ -495,10 +444,10 @@ jobs:
|
||||
CONFLICTS_BLOCK=$(echo "${conflicts}" | tr ',' '\n')
|
||||
MERGE_COMMIT_SHORT="${MERGE_COMMIT:0:7}"
|
||||
|
||||
export target MERGE_COMMIT_SHORT BACKPORT_BRANCH CONFLICTS_BLOCK AGENT_PROMPT PR_AUTHOR
|
||||
COMMENT_BODY=$(envsubst '${target} ${MERGE_COMMIT_SHORT} ${BACKPORT_BRANCH} ${CONFLICTS_BLOCK} ${AGENT_PROMPT} ${PR_AUTHOR}' <<<"$COMMENT_BODY_TEMPLATE")
|
||||
export target MERGE_COMMIT_SHORT CONFLICTS_BLOCK AGENT_PROMPT PR_AUTHOR
|
||||
COMMENT_BODY=$(envsubst '${target} ${MERGE_COMMIT_SHORT} ${CONFLICTS_BLOCK} ${AGENT_PROMPT} ${PR_AUTHOR}' <<<"$COMMENT_BODY_TEMPLATE")
|
||||
|
||||
post_comment "${COMMENT_BODY}" "cherry-pick conflict on ${target} (backport manually onto ${BACKPORT_BRANCH})"
|
||||
gh pr comment "${PR_NUMBER}" --body "${COMMENT_BODY}"
|
||||
fi
|
||||
done
|
||||
|
||||
|
||||
14
.github/workflows/release-version-bump.yaml
vendored
14
.github/workflows/release-version-bump.yaml
vendored
@@ -202,20 +202,6 @@ jobs:
|
||||
${{ steps.capitalised.outputs.capitalised }} version increment to ${{ steps.bump-version.outputs.NEW_VERSION }}
|
||||
|
||||
**Base branch:** `${{ steps.prepared-inputs.outputs.branch }}`
|
||||
|
||||
## Reproduction or validation steps
|
||||
1. Review the package and lockfile version changes.
|
||||
2. Confirm release checks pass for the target branch in CI.
|
||||
|
||||
## Evidence type
|
||||
- [ ] Visual
|
||||
- [x] Non-visual
|
||||
|
||||
## Non-visual rationale
|
||||
N/A: This automated version bump has no visual behavior.
|
||||
|
||||
## Test or log evidence
|
||||
`pnpm version ${{ steps.prepared-inputs.outputs.version_type }} --no-git-tag-version` passed in workflow run `${{ github.run_id }}`.
|
||||
branch: version-bump-${{ steps.bump-version.outputs.NEW_VERSION }}
|
||||
base: ${{ steps.prepared-inputs.outputs.branch }}
|
||||
labels: |
|
||||
|
||||
14
.github/workflows/release-website.yaml
vendored
14
.github/workflows/release-website.yaml
vendored
@@ -63,20 +63,6 @@ jobs:
|
||||
snapshot (with a warning annotation in CI).
|
||||
|
||||
Triggered by workflow run `${{ github.run_id }}`.
|
||||
|
||||
## Reproduction or validation steps
|
||||
1. Review both regenerated snapshot diffs for expected remote data.
|
||||
2. Confirm the Vercel website preview builds successfully.
|
||||
|
||||
## Evidence type
|
||||
- [ ] Visual
|
||||
- [x] Non-visual
|
||||
|
||||
## Non-visual rationale
|
||||
N/A: This automated snapshot refresh changes data files only.
|
||||
|
||||
## Test or log evidence
|
||||
Status: success for snapshot generation in workflow run `${{ github.run_id }}`.
|
||||
branch: chore/refresh-website-snapshots-${{ github.run_id }}
|
||||
base: main
|
||||
labels: |
|
||||
|
||||
14
.github/workflows/version-bump-desktop-ui.yaml
vendored
14
.github/workflows/version-bump-desktop-ui.yaml
vendored
@@ -86,20 +86,6 @@ jobs:
|
||||
${{ steps.capitalised.outputs.capitalised }} version increment for @comfyorg/desktop-ui to ${{ steps.bump-version.outputs.NEW_VERSION }}
|
||||
|
||||
**Base branch:** `${{ github.event.inputs.branch }}`
|
||||
|
||||
## Reproduction or validation steps
|
||||
1. Review the desktop UI package and lockfile version changes.
|
||||
2. Confirm desktop UI release checks pass for the target branch in CI.
|
||||
|
||||
## Evidence type
|
||||
- [ ] Visual
|
||||
- [x] Non-visual
|
||||
|
||||
## Non-visual rationale
|
||||
N/A: This automated package version bump has no visual behavior.
|
||||
|
||||
## Test or log evidence
|
||||
`pnpm -C apps/desktop-ui version ${{ github.event.inputs.version_type }} --no-git-tag-version` passed in workflow run `${{ github.run_id }}`.
|
||||
branch: desktop-ui-version-bump-${{ steps.bump-version.outputs.NEW_VERSION }}
|
||||
base: ${{ github.event.inputs.branch }}
|
||||
labels: |
|
||||
|
||||
21
.github/workflows/weekly-docs-check.yaml
vendored
21
.github/workflows/weekly-docs-check.yaml
vendored
@@ -84,7 +84,6 @@ jobs:
|
||||
- ## Review Notes section with any important context
|
||||
3. Be specific about which files were updated and why
|
||||
4. If no changes were needed, write a brief message stating documentation is up to date
|
||||
5. Do not add evidence-contract headings; the workflow appends those sections
|
||||
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||
claude_args: "--max-turns 256 --allowedTools 'Bash(git status),Bash(git diff),Bash(git log),Bash(pnpm:*),Bash(npm:*),Bash(node:*),Bash(tsc:*),Bash(echo:*),Read,Write,Edit,Glob,Grep'"
|
||||
continue-on-error: false
|
||||
@@ -128,26 +127,6 @@ jobs:
|
||||
EOF
|
||||
fi
|
||||
|
||||
- name: Add required PR evidence
|
||||
if: steps.check_changes.outputs.has_changes == 'true'
|
||||
run: |
|
||||
cat >> /tmp/pr-body-${{ github.run_id }}.md <<'EOF'
|
||||
|
||||
## Reproduction or validation steps
|
||||
1. Review each documentation diff against the current implementation.
|
||||
2. Confirm referenced commands and links work as documented.
|
||||
|
||||
## Evidence type
|
||||
- [ ] Visual
|
||||
- [x] Non-visual
|
||||
|
||||
## Non-visual rationale
|
||||
N/A: This automated review changes documentation only.
|
||||
|
||||
## Test or log evidence
|
||||
Status: success. The documentation review completed before this PR was created.
|
||||
EOF
|
||||
|
||||
- name: Create or Update Pull Request
|
||||
if: steps.check_changes.outputs.has_changes == 'true'
|
||||
uses: peter-evans/create-pull-request@c0f553fe549906ede9cf27b5156039d195d2ece0 # v8.1.0
|
||||
|
||||
@@ -258,11 +258,8 @@ The original litegraph repository (https://github.com/Comfy-Org/litegraph.js) is
|
||||
|
||||
1. Ensure your branch is up to date with main
|
||||
2. Run all tests and ensure they pass
|
||||
3. Create a pull request with a clear title, description, and exact reproduction or validation steps
|
||||
4. Include contribution evidence:
|
||||
- For visual behavior or presentation changes, add distinct Before and After screenshots or one screencast that demonstrates both states.
|
||||
- For a truly non-visual change, select Non-visual, add an `N/A:` rationale, and include the test command and result or relevant logs.
|
||||
5. Use conventional commit format for PR titles:
|
||||
3. Create a pull request with a clear title and description
|
||||
4. Use conventional commit format for PR titles:
|
||||
- `feat:` for new features
|
||||
- `fix:` for bug fixes
|
||||
- `docs:` for documentation
|
||||
@@ -273,51 +270,9 @@ The original litegraph repository (https://github.com/Comfy-Org/litegraph.js) is
|
||||
### Review Process
|
||||
|
||||
1. All PRs require at least one review
|
||||
2. Issues and pull requests under active execution require an active human member of the Comfy-Org `comfy_frontend_devs` team as an assignee. Automation and service accounts are executors, not accountable owners. Choose the person with the most context, using CODEOWNERS and recent changes to the related code path. The policy preserves existing assignees; COMOS performs contextual selection for artifacts it creates.
|
||||
3. Address review feedback promptly
|
||||
4. Keep PRs focused - one feature/fix per PR
|
||||
5. Large features should be discussed in an issue first
|
||||
|
||||
The `pr-evidence` check fails until both the evidence contract and human owner
|
||||
contract are satisfied. Issues receive `blocked: needs-evidence` and
|
||||
`blocked: needs-maintainer` labels as applicable, and the policy workflow fails
|
||||
until both conditions are fixed. Opaque GitHub attachment links in Screencast
|
||||
fields are fetched without credentials and accepted only when GitHub serves
|
||||
video content or an animated GIF. A static screenshot labeled as a screencast
|
||||
does not satisfy the check.
|
||||
|
||||
Repository administrators must configure the
|
||||
`COMFY_ORG_MEMBERS_READ_TOKEN` Actions secret before enabling `pr-evidence` in
|
||||
the ProtectMain or Core release branch rulesets. Use a dedicated fine-grained
|
||||
token with `Comfy-Org` organization `Members: read` permission, including team
|
||||
membership reads, and no repository write permission. The trusted workflows use
|
||||
it to verify the assignee's GitHub user type, active organization membership,
|
||||
and active `comfy_frontend_devs` team membership. They fail closed when the
|
||||
secret or any lookup is unavailable. Merge the workflow to the default branch
|
||||
and verify a successful check before making it required.
|
||||
|
||||
#### Contribution policy rollout
|
||||
|
||||
Do not make `pr-evidence` required when the workflow first lands. Use this
|
||||
sequence so existing open contributions are not blocked without warning:
|
||||
|
||||
1. Configure and verify `COMFY_ORG_MEMBERS_READ_TOKEN`.
|
||||
2. Merge the policy workflows while `pr-evidence` is not a required check. New
|
||||
and updated contributions still report failures and blocked labels, which is
|
||||
the audit signal.
|
||||
3. Observe a small canary across manual, fork, and automated contributions.
|
||||
Confirm assignment edits rerun the check and valid edits clear issue labels.
|
||||
4. Audit and backfill the active pull request and issue queues with evidence and
|
||||
a contextual Comfy-Org human assignee.
|
||||
5. Add `pr-evidence` to ProtectMain ruleset `991238` only after the active main
|
||||
queue is ready.
|
||||
6. Test a canary against both `core/**` and `cloud/**`. Add `pr-evidence` to Core
|
||||
release branches ruleset `7297873` only after both targets are confirmed to
|
||||
trigger the trusted workflow. Confirm the merge-group check refetches and
|
||||
validates current PR bodies and assignees before enabling the ruleset.
|
||||
|
||||
The workflow is intentionally fail closed during the audit phase. It remains
|
||||
non-blocking only because the rulesets do not require its check yet.
|
||||
2. Address review feedback promptly
|
||||
3. Keep PRs focused - one feature/fix per PR
|
||||
4. Large features should be discussed in an issue first
|
||||
|
||||
## Questions?
|
||||
|
||||
|
||||
@@ -76,14 +76,10 @@ test.describe('Affiliates landing — desktop interactions', () => {
|
||||
return match?.textContent ?? null
|
||||
})
|
||||
expect(faqJsonLd, 'FAQ JSON-LD script').not.toBeNull()
|
||||
const graph = JSON.parse(faqJsonLd!)['@graph'] as {
|
||||
'@type': string
|
||||
mainEntity?: unknown[]
|
||||
}[]
|
||||
const faqPage = graph.find((node) => node['@type'] === 'FAQPage')
|
||||
expect(faqPage, 'FAQPage node in @graph').toBeDefined()
|
||||
expect(Array.isArray(faqPage!.mainEntity)).toBe(true)
|
||||
expect(faqPage!.mainEntity!.length).toBe(FAQ_COUNT)
|
||||
const parsed = JSON.parse(faqJsonLd!)
|
||||
expect(parsed['@type']).toBe('FAQPage')
|
||||
expect(Array.isArray(parsed.mainEntity)).toBe(true)
|
||||
expect(parsed.mainEntity.length).toBe(FAQ_COUNT)
|
||||
})
|
||||
|
||||
test('Apply Now CTA opens the application form in a new tab', async ({
|
||||
|
||||
@@ -1,158 +0,0 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import { learningTutorials } from '../src/data/learningTutorials'
|
||||
import { t } from '../src/i18n/translations'
|
||||
import { test } from './fixtures/blockExternalMedia'
|
||||
|
||||
const tutorialButtonName = (title: string, locale: 'en' | 'zh-CN') =>
|
||||
`${t('learning.tutorials.titlePrefix', locale)} ${title}`
|
||||
|
||||
test.describe('Learning page @smoke', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/learning')
|
||||
})
|
||||
|
||||
test('has correct title', async ({ page }) => {
|
||||
await expect(page).toHaveTitle('Learning — Comfy')
|
||||
})
|
||||
|
||||
test('hero headline references ComfyUI', async ({ page }) => {
|
||||
const heading = page.getByRole('heading', { level: 1 })
|
||||
await expect(heading).toBeVisible()
|
||||
await expect(heading).toContainText(t('learning.heroTitle.before', 'en'))
|
||||
await expect(heading).toContainText('ComfyUI')
|
||||
await expect(heading).toContainText(t('learning.heroTitle.line2', 'en'))
|
||||
})
|
||||
|
||||
test('featured workflow section shows title and author', async ({ page }) => {
|
||||
await expect(
|
||||
page.getByRole('heading', {
|
||||
name: t('learning.featured.title', 'en'),
|
||||
level: 2
|
||||
})
|
||||
).toBeVisible()
|
||||
await expect(
|
||||
page.getByText(t('learning.featured.author', 'en'))
|
||||
).toBeVisible()
|
||||
})
|
||||
|
||||
test('renders every tutorial from the data source', async ({ page }) => {
|
||||
await expect(
|
||||
page.getByRole('heading', {
|
||||
name: t('learning.tutorials.heading', 'en'),
|
||||
level: 2
|
||||
})
|
||||
).toBeVisible()
|
||||
|
||||
for (const tutorial of learningTutorials) {
|
||||
await expect(
|
||||
page.getByRole('button', {
|
||||
name: tutorialButtonName(tutorial.title.en, 'en')
|
||||
})
|
||||
).toBeVisible()
|
||||
}
|
||||
})
|
||||
|
||||
test('tutorials with a workflow link expose an external Try Workflow link', async ({
|
||||
page
|
||||
}) => {
|
||||
const linkedTutorials = learningTutorials.filter(
|
||||
(tutorial) => tutorial.href
|
||||
)
|
||||
const workflowLinks = page.getByRole('link', {
|
||||
name: t('cta.tryWorkflow', 'en')
|
||||
})
|
||||
const hrefs = await workflowLinks.evaluateAll((links) =>
|
||||
links.map((link) => link.getAttribute('href'))
|
||||
)
|
||||
for (const tutorial of linkedTutorials) {
|
||||
expect(hrefs).toContain(tutorial.href)
|
||||
}
|
||||
})
|
||||
|
||||
test('call to action links to contact sales', async ({ page }) => {
|
||||
await expect(
|
||||
page.getByRole('heading', {
|
||||
name: t('learning.cta.heading', 'en'),
|
||||
level: 2
|
||||
})
|
||||
).toBeVisible()
|
||||
await expect(
|
||||
page.getByRole('link', { name: t('learning.cta.contactSales', 'en') })
|
||||
).toHaveAttribute('href', '/contact')
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Learning tutorial dialog', () => {
|
||||
test('opens a tutorial video and dismisses via the close button', async ({
|
||||
page
|
||||
}) => {
|
||||
const [firstTutorial] = learningTutorials
|
||||
await page.goto('/learning')
|
||||
|
||||
const openButton = page.getByRole('button', {
|
||||
name: tutorialButtonName(firstTutorial.title.en, 'en')
|
||||
})
|
||||
await openButton.scrollIntoViewIfNeeded()
|
||||
|
||||
const dialog = page.getByRole('dialog', { name: firstTutorial.title.en })
|
||||
// TutorialsSection is hydrated via `client:visible`; retry the click until
|
||||
// Vue responds by opening the dialog.
|
||||
await expect(async () => {
|
||||
await openButton.click()
|
||||
await expect(dialog).toBeVisible({ timeout: 1_000 })
|
||||
}).toPass({ timeout: 10_000 })
|
||||
|
||||
await expect(
|
||||
dialog.getByRole('heading', { level: 2, name: firstTutorial.title.en })
|
||||
).toBeVisible()
|
||||
|
||||
await dialog
|
||||
.getByRole('button', { name: t('gallery.detail.close', 'en') })
|
||||
.click()
|
||||
await expect(dialog).toBeHidden()
|
||||
})
|
||||
|
||||
test('dismisses the dialog with the Escape key', async ({ page }) => {
|
||||
const [firstTutorial] = learningTutorials
|
||||
await page.goto('/learning')
|
||||
|
||||
const openButton = page.getByRole('button', {
|
||||
name: tutorialButtonName(firstTutorial.title.en, 'en')
|
||||
})
|
||||
await openButton.scrollIntoViewIfNeeded()
|
||||
|
||||
const dialog = page.getByRole('dialog', { name: firstTutorial.title.en })
|
||||
await expect(async () => {
|
||||
await openButton.click()
|
||||
await expect(dialog).toBeVisible({ timeout: 1_000 })
|
||||
}).toPass({ timeout: 10_000 })
|
||||
|
||||
await page.keyboard.press('Escape')
|
||||
await expect(dialog).toBeHidden()
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Learning page (zh-CN) @smoke', () => {
|
||||
test('renders localized title, headings, and tutorials', async ({ page }) => {
|
||||
await page.goto('/zh-CN/learning')
|
||||
|
||||
await expect(page).toHaveTitle('学习 — Comfy')
|
||||
await expect(page.getByRole('heading', { level: 1 })).toContainText(
|
||||
/[一-鿿]/
|
||||
)
|
||||
await expect(
|
||||
page.getByRole('heading', {
|
||||
name: t('learning.tutorials.heading', 'zh-CN'),
|
||||
level: 2
|
||||
})
|
||||
).toBeVisible()
|
||||
|
||||
const [firstTutorial] = learningTutorials
|
||||
await expect(
|
||||
page.getByRole('button', {
|
||||
name: tutorialButtonName(firstTutorial.title['zh-CN'], 'zh-CN')
|
||||
})
|
||||
).toBeVisible()
|
||||
})
|
||||
})
|
||||
@@ -17,8 +17,7 @@
|
||||
"test:visual:update": "playwright test --project visual --update-snapshots",
|
||||
"ashby:refresh-snapshot": "tsx ./scripts/refresh-ashby-snapshot.ts",
|
||||
"cloud-nodes:refresh-snapshot": "tsx ./scripts/refresh-cloud-nodes-snapshot.ts",
|
||||
"generate:models": "tsx ./scripts/generate-models.ts",
|
||||
"validate:jsonld": "tsx ./scripts/validate-jsonld.ts"
|
||||
"generate:models": "tsx ./scripts/generate-models.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@astrojs/sitemap": "catalog:",
|
||||
|
||||
@@ -1,129 +0,0 @@
|
||||
import { readFileSync, readdirSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
|
||||
import { collectGraphIds } from '../src/utils/jsonLd'
|
||||
|
||||
const DIST_DIR = join(process.cwd(), 'dist')
|
||||
const JSON_LD_BLOCK =
|
||||
/<script[^>]*type=["']application\/ld\+json["'][^>]*>([\s\S]*?)<\/script>/gi
|
||||
|
||||
interface Violation {
|
||||
file: string
|
||||
message: string
|
||||
}
|
||||
|
||||
function htmlFiles(dir: string): string[] {
|
||||
return readdirSync(dir, { recursive: true })
|
||||
.map(String)
|
||||
.filter((entry) => entry.endsWith('.html'))
|
||||
.map((entry) => join(dir, entry))
|
||||
}
|
||||
|
||||
function typesOf(node: Record<string, unknown>): string[] {
|
||||
const type = node['@type']
|
||||
if (typeof type === 'string') return [type]
|
||||
if (Array.isArray(type)) {
|
||||
return type.filter((t): t is string => typeof t === 'string')
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
function hasValidPrice(node: Record<string, unknown>): boolean {
|
||||
const price = node.price
|
||||
const priceStr = price == null ? '' : String(price).trim()
|
||||
return priceStr !== '' && !Number.isNaN(Number(priceStr))
|
||||
}
|
||||
|
||||
function checkHonesty(
|
||||
value: unknown,
|
||||
file: string,
|
||||
violations: Violation[]
|
||||
): void {
|
||||
const walk = (node: unknown): void => {
|
||||
if (Array.isArray(node)) {
|
||||
node.forEach(walk)
|
||||
return
|
||||
}
|
||||
if (!node || typeof node !== 'object') return
|
||||
const record = node as Record<string, unknown>
|
||||
const types = typesOf(record)
|
||||
if (types.includes('Review') || types.includes('AggregateRating')) {
|
||||
violations.push({
|
||||
file,
|
||||
message: `dishonest node type ${types.join('/')}`
|
||||
})
|
||||
}
|
||||
if ('aggregateRating' in record || 'review' in record) {
|
||||
violations.push({
|
||||
file,
|
||||
message: 'node carries a review/aggregateRating'
|
||||
})
|
||||
}
|
||||
if (
|
||||
types.includes('Offer') &&
|
||||
(!hasValidPrice(record) || !record.priceCurrency)
|
||||
) {
|
||||
violations.push({
|
||||
file,
|
||||
message: 'Offer missing priceCurrency or a concrete price'
|
||||
})
|
||||
}
|
||||
Object.values(record).forEach(walk)
|
||||
}
|
||||
walk(value)
|
||||
}
|
||||
|
||||
function validateFile(file: string): Violation[] {
|
||||
const html = readFileSync(file, 'utf8')
|
||||
const violations: Violation[] = []
|
||||
const definedIds = new Set<string>()
|
||||
const referencedIds: string[] = []
|
||||
|
||||
for (const match of html.matchAll(JSON_LD_BLOCK)) {
|
||||
let parsed: unknown
|
||||
try {
|
||||
parsed = JSON.parse(match[1])
|
||||
} catch (error) {
|
||||
violations.push({ file, message: `invalid JSON-LD: ${String(error)}` })
|
||||
continue
|
||||
}
|
||||
checkHonesty(parsed, file, violations)
|
||||
const { defined, references } = collectGraphIds(parsed)
|
||||
defined.forEach((id) => definedIds.add(id))
|
||||
referencedIds.push(...references)
|
||||
}
|
||||
|
||||
for (const id of referencedIds) {
|
||||
if (!definedIds.has(id)) {
|
||||
violations.push({ file, message: `unresolved @id reference: ${id}` })
|
||||
}
|
||||
}
|
||||
|
||||
return violations
|
||||
}
|
||||
|
||||
function main(): void {
|
||||
const files = htmlFiles(DIST_DIR)
|
||||
|
||||
if (files.length === 0) {
|
||||
console.error(
|
||||
`JSON-LD validation found no HTML in ${DIST_DIR} — build first.`
|
||||
)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const violations = files.flatMap(validateFile)
|
||||
if (violations.length > 0) {
|
||||
console.error(`JSON-LD validation failed (${violations.length} issue(s)):`)
|
||||
for (const { file, message } of violations) {
|
||||
console.error(` ${file.replace(DIST_DIR, 'dist')}: ${message}`)
|
||||
}
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
process.stdout.write(
|
||||
`JSON-LD validation passed across ${files.length} page(s).\n`
|
||||
)
|
||||
}
|
||||
|
||||
main()
|
||||
@@ -1,41 +0,0 @@
|
||||
import type { Meta, StoryObj } from '@storybook/vue3-vite'
|
||||
|
||||
import HeroBackdrop01 from './HeroBackdrop01.vue'
|
||||
|
||||
const sampleImage =
|
||||
'https://images.unsplash.com/photo-1451187580459-43490279c0fa?auto=format&fit=crop&w=1600&q=80'
|
||||
|
||||
const meta: Meta<typeof HeroBackdrop01> = {
|
||||
title: 'Website/Blocks/HeroBackdrop01',
|
||||
component: HeroBackdrop01,
|
||||
tags: ['autodocs'],
|
||||
args: {
|
||||
backdrop: { type: 'image', src: sampleImage, alt: 'Abstract gradient' },
|
||||
title: 'Build anything\nwith ComfyUI',
|
||||
subtitle:
|
||||
'A powerful, modular visual interface for building and running AI workflows.'
|
||||
}
|
||||
}
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
export const Default: Story = {}
|
||||
|
||||
export const WithBadge: Story = {
|
||||
args: {
|
||||
badgeText: 'New'
|
||||
}
|
||||
}
|
||||
|
||||
export const WithFootnote: Story = {
|
||||
args: {
|
||||
footnote: 'Available on Windows, macOS, and Linux.'
|
||||
}
|
||||
}
|
||||
|
||||
export const NoBackdrop: Story = {
|
||||
args: {
|
||||
backdrop: undefined
|
||||
}
|
||||
}
|
||||
@@ -1,193 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
import { computed } from 'vue'
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
|
||||
import { prefersReducedMotion } from '../../composables/useReducedMotion'
|
||||
import ProductHeroBadge from '../common/ProductHeroBadge.vue'
|
||||
|
||||
type Backdrop =
|
||||
| { type: 'image'; src: string; alt?: string }
|
||||
| { type: 'video'; src: string; poster?: string; alt?: string }
|
||||
|
||||
const {
|
||||
backdrop,
|
||||
mobileBackdrop,
|
||||
badgeText,
|
||||
badgeLogoSrc,
|
||||
badgeLogoAlt,
|
||||
title,
|
||||
subtitle,
|
||||
footnote,
|
||||
class: className
|
||||
} = defineProps<{
|
||||
backdrop?: Backdrop
|
||||
mobileBackdrop?: Backdrop
|
||||
badgeText?: string
|
||||
badgeLogoSrc?: string
|
||||
badgeLogoAlt?: string
|
||||
title: string
|
||||
subtitle?: string
|
||||
footnote?: string
|
||||
class?: HTMLAttributes['class']
|
||||
}>()
|
||||
|
||||
// Respect prefers-reduced-motion: don't autoplay the looping backdrop video
|
||||
// (WCAG 2.2.2). The paused video falls back to its poster/first frame.
|
||||
const reduceMotion = computed(() => prefersReducedMotion())
|
||||
|
||||
// Removing the reactive `autoplay` attribute only suppresses the *initial*
|
||||
// play; it can't pause a video the browser has already started. That is
|
||||
// exactly the SSR case: the server renders `autoplay` (it can't read the
|
||||
// client's motion preference), the browser begins playback on parse, and the
|
||||
// post-hydration attribute removal is too late. Pause on mount so
|
||||
// reduced-motion users get the poster frame instead of a looping video.
|
||||
const pauseIfReduced = (el: unknown) => {
|
||||
if (el instanceof HTMLVideoElement && reduceMotion.value) el.pause()
|
||||
}
|
||||
|
||||
// On mobile the backdrop is an in-flow rounded card above the content; on
|
||||
// desktop it is the full-bleed background behind it. A single element serves
|
||||
// both roles via responsive classes — mobileBackdrop only swaps the source.
|
||||
const sharedBackdropClass =
|
||||
'relative aspect-3/2 w-full rounded-3xl object-cover lg:absolute lg:inset-0 lg:aspect-auto lg:size-full lg:rounded-none'
|
||||
|
||||
// When both breakpoints use images, serve them from a single responsive <img>
|
||||
// so the browser fetches only the source matching the viewport. Two
|
||||
// `hidden`/`lg:hidden`-toggled <img> layers would each download (display:none
|
||||
// does not stop the fetch), doubling the high-priority load on an
|
||||
// LCP-critical hero. Videos or a mixed image/video pair can't collapse this
|
||||
// way and fall back to breakpoint-toggled layers below.
|
||||
const responsiveImage = computed(() => {
|
||||
if (backdrop?.type !== 'image') return null
|
||||
if (mobileBackdrop && mobileBackdrop.type !== 'image') return null
|
||||
const base = mobileBackdrop ?? backdrop
|
||||
return {
|
||||
src: base.src,
|
||||
alt: backdrop.alt ?? mobileBackdrop?.alt ?? '',
|
||||
// Larger-viewport source; omitted when one image serves both breakpoints.
|
||||
desktopSrc: mobileBackdrop ? backdrop.src : undefined
|
||||
}
|
||||
})
|
||||
|
||||
// Fallback for videos and mixed image/video pairs: toggle assets by breakpoint.
|
||||
const backdropLayers = computed(() => {
|
||||
if (!backdrop) return []
|
||||
if (mobileBackdrop) {
|
||||
return [
|
||||
{
|
||||
backdrop: mobileBackdrop,
|
||||
class: 'relative aspect-3/2 w-full rounded-3xl object-cover lg:hidden'
|
||||
},
|
||||
{
|
||||
backdrop,
|
||||
class: 'absolute inset-0 hidden size-full object-cover lg:block'
|
||||
}
|
||||
]
|
||||
}
|
||||
return [{ backdrop, class: sharedBackdropClass }]
|
||||
})
|
||||
|
||||
const scrimShape = 'farthest-side at 50% 50%'
|
||||
const scrimStyle = {
|
||||
background: `radial-gradient(${scrimShape}, color-mix(in srgb, var(--color-primary-warm-white) 80%, transparent) 0%, transparent 80%)`,
|
||||
maskImage: `radial-gradient(${scrimShape}, #000 45%, transparent 90%)`,
|
||||
WebkitMaskImage: `radial-gradient(${scrimShape}, #000 45%, transparent 90%)`
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section
|
||||
:class="cn('max-w-9xl mx-auto px-4 pt-4 lg:px-6 lg:pt-6', className)"
|
||||
>
|
||||
<div class="relative overflow-hidden rounded-3xl">
|
||||
<slot name="backdrop">
|
||||
<picture v-if="responsiveImage" class="contents">
|
||||
<source
|
||||
v-if="responsiveImage.desktopSrc"
|
||||
:srcset="responsiveImage.desktopSrc"
|
||||
media="(min-width: 1024px)"
|
||||
/>
|
||||
<img
|
||||
:src="responsiveImage.src"
|
||||
:alt="responsiveImage.alt"
|
||||
fetchpriority="high"
|
||||
decoding="async"
|
||||
:class="sharedBackdropClass"
|
||||
/>
|
||||
</picture>
|
||||
|
||||
<template v-else>
|
||||
<template v-for="(layer, i) in backdropLayers" :key="i">
|
||||
<video
|
||||
v-if="layer.backdrop.type === 'video'"
|
||||
:ref="pauseIfReduced"
|
||||
:src="layer.backdrop.src"
|
||||
:poster="layer.backdrop.poster"
|
||||
:aria-label="layer.backdrop.alt"
|
||||
:aria-hidden="layer.backdrop.alt ? undefined : true"
|
||||
:autoplay="!reduceMotion"
|
||||
loop
|
||||
muted
|
||||
playsinline
|
||||
preload="metadata"
|
||||
:class="layer.class"
|
||||
/>
|
||||
<img
|
||||
v-else
|
||||
:src="layer.backdrop.src"
|
||||
:alt="layer.backdrop.alt ?? ''"
|
||||
fetchpriority="high"
|
||||
decoding="async"
|
||||
:class="layer.class"
|
||||
/>
|
||||
</template>
|
||||
</template>
|
||||
</slot>
|
||||
|
||||
<div
|
||||
class="relative flex flex-col justify-center px-0 pt-6 pb-8 lg:min-h-176 lg:px-16 lg:py-24"
|
||||
>
|
||||
<div class="relative w-full max-w-xl">
|
||||
<div
|
||||
aria-hidden="true"
|
||||
class="pointer-events-none absolute -inset-12 hidden backdrop-blur-md lg:-inset-16 lg:block"
|
||||
:style="scrimStyle"
|
||||
/>
|
||||
|
||||
<div class="relative">
|
||||
<ProductHeroBadge
|
||||
v-if="badgeText"
|
||||
:text="badgeText"
|
||||
:logo-src="badgeLogoSrc"
|
||||
:logo-alt="badgeLogoAlt"
|
||||
/>
|
||||
|
||||
<h1
|
||||
class="mt-10 text-4xl/tight font-light tracking-tight whitespace-pre-line text-primary-comfy-canvas lg:text-6xl/tight lg:text-primary-comfy-ink"
|
||||
>
|
||||
{{ title }}
|
||||
</h1>
|
||||
|
||||
<p
|
||||
v-if="subtitle"
|
||||
class="mt-8 max-w-md text-base text-primary-comfy-canvas lg:text-lg lg:text-primary-comfy-ink"
|
||||
>
|
||||
{{ subtitle }}
|
||||
</p>
|
||||
|
||||
<p
|
||||
v-if="footnote"
|
||||
class="mt-10 text-sm text-primary-comfy-canvas lg:text-primary-comfy-ink"
|
||||
>
|
||||
{{ footnote }}
|
||||
</p>
|
||||
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
@@ -1,12 +0,0 @@
|
||||
---
|
||||
import type { JsonLdGraph } from '../../utils/jsonLd'
|
||||
import { escapeJsonLd } from '../../utils/escapeJsonLd'
|
||||
|
||||
interface Props {
|
||||
graph: JsonLdGraph
|
||||
}
|
||||
|
||||
const { graph } = Astro.props
|
||||
---
|
||||
|
||||
<script is:inline type="application/ld+json" set:html={escapeJsonLd(graph)} />
|
||||
@@ -86,7 +86,6 @@ const companyColumn: { title: string; links: FooterLink[] } = {
|
||||
{ label: t('footer.about', locale), href: routes.about },
|
||||
{ label: t('nav.careers', locale), href: routes.careers },
|
||||
{ label: t('footer.termsOfService', locale), href: routes.termsOfService },
|
||||
{ label: t('footer.enterpriseMsa', locale), href: routes.enterpriseMsa },
|
||||
{ label: t('footer.privacyPolicy', locale), href: routes.privacyPolicy }
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import type { Locale, TranslationKey } from '../../i18n/translations'
|
||||
|
||||
import { localizeHref } from '../../config/routes'
|
||||
import { t } from '../../i18n/translations'
|
||||
|
||||
const {
|
||||
@@ -16,7 +15,8 @@ const {
|
||||
locale?: Locale
|
||||
}>()
|
||||
|
||||
const nextHref = localizeHref(`/demos/${nextSlug}`, locale)
|
||||
const localePrefix = locale === 'en' ? '' : `/${locale}`
|
||||
const nextHref = `${localePrefix}/demos/${nextSlug}`
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
||||
@@ -1,46 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { getRoutes } from '../../config/routes'
|
||||
import { hasKey, translationKeys } from '../../i18n/translations'
|
||||
|
||||
const PREFIX = 'enterprise-msa'
|
||||
|
||||
function deriveMsaSectionIds(): string[] {
|
||||
const labelRegex = new RegExp(`^${PREFIX}\\.([0-9]+-[a-z-]+)\\.label$`)
|
||||
const ids: string[] = []
|
||||
for (const key of translationKeys) {
|
||||
const match = key.match(labelRegex)
|
||||
if (match && !ids.includes(match[1])) ids.push(match[1])
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
describe('enterprise MSA i18n', () => {
|
||||
it('every derived section has a title and at least one block', () => {
|
||||
const sectionIds = deriveMsaSectionIds()
|
||||
expect(sectionIds.length).toBeGreaterThan(0)
|
||||
for (const id of sectionIds) {
|
||||
expect(hasKey(`${PREFIX}.${id}.title`)).toBe(true)
|
||||
expect(hasKey(`${PREFIX}.${id}.block.0`)).toBe(true)
|
||||
}
|
||||
})
|
||||
|
||||
it('exposes the page-chrome keys the .astro file references', () => {
|
||||
for (const suffix of [
|
||||
'effective-date',
|
||||
'page.title',
|
||||
'page.description',
|
||||
'page.heading',
|
||||
'page.tocLabel',
|
||||
'page.effectiveDateLabel',
|
||||
'page.parties'
|
||||
]) {
|
||||
expect(hasKey(`${PREFIX}.${suffix}`)).toBe(true)
|
||||
}
|
||||
})
|
||||
|
||||
it('serves the enterprise MSA at the canonical /enterprise-msa path regardless of locale', () => {
|
||||
expect(getRoutes('en').enterpriseMsa).toBe('/enterprise-msa')
|
||||
expect(getRoutes('zh-CN').enterpriseMsa).toBe('/enterprise-msa')
|
||||
})
|
||||
})
|
||||
@@ -1,10 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
import { Check, Copy } from '@lucide/vue'
|
||||
import { useClipboard } from '@vueuse/core'
|
||||
|
||||
import { computed } from 'vue'
|
||||
|
||||
// Interactive: the copy button is inert until its host island is hydrated.
|
||||
// Render under a `client:*` directive (e.g. `client:visible`) when the page
|
||||
// needs it to work.
|
||||
@@ -14,8 +11,6 @@ const {
|
||||
copiedLabel = 'Copied'
|
||||
} = defineProps<{ value: string; copyLabel?: string; copiedLabel?: string }>()
|
||||
|
||||
const multiline = computed(() => value.includes('\n'))
|
||||
|
||||
const { copy, copied } = useClipboard({ copiedDuring: 2000 })
|
||||
|
||||
function handleCopy() {
|
||||
@@ -25,32 +20,15 @@ function handleCopy() {
|
||||
|
||||
<template>
|
||||
<div
|
||||
:class="
|
||||
cn(
|
||||
'bg-transparency-white-t4 border-primary-warm-gray flex gap-2 rounded-xl border px-4 py-3',
|
||||
multiline ? 'items-start' : 'items-center'
|
||||
)
|
||||
"
|
||||
class="bg-transparency-white-t4 border-primary-warm-gray flex items-center gap-2 rounded-xl border px-4 py-3"
|
||||
>
|
||||
<span
|
||||
:class="
|
||||
cn(
|
||||
'flex-1 font-mono text-xs text-primary-comfy-canvas',
|
||||
multiline ? 'wrap-break-word whitespace-pre-line' : 'truncate'
|
||||
)
|
||||
"
|
||||
>
|
||||
<span class="flex-1 truncate font-mono text-xs text-primary-comfy-canvas">
|
||||
{{ value }}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
:aria-label="copied ? copiedLabel : copyLabel"
|
||||
:class="
|
||||
cn(
|
||||
'text-primary-warm-gray shrink-0 cursor-pointer transition-colors hover:text-primary-comfy-canvas',
|
||||
multiline && 'mt-0.5'
|
||||
)
|
||||
"
|
||||
class="text-primary-warm-gray shrink-0 cursor-pointer transition-colors hover:text-primary-comfy-canvas"
|
||||
@click="handleCopy"
|
||||
>
|
||||
<component :is="copied ? Check : Copy" class="size-4" />
|
||||
|
||||
@@ -1,31 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { isHrefActive } from './useCurrentPath'
|
||||
|
||||
describe('isHrefActive', () => {
|
||||
it('matches the current page', () => {
|
||||
expect(isHrefActive('/mcp', '/mcp')).toBe(true)
|
||||
})
|
||||
|
||||
it('does not match other pages', () => {
|
||||
expect(isHrefActive('/mcp', '/pricing')).toBe(false)
|
||||
})
|
||||
|
||||
it('matches regardless of a trailing slash', () => {
|
||||
expect(isHrefActive('/mcp', '/mcp/')).toBe(true)
|
||||
})
|
||||
|
||||
it('ignores query and hash on the href', () => {
|
||||
expect(isHrefActive('/mcp?ref=banner#setup', '/mcp')).toBe(true)
|
||||
})
|
||||
|
||||
it('never matches an external href', () => {
|
||||
expect(
|
||||
isHrefActive('https://docs.comfy.org/agent-tools/cloud', '/mcp')
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('never matches an empty href', () => {
|
||||
expect(isHrefActive('', '/mcp')).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -3,7 +3,6 @@ import type { Locale, TranslationKey } from '../i18n/translations'
|
||||
|
||||
import { t } from '../i18n/translations'
|
||||
import { resolveRel } from '../utils/cta'
|
||||
import { localizeHref } from './routes'
|
||||
|
||||
// The banner "CMS": a single typed config resolved through i18n at build time.
|
||||
// `isActive` is the master on/off switch (supersedes the old SHOW_ANNOUNCEMENT_BANNER).
|
||||
@@ -74,7 +73,7 @@ export function getBannerData(
|
||||
: undefined,
|
||||
link: link
|
||||
? {
|
||||
href: localizeHref(link.href, locale),
|
||||
href: link.href,
|
||||
title: t(link.titleKey, locale),
|
||||
target,
|
||||
rel: resolveRel({ target: target ?? '_self' }),
|
||||
|
||||
@@ -1,53 +0,0 @@
|
||||
import { t } from '../i18n/translations'
|
||||
import type { Locale, TranslationKey } from '../i18n/translations'
|
||||
import { externalLinks } from './routes'
|
||||
|
||||
interface PricingTier {
|
||||
slug: string
|
||||
labelKey: TranslationKey
|
||||
priceKey: TranslationKey
|
||||
}
|
||||
|
||||
const tiers: PricingTier[] = [
|
||||
{
|
||||
slug: 'standard',
|
||||
labelKey: 'pricing.plan.standard.label',
|
||||
priceKey: 'pricing.plan.standard.price'
|
||||
},
|
||||
{
|
||||
slug: 'creator',
|
||||
labelKey: 'pricing.plan.creator.label',
|
||||
priceKey: 'pricing.plan.creator.price'
|
||||
},
|
||||
{
|
||||
slug: 'pro',
|
||||
labelKey: 'pricing.plan.pro.label',
|
||||
priceKey: 'pricing.plan.pro.price'
|
||||
}
|
||||
]
|
||||
|
||||
export interface PricingOffer {
|
||||
name: string
|
||||
price: string
|
||||
url: string
|
||||
}
|
||||
|
||||
export function pricingOffers(locale: Locale): PricingOffer[] {
|
||||
return tiers.flatMap((tier) => {
|
||||
const display = t(tier.priceKey, locale).trim()
|
||||
const match = /^\$(\d+(?:\.\d+)?)$/.exec(display)
|
||||
if (!match) {
|
||||
console.warn(
|
||||
`pricingOffers: skipping tier "${tier.slug}" (${locale}) — price "${display}" is not a plain USD amount`
|
||||
)
|
||||
return []
|
||||
}
|
||||
return [
|
||||
{
|
||||
name: t(tier.labelKey, locale),
|
||||
price: match[1],
|
||||
url: `${externalLinks.cloud}/cloud/subscribe?tier=${tier.slug}&cycle=monthly`
|
||||
}
|
||||
]
|
||||
})
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { localizeHref } from './routes'
|
||||
|
||||
describe('localizeHref', () => {
|
||||
it('prefixes an internal path for a non-default locale', () => {
|
||||
expect(localizeHref('/mcp', 'zh-CN')).toBe('/zh-CN/mcp')
|
||||
})
|
||||
|
||||
it('leaves the default locale unprefixed', () => {
|
||||
expect(localizeHref('/mcp', 'en')).toBe('/mcp')
|
||||
})
|
||||
|
||||
it('passes external URLs through unchanged', () => {
|
||||
expect(
|
||||
localizeHref('https://docs.comfy.org/agent-tools/cloud', 'zh-CN')
|
||||
).toBe('https://docs.comfy.org/agent-tools/cloud')
|
||||
})
|
||||
|
||||
it('never prefixes locale-invariant routes', () => {
|
||||
expect(localizeHref('/terms-of-service', 'zh-CN')).toBe('/terms-of-service')
|
||||
})
|
||||
})
|
||||
@@ -15,7 +15,6 @@ const baseRoutes = {
|
||||
demos: '/demos',
|
||||
learning: '/learning',
|
||||
termsOfService: '/terms-of-service',
|
||||
enterpriseMsa: '/enterprise-msa',
|
||||
privacyPolicy: '/privacy-policy',
|
||||
affiliates: '/affiliates',
|
||||
affiliateTerms: '/affiliates/terms',
|
||||
@@ -36,37 +35,19 @@ type Routes = typeof baseRoutes
|
||||
// block in src/i18n/translations.ts for the reasoning.
|
||||
//
|
||||
// termsOfService: legal-reviewed English-only document, same reasoning.
|
||||
//
|
||||
// enterpriseMsa: legal-reviewed English-only document (Comfy Enterprise
|
||||
// Customer Agreement template), same reasoning. See the comment header
|
||||
// in src/pages/enterprise-msa.astro.
|
||||
const LOCALE_INVARIANT_ROUTE_KEYS = new Set<keyof Routes>([
|
||||
'affiliates',
|
||||
'affiliateTerms',
|
||||
'termsOfService',
|
||||
'enterpriseMsa'
|
||||
'termsOfService'
|
||||
])
|
||||
|
||||
const LOCALE_INVARIANT_PATHS = new Set<string>(
|
||||
[...LOCALE_INVARIANT_ROUTE_KEYS].map((key) => baseRoutes[key])
|
||||
)
|
||||
|
||||
/**
|
||||
* Prefix an internal path with the locale (`/mcp` → `/zh-CN/mcp`). External
|
||||
* URLs and locale-invariant routes pass through unchanged.
|
||||
*/
|
||||
export function localizeHref(href: string, locale: Locale = 'en'): string {
|
||||
if (locale === 'en' || !href.startsWith('/')) return href
|
||||
if (LOCALE_INVARIANT_PATHS.has(href)) return href
|
||||
return `/${locale}${href}`
|
||||
}
|
||||
|
||||
export function getRoutes(locale: Locale = 'en'): Routes {
|
||||
if (locale === 'en') return baseRoutes
|
||||
const prefix = `/${locale}`
|
||||
return Object.fromEntries(
|
||||
Object.entries(baseRoutes).map(([key, path]) => [
|
||||
key,
|
||||
localizeHref(path, locale)
|
||||
Object.entries(baseRoutes).map(([k, v]) => [
|
||||
k,
|
||||
LOCALE_INVARIANT_ROUTE_KEYS.has(k as keyof Routes) ? v : `${prefix}${v}`
|
||||
])
|
||||
) as unknown as Routes
|
||||
}
|
||||
@@ -79,22 +60,18 @@ export const externalLinks = {
|
||||
cloudStatus: 'https://status.comfy.org',
|
||||
discord: 'https://discord.com/invite/comfyorg',
|
||||
docs: 'https://docs.comfy.org/',
|
||||
docsApi: 'https://docs.comfy.org/development/cloud/overview#quick-start',
|
||||
docsApi: 'https://docs.comfy.org/api-reference/cloud',
|
||||
docsMcp: 'https://docs.comfy.org/agent-tools/cloud',
|
||||
docsSubscription: 'https://docs.comfy.org/support/subscription/subscribing',
|
||||
g2ComfyUi: 'https://www.g2.com/products/comfyui',
|
||||
github: 'https://github.com/Comfy-Org/ComfyUI',
|
||||
githubInstall: 'https://github.com/Comfy-Org/ComfyUI#installing',
|
||||
instagram: 'https://www.instagram.com/comfyui/',
|
||||
linkedin: 'https://www.linkedin.com/company/comfyui',
|
||||
mcpServer: 'https://cloud.comfy.org/mcp',
|
||||
mcpSkills: 'https://github.com/Comfy-Org/comfy-skills',
|
||||
platform: 'https://platform.comfy.org',
|
||||
platformUsage: 'https://platform.comfy.org/profile/usage',
|
||||
reddit: 'https://www.reddit.com/r/comfyui/',
|
||||
support: 'https://support.comfy.org/hc/en-us',
|
||||
wikidataComfyOrg: 'https://www.wikidata.org/wiki/Q130598554',
|
||||
wikidataComfyUi: 'https://www.wikidata.org/wiki/Q127798647',
|
||||
wikipediaComfyUi: 'https://en.wikipedia.org/wiki/ComfyUI',
|
||||
workflows: 'https://comfy.org/workflows',
|
||||
x: 'https://x.com/ComfyUI',
|
||||
youtube: 'https://www.youtube.com/@ComfyOrg'
|
||||
|
||||
@@ -72,24 +72,6 @@ export const drops: readonly Drop[] = [
|
||||
href: { en: '/download', 'zh-CN': '/zh-CN/download' }
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'comfy-mcp',
|
||||
badge: NEW_BADGE,
|
||||
category: CLOUD,
|
||||
media: imageFor('Drops_2x2card_MCP.jpg', {
|
||||
en: 'Comfy MCP',
|
||||
'zh-CN': 'Comfy MCP'
|
||||
}),
|
||||
title: { en: 'Comfy MCP', 'zh-CN': 'Comfy MCP' },
|
||||
description: {
|
||||
en: 'The full power of ComfyUI from anywhere — no setup, no GPU required.',
|
||||
'zh-CN': '随时随地体验 ComfyUI 的全部能力 — 无需配置,无需 GPU。'
|
||||
},
|
||||
cta: {
|
||||
label: EXPLORE,
|
||||
href: { en: '/mcp', 'zh-CN': '/zh-CN/mcp' }
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'app-mode',
|
||||
badge: NEW_BADGE,
|
||||
@@ -130,6 +112,24 @@ export const drops: readonly Drop[] = [
|
||||
href: { en: '/api', 'zh-CN': '/zh-CN/api' }
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'comfy-mcp',
|
||||
badge: NEW_BADGE,
|
||||
category: CLOUD,
|
||||
media: imageFor('Drops_2x2card_MCP.jpg', {
|
||||
en: 'Comfy MCP',
|
||||
'zh-CN': 'Comfy MCP'
|
||||
}),
|
||||
title: { en: 'Comfy MCP', 'zh-CN': 'Comfy MCP' },
|
||||
description: {
|
||||
en: 'The full power of ComfyUI from anywhere — no setup, no GPU required.',
|
||||
'zh-CN': '随时随地体验 ComfyUI 的全部能力 — 无需配置,无需 GPU。'
|
||||
},
|
||||
cta: {
|
||||
label: EXPLORE,
|
||||
href: { en: '/mcp', 'zh-CN': '/zh-CN/mcp' }
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'community-workflows',
|
||||
category: COMMUNITY,
|
||||
|
||||
@@ -1872,10 +1872,6 @@ const translations = {
|
||||
en: 'VIEW DOCS',
|
||||
'zh-CN': '查看文档'
|
||||
},
|
||||
'mcp.hero.installMcp': {
|
||||
en: 'INSTALL MCP',
|
||||
'zh-CN': '安装 MCP'
|
||||
},
|
||||
'mcp.hero.runWorkflow': {
|
||||
en: 'RUN A WORKFLOW',
|
||||
'zh-CN': '运行工作流'
|
||||
@@ -1913,27 +1909,21 @@ const translations = {
|
||||
},
|
||||
'mcp.setup.step1.label': { en: 'STEP 1', 'zh-CN': '第 1 步' },
|
||||
'mcp.setup.step1.title': {
|
||||
en: 'Ask your agent to install Comfy MCP',
|
||||
'zh-CN': '让你的智能体安装 Comfy MCP'
|
||||
},
|
||||
'mcp.setup.step1.command': {
|
||||
en: 'Help me install Comfy MCP.\nFollow the setup guide at {url}',
|
||||
'zh-CN': '帮我安装 Comfy MCP。\n请按照 {url} 上的设置指南操作。'
|
||||
en: 'Copy the MCP URL',
|
||||
'zh-CN': '复制 MCP URL'
|
||||
},
|
||||
'mcp.setup.step1.description': {
|
||||
en: 'Paste this into Claude, Cursor, Codex, or any MCP-compatible agent. It reads the docs and adds the connector for you.',
|
||||
'zh-CN':
|
||||
'将它粘贴到 Claude、Cursor、Codex 或任意兼容 MCP 的智能体中。它会读取文档并为你添加连接器。'
|
||||
en: "Click the copy button below. You'll paste it into your client in the next step.",
|
||||
'zh-CN': '点击下方的复制按钮,下一步将其粘贴到你的客户端中。'
|
||||
},
|
||||
'mcp.setup.step2.label': { en: 'STEP 2', 'zh-CN': '第 2 步' },
|
||||
'mcp.setup.step2.title': {
|
||||
en: 'Or add it by hand',
|
||||
'zh-CN': '或手动添加'
|
||||
en: 'Add the connector',
|
||||
'zh-CN': '添加连接器'
|
||||
},
|
||||
'mcp.setup.step2.description': {
|
||||
en: 'Prefer manual setup? Add Comfy Cloud as a custom connector with the MCP URL. The docs cover every client.',
|
||||
'zh-CN':
|
||||
'想手动配置?用 MCP URL 将 Comfy Cloud 添加为自定义连接器。文档涵盖各类客户端。'
|
||||
en: 'Name it Comfy Cloud and paste the URL. The docs below cover every client.',
|
||||
'zh-CN': '将其命名为 Comfy Cloud 并粘贴 URL。下方文档涵盖各类客户端。'
|
||||
},
|
||||
'mcp.setup.step2.cta': {
|
||||
en: 'COMFY CLOUD MCP DOCS',
|
||||
@@ -2190,13 +2180,6 @@ const translations = {
|
||||
'nav.ctaCloudPrefix': { en: 'LAUNCH', 'zh-CN': '启动' },
|
||||
'nav.ctaCloudCore': { en: 'CLOUD', 'zh-CN': '云端' },
|
||||
'nav.home': { en: 'Comfy home', 'zh-CN': 'Comfy 首页' },
|
||||
'breadcrumb.home': { en: 'Home', 'zh-CN': '首页' },
|
||||
'breadcrumb.about': { en: 'About Us', 'zh-CN': '关于我们' },
|
||||
'breadcrumb.contact': { en: 'Contact', 'zh-CN': '联系我们' },
|
||||
'breadcrumb.download': { en: 'Download', 'zh-CN': '下载' },
|
||||
'breadcrumb.careers': { en: 'Careers', 'zh-CN': '招聘' },
|
||||
'breadcrumb.pricing': { en: 'Pricing', 'zh-CN': '定价' },
|
||||
'breadcrumb.supportedNodes': { en: 'Supported Nodes', 'zh-CN': '支持的节点' },
|
||||
'nav.menu': { en: 'Menu', 'zh-CN': '菜单' },
|
||||
'nav.toggleMenu': { en: 'Toggle menu', 'zh-CN': '切换菜单' },
|
||||
'nav.close': { en: 'Close', 'zh-CN': '关闭' },
|
||||
@@ -3503,429 +3486,6 @@ const translations = {
|
||||
'zh-CN': '生效日期'
|
||||
},
|
||||
|
||||
// ── Enterprise MSA ─────────────────────────────────────────────────
|
||||
// English-only, by design. This is a legal-reviewed customer-facing
|
||||
// template. Serving a translated variant would expose Comfy to
|
||||
// liability from the translation diverging from the approved English
|
||||
// source. See the matching header comment in
|
||||
// src/pages/enterprise-msa.astro and the LOCALE_INVARIANT_ROUTE_KEYS
|
||||
// entry in src/config/routes.ts.
|
||||
'enterprise-msa.effective-date': {
|
||||
en: 'May 22, 2026',
|
||||
'zh-CN': 'May 22, 2026'
|
||||
},
|
||||
'enterprise-msa.1-definitions.label': {
|
||||
en: 'DEFINITIONS',
|
||||
'zh-CN': 'DEFINITIONS'
|
||||
},
|
||||
'enterprise-msa.1-definitions.title': {
|
||||
en: '1. Definitions',
|
||||
'zh-CN': '1. Definitions'
|
||||
},
|
||||
'enterprise-msa.1-definitions.block.0': {
|
||||
en: '<strong>“Affiliates”</strong> means any entity that directly or indirectly controls, is controlled by, or is under common control with a party, where “control” means the ownership of more than fifty percent (50%) of the voting securities or other voting interests of such entity.',
|
||||
'zh-CN':
|
||||
'<strong>“Affiliates”</strong> means any entity that directly or indirectly controls, is controlled by, or is under common control with a party, where “control” means the ownership of more than fifty percent (50%) of the voting securities or other voting interests of such entity.'
|
||||
},
|
||||
'enterprise-msa.1-definitions.block.1': {
|
||||
en: '<strong>“Applicable Laws”</strong> means all federal and state laws, treaties, rules, regulations, regulatory and supervisory guidance, directives, policies, orders or determinations of a regulatory authority applicable to the activities and obligations contemplated under this Agreement.',
|
||||
'zh-CN':
|
||||
'<strong>“Applicable Laws”</strong> means all federal and state laws, treaties, rules, regulations, regulatory and supervisory guidance, directives, policies, orders or determinations of a regulatory authority applicable to the activities and obligations contemplated under this Agreement.'
|
||||
},
|
||||
'enterprise-msa.1-definitions.block.2': {
|
||||
en: '<strong>“Comfy API”</strong> means the application programming interface and related developer tools made available by Comfy that allows Customer to access and execute visual AI workflows programmatically as production endpoints from within Customer’s own applications or systems.',
|
||||
'zh-CN':
|
||||
'<strong>“Comfy API”</strong> means the application programming interface and related developer tools made available by Comfy that allows Customer to access and execute visual AI workflows programmatically as production endpoints from within Customer’s own applications or systems.'
|
||||
},
|
||||
'enterprise-msa.1-definitions.block.3': {
|
||||
en: '<strong>“Comfy Branding”</strong> means the names, logos, and associated trademarks owned or in progress of being owned by Comfy.',
|
||||
'zh-CN':
|
||||
'<strong>“Comfy Branding”</strong> means the names, logos, and associated trademarks owned or in progress of being owned by Comfy.'
|
||||
},
|
||||
'enterprise-msa.1-definitions.block.4': {
|
||||
en: '<strong>“Comfy Cloud”</strong> means the cloud-based hosting environment made available by Comfy that allows Customer to access and run visual AI workflows remotely through Comfy’s infrastructure, without requiring local installation or hardware.',
|
||||
'zh-CN':
|
||||
'<strong>“Comfy Cloud”</strong> means the cloud-based hosting environment made available by Comfy that allows Customer to access and run visual AI workflows remotely through Comfy’s infrastructure, without requiring local installation or hardware.'
|
||||
},
|
||||
'enterprise-msa.1-definitions.block.5': {
|
||||
en: '<strong>“Comfy Enterprise”</strong> means the enterprise-grade product tier made available by Comfy that provides organizations with dedicated infrastructure, enhanced security, administrative controls, and related support services for deploying and managing visual AI workflows at scale.',
|
||||
'zh-CN':
|
||||
'<strong>“Comfy Enterprise”</strong> means the enterprise-grade product tier made available by Comfy that provides organizations with dedicated infrastructure, enhanced security, administrative controls, and related support services for deploying and managing visual AI workflows at scale.'
|
||||
},
|
||||
'enterprise-msa.1-definitions.block.6': {
|
||||
en: '<strong>“Comfy OSS”</strong> means the open-source software, source code, libraries, tools, and related components made available by Comfy under one or more open source licenses, including the software repositories published by Comfy at <a href="https://github.com/Comfy-Org" class="text-white underline">https://github.com/Comfy-Org</a>, as updated, modified, or supplemented from time to time. For the avoidance of doubt, Comfy OSS does not include any proprietary software, infrastructure, or functionality made available by Comfy under this Agreement or in connection with any commercial product or offering.',
|
||||
'zh-CN':
|
||||
'<strong>“Comfy OSS”</strong> means the open-source software, source code, libraries, tools, and related components made available by Comfy under one or more open source licenses, including the software repositories published by Comfy at <a href="https://github.com/Comfy-Org" class="text-white underline">https://github.com/Comfy-Org</a>, as updated, modified, or supplemented from time to time. For the avoidance of doubt, Comfy OSS does not include any proprietary software, infrastructure, or functionality made available by Comfy under this Agreement or in connection with any commercial product or offering.'
|
||||
},
|
||||
'enterprise-msa.1-definitions.block.7': {
|
||||
en: '<strong>“Comfy Products”</strong> means Comfy Cloud, Comfy API, Comfy Enterprise and other products, software, features, tools, and functionality made available by Comfy to Customer under this Agreement, excluding any Comfy OSS.',
|
||||
'zh-CN':
|
||||
'<strong>“Comfy Products”</strong> means Comfy Cloud, Comfy API, Comfy Enterprise and other products, software, features, tools, and functionality made available by Comfy to Customer under this Agreement, excluding any Comfy OSS.'
|
||||
},
|
||||
'enterprise-msa.1-definitions.block.8': {
|
||||
en: '<strong>“Customer Data”</strong> means electronic data and information submitted or generated by Customer in connection with its use of the Comfy Products, including all Inputs and Outputs.',
|
||||
'zh-CN':
|
||||
'<strong>“Customer Data”</strong> means electronic data and information submitted or generated by Customer in connection with its use of the Comfy Products, including all Inputs and Outputs.'
|
||||
},
|
||||
'enterprise-msa.1-definitions.block.9': {
|
||||
en: '<strong>“Open Source License”</strong> means the open source license(s) under which Comfy makes Comfy OSS available, as identified in the applicable source code repository.',
|
||||
'zh-CN':
|
||||
'<strong>“Open Source License”</strong> means the open source license(s) under which Comfy makes Comfy OSS available, as identified in the applicable source code repository.'
|
||||
},
|
||||
'enterprise-msa.1-definitions.block.10': {
|
||||
en: '<strong>“Operational Metadata”</strong> means usage and diagnostic information generated by the Comfy Products and collected by Comfy to support, maintain, and optimize the performance and security of the Comfy Products, including information regarding software versions, system configuration, uptime, error logs, health metrics, and feature usage. Operational Metadata does not include Customer Data or Confidential Information.',
|
||||
'zh-CN':
|
||||
'<strong>“Operational Metadata”</strong> means usage and diagnostic information generated by the Comfy Products and collected by Comfy to support, maintain, and optimize the performance and security of the Comfy Products, including information regarding software versions, system configuration, uptime, error logs, health metrics, and feature usage. Operational Metadata does not include Customer Data or Confidential Information.'
|
||||
},
|
||||
'enterprise-msa.1-definitions.block.11': {
|
||||
en: '<strong>“Order Form”</strong> means the online sign-up flow, order form or other ordering document entered into or otherwise agreed by Customer that references this Agreement. The initial Order Form is attached as Exhibit A.',
|
||||
'zh-CN':
|
||||
'<strong>“Order Form”</strong> means the online sign-up flow, order form or other ordering document entered into or otherwise agreed by Customer that references this Agreement. The initial Order Form is attached as Exhibit A.'
|
||||
},
|
||||
'enterprise-msa.1-definitions.block.12': {
|
||||
en: '<strong>“User”</strong> means Customer’s or Customer’s Affiliates’ employees and contractors who are authorized by Customer to access and use the Comfy Products on Customer’s or Customer’s Affiliates’ behalf according to the terms of this Agreement.',
|
||||
'zh-CN':
|
||||
'<strong>“User”</strong> means Customer’s or Customer’s Affiliates’ employees and contractors who are authorized by Customer to access and use the Comfy Products on Customer’s or Customer’s Affiliates’ behalf according to the terms of this Agreement.'
|
||||
},
|
||||
'enterprise-msa.2-comfy-products.label': {
|
||||
en: 'PRODUCTS',
|
||||
'zh-CN': 'PRODUCTS'
|
||||
},
|
||||
'enterprise-msa.2-comfy-products.title': {
|
||||
en: '2. Comfy Products',
|
||||
'zh-CN': '2. Comfy Products'
|
||||
},
|
||||
'enterprise-msa.2-comfy-products.block.0': {
|
||||
en: '<strong>Right to Access and Use Comfy Products.</strong> Subject to Customer’s compliance with all of the terms and conditions of this Agreement, Comfy grants Customer and Customer’s Users a non-exclusive, non-sublicensable, non-transferable right during the term of this Agreement to access and use the Comfy Products as set forth in the applicable Order Form for Customer’s internal business purposes.',
|
||||
'zh-CN':
|
||||
'<strong>Right to Access and Use Comfy Products.</strong> Subject to Customer’s compliance with all of the terms and conditions of this Agreement, Comfy grants Customer and Customer’s Users a non-exclusive, non-sublicensable, non-transferable right during the term of this Agreement to access and use the Comfy Products as set forth in the applicable Order Form for Customer’s internal business purposes.'
|
||||
},
|
||||
'enterprise-msa.2-comfy-products.block.1': {
|
||||
en: '<strong>Customer Data.</strong> As between Comfy and Customer, Customer retains all right, title, and interest in and to any data, images, videos, prompts, models, workflows, nodes, parameters, or other materials submitted or uploaded by Customer to the Comfy Products (“Input”), as well as any images, videos, designs, or other visual content generated through Customer’s use of the Comfy Products as a result of processing Customer’s Input (“Output”). Customer acknowledges that due to the nature of artificial intelligence, Comfy may generate the same or similar Output for other customers, and Customer shall have no right, title, or interest in or to Output generated for any other customer.',
|
||||
'zh-CN':
|
||||
'<strong>Customer Data.</strong> As between Comfy and Customer, Customer retains all right, title, and interest in and to any data, images, videos, prompts, models, workflows, nodes, parameters, or other materials submitted or uploaded by Customer to the Comfy Products (“Input”), as well as any images, videos, designs, or other visual content generated through Customer’s use of the Comfy Products as a result of processing Customer’s Input (“Output”). Customer acknowledges that due to the nature of artificial intelligence, Comfy may generate the same or similar Output for other customers, and Customer shall have no right, title, or interest in or to Output generated for any other customer.'
|
||||
},
|
||||
'enterprise-msa.2-comfy-products.block.2': {
|
||||
en: '<strong>No AI Training.</strong> Comfy will not use Input or Output to train generative AI or diffusion models. Comfy may, however, collect and use limited metadata derived from Customer’s use of the Comfy Products, such as prompt classifications, workflow structures, and node configurations, to improve the performance, functionality, and user experience of the Comfy Products.',
|
||||
'zh-CN':
|
||||
'<strong>No AI Training.</strong> Comfy will not use Input or Output to train generative AI or diffusion models. Comfy may, however, collect and use limited metadata derived from Customer’s use of the Comfy Products, such as prompt classifications, workflow structures, and node configurations, to improve the performance, functionality, and user experience of the Comfy Products.'
|
||||
},
|
||||
'enterprise-msa.2-comfy-products.block.3': {
|
||||
en: '<strong>Comfy OSS.</strong> Customer may use Comfy OSS under the terms of the applicable Open Source License(s) governing each respective component, as identified in the corresponding source code repository, rather than under this Agreement. Nothing in this Agreement shall be construed to limit, supersede, or modify any rights or obligations arising under an applicable Open Source License. If Customer chooses to use the Comfy Products in conjunction with Comfy OSS, this Agreement applies solely to Customer’s use of the Comfy Products and not to the Comfy OSS itself.',
|
||||
'zh-CN':
|
||||
'<strong>Comfy OSS.</strong> Customer may use Comfy OSS under the terms of the applicable Open Source License(s) governing each respective component, as identified in the corresponding source code repository, rather than under this Agreement. Nothing in this Agreement shall be construed to limit, supersede, or modify any rights or obligations arising under an applicable Open Source License. If Customer chooses to use the Comfy Products in conjunction with Comfy OSS, this Agreement applies solely to Customer’s use of the Comfy Products and not to the Comfy OSS itself.'
|
||||
},
|
||||
'enterprise-msa.2-comfy-products.block.4': {
|
||||
en: '<strong>Partner Nodes.</strong> Certain features of the Comfy Products allow Customer to access third-party AI model providers (“Partner Nodes”) through Comfy. When Customer uses a Partner Node, Comfy proxies Customer’s request to the applicable third-party provider, transmitting the information necessary to fulfill Customer’s request, including prompts, images, models, and parameters. Comfy does not transmit Customer’s identity or account information to third-party providers in connection with Partner Node requests. Customer’s use of Partner Nodes is subject to the terms and policies of the applicable third-party provider, and Comfy is not responsible for the data practices of such providers. Usage of Partner Nodes is metered and billed through Comfy.',
|
||||
'zh-CN':
|
||||
'<strong>Partner Nodes.</strong> Certain features of the Comfy Products allow Customer to access third-party AI model providers (“Partner Nodes”) through Comfy. When Customer uses a Partner Node, Comfy proxies Customer’s request to the applicable third-party provider, transmitting the information necessary to fulfill Customer’s request, including prompts, images, models, and parameters. Comfy does not transmit Customer’s identity or account information to third-party providers in connection with Partner Node requests. Customer’s use of Partner Nodes is subject to the terms and policies of the applicable third-party provider, and Comfy is not responsible for the data practices of such providers. Usage of Partner Nodes is metered and billed through Comfy.'
|
||||
},
|
||||
'enterprise-msa.2-comfy-products.block.5': {
|
||||
en: '<strong>Modification of Comfy Products.</strong> Comfy may, at any time and in its sole discretion, modify, update, enhance, restrict, suspend, or discontinue the Comfy Products, in whole or in part, including by changing or removing features, functionality, endpoints, specifications, documentation, access methods, usage limits, or availability. Comfy has no obligation to maintain or support any particular version of the Comfy Products or to ensure backward compatibility. Any such modifications may be made with or without notice and may result in interruptions to or degradation of the Comfy Products. Comfy shall have no liability arising out of or related to any modification, suspension, or discontinuation of the Comfy Products, and Customer acknowledges that its use of the Comfy Products is at its own risk and that it should not rely on the continued availability of any aspect of the Comfy Products.',
|
||||
'zh-CN':
|
||||
'<strong>Modification of Comfy Products.</strong> Comfy may, at any time and in its sole discretion, modify, update, enhance, restrict, suspend, or discontinue the Comfy Products, in whole or in part, including by changing or removing features, functionality, endpoints, specifications, documentation, access methods, usage limits, or availability. Comfy has no obligation to maintain or support any particular version of the Comfy Products or to ensure backward compatibility. Any such modifications may be made with or without notice and may result in interruptions to or degradation of the Comfy Products. Comfy shall have no liability arising out of or related to any modification, suspension, or discontinuation of the Comfy Products, and Customer acknowledges that its use of the Comfy Products is at its own risk and that it should not rely on the continued availability of any aspect of the Comfy Products.'
|
||||
},
|
||||
'enterprise-msa.2-comfy-products.block.6': {
|
||||
en: '<strong>Data Retention and Deletion.</strong> Comfy retains Customer Data for as long as Customer’s account remains active or as otherwise necessary to provide the Comfy Products, comply with applicable legal obligations, resolve disputes, and enforce this Agreement. Specific retention periods for different categories of Customer Data are set forth in Comfy’s retention documentation, available at <a href="https://docs.comfy.org/support/data-retention" class="text-white underline">docs.comfy.org/support/data-retention</a>, as updated from time to time. Customer may request deletion of Customer’s account and associated Customer Data by contacting Comfy at <a href="mailto:legal@comfy.org" class="text-white underline">legal@comfy.org</a>. Upon receipt of a verified deletion request, Comfy will use commercially reasonable efforts to delete or de-identify Customer’s personal information from its primary systems within a reasonable time. Customer acknowledges that: (i) deletion may not propagate immediately to all backup systems, third-party analytics providers, or observability systems, which retain data subject to their own retention policies; (ii) certain Customer Data may be retained as required by applicable law or for legitimate business purposes such as billing records; and (iii) aggregated or de-identified data derived from Customer’s use of the Comfy Products may be retained indefinitely.',
|
||||
'zh-CN':
|
||||
'<strong>Data Retention and Deletion.</strong> Comfy retains Customer Data for as long as Customer’s account remains active or as otherwise necessary to provide the Comfy Products, comply with applicable legal obligations, resolve disputes, and enforce this Agreement. Specific retention periods for different categories of Customer Data are set forth in Comfy’s retention documentation, available at <a href="https://docs.comfy.org/support/data-retention" class="text-white underline">docs.comfy.org/support/data-retention</a>, as updated from time to time. Customer may request deletion of Customer’s account and associated Customer Data by contacting Comfy at <a href="mailto:legal@comfy.org" class="text-white underline">legal@comfy.org</a>. Upon receipt of a verified deletion request, Comfy will use commercially reasonable efforts to delete or de-identify Customer’s personal information from its primary systems within a reasonable time. Customer acknowledges that: (i) deletion may not propagate immediately to all backup systems, third-party analytics providers, or observability systems, which retain data subject to their own retention policies; (ii) certain Customer Data may be retained as required by applicable law or for legitimate business purposes such as billing records; and (iii) aggregated or de-identified data derived from Customer’s use of the Comfy Products may be retained indefinitely.'
|
||||
},
|
||||
'enterprise-msa.3-customer-responsibilities.label': {
|
||||
en: 'CUSTOMER',
|
||||
'zh-CN': 'CUSTOMER'
|
||||
},
|
||||
'enterprise-msa.3-customer-responsibilities.title': {
|
||||
en: '3. Customer Responsibilities',
|
||||
'zh-CN': '3. Customer Responsibilities'
|
||||
},
|
||||
'enterprise-msa.3-customer-responsibilities.block.0': {
|
||||
en: '<strong>Registration.</strong> To access and use the Comfy Products, Customer may be required to register one or more accounts by providing Comfy with the information specified in the applicable registration form, including Customer’s email address. Customer shall ensure that all registration information provided to Comfy is complete and accurate, and shall promptly update such information as necessary to keep it current. Customer shall be liable for all activities conducted through its account, including any unauthorized access or use resulting from Customer’s failure to implement reasonable access controls or to limit access to its systems and devices.',
|
||||
'zh-CN':
|
||||
'<strong>Registration.</strong> To access and use the Comfy Products, Customer may be required to register one or more accounts by providing Comfy with the information specified in the applicable registration form, including Customer’s email address. Customer shall ensure that all registration information provided to Comfy is complete and accurate, and shall promptly update such information as necessary to keep it current. Customer shall be liable for all activities conducted through its account, including any unauthorized access or use resulting from Customer’s failure to implement reasonable access controls or to limit access to its systems and devices.'
|
||||
},
|
||||
'enterprise-msa.3-customer-responsibilities.block.1': {
|
||||
en: '<strong>General Technology Restrictions.</strong> Customer agrees that it will not, directly or indirectly: (i) sublicense the Comfy Products for use by a third party; (ii) reverse engineer or attempt to extract the source code or underlying methodology from the Comfy Products or any related software, except to the extent that this restriction is expressly prohibited by Applicable Laws; (iii) use or facilitate the use of the Comfy Products for any activities that are prohibited by Applicable Laws or otherwise; (iv) bypass or circumvent measures employed to prevent or limit access to the Comfy Products; (v) use the Comfy Products to create a product or service competitive with Comfy’s products or services; (vi) create derivative works of or otherwise create, attempt to create or derive, or knowingly assist any third party to create or derive, the source code underlying the Comfy Products; or (vii) otherwise use or interact with the Comfy Products for any purpose not expressly permitted under this Agreement.',
|
||||
'zh-CN':
|
||||
'<strong>General Technology Restrictions.</strong> Customer agrees that it will not, directly or indirectly: (i) sublicense the Comfy Products for use by a third party; (ii) reverse engineer or attempt to extract the source code or underlying methodology from the Comfy Products or any related software, except to the extent that this restriction is expressly prohibited by Applicable Laws; (iii) use or facilitate the use of the Comfy Products for any activities that are prohibited by Applicable Laws or otherwise; (iv) bypass or circumvent measures employed to prevent or limit access to the Comfy Products; (v) use the Comfy Products to create a product or service competitive with Comfy’s products or services; (vi) create derivative works of or otherwise create, attempt to create or derive, or knowingly assist any third party to create or derive, the source code underlying the Comfy Products; or (vii) otherwise use or interact with the Comfy Products for any purpose not expressly permitted under this Agreement.'
|
||||
},
|
||||
'enterprise-msa.3-customer-responsibilities.block.2': {
|
||||
en: '<strong>Acceptable Use; Prohibited Customer Data.</strong> Customer is solely responsible for ensuring that all Input submitted to the Comfy Products complies with all Applicable Laws, and Customer agrees that it will not, and will not permit any third party to submit to Comfy or the Comfy Products or otherwise use the Comfy Products to create: (i) any data, designs, or other materials subject to U.S. export control laws and regulations; (ii) any viruses, malware, ransomware, Trojan horses, worms, spyware, or other malicious or harmful code or content that could damage, disrupt, interfere with, or compromise the Comfy Products, Comfy’s systems or infrastructure, or the data or systems of any other user or third party; (iii) any Customer Data that depicts, promotes, or facilitates illegal activity, including without limitation child sexual abuse material, non-consensual intimate imagery, or content that incites violence or hatred against any individual or group; (iv) any Customer Data that infringes or misappropriates the intellectual property rights, privacy rights, or publicity rights of any third party, including without limitation by submitting models, images, or other materials without the right to do so; (v) any content or information that is intentionally deceptive or misleading, including without limitation synthetic media designed to impersonate a real individual without their consent; or (vi) any Customer Data that could reasonably be expected to cause harm to any individual or group.',
|
||||
'zh-CN':
|
||||
'<strong>Acceptable Use; Prohibited Customer Data.</strong> Customer is solely responsible for ensuring that all Input submitted to the Comfy Products complies with all Applicable Laws, and Customer agrees that it will not, and will not permit any third party to submit to Comfy or the Comfy Products or otherwise use the Comfy Products to create: (i) any data, designs, or other materials subject to U.S. export control laws and regulations; (ii) any viruses, malware, ransomware, Trojan horses, worms, spyware, or other malicious or harmful code or content that could damage, disrupt, interfere with, or compromise the Comfy Products, Comfy’s systems or infrastructure, or the data or systems of any other user or third party; (iii) any Customer Data that depicts, promotes, or facilitates illegal activity, including without limitation child sexual abuse material, non-consensual intimate imagery, or content that incites violence or hatred against any individual or group; (iv) any Customer Data that infringes or misappropriates the intellectual property rights, privacy rights, or publicity rights of any third party, including without limitation by submitting models, images, or other materials without the right to do so; (v) any content or information that is intentionally deceptive or misleading, including without limitation synthetic media designed to impersonate a real individual without their consent; or (vi) any Customer Data that could reasonably be expected to cause harm to any individual or group.'
|
||||
},
|
||||
'enterprise-msa.4-payment.label': {
|
||||
en: 'PAYMENT',
|
||||
'zh-CN': 'PAYMENT'
|
||||
},
|
||||
'enterprise-msa.4-payment.title': {
|
||||
en: '4. Payment',
|
||||
'zh-CN': '4. Payment'
|
||||
},
|
||||
'enterprise-msa.4-payment.block.0': {
|
||||
en: '<strong>Fees.</strong> Customer will pay Comfy the fees set forth in the applicable Order Form. Customer shall pay those amounts due and not disputed in good faith within seven (7) days of the date of receipt of the applicable invoice, unless a specific date for payment is set forth in such Order Form, in which case payment will be due on the date specified. Except as otherwise specified herein or in any applicable Order Form, (a) fees are quoted and payable in United States dollars and (b) payment obligations are non-cancelable and non-pro-ratable for partial months, and fees paid are non-refundable. Comfy reserves the right to change its fees upon each renewal term. Customer is responsible for all usage under Customer’s account, including usage by Customer’s Users and under Customer’s credentials and API keys.',
|
||||
'zh-CN':
|
||||
'<strong>Fees.</strong> Customer will pay Comfy the fees set forth in the applicable Order Form. Customer shall pay those amounts due and not disputed in good faith within seven (7) days of the date of receipt of the applicable invoice, unless a specific date for payment is set forth in such Order Form, in which case payment will be due on the date specified. Except as otherwise specified herein or in any applicable Order Form, (a) fees are quoted and payable in United States dollars and (b) payment obligations are non-cancelable and non-pro-ratable for partial months, and fees paid are non-refundable. Comfy reserves the right to change its fees upon each renewal term. Customer is responsible for all usage under Customer’s account, including usage by Customer’s Users and under Customer’s credentials and API keys.'
|
||||
},
|
||||
'enterprise-msa.4-payment.block.1': {
|
||||
en: '<strong>Prepaid Credits.</strong> Customer may prepay for usage credits (“Credits”) which may be applied toward usage of the Comfy Products at the rates set forth on Comfy’s pricing page. Except for documented billing errors or similar service issues attributed to Comfy, all purchases of Credits are final and non-refundable, and Comfy will not issue refunds or credits for any unused, partially used, or remaining Credits under any circumstances, including upon termination or expiration of Customer’s account. Comfy reserves the right to modify the pricing or Credit redemption rates applicable to future Credit purchases upon reasonable notice, but any Credits purchased prior to such modification will be honored at the rates in effect at the time of purchase.',
|
||||
'zh-CN':
|
||||
'<strong>Prepaid Credits.</strong> Customer may prepay for usage credits (“Credits”) which may be applied toward usage of the Comfy Products at the rates set forth on Comfy’s pricing page. Except for documented billing errors or similar service issues attributed to Comfy, all purchases of Credits are final and non-refundable, and Comfy will not issue refunds or credits for any unused, partially used, or remaining Credits under any circumstances, including upon termination or expiration of Customer’s account. Comfy reserves the right to modify the pricing or Credit redemption rates applicable to future Credit purchases upon reasonable notice, but any Credits purchased prior to such modification will be honored at the rates in effect at the time of purchase.'
|
||||
},
|
||||
'enterprise-msa.4-payment.block.2': {
|
||||
en: '<strong>Taxes.</strong> Fees are exclusive of all taxes, duties, levies, and similar governmental assessments (including sales, use, VAT/GST, and withholding taxes), and Customer is responsible for all such amounts other than taxes based on Comfy’s net income; if withholding is required by law, Customer will gross up payments so Comfy receives the invoiced amount, unless prohibited by law.',
|
||||
'zh-CN':
|
||||
'<strong>Taxes.</strong> Fees are exclusive of all taxes, duties, levies, and similar governmental assessments (including sales, use, VAT/GST, and withholding taxes), and Customer is responsible for all such amounts other than taxes based on Comfy’s net income; if withholding is required by law, Customer will gross up payments so Comfy receives the invoiced amount, unless prohibited by law.'
|
||||
},
|
||||
'enterprise-msa.4-payment.block.3': {
|
||||
en: '<strong>Late Payments; Suspension.</strong> Overdue undisputed amounts may accrue interest at the lesser of 1.5% per month or the maximum rate permitted by law, plus reasonable collection costs. Comfy may suspend or limit access to the Comfy Products (including throttling, disabling API keys, or downgrading to the Free Tier) for non-payment of undisputed amounts after providing commercially reasonable notice and an opportunity to cure, unless Comfy reasonably determines immediate suspension is necessary to protect the Comfy Products or comply with Applicable Laws.',
|
||||
'zh-CN':
|
||||
'<strong>Late Payments; Suspension.</strong> Overdue undisputed amounts may accrue interest at the lesser of 1.5% per month or the maximum rate permitted by law, plus reasonable collection costs. Comfy may suspend or limit access to the Comfy Products (including throttling, disabling API keys, or downgrading to the Free Tier) for non-payment of undisputed amounts after providing commercially reasonable notice and an opportunity to cure, unless Comfy reasonably determines immediate suspension is necessary to protect the Comfy Products or comply with Applicable Laws.'
|
||||
},
|
||||
'enterprise-msa.5-term-termination.label': {
|
||||
en: 'TERM',
|
||||
'zh-CN': 'TERM'
|
||||
},
|
||||
'enterprise-msa.5-term-termination.title': {
|
||||
en: '5. Term; Termination',
|
||||
'zh-CN': '5. Term; Termination'
|
||||
},
|
||||
'enterprise-msa.5-term-termination.block.0': {
|
||||
en: '<strong>Term.</strong> The term of this Agreement will commence on the Effective Date and continue until terminated as set forth below (“Term”). The initial term of each Order Form will begin on the Subscription Start Date of such Order Form and will continue for the subscription term set forth therein. Except as set forth in such Order Form, the Order Form will renew for successive renewal terms equal to the length of the Initial Subscription Term.',
|
||||
'zh-CN':
|
||||
'<strong>Term.</strong> The term of this Agreement will commence on the Effective Date and continue until terminated as set forth below (“Term”). The initial term of each Order Form will begin on the Subscription Start Date of such Order Form and will continue for the subscription term set forth therein. Except as set forth in such Order Form, the Order Form will renew for successive renewal terms equal to the length of the Initial Subscription Term.'
|
||||
},
|
||||
'enterprise-msa.5-term-termination.block.1': {
|
||||
en: '<strong>Termination of Agreement.</strong> Each party may terminate this Agreement upon written notice to the other party if there are no Order Forms then in effect. Each party may also terminate this Agreement or the applicable Order Form upon written notice in the event (a) the other party commits any material breach of this Agreement or the applicable Order Form and fails to remedy such breach within thirty (30) days after written notice of such breach or (b) subject to applicable law, upon the other party’s liquidation, commencement of dissolution proceedings or assignment of substantially all its assets for the benefit of creditors, or if the other party becomes the subject of bankruptcy or similar proceeding that is not dismissed within sixty (60) days.',
|
||||
'zh-CN':
|
||||
'<strong>Termination of Agreement.</strong> Each party may terminate this Agreement upon written notice to the other party if there are no Order Forms then in effect. Each party may also terminate this Agreement or the applicable Order Form upon written notice in the event (a) the other party commits any material breach of this Agreement or the applicable Order Form and fails to remedy such breach within thirty (30) days after written notice of such breach or (b) subject to applicable law, upon the other party’s liquidation, commencement of dissolution proceedings or assignment of substantially all its assets for the benefit of creditors, or if the other party becomes the subject of bankruptcy or similar proceeding that is not dismissed within sixty (60) days.'
|
||||
},
|
||||
'enterprise-msa.5-term-termination.block.2': {
|
||||
en: '<strong>Deletion of Customer Data Upon Termination.</strong> Upon expiration or termination of this Agreement, Comfy will delete Customer Data from its primary production systems within sixty (60) days. Notwithstanding the foregoing, Customer Data may persist in routine backup systems beyond such period solely to the extent necessary under Comfy’s standard backup retention schedule, provided that such data is not actively accessed or used by Comfy and remains subject to the confidentiality obligations of this Agreement.',
|
||||
'zh-CN':
|
||||
'<strong>Deletion of Customer Data Upon Termination.</strong> Upon expiration or termination of this Agreement, Comfy will delete Customer Data from its primary production systems within sixty (60) days. Notwithstanding the foregoing, Customer Data may persist in routine backup systems beyond such period solely to the extent necessary under Comfy’s standard backup retention schedule, provided that such data is not actively accessed or used by Comfy and remains subject to the confidentiality obligations of this Agreement.'
|
||||
},
|
||||
'enterprise-msa.5-term-termination.block.3': {
|
||||
en: '<strong>Survival.</strong> Termination or expiration will not affect any rights or obligations, including the payment of amounts due, which have accrued under this Agreement up to the date of termination or expiration. Upon termination or expiration of this Agreement, the provisions that are intended by their nature to survive termination will survive and continue in full force and effect in accordance with their terms, including confidentiality obligations, proprietary rights, indemnification, limitations of liability, and disclaimers.',
|
||||
'zh-CN':
|
||||
'<strong>Survival.</strong> Termination or expiration will not affect any rights or obligations, including the payment of amounts due, which have accrued under this Agreement up to the date of termination or expiration. Upon termination or expiration of this Agreement, the provisions that are intended by their nature to survive termination will survive and continue in full force and effect in accordance with their terms, including confidentiality obligations, proprietary rights, indemnification, limitations of liability, and disclaimers.'
|
||||
},
|
||||
'enterprise-msa.6-confidentiality.label': {
|
||||
en: 'CONFIDENTIALITY',
|
||||
'zh-CN': 'CONFIDENTIALITY'
|
||||
},
|
||||
'enterprise-msa.6-confidentiality.title': {
|
||||
en: '6. Confidentiality',
|
||||
'zh-CN': '6. Confidentiality'
|
||||
},
|
||||
'enterprise-msa.6-confidentiality.block.0': {
|
||||
en: '<strong>Definition of Confidential Information.</strong> “Confidential Information” means all non-public information disclosed by a party (“Disclosing Party”) to the other party (“Receiving Party”), whether oral or written, that is designated as confidential or that reasonably should be understood to be confidential given the nature of the information and circumstances of disclosure. Confidential Information of Customer includes Customer Data; Confidential Information of Comfy includes the Comfy Products; and each party’s Confidential Information includes the terms of this Agreement and any Order Forms (including pricing), as well as business, financial, marketing, technical, and product information. Confidential Information excludes information that the Receiving Party can demonstrate: (i) is or becomes publicly available without breach; (ii) was known prior to disclosure without breach; (iii) is received from a third party without breach; or (iv) was independently developed without use of or reference to the Disclosing Party’s Confidential Information.',
|
||||
'zh-CN':
|
||||
'<strong>Definition of Confidential Information.</strong> “Confidential Information” means all non-public information disclosed by a party (“Disclosing Party”) to the other party (“Receiving Party”), whether oral or written, that is designated as confidential or that reasonably should be understood to be confidential given the nature of the information and circumstances of disclosure. Confidential Information of Customer includes Customer Data; Confidential Information of Comfy includes the Comfy Products; and each party’s Confidential Information includes the terms of this Agreement and any Order Forms (including pricing), as well as business, financial, marketing, technical, and product information. Confidential Information excludes information that the Receiving Party can demonstrate: (i) is or becomes publicly available without breach; (ii) was known prior to disclosure without breach; (iii) is received from a third party without breach; or (iv) was independently developed without use of or reference to the Disclosing Party’s Confidential Information.'
|
||||
},
|
||||
'enterprise-msa.6-confidentiality.block.1': {
|
||||
en: '<strong>Protection of Confidential Information.</strong> The Receiving Party will: (a) protect Confidential Information using at least reasonable care; (b) use it solely to perform under this Agreement; and (c) limit access to its and its Affiliates’ employees and contractors with a need to know and confidentiality obligations at least as protective as those herein. Neither party may disclose the terms of this Agreement or any Order Form except to its Affiliates, legal counsel, or accountants, and remains responsible for their compliance. Upon written request, the Receiving Party will promptly return or destroy Confidential Information, except for information retained in routine backups or as required by law or internal retention policies.',
|
||||
'zh-CN':
|
||||
'<strong>Protection of Confidential Information.</strong> The Receiving Party will: (a) protect Confidential Information using at least reasonable care; (b) use it solely to perform under this Agreement; and (c) limit access to its and its Affiliates’ employees and contractors with a need to know and confidentiality obligations at least as protective as those herein. Neither party may disclose the terms of this Agreement or any Order Form except to its Affiliates, legal counsel, or accountants, and remains responsible for their compliance. Upon written request, the Receiving Party will promptly return or destroy Confidential Information, except for information retained in routine backups or as required by law or internal retention policies.'
|
||||
},
|
||||
'enterprise-msa.6-confidentiality.block.2': {
|
||||
en: '<strong>Compelled Disclosure.</strong> The Receiving Party may disclose Confidential Information if legally required, provided it gives prior notice (where permitted) and reasonable assistance, at the Disclosing Party’s expense, to seek protective treatment. Any disclosure will be limited to what is legally required, and the Receiving Party will request confidential treatment. These obligations survive while Confidential Information remains in the Receiving Party’s possession.',
|
||||
'zh-CN':
|
||||
'<strong>Compelled Disclosure.</strong> The Receiving Party may disclose Confidential Information if legally required, provided it gives prior notice (where permitted) and reasonable assistance, at the Disclosing Party’s expense, to seek protective treatment. Any disclosure will be limited to what is legally required, and the Receiving Party will request confidential treatment. These obligations survive while Confidential Information remains in the Receiving Party’s possession.'
|
||||
},
|
||||
'enterprise-msa.6-confidentiality.block.3': {
|
||||
en: '<strong>Data Security.</strong> Comfy will implement and maintain commercially reasonable administrative, technical, and physical safeguards designed to protect Customer Data against unauthorized access, disclosure, alteration, or destruction. These measures will be no less protective than those Comfy uses to protect its own confidential information of a similar nature. In the event Comfy becomes aware of a confirmed security breach that results in unauthorized access to or disclosure of Customer Data, Comfy will notify Customer without undue delay and will provide reasonable cooperation to assist Customer in investigating and mitigating the effects of such breach. Customer acknowledges that no security measures are perfect or impenetrable, and Comfy does not guarantee that Customer Data will be free from unauthorized access or disclosure.',
|
||||
'zh-CN':
|
||||
'<strong>Data Security.</strong> Comfy will implement and maintain commercially reasonable administrative, technical, and physical safeguards designed to protect Customer Data against unauthorized access, disclosure, alteration, or destruction. These measures will be no less protective than those Comfy uses to protect its own confidential information of a similar nature. In the event Comfy becomes aware of a confirmed security breach that results in unauthorized access to or disclosure of Customer Data, Comfy will notify Customer without undue delay and will provide reasonable cooperation to assist Customer in investigating and mitigating the effects of such breach. Customer acknowledges that no security measures are perfect or impenetrable, and Comfy does not guarantee that Customer Data will be free from unauthorized access or disclosure.'
|
||||
},
|
||||
'enterprise-msa.7-proprietary-rights.label': {
|
||||
en: 'IP',
|
||||
'zh-CN': 'IP'
|
||||
},
|
||||
'enterprise-msa.7-proprietary-rights.title': {
|
||||
en: '7. Proprietary Rights',
|
||||
'zh-CN': '7. Proprietary Rights'
|
||||
},
|
||||
'enterprise-msa.7-proprietary-rights.block.0': {
|
||||
en: '<strong>Reservation of Rights.</strong> Comfy and its licensors retain all right, title, and interest, including all intellectual property and proprietary rights, in and to the Comfy Products, Comfy Branding, and all software, code, algorithms, protocols, interfaces, tools, documentation, data structures, and other technology underlying or embodied in, or used to provide, the Comfy Products (collectively, “Comfy Materials”). Except for the limited rights expressly granted to Customer under this Agreement, no rights or licenses are granted, whether by implication, estoppel, or otherwise. Comfy expressly reserves all rights in and to the Comfy Materials not expressly granted hereunder.',
|
||||
'zh-CN':
|
||||
'<strong>Reservation of Rights.</strong> Comfy and its licensors retain all right, title, and interest, including all intellectual property and proprietary rights, in and to the Comfy Products, Comfy Branding, and all software, code, algorithms, protocols, interfaces, tools, documentation, data structures, and other technology underlying or embodied in, or used to provide, the Comfy Products (collectively, “Comfy Materials”). Except for the limited rights expressly granted to Customer under this Agreement, no rights or licenses are granted, whether by implication, estoppel, or otherwise. Comfy expressly reserves all rights in and to the Comfy Materials not expressly granted hereunder.'
|
||||
},
|
||||
'enterprise-msa.7-proprietary-rights.block.1': {
|
||||
en: '<strong>Feedback.</strong> Customer may from time to time provide feedback (including suggestions, comments for enhancements, functionality or usability, etc.) (“Feedback”) to Comfy regarding Customer’s experience using, and needs and integration requirements for, the Comfy Products. Comfy shall have full discretion to determine whether or not to proceed with the development of any requested enhancements, new features or functionality, and Customer hereby grants Comfy the full, unencumbered, royalty-free right to incorporate and otherwise fully exploit Feedback in connection with Comfy’s products and services.',
|
||||
'zh-CN':
|
||||
'<strong>Feedback.</strong> Customer may from time to time provide feedback (including suggestions, comments for enhancements, functionality or usability, etc.) (“Feedback”) to Comfy regarding Customer’s experience using, and needs and integration requirements for, the Comfy Products. Comfy shall have full discretion to determine whether or not to proceed with the development of any requested enhancements, new features or functionality, and Customer hereby grants Comfy the full, unencumbered, royalty-free right to incorporate and otherwise fully exploit Feedback in connection with Comfy’s products and services.'
|
||||
},
|
||||
'enterprise-msa.7-proprietary-rights.block.2': {
|
||||
en: '<strong>Operational Metadata.</strong> Customer agrees that Comfy may collect and use Operational Metadata to operate, maintain, improve, and support the Comfy Products, including for diagnostics, analytics, system performance, and reporting purposes. Comfy will only disclose Operational Metadata externally if such data is (a) aggregated or anonymized with data across other customers, and (b) does not disclose the identity of Customer or any Customer Confidential Information.',
|
||||
'zh-CN':
|
||||
'<strong>Operational Metadata.</strong> Customer agrees that Comfy may collect and use Operational Metadata to operate, maintain, improve, and support the Comfy Products, including for diagnostics, analytics, system performance, and reporting purposes. Comfy will only disclose Operational Metadata externally if such data is (a) aggregated or anonymized with data across other customers, and (b) does not disclose the identity of Customer or any Customer Confidential Information.'
|
||||
},
|
||||
'enterprise-msa.8-warranties-disclaimer.label': {
|
||||
en: 'WARRANTIES',
|
||||
'zh-CN': 'WARRANTIES'
|
||||
},
|
||||
'enterprise-msa.8-warranties-disclaimer.title': {
|
||||
en: '8. Warranties; Disclaimer',
|
||||
'zh-CN': '8. Warranties; Disclaimer'
|
||||
},
|
||||
'enterprise-msa.8-warranties-disclaimer.block.0': {
|
||||
en: '<strong>Comfy.</strong> Comfy warrants that it will, consistent with prevailing industry standards, provide the Comfy Products in a professional and workmanlike manner and the Comfy Products will conform in all material respects with the Documentation. For material breach of the foregoing express warranty, Customer’s exclusive remedy shall be the re-performance of the deficient Comfy Products or, if Comfy cannot re-perform such deficient Comfy Products as warranted within thirty (30) days after receipt of written notice of the warranty breach, Customer shall be entitled to terminate the applicable Order Form and recover a pro-rata portion of the prepaid subscription fees corresponding to the terminated portion of the applicable subscription term.',
|
||||
'zh-CN':
|
||||
'<strong>Comfy.</strong> Comfy warrants that it will, consistent with prevailing industry standards, provide the Comfy Products in a professional and workmanlike manner and the Comfy Products will conform in all material respects with the Documentation. For material breach of the foregoing express warranty, Customer’s exclusive remedy shall be the re-performance of the deficient Comfy Products or, if Comfy cannot re-perform such deficient Comfy Products as warranted within thirty (30) days after receipt of written notice of the warranty breach, Customer shall be entitled to terminate the applicable Order Form and recover a pro-rata portion of the prepaid subscription fees corresponding to the terminated portion of the applicable subscription term.'
|
||||
},
|
||||
'enterprise-msa.8-warranties-disclaimer.block.1': {
|
||||
en: '<strong>Customer.</strong> Customer represents and warrants that it owns or has obtained all necessary rights, licenses, and permissions to submit Customer Data to the Comfy Products, and that Customer Data does not include any content that Customer is legally prohibited from sharing or processing through the Comfy Products.',
|
||||
'zh-CN':
|
||||
'<strong>Customer.</strong> Customer represents and warrants that it owns or has obtained all necessary rights, licenses, and permissions to submit Customer Data to the Comfy Products, and that Customer Data does not include any content that Customer is legally prohibited from sharing or processing through the Comfy Products.'
|
||||
},
|
||||
'enterprise-msa.8-warranties-disclaimer.block.2': {
|
||||
en: '<strong>Disclaimer.</strong> EXCEPT AS SET FORTH HEREIN, THE COMFY PRODUCTS AND OUTPUT ARE PROVIDED “AS IS” WITHOUT ANY WARRANTY OF ANY KIND. COMFY DISCLAIMS ANY AND ALL WARRANTIES, REPRESENTATIONS, AND CONDITIONS RELATING TO THE COMFY PRODUCTS (INCLUDING ANY OUTPUT), WHETHER EXPRESS, IMPLIED, INCLUDING, BUT NOT LIMITED TO, ANY REPRESENTATION, WARRANTY, OR CONDITION OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, TITLE OR NON-INFRINGEMENT. CUSTOMER AGREES AND ACKNOWLEDGES THAT CUSTOMER’S USE OF ANY OUTPUT PROVIDED BY THE COMFY PRODUCTS IS AT CUSTOMER’S OWN RISK. Customer is solely responsible for (a) verifying the Output is appropriate for Customer’s use case, and (b) any decisions, actions, or omissions taken in reliance on the OUTPUT. IN NO EVENT WILL COMFY BE LIABLE FOR ANY DAMAGES OR LOSSES ARISING FROM OR RELATED TO CUSTOMER’S USE OF OR RELIANCE ON THE OUTPUT, INCLUDING ANY DECISIONS MADE OR ACTIONS TAKEN BASED ON THE OUTPUT.',
|
||||
'zh-CN':
|
||||
'<strong>Disclaimer.</strong> EXCEPT AS SET FORTH HEREIN, THE COMFY PRODUCTS AND OUTPUT ARE PROVIDED “AS IS” WITHOUT ANY WARRANTY OF ANY KIND. COMFY DISCLAIMS ANY AND ALL WARRANTIES, REPRESENTATIONS, AND CONDITIONS RELATING TO THE COMFY PRODUCTS (INCLUDING ANY OUTPUT), WHETHER EXPRESS, IMPLIED, INCLUDING, BUT NOT LIMITED TO, ANY REPRESENTATION, WARRANTY, OR CONDITION OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, TITLE OR NON-INFRINGEMENT. CUSTOMER AGREES AND ACKNOWLEDGES THAT CUSTOMER’S USE OF ANY OUTPUT PROVIDED BY THE COMFY PRODUCTS IS AT CUSTOMER’S OWN RISK. Customer is solely responsible for (a) verifying the Output is appropriate for Customer’s use case, and (b) any decisions, actions, or omissions taken in reliance on the OUTPUT. IN NO EVENT WILL COMFY BE LIABLE FOR ANY DAMAGES OR LOSSES ARISING FROM OR RELATED TO CUSTOMER’S USE OF OR RELIANCE ON THE OUTPUT, INCLUDING ANY DECISIONS MADE OR ACTIONS TAKEN BASED ON THE OUTPUT.'
|
||||
},
|
||||
'enterprise-msa.9-limitation-of-liability.label': {
|
||||
en: 'LIABILITY',
|
||||
'zh-CN': 'LIABILITY'
|
||||
},
|
||||
'enterprise-msa.9-limitation-of-liability.title': {
|
||||
en: '9. Limitation of Liability',
|
||||
'zh-CN': '9. Limitation of Liability'
|
||||
},
|
||||
'enterprise-msa.9-limitation-of-liability.block.0': {
|
||||
en: 'UNDER NO LEGAL THEORY, WHETHER IN TORT, CONTRACT, OR OTHERWISE, WILL EITHER PARTY BE LIABLE TO THE OTHER UNDER THIS AGREEMENT FOR (A) ANY INDIRECT, SPECIAL, INCIDENTAL, CONSEQUENTIAL OR PUNITIVE DAMAGES OF ANY CHARACTER, INCLUDING DAMAGES FOR LOSS OF GOODWILL, LOST PROFITS, LOST SALES OR BUSINESS, WORK STOPPAGE, COMPUTER FAILURE OR MALFUNCTION, LOST CONTENT OR DATA, EVEN IF A REPRESENTATIVE OF SUCH PARTY HAS BEEN ADVISED, KNEW OR SHOULD HAVE KNOWN OF THE POSSIBILITY OF SUCH DAMAGES, OR (B) EXCLUDING CUSTOMER’S PAYMENT OBLIGATIONS, ANY AGGREGATE DAMAGES, COSTS, OR LIABILITIES IN EXCESS OF THE AMOUNTS PAID BY CUSTOMER UNDER THE APPLICABLE ORDER FORM DURING THE TWELVE (12) MONTHS PRECEDING THE CLAIM.',
|
||||
'zh-CN':
|
||||
'UNDER NO LEGAL THEORY, WHETHER IN TORT, CONTRACT, OR OTHERWISE, WILL EITHER PARTY BE LIABLE TO THE OTHER UNDER THIS AGREEMENT FOR (A) ANY INDIRECT, SPECIAL, INCIDENTAL, CONSEQUENTIAL OR PUNITIVE DAMAGES OF ANY CHARACTER, INCLUDING DAMAGES FOR LOSS OF GOODWILL, LOST PROFITS, LOST SALES OR BUSINESS, WORK STOPPAGE, COMPUTER FAILURE OR MALFUNCTION, LOST CONTENT OR DATA, EVEN IF A REPRESENTATIVE OF SUCH PARTY HAS BEEN ADVISED, KNEW OR SHOULD HAVE KNOWN OF THE POSSIBILITY OF SUCH DAMAGES, OR (B) EXCLUDING CUSTOMER’S PAYMENT OBLIGATIONS, ANY AGGREGATE DAMAGES, COSTS, OR LIABILITIES IN EXCESS OF THE AMOUNTS PAID BY CUSTOMER UNDER THE APPLICABLE ORDER FORM DURING THE TWELVE (12) MONTHS PRECEDING THE CLAIM.'
|
||||
},
|
||||
'enterprise-msa.10-indemnification.label': {
|
||||
en: 'INDEMNITY',
|
||||
'zh-CN': 'INDEMNITY'
|
||||
},
|
||||
'enterprise-msa.10-indemnification.title': {
|
||||
en: '10. Indemnification',
|
||||
'zh-CN': '10. Indemnification'
|
||||
},
|
||||
'enterprise-msa.10-indemnification.block.0': {
|
||||
en: '<strong>Indemnity by Comfy.</strong> Comfy will defend Customer against any claim, demand, suit, or proceeding (“Claim”) made or brought against Customer by a third party alleging that the Comfy Products as provided by Comfy infringes or misappropriates a U.S. patent, copyright or trade secret and will indemnify Customer for any damages finally awarded against Customer (or any settlement approved by Comfy) in connection with any such Claim; provided that (a) Customer will promptly notify Comfy of such Claim, (b) Comfy will have the sole and exclusive authority to defend and/or settle any such Claim (provided that Comfy may not settle any Claim without Customer’s prior written consent, which will not be unreasonably withheld, unless it unconditionally releases Customer of all related liability) and (c) Customer reasonably cooperates with Comfy in connection therewith. If the use of the Comfy Products by Customer has become, or in Comfy’s opinion is likely to become, the subject of any claim of infringement, Comfy may at its option and expense (i) procure for Customer the right to continue using and receiving the Comfy Products as set forth hereunder; (ii) replace or modify the Comfy Products to make it non-infringing (with comparable functionality); or (iii) if the options in clauses (i) or (ii) are not reasonably practicable, terminate the applicable Order Form and provide a pro rata refund of any prepaid subscription fees corresponding to the terminated portion of the applicable subscription term. Comfy will have no liability or obligation with respect to any Claim to the extent such Claim is caused by (A) prompts, inputs, or other instructions or materials submitted by Customer or its Users; (B) Customer’s use of any outputs, generated content, or models in a manner not authorized under this Agreement; (C) modification of any generated outputs by or on behalf of Customer; (D) Customer Data, including any third-party intellectual property, likenesses, or other proprietary material incorporated therein; or (E) Customer’s failure to obtain rights, consents, or clearances required for the submission or use of any content through the Comfy Products (clauses (A) through (E), “Excluded Claims”). This Section states Comfy’s sole and exclusive liability and obligation, and Customer’s exclusive remedy, for any claim of any nature related to infringement or misappropriation of intellectual property.',
|
||||
'zh-CN':
|
||||
'<strong>Indemnity by Comfy.</strong> Comfy will defend Customer against any claim, demand, suit, or proceeding (“Claim”) made or brought against Customer by a third party alleging that the Comfy Products as provided by Comfy infringes or misappropriates a U.S. patent, copyright or trade secret and will indemnify Customer for any damages finally awarded against Customer (or any settlement approved by Comfy) in connection with any such Claim; provided that (a) Customer will promptly notify Comfy of such Claim, (b) Comfy will have the sole and exclusive authority to defend and/or settle any such Claim (provided that Comfy may not settle any Claim without Customer’s prior written consent, which will not be unreasonably withheld, unless it unconditionally releases Customer of all related liability) and (c) Customer reasonably cooperates with Comfy in connection therewith. If the use of the Comfy Products by Customer has become, or in Comfy’s opinion is likely to become, the subject of any claim of infringement, Comfy may at its option and expense (i) procure for Customer the right to continue using and receiving the Comfy Products as set forth hereunder; (ii) replace or modify the Comfy Products to make it non-infringing (with comparable functionality); or (iii) if the options in clauses (i) or (ii) are not reasonably practicable, terminate the applicable Order Form and provide a pro rata refund of any prepaid subscription fees corresponding to the terminated portion of the applicable subscription term. Comfy will have no liability or obligation with respect to any Claim to the extent such Claim is caused by (A) prompts, inputs, or other instructions or materials submitted by Customer or its Users; (B) Customer’s use of any outputs, generated content, or models in a manner not authorized under this Agreement; (C) modification of any generated outputs by or on behalf of Customer; (D) Customer Data, including any third-party intellectual property, likenesses, or other proprietary material incorporated therein; or (E) Customer’s failure to obtain rights, consents, or clearances required for the submission or use of any content through the Comfy Products (clauses (A) through (E), “Excluded Claims”). This Section states Comfy’s sole and exclusive liability and obligation, and Customer’s exclusive remedy, for any claim of any nature related to infringement or misappropriation of intellectual property.'
|
||||
},
|
||||
'enterprise-msa.10-indemnification.block.1': {
|
||||
en: '<strong>Indemnification by Customer.</strong> Customer will defend Comfy against any Claim made or brought against Comfy by a third party to the extent arising out of Customer’s breach of Section 3 or the Excluded Claims, and Customer will indemnify Comfy for any damages finally awarded against Comfy (or any settlement approved by Customer) in connection with any such Claim; provided that (a) Comfy will promptly notify Customer of such Claim, (b) Customer will have the sole and exclusive authority to defend and/or settle any such Claim (provided that Customer may not settle any Claim without Comfy’s prior written consent, which will not be unreasonably withheld, unless it unconditionally releases Comfy of all liability) and (c) Comfy reasonably cooperates with Customer in connection therewith.',
|
||||
'zh-CN':
|
||||
'<strong>Indemnification by Customer.</strong> Customer will defend Comfy against any Claim made or brought against Comfy by a third party to the extent arising out of Customer’s breach of Section 3 or the Excluded Claims, and Customer will indemnify Comfy for any damages finally awarded against Comfy (or any settlement approved by Customer) in connection with any such Claim; provided that (a) Comfy will promptly notify Customer of such Claim, (b) Customer will have the sole and exclusive authority to defend and/or settle any such Claim (provided that Customer may not settle any Claim without Comfy’s prior written consent, which will not be unreasonably withheld, unless it unconditionally releases Comfy of all liability) and (c) Comfy reasonably cooperates with Customer in connection therewith.'
|
||||
},
|
||||
'enterprise-msa.11-miscellaneous.label': {
|
||||
en: 'MISCELLANEOUS',
|
||||
'zh-CN': 'MISCELLANEOUS'
|
||||
},
|
||||
'enterprise-msa.11-miscellaneous.title': {
|
||||
en: '11. Miscellaneous',
|
||||
'zh-CN': '11. Miscellaneous'
|
||||
},
|
||||
'enterprise-msa.11-miscellaneous.block.0': {
|
||||
en: '<strong>Governing Law.</strong> This Agreement will be governed by the laws of the State of California, exclusive of its rules governing choice of law and conflict of laws. The parties agree to the exclusive jurisdiction and venue of the state and federal courts located in San Francisco, CA and each party irrevocably submits to such jurisdiction and venue and waives any objection based on inconvenient forum. This Agreement will not be governed by the United Nations Convention on Contracts for the International Sale of Goods.',
|
||||
'zh-CN':
|
||||
'<strong>Governing Law.</strong> This Agreement will be governed by the laws of the State of California, exclusive of its rules governing choice of law and conflict of laws. The parties agree to the exclusive jurisdiction and venue of the state and federal courts located in San Francisco, CA and each party irrevocably submits to such jurisdiction and venue and waives any objection based on inconvenient forum. This Agreement will not be governed by the United Nations Convention on Contracts for the International Sale of Goods.'
|
||||
},
|
||||
'enterprise-msa.11-miscellaneous.block.1': {
|
||||
en: '<strong>Export Compliance.</strong> Customer will comply with the export laws and regulations of the United States, the European Union and other applicable jurisdictions in using the Comfy Products.',
|
||||
'zh-CN':
|
||||
'<strong>Export Compliance.</strong> Customer will comply with the export laws and regulations of the United States, the European Union and other applicable jurisdictions in using the Comfy Products.'
|
||||
},
|
||||
'enterprise-msa.11-miscellaneous.block.2': {
|
||||
en: '<strong>Publicity.</strong> Customer agrees that Comfy may refer to Customer’s name, logo, and trademarks in Comfy’s marketing materials and website; however, Comfy will not use Customer’s name or trademarks in any other publicity (e.g., press releases, customer references and case studies) without Customer’s prior written consent (which may be by email) not to be unreasonably withheld, conditioned, or delayed.',
|
||||
'zh-CN':
|
||||
'<strong>Publicity.</strong> Customer agrees that Comfy may refer to Customer’s name, logo, and trademarks in Comfy’s marketing materials and website; however, Comfy will not use Customer’s name or trademarks in any other publicity (e.g., press releases, customer references and case studies) without Customer’s prior written consent (which may be by email) not to be unreasonably withheld, conditioned, or delayed.'
|
||||
},
|
||||
'enterprise-msa.11-miscellaneous.block.3': {
|
||||
en: '<strong>Third-Party Infrastructure.</strong> Customer acknowledges that the Comfy Products relies on third-party infrastructure, hardware, and services, including cloud computing providers and GPU infrastructure providers (collectively, “Third-Party Infrastructure”), and that the availability, performance, and security of the Comfy Products may be affected by the operation, maintenance, or failure of such Third-Party Infrastructure. Comfy will use commercially reasonable efforts to maintain Comfy Products availability but makes no representation or warranty regarding the performance or availability of any Third-Party Infrastructure, and Comfy shall have no liability to Customer for any interruption, degradation, loss of data, or other harm arising out of or related to any failure, outage, or limitation of Third-Party Infrastructure, whether or not within Comfy’s control.',
|
||||
'zh-CN':
|
||||
'<strong>Third-Party Infrastructure.</strong> Customer acknowledges that the Comfy Products relies on third-party infrastructure, hardware, and services, including cloud computing providers and GPU infrastructure providers (collectively, “Third-Party Infrastructure”), and that the availability, performance, and security of the Comfy Products may be affected by the operation, maintenance, or failure of such Third-Party Infrastructure. Comfy will use commercially reasonable efforts to maintain Comfy Products availability but makes no representation or warranty regarding the performance or availability of any Third-Party Infrastructure, and Comfy shall have no liability to Customer for any interruption, degradation, loss of data, or other harm arising out of or related to any failure, outage, or limitation of Third-Party Infrastructure, whether or not within Comfy’s control.'
|
||||
},
|
||||
'enterprise-msa.11-miscellaneous.block.4': {
|
||||
en: '<strong>Assignment; Delegation.</strong> Neither party hereto may assign or otherwise transfer this Agreement, in whole or in part, without the other party’s prior written consent, except that Comfy may assign this Agreement without consent to a successor to all or substantially all of its assets or business related to this Agreement. Any attempted assignment, delegation, or transfer by either party in violation hereof will be null and void. Subject to the foregoing, this Agreement will be binding on the parties and their successors and assigns.',
|
||||
'zh-CN':
|
||||
'<strong>Assignment; Delegation.</strong> Neither party hereto may assign or otherwise transfer this Agreement, in whole or in part, without the other party’s prior written consent, except that Comfy may assign this Agreement without consent to a successor to all or substantially all of its assets or business related to this Agreement. Any attempted assignment, delegation, or transfer by either party in violation hereof will be null and void. Subject to the foregoing, this Agreement will be binding on the parties and their successors and assigns.'
|
||||
},
|
||||
'enterprise-msa.11-miscellaneous.block.5': {
|
||||
en: '<strong>Amendment; Waiver.</strong> No amendment or modification to this Agreement, nor any waiver of any rights hereunder, will be effective unless assented to in writing by both parties. Any such waiver will be only to the specific provision and under the specific circumstances for which it was given and will not apply with respect to any repeated or continued violation of the same provision or any other provision. Failure or delay by either party to enforce any provision of this Agreement will not be deemed a waiver of future enforcement of that or any other provision.',
|
||||
'zh-CN':
|
||||
'<strong>Amendment; Waiver.</strong> No amendment or modification to this Agreement, nor any waiver of any rights hereunder, will be effective unless assented to in writing by both parties. Any such waiver will be only to the specific provision and under the specific circumstances for which it was given and will not apply with respect to any repeated or continued violation of the same provision or any other provision. Failure or delay by either party to enforce any provision of this Agreement will not be deemed a waiver of future enforcement of that or any other provision.'
|
||||
},
|
||||
'enterprise-msa.11-miscellaneous.block.6': {
|
||||
en: '<strong>Relationship.</strong> Nothing contained herein will in any way constitute any association, partnership, agency, employment or joint venture between the parties hereto, or be construed to evidence the intention of the parties to establish any such relationship. Neither party will have the authority to obligate or bind the other in any manner, and nothing herein contained will give rise to, or is intended to give rise to any rights of any kind in favor of any third parties.',
|
||||
'zh-CN':
|
||||
'<strong>Relationship.</strong> Nothing contained herein will in any way constitute any association, partnership, agency, employment or joint venture between the parties hereto, or be construed to evidence the intention of the parties to establish any such relationship. Neither party will have the authority to obligate or bind the other in any manner, and nothing herein contained will give rise to, or is intended to give rise to any rights of any kind in favor of any third parties.'
|
||||
},
|
||||
'enterprise-msa.11-miscellaneous.block.7': {
|
||||
en: '<strong>Unenforceability.</strong> If a court of competent jurisdiction determines that any provision of this Agreement is invalid, illegal, or otherwise unenforceable, such provision will be enforced as nearly as possible in accordance with the stated intention of the parties, while the remainder of this Agreement will remain in full force and effect and bind the parties according to its terms.',
|
||||
'zh-CN':
|
||||
'<strong>Unenforceability.</strong> If a court of competent jurisdiction determines that any provision of this Agreement is invalid, illegal, or otherwise unenforceable, such provision will be enforced as nearly as possible in accordance with the stated intention of the parties, while the remainder of this Agreement will remain in full force and effect and bind the parties according to its terms.'
|
||||
},
|
||||
'enterprise-msa.11-miscellaneous.block.8': {
|
||||
en: '<strong>Notices.</strong> Any notice required or permitted to be given hereunder will be given in writing by personal delivery, certified mail, return receipt requested, or by overnight delivery. Notices to the parties must be sent to the respective address set forth in the signature blocks below, or such other address designated pursuant to this Section.',
|
||||
'zh-CN':
|
||||
'<strong>Notices.</strong> Any notice required or permitted to be given hereunder will be given in writing by personal delivery, certified mail, return receipt requested, or by overnight delivery. Notices to the parties must be sent to the respective address set forth in the signature blocks below, or such other address designated pursuant to this Section.'
|
||||
},
|
||||
'enterprise-msa.11-miscellaneous.block.9': {
|
||||
en: '<strong>Force Majeure.</strong> Neither party will be deemed in breach hereunder for any cessation, interruption or delay in the performance of its obligations due to causes beyond its reasonable control, including earthquake, flood, or other natural disaster, act of God, labor controversy, civil disturbance, terrorism, war (whether or not officially declared), cyber attacks (e.g., denial of service attacks), or the inability to obtain sufficient supplies, transportation, or other essential commodity or service required in the conduct of its business, or any change in or the adoption of any law, regulation, judgment or decree for which the party could not reasonably prepare mitigation in advance.',
|
||||
'zh-CN':
|
||||
'<strong>Force Majeure.</strong> Neither party will be deemed in breach hereunder for any cessation, interruption or delay in the performance of its obligations due to causes beyond its reasonable control, including earthquake, flood, or other natural disaster, act of God, labor controversy, civil disturbance, terrorism, war (whether or not officially declared), cyber attacks (e.g., denial of service attacks), or the inability to obtain sufficient supplies, transportation, or other essential commodity or service required in the conduct of its business, or any change in or the adoption of any law, regulation, judgment or decree for which the party could not reasonably prepare mitigation in advance.'
|
||||
},
|
||||
'enterprise-msa.11-miscellaneous.block.10': {
|
||||
en: '<strong>Entire Agreement.</strong> This Agreement comprises the entire agreement between Customer and Comfy with respect to its subject matter, and supersedes all prior and contemporaneous proposals, statements, sales materials or presentations and agreements (oral and written). No oral or written information or advice given by Comfy, its agents or employees will create a warranty or in any way increase the scope of the warranties in this Agreement.',
|
||||
'zh-CN':
|
||||
'<strong>Entire Agreement.</strong> This Agreement comprises the entire agreement between Customer and Comfy with respect to its subject matter, and supersedes all prior and contemporaneous proposals, statements, sales materials or presentations and agreements (oral and written). No oral or written information or advice given by Comfy, its agents or employees will create a warranty or in any way increase the scope of the warranties in this Agreement.'
|
||||
},
|
||||
'enterprise-msa.12-exhibit-a.label': {
|
||||
en: 'EXHIBIT A',
|
||||
'zh-CN': 'EXHIBIT A'
|
||||
},
|
||||
'enterprise-msa.12-exhibit-a.title': {
|
||||
en: 'Exhibit A. Order Form',
|
||||
'zh-CN': 'Exhibit A. Order Form'
|
||||
},
|
||||
'enterprise-msa.12-exhibit-a.block.0': {
|
||||
en: 'The initial Order Form is attached as <strong>Exhibit A</strong> to the executed copy of this Agreement. Each Order Form is subject to the terms and conditions of this Agreement, and by executing an Order Form, Customer agrees to be bound by the terms and conditions of this Agreement.',
|
||||
'zh-CN':
|
||||
'The initial Order Form is attached as <strong>Exhibit A</strong> to the executed copy of this Agreement. Each Order Form is subject to the terms and conditions of this Agreement, and by executing an Order Form, Customer agrees to be bound by the terms and conditions of this Agreement.'
|
||||
},
|
||||
'enterprise-msa.12-exhibit-a.block.1': {
|
||||
en: 'This document reproduces the current template of the Enterprise Customer Agreement for reference only. The executed Agreement between Comfy and Customer, together with any signed Order Forms, governs the relationship between the parties. To request an executable copy, please contact <a href="mailto:sales@comfy.org" class="text-white underline">sales@comfy.org</a>.',
|
||||
'zh-CN':
|
||||
'This document reproduces the current template of the Enterprise Customer Agreement for reference only. The executed Agreement between Comfy and Customer, together with any signed Order Forms, governs the relationship between the parties. To request an executable copy, please contact <a href="mailto:sales@comfy.org" class="text-white underline">sales@comfy.org</a>.'
|
||||
},
|
||||
'enterprise-msa.page.title': {
|
||||
en: 'Enterprise MSA — Comfy',
|
||||
'zh-CN': 'Enterprise MSA — Comfy'
|
||||
},
|
||||
'enterprise-msa.page.description': {
|
||||
en: 'Comfy Enterprise Customer Agreement — the master services agreement that governs Comfy Enterprise deployments of Comfy Cloud, Comfy API, and related products.',
|
||||
'zh-CN':
|
||||
'Comfy Enterprise Customer Agreement — the master services agreement that governs Comfy Enterprise deployments of Comfy Cloud, Comfy API, and related products.'
|
||||
},
|
||||
'enterprise-msa.page.heading': {
|
||||
en: 'Enterprise Customer Agreement',
|
||||
'zh-CN': 'Enterprise Customer Agreement'
|
||||
},
|
||||
'enterprise-msa.page.tocLabel': {
|
||||
en: 'On this page',
|
||||
'zh-CN': 'On this page'
|
||||
},
|
||||
'enterprise-msa.page.effectiveDateLabel': {
|
||||
en: 'Effective Date',
|
||||
'zh-CN': 'Effective Date'
|
||||
},
|
||||
'enterprise-msa.page.parties': {
|
||||
en: 'This Enterprise Customer Agreement (the “Agreement”) is entered into by and between Comfy Organization, Inc., a Delaware corporation (“Comfy”), and the entity identified on the applicable Order Form (“Customer”), and is effective as of the date set forth on the applicable Order Form (the “Effective Date”).',
|
||||
'zh-CN':
|
||||
'This Enterprise Customer Agreement (the “Agreement”) is entered into by and between Comfy Organization, Inc., a Delaware corporation (“Comfy”), and the entity identified on the applicable Order Form (“Customer”), and is effective as of the date set forth on the applicable Order Form (the “Effective Date”).'
|
||||
},
|
||||
'footer.enterpriseMsa': {
|
||||
en: 'Enterprise MSA',
|
||||
'zh-CN': 'Enterprise MSA'
|
||||
},
|
||||
|
||||
// Customers page
|
||||
'customers.hero.label': {
|
||||
en: 'CUSTOMER STORIES',
|
||||
@@ -4068,6 +3628,7 @@ const translations = {
|
||||
en: 'This page is being redesigned. Check back soon.',
|
||||
'zh-CN': '此页面正在重新设计中,请稍后再来。'
|
||||
},
|
||||
'demos.breadcrumb.home': { en: 'Home', 'zh-CN': '首页' },
|
||||
'demos.breadcrumb.demos': { en: 'Demos', 'zh-CN': '演示' },
|
||||
|
||||
'customers.story.whatsNext': {
|
||||
@@ -4163,6 +3724,10 @@ const translations = {
|
||||
en: "Run the world's leading AI models in ComfyUI",
|
||||
'zh-CN': '在 ComfyUI 中运行世界领先的 AI 模型'
|
||||
},
|
||||
'models.breadcrumb.home': {
|
||||
en: 'Home',
|
||||
'zh-CN': '首页'
|
||||
},
|
||||
'models.breadcrumb.models': {
|
||||
en: 'Supported Models',
|
||||
'zh-CN': '支持的模型'
|
||||
|
||||
@@ -7,17 +7,14 @@ import SiteFooter from '../components/common/SiteFooter.vue'
|
||||
import HeaderMain from '../components/common/HeaderMain/HeaderMain.vue'
|
||||
import AnnouncementBanner from '../templates/drops/AnnouncementBanner.vue'
|
||||
import { bannerConfig, getBannerData } from '../config/banner'
|
||||
import { isHrefActive } from '../composables/useCurrentPath'
|
||||
import {
|
||||
BANNER_DISMISS_ATTR,
|
||||
BANNER_STORAGE_KEY,
|
||||
createBannerVersion,
|
||||
evaluateBannerVisibility
|
||||
} from '../utils/banner'
|
||||
import { escapeJsonLd } from '../utils/escapeJsonLd'
|
||||
import { fetchGitHubStars, formatStarCount } from '../utils/github'
|
||||
import { buildPageGraph, pageContext } from '../utils/jsonLd'
|
||||
import type { Crumb, JsonLdNode, WebPageType } from '../utils/jsonLd'
|
||||
import JsonLdGraph from '../components/common/JsonLdGraph.astro'
|
||||
|
||||
interface Props {
|
||||
title: string
|
||||
@@ -25,10 +22,6 @@ interface Props {
|
||||
keywords?: string[]
|
||||
ogImage?: string
|
||||
noindex?: boolean
|
||||
pageType?: WebPageType
|
||||
breadcrumbs?: Crumb[]
|
||||
mainEntityId?: string
|
||||
extraJsonLd?: (JsonLdNode | null | undefined)[]
|
||||
}
|
||||
|
||||
const {
|
||||
@@ -37,54 +30,52 @@ const {
|
||||
keywords,
|
||||
ogImage = 'https://media.comfy.org/website/comfy.webp',
|
||||
noindex = false,
|
||||
pageType,
|
||||
breadcrumbs,
|
||||
mainEntityId,
|
||||
extraJsonLd,
|
||||
} = Astro.props
|
||||
|
||||
const keywordsContent = keywords && keywords.length > 0 ? keywords.join(', ') : undefined
|
||||
|
||||
const { siteUrl, locale, url } = pageContext(
|
||||
Astro.site,
|
||||
Astro.url.pathname,
|
||||
Astro.currentLocale,
|
||||
)
|
||||
const canonicalURL = new URL(url)
|
||||
const ogImageURL = new URL(ogImage, Astro.site ?? 'https://comfy.org')
|
||||
const siteBase = Astro.site ?? 'https://comfy.org'
|
||||
const canonicalURL = new URL(Astro.url.pathname, siteBase)
|
||||
const ogImageURL = new URL(ogImage, siteBase)
|
||||
const rawLocale = Astro.currentLocale ?? 'en'
|
||||
const locale: Locale = rawLocale === 'zh-CN' ? 'zh-CN' : 'en'
|
||||
const rawStars = await fetchGitHubStars('Comfy-Org', 'ComfyUI')
|
||||
const githubStars = rawStars ? formatStarCount(rawStars) : ''
|
||||
|
||||
// Announcement banner — build-time visibility gate + content-hash version key.
|
||||
// A promo never advertises the page you are already on, so the banner is
|
||||
// suppressed when its CTA points at the current path.
|
||||
const bannerData = getBannerData(bannerConfig, locale)
|
||||
const bannerVisible =
|
||||
evaluateBannerVisibility(bannerConfig, {
|
||||
currentLocale: locale,
|
||||
currentSection: 'sitewide',
|
||||
now: new Date(),
|
||||
}) && !isHrefActive(bannerData.link?.href ?? '', Astro.url.pathname)
|
||||
const bannerVisible = evaluateBannerVisibility(bannerConfig, {
|
||||
currentLocale: locale,
|
||||
currentSection: 'sitewide',
|
||||
now: new Date(),
|
||||
})
|
||||
const bannerVersion = createBannerVersion(bannerData, locale)
|
||||
|
||||
const gtmId = 'GTM-NP9JM6K7'
|
||||
const gtmEnabled = import.meta.env.PROD
|
||||
|
||||
const structuredData = noindex
|
||||
? undefined
|
||||
: buildPageGraph(
|
||||
{ siteUrl, locale },
|
||||
{
|
||||
url,
|
||||
name: title,
|
||||
description,
|
||||
imageUrl: ogImageURL.href,
|
||||
type: pageType,
|
||||
crumbs: breadcrumbs,
|
||||
mainEntityId,
|
||||
},
|
||||
...(extraJsonLd ?? []),
|
||||
)
|
||||
const organizationJsonLd = {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'Organization',
|
||||
name: 'Comfy Org',
|
||||
url: 'https://comfy.org',
|
||||
logo: 'https://comfy.org/icons/logomark.svg',
|
||||
sameAs: [
|
||||
'https://github.com/comfyanonymous/ComfyUI',
|
||||
'https://discord.gg/comfyorg',
|
||||
'https://x.com/comaboratory',
|
||||
'https://reddit.com/r/comfyui',
|
||||
'https://linkedin.com/company/comfyorg',
|
||||
'https://instagram.com/comfyorg',
|
||||
],
|
||||
}
|
||||
|
||||
const websiteJsonLd = {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'WebSite',
|
||||
name: 'Comfy',
|
||||
url: 'https://comfy.org',
|
||||
}
|
||||
---
|
||||
|
||||
<!doctype html>
|
||||
@@ -126,7 +117,10 @@ const structuredData = noindex
|
||||
<meta name="twitter:image" content={ogImageURL.href} />
|
||||
|
||||
<!-- Structured Data -->
|
||||
{structuredData && <JsonLdGraph graph={structuredData} />}
|
||||
<script is:inline type="application/ld+json" set:html={escapeJsonLd(organizationJsonLd)} />
|
||||
<script is:inline type="application/ld+json" set:html={escapeJsonLd(websiteJsonLd)} />
|
||||
<slot name="head" />
|
||||
|
||||
<slot name="head" />
|
||||
|
||||
<!-- Google Tag Manager -->
|
||||
@@ -146,6 +140,7 @@ const structuredData = noindex
|
||||
)}
|
||||
|
||||
<ClientRouter />
|
||||
<slot name="head" />
|
||||
|
||||
<!-- Hide an already-dismissed announcement banner before first paint (no flash/shift). -->
|
||||
{bannerVisible && (
|
||||
|
||||
@@ -5,25 +5,9 @@ import StorySection from '../components/about/StorySection.vue'
|
||||
import OurValuesSection from '../components/about/OurValuesSection.vue'
|
||||
import ValuesSection from '../components/about/ValuesSection.vue'
|
||||
import CareersSection from '../components/about/CareersSection.vue'
|
||||
import { t } from '../i18n/translations'
|
||||
import { absoluteUrl, organizationId, pageContext } from '../utils/jsonLd'
|
||||
|
||||
const { siteUrl, locale } = pageContext(
|
||||
Astro.site,
|
||||
Astro.url.pathname,
|
||||
Astro.currentLocale,
|
||||
)
|
||||
---
|
||||
|
||||
<BaseLayout
|
||||
title="About Us — Comfy"
|
||||
pageType="AboutPage"
|
||||
mainEntityId={organizationId(siteUrl)}
|
||||
breadcrumbs={[
|
||||
{ name: t('breadcrumb.home', locale), url: absoluteUrl(Astro.site, '/') },
|
||||
{ name: t('breadcrumb.about', locale) },
|
||||
]}
|
||||
>
|
||||
<BaseLayout title="About Us — Comfy">
|
||||
<HeroSection client:load />
|
||||
<StorySection />
|
||||
<OurValuesSection />
|
||||
|
||||
@@ -9,36 +9,34 @@ import HeroSection from '../../templates/affiliate/HeroSection.vue'
|
||||
import HowItWorksSection from '../../templates/affiliate/HowItWorksSection.vue'
|
||||
import { affiliateFaqs } from '../../data/affiliateFaq'
|
||||
import { t } from '../../i18n/translations'
|
||||
import type { JsonLdNode } from '../../utils/jsonLd'
|
||||
import { absoluteUrl, jsonLdId, pageContext } from '../../utils/jsonLd'
|
||||
|
||||
const pageTitle = t('affiliate.page.title', 'en')
|
||||
const pageDescription = t('affiliate.page.description', 'en')
|
||||
const { locale, url } = pageContext(
|
||||
Astro.site,
|
||||
Astro.url.pathname,
|
||||
Astro.currentLocale,
|
||||
)
|
||||
const faqPage: JsonLdNode = {
|
||||
const locale = 'en' as const
|
||||
|
||||
const faqJsonLd = {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'FAQPage',
|
||||
'@id': jsonLdId(url, 'faq'),
|
||||
mainEntity: affiliateFaqs.map((faq) => ({
|
||||
'@type': 'Question',
|
||||
name: faq.question[locale],
|
||||
acceptedAnswer: { '@type': 'Answer', text: faq.answer[locale] },
|
||||
})),
|
||||
acceptedAnswer: {
|
||||
'@type': 'Answer',
|
||||
text: faq.answer[locale]
|
||||
}
|
||||
}))
|
||||
}
|
||||
---
|
||||
|
||||
<BaseLayout
|
||||
title={pageTitle}
|
||||
description={pageDescription}
|
||||
breadcrumbs={[
|
||||
{ name: t('breadcrumb.home', locale), url: absoluteUrl(Astro.site, '/') },
|
||||
{ name: pageTitle },
|
||||
]}
|
||||
extraJsonLd={[faqPage]}
|
||||
title={t('affiliate.page.title', locale)}
|
||||
description={t('affiliate.page.description', locale)}
|
||||
>
|
||||
<Fragment slot="head">
|
||||
<script
|
||||
is:inline
|
||||
type="application/ld+json"
|
||||
set:html={JSON.stringify(faqJsonLd)}
|
||||
/>
|
||||
</Fragment>
|
||||
|
||||
<HeroSection />
|
||||
<HowItWorksSection />
|
||||
|
||||
@@ -7,13 +7,6 @@ import TeamPhotosSection from '../components/careers/TeamPhotosSection.vue'
|
||||
import FAQSection from '../components/common/FAQSection.vue'
|
||||
import { fetchRolesForBuild } from '../utils/ashby'
|
||||
import { reportAshbyOutcome } from '../utils/ashby.ci'
|
||||
import { t } from '../i18n/translations'
|
||||
import {
|
||||
absoluteUrl,
|
||||
itemListNode,
|
||||
jsonLdId,
|
||||
pageContext,
|
||||
} from '../utils/jsonLd'
|
||||
|
||||
const outcome = await fetchRolesForBuild()
|
||||
reportAshbyOutcome(outcome)
|
||||
@@ -26,31 +19,11 @@ if (outcome.status === 'failed') {
|
||||
}
|
||||
|
||||
const departments = outcome.snapshot.departments
|
||||
|
||||
const { siteUrl, locale, url } = pageContext(
|
||||
Astro.site,
|
||||
Astro.url.pathname,
|
||||
Astro.currentLocale,
|
||||
)
|
||||
const roles = itemListNode(
|
||||
url,
|
||||
t('breadcrumb.careers', locale),
|
||||
departments.flatMap((department) =>
|
||||
department.roles.map((role) => ({ name: role.title, url: role.jobUrl })),
|
||||
),
|
||||
)
|
||||
---
|
||||
|
||||
<BaseLayout
|
||||
title="Careers — Comfy"
|
||||
description="Join the team building the operating system for generative AI. Open roles in engineering, design, marketing, and more."
|
||||
pageType="CollectionPage"
|
||||
mainEntityId={jsonLdId(url, 'itemlist')}
|
||||
breadcrumbs={[
|
||||
{ name: t('breadcrumb.home', locale), url: absoluteUrl(Astro.site, '/') },
|
||||
{ name: t('breadcrumb.careers', locale) },
|
||||
]}
|
||||
extraJsonLd={[roles]}
|
||||
>
|
||||
<HeroSection />
|
||||
<RolesSection departments={departments} client:visible />
|
||||
|
||||
@@ -2,41 +2,9 @@
|
||||
import BaseLayout from '../../layouts/BaseLayout.astro'
|
||||
import PriceSection from '../../components/pricing/PriceSection.vue'
|
||||
import WhatsIncludedSection from '../../components/pricing/WhatsIncludedSection.vue'
|
||||
import { pricingOffers } from '../../config/pricing'
|
||||
import { t } from '../../i18n/translations'
|
||||
import {
|
||||
absoluteUrl,
|
||||
jsonLdId,
|
||||
pageContext,
|
||||
productNode,
|
||||
} from '../../utils/jsonLd'
|
||||
|
||||
const { siteUrl, locale, url } = pageContext(
|
||||
Astro.site,
|
||||
Astro.url.pathname,
|
||||
Astro.currentLocale,
|
||||
)
|
||||
const productId = jsonLdId(url, 'product')
|
||||
---
|
||||
|
||||
<BaseLayout
|
||||
title="Pricing — Comfy Cloud"
|
||||
mainEntityId={productId}
|
||||
breadcrumbs={[
|
||||
{ name: t('breadcrumb.home', locale), url: absoluteUrl(Astro.site, '/') },
|
||||
{ name: 'Comfy Cloud', url: absoluteUrl(Astro.site, '/cloud') },
|
||||
{ name: t('breadcrumb.pricing', locale) },
|
||||
]}
|
||||
extraJsonLd={[
|
||||
productNode({
|
||||
siteUrl,
|
||||
id: productId,
|
||||
name: 'Comfy Cloud',
|
||||
url,
|
||||
offers: pricingOffers(locale),
|
||||
}),
|
||||
]}
|
||||
>
|
||||
<BaseLayout title="Pricing — Comfy Cloud">
|
||||
<PriceSection client:load />
|
||||
<WhatsIncludedSection />
|
||||
</BaseLayout>
|
||||
|
||||
@@ -4,44 +4,39 @@ import HeroSection from '../../components/cloud-nodes/HeroSection.vue'
|
||||
import PackGridSection from '../../components/cloud-nodes/PackGridSection.vue'
|
||||
import { t } from '../../i18n/translations'
|
||||
import { loadPacksForBuild } from '../../utils/cloudNodes.build'
|
||||
import {
|
||||
absoluteUrl,
|
||||
itemListNode,
|
||||
jsonLdId,
|
||||
pageContext,
|
||||
} from '../../utils/jsonLd'
|
||||
import { escapeJsonLd } from '../../utils/escapeJsonLd'
|
||||
|
||||
const packs = await loadPacksForBuild()
|
||||
|
||||
const title = t('cloudNodes.meta.title', 'en')
|
||||
const description = t('cloudNodes.meta.description', 'en')
|
||||
const { url, locale } = pageContext(
|
||||
Astro.site,
|
||||
Astro.url.pathname,
|
||||
Astro.currentLocale,
|
||||
)
|
||||
const packList = itemListNode(
|
||||
url,
|
||||
title,
|
||||
packs.map((pack) => ({
|
||||
const siteBase = Astro.site ?? new URL('https://comfy.org')
|
||||
const pageUrl = new URL('/cloud/supported-nodes', siteBase).href
|
||||
|
||||
const itemListJsonLd = {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'ItemList',
|
||||
name: 'Custom-node packs supported on Comfy Cloud',
|
||||
url: pageUrl,
|
||||
numberOfItems: packs.length,
|
||||
itemListElement: packs.map((pack, index) => ({
|
||||
'@type': 'ListItem',
|
||||
position: index + 1,
|
||||
url: new URL(`/cloud/supported-nodes/${pack.id}`, siteBase).href,
|
||||
name: pack.displayName,
|
||||
url: absoluteUrl(Astro.site, `/cloud/supported-nodes/${pack.id}`),
|
||||
})),
|
||||
)
|
||||
image: pack.bannerUrl || pack.iconUrl
|
||||
}))
|
||||
}
|
||||
---
|
||||
|
||||
<BaseLayout
|
||||
title={title}
|
||||
description={description}
|
||||
pageType="CollectionPage"
|
||||
mainEntityId={jsonLdId(url, 'itemlist')}
|
||||
breadcrumbs={[
|
||||
{ name: t('breadcrumb.home', locale), url: absoluteUrl(Astro.site, '/') },
|
||||
{ name: 'Comfy Cloud', url: absoluteUrl(Astro.site, '/cloud') },
|
||||
{ name: t('breadcrumb.supportedNodes', locale) },
|
||||
]}
|
||||
extraJsonLd={[packList]}
|
||||
title={t('cloudNodes.meta.title', 'en')}
|
||||
description={t('cloudNodes.meta.description', 'en')}
|
||||
>
|
||||
<script
|
||||
is:inline
|
||||
slot="head"
|
||||
type="application/ld+json"
|
||||
set:html={escapeJsonLd(itemListJsonLd)}
|
||||
/>
|
||||
<HeroSection client:visible />
|
||||
<PackGridSection packs={packs} client:visible />
|
||||
</BaseLayout>
|
||||
|
||||
@@ -7,12 +7,7 @@ import PackDetail from '../../../components/cloud-nodes/PackDetail.vue'
|
||||
import BaseLayout from '../../../layouts/BaseLayout.astro'
|
||||
import { t } from '../../../i18n/translations'
|
||||
import { loadPacksForBuild } from '../../../utils/cloudNodes.build'
|
||||
import {
|
||||
absoluteUrl,
|
||||
jsonLdId,
|
||||
pageContext,
|
||||
softwareApplicationNode,
|
||||
} from '../../../utils/jsonLd'
|
||||
import { escapeJsonLd } from '../../../utils/escapeJsonLd'
|
||||
|
||||
export const getStaticPaths: GetStaticPaths = async () => {
|
||||
const packs = await loadPacksForBuild()
|
||||
@@ -34,45 +29,35 @@ const metaDescription = t('cloudNodes.detail.metaDescription', 'en')
|
||||
.replace('{nodeCount}', String(pack.nodes.length))
|
||||
.replace('{description}', description)
|
||||
|
||||
const { siteUrl, locale, url } = pageContext(
|
||||
Astro.site,
|
||||
Astro.url.pathname,
|
||||
Astro.currentLocale,
|
||||
)
|
||||
const softwareId = jsonLdId(url, 'software')
|
||||
const software = softwareApplicationNode({
|
||||
siteUrl,
|
||||
id: softwareId,
|
||||
const siteBase = Astro.site ?? new URL('https://comfy.org')
|
||||
const pageUrl = new URL(`/cloud/supported-nodes/${pack.id}`, siteBase).href
|
||||
|
||||
const softwareJsonLd = {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'SoftwareApplication',
|
||||
name: pack.displayName,
|
||||
url,
|
||||
applicationCategory: 'DeveloperApplication',
|
||||
applicationSubCategory: 'ComfyUI custom-node pack',
|
||||
operatingSystem: 'Comfy Cloud (managed)',
|
||||
description: pack.description || undefined,
|
||||
url: pageUrl,
|
||||
description,
|
||||
image: pack.bannerUrl || pack.iconUrl,
|
||||
softwareVersion: pack.latestVersion,
|
||||
license: pack.license,
|
||||
codeRepository: pack.repoUrl,
|
||||
authorName: pack.publisher?.name,
|
||||
isFree: true,
|
||||
})
|
||||
author: pack.publisher?.name
|
||||
? { '@type': 'Person', name: pack.publisher.name }
|
||||
: undefined,
|
||||
offers: { '@type': 'Offer', price: 0, priceCurrency: 'USD' }
|
||||
}
|
||||
---
|
||||
|
||||
<BaseLayout
|
||||
title={title}
|
||||
description={metaDescription}
|
||||
ogImage={pack.bannerUrl}
|
||||
mainEntityId={softwareId}
|
||||
breadcrumbs={[
|
||||
{ name: t('breadcrumb.home', locale), url: absoluteUrl(Astro.site, '/') },
|
||||
{ name: 'Comfy Cloud', url: absoluteUrl(Astro.site, '/cloud') },
|
||||
{
|
||||
name: t('breadcrumb.supportedNodes', locale),
|
||||
url: absoluteUrl(Astro.site, '/cloud/supported-nodes'),
|
||||
},
|
||||
{ name: pack.displayName },
|
||||
]}
|
||||
extraJsonLd={[software]}
|
||||
>
|
||||
<BaseLayout title={title} description={metaDescription} ogImage={pack.bannerUrl}>
|
||||
<script
|
||||
is:inline
|
||||
slot="head"
|
||||
type="application/ld+json"
|
||||
set:html={escapeJsonLd(softwareJsonLd)}
|
||||
/>
|
||||
<PackDetail pack={pack} />
|
||||
</BaseLayout>
|
||||
|
||||
@@ -2,25 +2,9 @@
|
||||
import BaseLayout from '../layouts/BaseLayout.astro'
|
||||
import FormSection from '../components/contact/FormSection.vue'
|
||||
import SocialProofBarSection from '../components/common/SocialProofBarSection.vue'
|
||||
import { t } from '../i18n/translations'
|
||||
import { absoluteUrl, organizationId, pageContext } from '../utils/jsonLd'
|
||||
|
||||
const { siteUrl, locale } = pageContext(
|
||||
Astro.site,
|
||||
Astro.url.pathname,
|
||||
Astro.currentLocale,
|
||||
)
|
||||
---
|
||||
|
||||
<BaseLayout
|
||||
title="Contact — Comfy"
|
||||
pageType="ContactPage"
|
||||
mainEntityId={organizationId(siteUrl)}
|
||||
breadcrumbs={[
|
||||
{ name: t('breadcrumb.home', locale), url: absoluteUrl(Astro.site, '/') },
|
||||
{ name: t('breadcrumb.contact', locale) },
|
||||
]}
|
||||
>
|
||||
<BaseLayout title="Contact — Comfy">
|
||||
<FormSection client:load />
|
||||
<SocialProofBarSection />
|
||||
</BaseLayout>
|
||||
|
||||
@@ -7,13 +7,6 @@ import DemoTranscript from '../../components/demos/DemoTranscript.vue'
|
||||
import DemoNavSection from '../../components/demos/DemoNavSection.vue'
|
||||
import { demos, getDemoBySlug, getNextDemo } from '../../config/demos'
|
||||
import { t } from '../../i18n/translations'
|
||||
import type { JsonLdNode } from '../../utils/jsonLd'
|
||||
import {
|
||||
absoluteUrl,
|
||||
jsonLdId,
|
||||
organizationId,
|
||||
pageContext,
|
||||
} from '../../utils/jsonLd'
|
||||
|
||||
export const getStaticPaths: GetStaticPaths = () => {
|
||||
return demos.map((demo) => ({
|
||||
@@ -26,34 +19,68 @@ const demo = getDemoBySlug(slug as string)!
|
||||
const nextDemo = getNextDemo(slug as string)
|
||||
const title = t(demo.title)
|
||||
const description = t(demo.description)
|
||||
const canonicalURL = new URL(`/demos/${demo.slug}`, Astro.site)
|
||||
|
||||
const { siteUrl, locale, url } = pageContext(
|
||||
Astro.site,
|
||||
Astro.url.pathname,
|
||||
Astro.currentLocale,
|
||||
)
|
||||
const educationalLevel =
|
||||
demo.difficulty === 'beginner'
|
||||
const howToJsonLd = {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'HowTo',
|
||||
name: title,
|
||||
description,
|
||||
image: new URL(demo.ogImage, Astro.site).href,
|
||||
totalTime: demo.durationIso,
|
||||
datePublished: demo.publishedDate,
|
||||
dateModified: demo.modifiedDate,
|
||||
author: {
|
||||
'@type': 'Organization',
|
||||
name: 'Comfy Org',
|
||||
url: 'https://comfy.org'
|
||||
}
|
||||
}
|
||||
|
||||
const learningResourceJsonLd = {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'LearningResource',
|
||||
name: title,
|
||||
description,
|
||||
learningResourceType: 'interactive tutorial',
|
||||
interactivityType: 'active',
|
||||
educationalLevel: demo.difficulty === 'beginner'
|
||||
? 'Beginner'
|
||||
: demo.difficulty === 'intermediate'
|
||||
? 'Intermediate'
|
||||
: 'Advanced'
|
||||
const learningId = jsonLdId(url, 'learning')
|
||||
const learningResource: JsonLdNode = {
|
||||
'@type': 'LearningResource',
|
||||
'@id': learningId,
|
||||
name: title,
|
||||
description,
|
||||
url,
|
||||
image: new URL(demo.ogImage, Astro.site).href,
|
||||
learningResourceType: 'interactive tutorial',
|
||||
interactivityType: 'active',
|
||||
educationalLevel,
|
||||
timeRequired: demo.durationIso,
|
||||
: 'Advanced',
|
||||
url: canonicalURL.href,
|
||||
datePublished: demo.publishedDate,
|
||||
dateModified: demo.modifiedDate,
|
||||
isPartOf: { '@id': jsonLdId(url, 'webpage') },
|
||||
author: { '@id': organizationId(siteUrl) },
|
||||
author: {
|
||||
'@type': 'Organization',
|
||||
name: 'Comfy Org',
|
||||
url: 'https://comfy.org'
|
||||
}
|
||||
}
|
||||
|
||||
const breadcrumbJsonLd = {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'BreadcrumbList',
|
||||
itemListElement: [
|
||||
{
|
||||
'@type': 'ListItem',
|
||||
position: 1,
|
||||
name: t('demos.breadcrumb.home'),
|
||||
item: 'https://comfy.org'
|
||||
},
|
||||
{
|
||||
'@type': 'ListItem',
|
||||
position: 2,
|
||||
name: t('demos.breadcrumb.demos'),
|
||||
item: 'https://comfy.org/demos'
|
||||
},
|
||||
{
|
||||
'@type': 'ListItem',
|
||||
position: 3,
|
||||
name: title
|
||||
}
|
||||
]
|
||||
}
|
||||
---
|
||||
|
||||
@@ -61,20 +88,25 @@ const learningResource: JsonLdNode = {
|
||||
title={`${title} — Comfy`}
|
||||
description={description}
|
||||
ogImage={demo.ogImage}
|
||||
mainEntityId={learningId}
|
||||
breadcrumbs={[
|
||||
{ name: t('breadcrumb.home', locale), url: absoluteUrl(Astro.site, '/') },
|
||||
{
|
||||
name: t('demos.breadcrumb.demos', locale),
|
||||
url: absoluteUrl(Astro.site, '/demos'),
|
||||
},
|
||||
{ name: title },
|
||||
]}
|
||||
extraJsonLd={[learningResource]}
|
||||
>
|
||||
<Fragment slot="head">
|
||||
<meta property="article:published_time" content={demo.publishedDate} />
|
||||
<meta property="article:modified_time" content={demo.modifiedDate} />
|
||||
<script
|
||||
is:inline
|
||||
type="application/ld+json"
|
||||
set:html={JSON.stringify(howToJsonLd)}
|
||||
/>
|
||||
<script
|
||||
is:inline
|
||||
type="application/ld+json"
|
||||
set:html={JSON.stringify(learningResourceJsonLd)}
|
||||
/>
|
||||
<script
|
||||
is:inline
|
||||
type="application/ld+json"
|
||||
set:html={JSON.stringify(breadcrumbJsonLd)}
|
||||
/>
|
||||
<link rel="preconnect" href="https://demo.arcade.software" />
|
||||
</Fragment>
|
||||
|
||||
|
||||
@@ -8,29 +8,11 @@ import EcoSystemSection from '../components/product/local/EcoSystemSection.vue'
|
||||
import ProductCardsSection from '../components/product/local/ProductCardsSection.vue'
|
||||
import FAQSection from '../components/product/local/FAQSection.vue'
|
||||
import { t } from '../i18n/translations'
|
||||
import {
|
||||
absoluteUrl,
|
||||
comfyUiApplicationNode,
|
||||
comfyUiSoftwareId,
|
||||
pageContext,
|
||||
} from '../utils/jsonLd'
|
||||
|
||||
const { siteUrl, locale } = pageContext(
|
||||
Astro.site,
|
||||
Astro.url.pathname,
|
||||
Astro.currentLocale,
|
||||
)
|
||||
---
|
||||
|
||||
<BaseLayout
|
||||
title="Download Comfy Desktop — Run AI on Your Hardware"
|
||||
description={t('download.hero.subtitle', 'en')}
|
||||
mainEntityId={comfyUiSoftwareId(siteUrl)}
|
||||
breadcrumbs={[
|
||||
{ name: t('breadcrumb.home', locale), url: absoluteUrl(Astro.site, '/') },
|
||||
{ name: t('breadcrumb.download', locale) },
|
||||
]}
|
||||
extraJsonLd={[comfyUiApplicationNode(siteUrl)]}
|
||||
keywords={['comfyui app', 'comfyui desktop app', 'comfyui desktop', 'comfy ui application', 'comfyui download', 'download comfyui', 'comfyui windows', 'comfyui mac', 'comfyui linux']}
|
||||
>
|
||||
<CloudBannerSection />
|
||||
|
||||
@@ -1,36 +0,0 @@
|
||||
---
|
||||
// Enterprise Customer Agreement (Enterprise MSA) — English only, by design.
|
||||
// Legal-reviewed copy must not be served under a localized route until legal
|
||||
// explicitly approves a translation; rendering an unreviewed translation as
|
||||
// the active MSA exposes us to liability from the translation diverging from
|
||||
// the approved English source. See the matching comment in
|
||||
// src/i18n/translations.ts for the i18n block, and the entry in
|
||||
// LOCALE_INVARIANT_ROUTE_KEYS in src/config/routes.ts.
|
||||
import BaseLayout from '../layouts/BaseLayout.astro'
|
||||
import HeroSection from '../components/legal/HeroSection.vue'
|
||||
import LegalContentSection from '../components/legal/LegalContentSection.vue'
|
||||
import { t } from '../i18n/translations'
|
||||
---
|
||||
|
||||
<BaseLayout
|
||||
title={t('enterprise-msa.page.title')}
|
||||
description={t('enterprise-msa.page.description')}
|
||||
>
|
||||
<HeroSection title={t('enterprise-msa.page.heading')} />
|
||||
<p class="text-primary-warm-gray mt-2 text-center text-sm">
|
||||
{t('enterprise-msa.page.effectiveDateLabel')}: {
|
||||
t('enterprise-msa.effective-date')
|
||||
}
|
||||
</p>
|
||||
<p
|
||||
class="text-primary-comfy-canvas mx-auto mt-8 max-w-3xl px-4 text-center text-sm/relaxed lg:px-0"
|
||||
>
|
||||
{t('enterprise-msa.page.parties')}
|
||||
</p>
|
||||
<LegalContentSection
|
||||
prefix="enterprise-msa"
|
||||
locale="en"
|
||||
tocLabelKey="enterprise-msa.page.tocLabel"
|
||||
client:load
|
||||
/>
|
||||
</BaseLayout>
|
||||
@@ -9,28 +9,11 @@ import CaseStudySpotlightSection from "../components/home/CaseStudySpotlightSect
|
||||
import GetStartedSection from "../components/home/GetStartedSection.vue";
|
||||
import BuildWhatSection from "../components/home/BuildWhatSection.vue";
|
||||
import { t } from "../i18n/translations";
|
||||
import {
|
||||
comfyUiApplicationNode,
|
||||
comfyUiSoftwareId,
|
||||
comfyUiSourceCodeNode,
|
||||
pageContext,
|
||||
} from "../utils/jsonLd";
|
||||
|
||||
const { siteUrl } = pageContext(
|
||||
Astro.site,
|
||||
Astro.url.pathname,
|
||||
Astro.currentLocale,
|
||||
);
|
||||
---
|
||||
|
||||
<BaseLayout
|
||||
title="Comfy — Professional Control of Visual AI"
|
||||
description={t("hero.subtitle", "en")}
|
||||
mainEntityId={comfyUiSoftwareId(siteUrl)}
|
||||
extraJsonLd={[
|
||||
comfyUiApplicationNode(siteUrl),
|
||||
comfyUiSourceCodeNode(siteUrl),
|
||||
]}
|
||||
keywords={[
|
||||
"comfyui app",
|
||||
"comfyui web app",
|
||||
|
||||
@@ -4,13 +4,6 @@ import BaseLayout from '../../../layouts/BaseLayout.astro'
|
||||
import ModelHeroSection from '../../../components/models/ModelHeroSection.vue'
|
||||
import { models, getModelBySlug } from '../../../config/models'
|
||||
import { t } from '../../../i18n/translations'
|
||||
import type { JsonLdNode } from '../../../utils/jsonLd'
|
||||
import {
|
||||
absoluteUrl,
|
||||
jsonLdId,
|
||||
pageContext,
|
||||
softwareApplicationNode,
|
||||
} from '../../../utils/jsonLd'
|
||||
|
||||
export const getStaticPaths: GetStaticPaths = () => {
|
||||
return models.map((model) => ({
|
||||
@@ -26,6 +19,7 @@ if (model.canonicalSlug) {
|
||||
}
|
||||
|
||||
const { displayName } = model
|
||||
const canonicalURL = new URL(`/p/supported-models/${model.slug}`, Astro.site)
|
||||
|
||||
const dirDescriptions: Record<string, string> = {
|
||||
diffusion_models: 'a diffusion model that generates images or video from text and image prompts',
|
||||
@@ -46,31 +40,55 @@ const dirDescriptions: Record<string, string> = {
|
||||
const dirDesc = dirDescriptions[model.directory] ?? 'an AI model'
|
||||
const whatIsDescription = `${displayName} is ${dirDesc}. You can run it locally in ComfyUI with full control over every parameter, or access it through Comfy Cloud. ComfyUI's node-based workflow editor lets you connect ${displayName} with ControlNets, LoRAs, upscalers, and custom nodes to build any pipeline you need. There are ${model.workflowCount} community workflow templates using ${displayName} on Comfy Hub, ready to load and customize.`
|
||||
|
||||
const pageTitle = `${displayName} in ComfyUI`
|
||||
const pageDescription = `Run ${displayName} in ComfyUI with full parameter control. ${model.workflowCount} community workflow templates, step-by-step tutorials, and free local inference.`
|
||||
|
||||
const { siteUrl, locale, url } = pageContext(
|
||||
Astro.site,
|
||||
Astro.url.pathname,
|
||||
Astro.currentLocale,
|
||||
)
|
||||
const softwareId = jsonLdId(url, 'software')
|
||||
const software = softwareApplicationNode({
|
||||
siteUrl,
|
||||
id: softwareId,
|
||||
const softwareAppJsonLd = {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'SoftwareApplication',
|
||||
name: displayName,
|
||||
url,
|
||||
applicationCategory: 'MultimediaApplication',
|
||||
operatingSystem: 'Any',
|
||||
})
|
||||
const faqPage: JsonLdNode = {
|
||||
url: canonicalURL.href,
|
||||
author: {
|
||||
'@type': 'Organization',
|
||||
name: 'Comfy Org',
|
||||
url: 'https://comfy.org'
|
||||
}
|
||||
}
|
||||
|
||||
const breadcrumbJsonLd = {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'BreadcrumbList',
|
||||
itemListElement: [
|
||||
{
|
||||
'@type': 'ListItem',
|
||||
position: 1,
|
||||
name: t('models.breadcrumb.home'),
|
||||
item: 'https://comfy.org'
|
||||
},
|
||||
{
|
||||
'@type': 'ListItem',
|
||||
position: 2,
|
||||
name: t('models.breadcrumb.models'),
|
||||
item: 'https://comfy.org/p/supported-models'
|
||||
},
|
||||
{
|
||||
'@type': 'ListItem',
|
||||
position: 3,
|
||||
name: displayName
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
const faqJsonLd = {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'FAQPage',
|
||||
'@id': jsonLdId(url, 'faq'),
|
||||
mainEntity: [
|
||||
{
|
||||
'@type': 'Question',
|
||||
name: `What is ${displayName}?`,
|
||||
acceptedAnswer: { '@type': 'Answer', text: whatIsDescription },
|
||||
acceptedAnswer: {
|
||||
'@type': 'Answer',
|
||||
text: whatIsDescription
|
||||
}
|
||||
},
|
||||
{
|
||||
'@type': 'Question',
|
||||
@@ -79,44 +97,54 @@ const faqPage: JsonLdNode = {
|
||||
'@type': 'Answer',
|
||||
text: model.docsUrl
|
||||
? `Follow the step-by-step tutorial at ${model.docsUrl}. You can also load any of the ${model.workflowCount} community workflow templates that use ${displayName} directly in ComfyUI.`
|
||||
: `Open ComfyUI and browse the ${model.workflowCount} community workflow templates that use ${displayName}. Load one as a starting point, then customize the nodes and parameters to fit your use case.`,
|
||||
},
|
||||
: `Open ComfyUI and browse the ${model.workflowCount} community workflow templates that use ${displayName}. Load one as a starting point, then customize the nodes and parameters to fit your use case.`
|
||||
}
|
||||
},
|
||||
{
|
||||
'@type': 'Question',
|
||||
name: `How many ComfyUI workflows use ${displayName}?`,
|
||||
acceptedAnswer: {
|
||||
'@type': 'Answer',
|
||||
text: `There are ${model.workflowCount} community workflow templates that use ${displayName} on Comfy Hub. Each template is ready to run in ComfyUI and can be customized to suit your project.`,
|
||||
},
|
||||
text: `There are ${model.workflowCount} community workflow templates that use ${displayName} on Comfy Hub. Each template is ready to run in ComfyUI and can be customized to suit your project.`
|
||||
}
|
||||
},
|
||||
{
|
||||
'@type': 'Question',
|
||||
name: `Is ${displayName} free to use in ComfyUI?`,
|
||||
acceptedAnswer: {
|
||||
'@type': 'Answer',
|
||||
text: `ComfyUI is free and open source. ${model.huggingFaceUrl ? `${displayName} weights are available to download from Hugging Face.` : `${displayName} is available as a cloud API through Comfy Cloud.`} You only pay for compute when running on Comfy Cloud; local inference on your own hardware is always free.`,
|
||||
},
|
||||
},
|
||||
],
|
||||
text: `ComfyUI is free and open source. ${model.huggingFaceUrl ? `${displayName} weights are available to download from Hugging Face.` : `${displayName} is available as a cloud API through Comfy Cloud.`} You only pay for compute when running on Comfy Cloud; local inference on your own hardware is always free.`
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
const pageTitle = `${displayName} in ComfyUI`
|
||||
const pageDescription = `Run ${displayName} in ComfyUI with full parameter control. ${model.workflowCount} community workflow templates, step-by-step tutorials, and free local inference.`
|
||||
---
|
||||
|
||||
<BaseLayout
|
||||
title={`${pageTitle} — Comfy`}
|
||||
description={pageDescription}
|
||||
ogImage={model.thumbnailUrl}
|
||||
mainEntityId={softwareId}
|
||||
breadcrumbs={[
|
||||
{ name: t('breadcrumb.home', locale), url: absoluteUrl(Astro.site, '/') },
|
||||
{
|
||||
name: t('models.breadcrumb.models', locale),
|
||||
url: absoluteUrl(Astro.site, '/p/supported-models'),
|
||||
},
|
||||
{ name: displayName },
|
||||
]}
|
||||
extraJsonLd={[software, faqPage]}
|
||||
>
|
||||
<Fragment slot="head">
|
||||
<script
|
||||
is:inline
|
||||
type="application/ld+json"
|
||||
set:html={JSON.stringify(softwareAppJsonLd)}
|
||||
/>
|
||||
<script
|
||||
is:inline
|
||||
type="application/ld+json"
|
||||
set:html={JSON.stringify(breadcrumbJsonLd)}
|
||||
/>
|
||||
<script
|
||||
is:inline
|
||||
type="application/ld+json"
|
||||
set:html={JSON.stringify(faqJsonLd)}
|
||||
/>
|
||||
</Fragment>
|
||||
|
||||
<ModelHeroSection
|
||||
displayName={displayName}
|
||||
|
||||
@@ -2,29 +2,10 @@
|
||||
import BaseLayout from '../../../layouts/BaseLayout.astro'
|
||||
import { models } from '../../../config/models'
|
||||
import { t } from '../../../i18n/translations'
|
||||
import {
|
||||
absoluteUrl,
|
||||
itemListNode,
|
||||
jsonLdId,
|
||||
pageContext,
|
||||
} from '../../../utils/jsonLd'
|
||||
|
||||
const title = t('models.index.title')
|
||||
const subtitle = t('models.index.subtitle')
|
||||
|
||||
const { url, locale } = pageContext(
|
||||
Astro.site,
|
||||
Astro.url.pathname,
|
||||
Astro.currentLocale,
|
||||
)
|
||||
const modelList = itemListNode(
|
||||
url,
|
||||
title,
|
||||
models.map((model) => ({
|
||||
url: absoluteUrl(Astro.site, `/p/supported-models/${model.slug}`),
|
||||
})),
|
||||
)
|
||||
|
||||
const dirLabel: Record<string, string> = {
|
||||
diffusion_models: 'Diffusion',
|
||||
checkpoints: 'Checkpoint',
|
||||
@@ -45,13 +26,6 @@ const dirLabel: Record<string, string> = {
|
||||
<BaseLayout
|
||||
title={`${title} — Comfy`}
|
||||
description={subtitle}
|
||||
pageType="CollectionPage"
|
||||
mainEntityId={jsonLdId(url, 'itemlist')}
|
||||
breadcrumbs={[
|
||||
{ name: t('breadcrumb.home', locale), url: absoluteUrl(Astro.site, '/') },
|
||||
{ name: title },
|
||||
]}
|
||||
extraJsonLd={[modelList]}
|
||||
>
|
||||
<div class="mx-auto max-w-7xl px-6 py-16 lg:px-8 lg:py-24">
|
||||
<header class="mb-12">
|
||||
|
||||
@@ -5,29 +5,9 @@ import StorySection from '../../components/about/StorySection.vue'
|
||||
import OurValuesSection from '../../components/about/OurValuesSection.vue'
|
||||
import ValuesSection from '../../components/about/ValuesSection.vue'
|
||||
import CareersSection from '../../components/about/CareersSection.vue'
|
||||
import { t } from '../../i18n/translations'
|
||||
import { absoluteUrl, organizationId, pageContext } from '../../utils/jsonLd'
|
||||
|
||||
const { siteUrl, locale } = pageContext(
|
||||
Astro.site,
|
||||
Astro.url.pathname,
|
||||
Astro.currentLocale,
|
||||
)
|
||||
---
|
||||
|
||||
<BaseLayout
|
||||
title="关于我们 — Comfy"
|
||||
description="了解 ComfyUI 背后的团队和使命——开源的生成式 AI 平台。"
|
||||
pageType="AboutPage"
|
||||
mainEntityId={organizationId(siteUrl)}
|
||||
breadcrumbs={[
|
||||
{
|
||||
name: t('breadcrumb.home', locale),
|
||||
url: absoluteUrl(Astro.site, '/zh-CN'),
|
||||
},
|
||||
{ name: t('breadcrumb.about', locale) },
|
||||
]}
|
||||
>
|
||||
<BaseLayout title="关于我们 — Comfy" description="了解 ComfyUI 背后的团队和使命——开源的生成式 AI 平台。">
|
||||
<HeroSection locale="zh-CN" client:load />
|
||||
<StorySection locale="zh-CN" />
|
||||
<OurValuesSection locale="zh-CN" />
|
||||
|
||||
@@ -7,13 +7,6 @@ import TeamPhotosSection from '../../components/careers/TeamPhotosSection.vue'
|
||||
import FAQSection from '../../components/common/FAQSection.vue'
|
||||
import { fetchRolesForBuild } from '../../utils/ashby'
|
||||
import { reportAshbyOutcome } from '../../utils/ashby.ci'
|
||||
import { t } from '../../i18n/translations'
|
||||
import {
|
||||
absoluteUrl,
|
||||
itemListNode,
|
||||
jsonLdId,
|
||||
pageContext,
|
||||
} from '../../utils/jsonLd'
|
||||
|
||||
const outcome = await fetchRolesForBuild()
|
||||
reportAshbyOutcome(outcome)
|
||||
@@ -26,34 +19,11 @@ if (outcome.status === 'failed') {
|
||||
}
|
||||
|
||||
const departments = outcome.snapshot.departments
|
||||
|
||||
const { siteUrl, locale, url } = pageContext(
|
||||
Astro.site,
|
||||
Astro.url.pathname,
|
||||
Astro.currentLocale,
|
||||
)
|
||||
const roles = itemListNode(
|
||||
url,
|
||||
t('breadcrumb.careers', locale),
|
||||
departments.flatMap((department) =>
|
||||
department.roles.map((role) => ({ name: role.title, url: role.jobUrl })),
|
||||
),
|
||||
)
|
||||
---
|
||||
|
||||
<BaseLayout
|
||||
title="招聘 — Comfy"
|
||||
description="加入构建生成式 AI 操作系统的团队。工程、设计、市场营销等岗位开放招聘中。"
|
||||
pageType="CollectionPage"
|
||||
mainEntityId={jsonLdId(url, 'itemlist')}
|
||||
breadcrumbs={[
|
||||
{
|
||||
name: t('breadcrumb.home', locale),
|
||||
url: absoluteUrl(Astro.site, '/zh-CN'),
|
||||
},
|
||||
{ name: t('breadcrumb.careers', locale) },
|
||||
]}
|
||||
extraJsonLd={[roles]}
|
||||
>
|
||||
<HeroSection locale="zh-CN" />
|
||||
<RolesSection locale="zh-CN" departments={departments} client:visible />
|
||||
|
||||
@@ -2,44 +2,9 @@
|
||||
import BaseLayout from '../../../layouts/BaseLayout.astro'
|
||||
import PriceSection from '../../../components/pricing/PriceSection.vue'
|
||||
import WhatsIncludedSection from '../../../components/pricing/WhatsIncludedSection.vue'
|
||||
import { pricingOffers } from '../../../config/pricing'
|
||||
import { t } from '../../../i18n/translations'
|
||||
import {
|
||||
absoluteUrl,
|
||||
jsonLdId,
|
||||
pageContext,
|
||||
productNode,
|
||||
} from '../../../utils/jsonLd'
|
||||
|
||||
const { siteUrl, locale, url } = pageContext(
|
||||
Astro.site,
|
||||
Astro.url.pathname,
|
||||
Astro.currentLocale,
|
||||
)
|
||||
const productId = jsonLdId(url, 'product')
|
||||
---
|
||||
|
||||
<BaseLayout
|
||||
title="定价 — Comfy Cloud"
|
||||
mainEntityId={productId}
|
||||
breadcrumbs={[
|
||||
{
|
||||
name: t('breadcrumb.home', locale),
|
||||
url: absoluteUrl(Astro.site, '/zh-CN'),
|
||||
},
|
||||
{ name: 'Comfy Cloud', url: absoluteUrl(Astro.site, '/zh-CN/cloud') },
|
||||
{ name: t('breadcrumb.pricing', locale) },
|
||||
]}
|
||||
extraJsonLd={[
|
||||
productNode({
|
||||
siteUrl,
|
||||
id: productId,
|
||||
name: 'Comfy Cloud',
|
||||
url,
|
||||
offers: pricingOffers(locale),
|
||||
}),
|
||||
]}
|
||||
>
|
||||
<BaseLayout title="定价 — Comfy Cloud">
|
||||
<PriceSection locale="zh-CN" client:load />
|
||||
<WhatsIncludedSection locale="zh-CN" />
|
||||
</BaseLayout>
|
||||
|
||||
@@ -4,47 +4,39 @@ import HeroSection from '../../../components/cloud-nodes/HeroSection.vue'
|
||||
import PackGridSection from '../../../components/cloud-nodes/PackGridSection.vue'
|
||||
import { t } from '../../../i18n/translations'
|
||||
import { loadPacksForBuild } from '../../../utils/cloudNodes.build'
|
||||
import {
|
||||
absoluteUrl,
|
||||
itemListNode,
|
||||
jsonLdId,
|
||||
pageContext,
|
||||
} from '../../../utils/jsonLd'
|
||||
import { escapeJsonLd } from '../../../utils/escapeJsonLd'
|
||||
|
||||
const packs = await loadPacksForBuild()
|
||||
|
||||
const title = t('cloudNodes.meta.title', 'zh-CN')
|
||||
const description = t('cloudNodes.meta.description', 'zh-CN')
|
||||
const { url, locale } = pageContext(
|
||||
Astro.site,
|
||||
Astro.url.pathname,
|
||||
Astro.currentLocale,
|
||||
)
|
||||
const packList = itemListNode(
|
||||
url,
|
||||
title,
|
||||
packs.map((pack) => ({
|
||||
const siteBase = Astro.site ?? new URL('https://comfy.org')
|
||||
const pageUrl = new URL('/zh-CN/cloud/supported-nodes', siteBase).href
|
||||
|
||||
const itemListJsonLd = {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'ItemList',
|
||||
name: 'Comfy Cloud 支持的自定义节点包',
|
||||
url: pageUrl,
|
||||
numberOfItems: packs.length,
|
||||
itemListElement: packs.map((pack, index) => ({
|
||||
'@type': 'ListItem',
|
||||
position: index + 1,
|
||||
url: new URL(`/zh-CN/cloud/supported-nodes/${pack.id}`, siteBase).href,
|
||||
name: pack.displayName,
|
||||
url: absoluteUrl(Astro.site, `/zh-CN/cloud/supported-nodes/${pack.id}`),
|
||||
})),
|
||||
)
|
||||
image: pack.bannerUrl || pack.iconUrl
|
||||
}))
|
||||
}
|
||||
---
|
||||
|
||||
<BaseLayout
|
||||
title={title}
|
||||
description={description}
|
||||
pageType="CollectionPage"
|
||||
mainEntityId={jsonLdId(url, 'itemlist')}
|
||||
breadcrumbs={[
|
||||
{
|
||||
name: t('breadcrumb.home', locale),
|
||||
url: absoluteUrl(Astro.site, '/zh-CN'),
|
||||
},
|
||||
{ name: 'Comfy Cloud', url: absoluteUrl(Astro.site, '/zh-CN/cloud') },
|
||||
{ name: t('breadcrumb.supportedNodes', locale) },
|
||||
]}
|
||||
extraJsonLd={[packList]}
|
||||
title={t('cloudNodes.meta.title', 'zh-CN')}
|
||||
description={t('cloudNodes.meta.description', 'zh-CN')}
|
||||
>
|
||||
<script
|
||||
is:inline
|
||||
slot="head"
|
||||
type="application/ld+json"
|
||||
set:html={escapeJsonLd(itemListJsonLd)}
|
||||
/>
|
||||
<HeroSection locale="zh-CN" client:visible />
|
||||
<PackGridSection locale="zh-CN" packs={packs} client:visible />
|
||||
</BaseLayout>
|
||||
|
||||
@@ -7,12 +7,7 @@ import PackDetail from '../../../../components/cloud-nodes/PackDetail.vue'
|
||||
import BaseLayout from '../../../../layouts/BaseLayout.astro'
|
||||
import { t } from '../../../../i18n/translations'
|
||||
import { loadPacksForBuild } from '../../../../utils/cloudNodes.build'
|
||||
import {
|
||||
absoluteUrl,
|
||||
jsonLdId,
|
||||
pageContext,
|
||||
softwareApplicationNode,
|
||||
} from '../../../../utils/jsonLd'
|
||||
import { escapeJsonLd } from '../../../../utils/escapeJsonLd'
|
||||
|
||||
export const getStaticPaths: GetStaticPaths = async () => {
|
||||
const packs = await loadPacksForBuild()
|
||||
@@ -34,48 +29,35 @@ const metaDescription = t('cloudNodes.detail.metaDescription', 'zh-CN')
|
||||
.replace('{nodeCount}', String(pack.nodes.length))
|
||||
.replace('{description}', description)
|
||||
|
||||
const { siteUrl, locale, url } = pageContext(
|
||||
Astro.site,
|
||||
Astro.url.pathname,
|
||||
Astro.currentLocale,
|
||||
)
|
||||
const softwareId = jsonLdId(url, 'software')
|
||||
const software = softwareApplicationNode({
|
||||
siteUrl,
|
||||
id: softwareId,
|
||||
const siteBase = Astro.site ?? new URL('https://comfy.org')
|
||||
const pageUrl = new URL(`/zh-CN/cloud/supported-nodes/${pack.id}`, siteBase).href
|
||||
|
||||
const softwareJsonLd = {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'SoftwareApplication',
|
||||
name: pack.displayName,
|
||||
url,
|
||||
applicationCategory: 'DeveloperApplication',
|
||||
applicationSubCategory: 'ComfyUI custom-node pack',
|
||||
operatingSystem: 'Comfy Cloud (managed)',
|
||||
description: pack.description || undefined,
|
||||
url: pageUrl,
|
||||
description,
|
||||
image: pack.bannerUrl || pack.iconUrl,
|
||||
softwareVersion: pack.latestVersion,
|
||||
license: pack.license,
|
||||
codeRepository: pack.repoUrl,
|
||||
authorName: pack.publisher?.name,
|
||||
isFree: true,
|
||||
})
|
||||
author: pack.publisher?.name
|
||||
? { '@type': 'Person', name: pack.publisher.name }
|
||||
: undefined,
|
||||
offers: { '@type': 'Offer', price: 0, priceCurrency: 'USD' }
|
||||
}
|
||||
---
|
||||
|
||||
<BaseLayout
|
||||
title={title}
|
||||
description={metaDescription}
|
||||
ogImage={pack.bannerUrl}
|
||||
mainEntityId={softwareId}
|
||||
breadcrumbs={[
|
||||
{
|
||||
name: t('breadcrumb.home', locale),
|
||||
url: absoluteUrl(Astro.site, '/zh-CN'),
|
||||
},
|
||||
{ name: 'Comfy Cloud', url: absoluteUrl(Astro.site, '/zh-CN/cloud') },
|
||||
{
|
||||
name: t('breadcrumb.supportedNodes', locale),
|
||||
url: absoluteUrl(Astro.site, '/zh-CN/cloud/supported-nodes'),
|
||||
},
|
||||
{ name: pack.displayName },
|
||||
]}
|
||||
extraJsonLd={[software]}
|
||||
>
|
||||
<BaseLayout title={title} description={metaDescription} ogImage={pack.bannerUrl}>
|
||||
<script
|
||||
is:inline
|
||||
slot="head"
|
||||
type="application/ld+json"
|
||||
set:html={escapeJsonLd(softwareJsonLd)}
|
||||
/>
|
||||
<PackDetail pack={pack} locale="zh-CN" />
|
||||
</BaseLayout>
|
||||
|
||||
@@ -2,28 +2,9 @@
|
||||
import BaseLayout from '../../layouts/BaseLayout.astro'
|
||||
import FormSection from '../../components/contact/FormSection.vue'
|
||||
import SocialProofBarSection from '../../components/common/SocialProofBarSection.vue'
|
||||
import { t } from '../../i18n/translations'
|
||||
import { absoluteUrl, organizationId, pageContext } from '../../utils/jsonLd'
|
||||
|
||||
const { siteUrl, locale } = pageContext(
|
||||
Astro.site,
|
||||
Astro.url.pathname,
|
||||
Astro.currentLocale,
|
||||
)
|
||||
---
|
||||
|
||||
<BaseLayout
|
||||
title="联系我们 — Comfy"
|
||||
pageType="ContactPage"
|
||||
mainEntityId={organizationId(siteUrl)}
|
||||
breadcrumbs={[
|
||||
{
|
||||
name: t('breadcrumb.home', locale),
|
||||
url: absoluteUrl(Astro.site, '/zh-CN'),
|
||||
},
|
||||
{ name: t('breadcrumb.contact', locale) },
|
||||
]}
|
||||
>
|
||||
<BaseLayout title="联系我们 — Comfy">
|
||||
<FormSection locale="zh-CN" client:load />
|
||||
<SocialProofBarSection />
|
||||
</BaseLayout>
|
||||
|
||||
@@ -7,13 +7,6 @@ import DemoTranscript from '../../../components/demos/DemoTranscript.vue'
|
||||
import DemoNavSection from '../../../components/demos/DemoNavSection.vue'
|
||||
import { demos, getDemoBySlug, getNextDemo } from '../../../config/demos'
|
||||
import { t } from '../../../i18n/translations'
|
||||
import type { JsonLdNode } from '../../../utils/jsonLd'
|
||||
import {
|
||||
absoluteUrl,
|
||||
jsonLdId,
|
||||
organizationId,
|
||||
pageContext,
|
||||
} from '../../../utils/jsonLd'
|
||||
|
||||
export const getStaticPaths: GetStaticPaths = () => {
|
||||
return demos.map((demo) => ({
|
||||
@@ -26,34 +19,68 @@ const demo = getDemoBySlug(slug as string)!
|
||||
const nextDemo = getNextDemo(slug as string)
|
||||
const title = t(demo.title, 'zh-CN')
|
||||
const description = t(demo.description, 'zh-CN')
|
||||
const canonicalURL = new URL(`/zh-CN/demos/${demo.slug}`, Astro.site)
|
||||
|
||||
const { siteUrl, locale, url } = pageContext(
|
||||
Astro.site,
|
||||
Astro.url.pathname,
|
||||
Astro.currentLocale,
|
||||
)
|
||||
const educationalLevel =
|
||||
demo.difficulty === 'beginner'
|
||||
const howToJsonLd = {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'HowTo',
|
||||
name: title,
|
||||
description,
|
||||
image: new URL(demo.ogImage, Astro.site).href,
|
||||
totalTime: demo.durationIso,
|
||||
datePublished: demo.publishedDate,
|
||||
dateModified: demo.modifiedDate,
|
||||
author: {
|
||||
'@type': 'Organization',
|
||||
name: 'Comfy Org',
|
||||
url: 'https://comfy.org'
|
||||
}
|
||||
}
|
||||
|
||||
const learningResourceJsonLd = {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'LearningResource',
|
||||
name: title,
|
||||
description,
|
||||
learningResourceType: 'interactive tutorial',
|
||||
interactivityType: 'active',
|
||||
educationalLevel: demo.difficulty === 'beginner'
|
||||
? 'Beginner'
|
||||
: demo.difficulty === 'intermediate'
|
||||
? 'Intermediate'
|
||||
: 'Advanced'
|
||||
const learningId = jsonLdId(url, 'learning')
|
||||
const learningResource: JsonLdNode = {
|
||||
'@type': 'LearningResource',
|
||||
'@id': learningId,
|
||||
name: title,
|
||||
description,
|
||||
url,
|
||||
image: new URL(demo.ogImage, Astro.site).href,
|
||||
learningResourceType: 'interactive tutorial',
|
||||
interactivityType: 'active',
|
||||
educationalLevel,
|
||||
timeRequired: demo.durationIso,
|
||||
: 'Advanced',
|
||||
url: canonicalURL.href,
|
||||
datePublished: demo.publishedDate,
|
||||
dateModified: demo.modifiedDate,
|
||||
isPartOf: { '@id': jsonLdId(url, 'webpage') },
|
||||
author: { '@id': organizationId(siteUrl) },
|
||||
author: {
|
||||
'@type': 'Organization',
|
||||
name: 'Comfy Org',
|
||||
url: 'https://comfy.org'
|
||||
}
|
||||
}
|
||||
|
||||
const breadcrumbJsonLd = {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'BreadcrumbList',
|
||||
itemListElement: [
|
||||
{
|
||||
'@type': 'ListItem',
|
||||
position: 1,
|
||||
name: t('demos.breadcrumb.home', 'zh-CN'),
|
||||
item: 'https://comfy.org/zh-CN'
|
||||
},
|
||||
{
|
||||
'@type': 'ListItem',
|
||||
position: 2,
|
||||
name: t('demos.breadcrumb.demos', 'zh-CN'),
|
||||
item: 'https://comfy.org/zh-CN/demos'
|
||||
},
|
||||
{
|
||||
'@type': 'ListItem',
|
||||
position: 3,
|
||||
name: title
|
||||
}
|
||||
]
|
||||
}
|
||||
---
|
||||
|
||||
@@ -61,23 +88,25 @@ const learningResource: JsonLdNode = {
|
||||
title={`${title} — Comfy`}
|
||||
description={description}
|
||||
ogImage={demo.ogImage}
|
||||
mainEntityId={learningId}
|
||||
breadcrumbs={[
|
||||
{
|
||||
name: t('breadcrumb.home', locale),
|
||||
url: absoluteUrl(Astro.site, '/zh-CN'),
|
||||
},
|
||||
{
|
||||
name: t('demos.breadcrumb.demos', locale),
|
||||
url: absoluteUrl(Astro.site, '/zh-CN/demos'),
|
||||
},
|
||||
{ name: title },
|
||||
]}
|
||||
extraJsonLd={[learningResource]}
|
||||
>
|
||||
<Fragment slot="head">
|
||||
<meta property="article:published_time" content={demo.publishedDate} />
|
||||
<meta property="article:modified_time" content={demo.modifiedDate} />
|
||||
<script
|
||||
is:inline
|
||||
type="application/ld+json"
|
||||
set:html={JSON.stringify(howToJsonLd)}
|
||||
/>
|
||||
<script
|
||||
is:inline
|
||||
type="application/ld+json"
|
||||
set:html={JSON.stringify(learningResourceJsonLd)}
|
||||
/>
|
||||
<script
|
||||
is:inline
|
||||
type="application/ld+json"
|
||||
set:html={JSON.stringify(breadcrumbJsonLd)}
|
||||
/>
|
||||
<link rel="preconnect" href="https://demo.arcade.software" />
|
||||
</Fragment>
|
||||
|
||||
|
||||
@@ -8,32 +8,11 @@ import EcoSystemSection from '../../components/product/local/EcoSystemSection.vu
|
||||
import ProductCardsSection from '../../components/product/local/ProductCardsSection.vue'
|
||||
import FAQSection from '../../components/product/local/FAQSection.vue'
|
||||
import { t } from '../../i18n/translations'
|
||||
import {
|
||||
absoluteUrl,
|
||||
comfyUiApplicationNode,
|
||||
comfyUiSoftwareId,
|
||||
pageContext,
|
||||
} from '../../utils/jsonLd'
|
||||
|
||||
const { siteUrl, locale } = pageContext(
|
||||
Astro.site,
|
||||
Astro.url.pathname,
|
||||
Astro.currentLocale,
|
||||
)
|
||||
---
|
||||
|
||||
<BaseLayout
|
||||
title="下载 Comfy 桌面版 — 在您的硬件上运行 AI"
|
||||
description={t('download.hero.subtitle', 'zh-CN')}
|
||||
mainEntityId={comfyUiSoftwareId(siteUrl)}
|
||||
breadcrumbs={[
|
||||
{
|
||||
name: t('breadcrumb.home', locale),
|
||||
url: absoluteUrl(Astro.site, '/zh-CN'),
|
||||
},
|
||||
{ name: t('breadcrumb.download', locale) },
|
||||
]}
|
||||
extraJsonLd={[comfyUiApplicationNode(siteUrl)]}
|
||||
keywords={['comfyui app', 'comfyui desktop app', 'comfyui download', 'ComfyUI 下载', 'ComfyUI 桌面应用', 'ComfyUI 应用', 'ComfyUI Windows', 'ComfyUI macOS', 'ComfyUI Linux']}
|
||||
>
|
||||
<CloudBannerSection locale="zh-CN" />
|
||||
|
||||
@@ -9,25 +9,11 @@ import CaseStudySpotlightSection from '../../components/home/CaseStudySpotlightS
|
||||
import GetStartedSection from '../../components/home/GetStartedSection.vue'
|
||||
import BuildWhatSection from '../../components/home/BuildWhatSection.vue'
|
||||
import { t } from '../../i18n/translations'
|
||||
import {
|
||||
comfyUiApplicationNode,
|
||||
comfyUiSoftwareId,
|
||||
comfyUiSourceCodeNode,
|
||||
pageContext,
|
||||
} from '../../utils/jsonLd'
|
||||
|
||||
const { siteUrl } = pageContext(
|
||||
Astro.site,
|
||||
Astro.url.pathname,
|
||||
Astro.currentLocale,
|
||||
)
|
||||
---
|
||||
|
||||
<BaseLayout
|
||||
title="Comfy — 视觉 AI 的最强可控性"
|
||||
description={t('hero.subtitle', 'zh-CN')}
|
||||
mainEntityId={comfyUiSoftwareId(siteUrl)}
|
||||
extraJsonLd={[comfyUiApplicationNode(siteUrl), comfyUiSourceCodeNode(siteUrl)]}
|
||||
keywords={['comfyui app', 'comfyui web app', 'comfyui application', 'ComfyUI 应用', 'ComfyUI 网页版', 'ComfyUI 桌面应用', 'ComfyUI 下载', '可视化 AI', '节点式 AI', '生成式 AI 工作流']}
|
||||
>
|
||||
<HeroSection locale="zh-CN" client:load />
|
||||
|
||||
@@ -17,7 +17,7 @@ const ctas = mcpCtas(locale)
|
||||
badge-text="MCP"
|
||||
:title="t('mcp.hero.heading', locale)"
|
||||
:subtitle="t('mcp.hero.subtitle', locale)"
|
||||
:primary-cta="ctas.installMcp"
|
||||
:primary-cta="ctas.runWorkflow"
|
||||
:secondary-cta="ctas.docs"
|
||||
>
|
||||
<template #media>
|
||||
|
||||
@@ -17,10 +17,7 @@ const cards: FeatureCard[] = [
|
||||
description: t('mcp.setup.step1.description', locale),
|
||||
action: {
|
||||
type: 'code',
|
||||
value: t('mcp.setup.step1.command', locale).replace(
|
||||
'{url}',
|
||||
externalLinks.docsMcp
|
||||
)
|
||||
value: externalLinks.mcpServer
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -56,8 +53,6 @@ const cards: FeatureCard[] = [
|
||||
|
||||
<template>
|
||||
<FeatureGrid01
|
||||
id="setup"
|
||||
class="scroll-mt-24 lg:scroll-mt-36"
|
||||
:eyebrow="t('mcp.setup.label', locale)"
|
||||
:heading="t('mcp.setup.heading', locale)"
|
||||
:subtitle="t('mcp.setup.subtitle', locale)"
|
||||
|
||||
@@ -9,25 +9,16 @@ export interface McpCta {
|
||||
}
|
||||
|
||||
/**
|
||||
* Calls-to-action for the MCP page: view the docs, jump to the on-page setup
|
||||
* steps, or run a workflow in the cloud. The hero leads with install + docs;
|
||||
* the "how it works" section pairs run-a-workflow with docs.
|
||||
* The two calls-to-action shared by the MCP hero and "how it works" sections:
|
||||
* view the docs, or run a workflow in the cloud.
|
||||
*/
|
||||
export function mcpCtas(locale: Locale): {
|
||||
docs: McpCta
|
||||
installMcp: McpCta
|
||||
runWorkflow: McpCta
|
||||
} {
|
||||
export function mcpCtas(locale: Locale): { docs: McpCta; runWorkflow: McpCta } {
|
||||
return {
|
||||
docs: {
|
||||
label: t('mcp.hero.viewDocs', locale),
|
||||
href: externalLinks.docsMcp,
|
||||
target: '_blank'
|
||||
},
|
||||
installMcp: {
|
||||
label: t('mcp.hero.installMcp', locale),
|
||||
href: '#setup'
|
||||
},
|
||||
runWorkflow: {
|
||||
label: t('mcp.hero.runWorkflow', locale),
|
||||
href: getRoutes(locale).cloud
|
||||
|
||||
@@ -1,212 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { externalLinks } from '../config/routes'
|
||||
import { escapeJsonLd } from './escapeJsonLd'
|
||||
import type { JsonLdGraph } from './jsonLd'
|
||||
import {
|
||||
absoluteUrl,
|
||||
buildPageGraph,
|
||||
collectGraphIds,
|
||||
comfyUiApplicationNode,
|
||||
comfyUiSoftwareId,
|
||||
comfyUiSourceCodeNode,
|
||||
itemListNode,
|
||||
jsonLdId,
|
||||
organizationId,
|
||||
pageContext,
|
||||
productNode,
|
||||
softwareApplicationNode
|
||||
} from './jsonLd'
|
||||
|
||||
const siteUrl = 'https://comfy.org'
|
||||
const site = new URL('https://comfy.org/')
|
||||
|
||||
function typeNames(graph: JsonLdGraph): string[] {
|
||||
return graph['@graph'].map((node) => node['@type'])
|
||||
}
|
||||
|
||||
describe('absoluteUrl', () => {
|
||||
it('resolves internal paths to their trailing-slash canonical form', () => {
|
||||
expect(absoluteUrl(site, '/cloud')).toBe('https://comfy.org/cloud/')
|
||||
expect(absoluteUrl(site, '/about/')).toBe('https://comfy.org/about/')
|
||||
expect(absoluteUrl(site, '/')).toBe('https://comfy.org/')
|
||||
})
|
||||
})
|
||||
|
||||
describe('pageContext', () => {
|
||||
it('derives siteUrl, locale and canonical url from the Astro globals', () => {
|
||||
expect(pageContext(site, '/about/', undefined)).toEqual({
|
||||
siteUrl,
|
||||
locale: 'en',
|
||||
url: 'https://comfy.org/about/'
|
||||
})
|
||||
expect(pageContext(site, '/zh-CN/', 'zh-CN').locale).toBe('zh-CN')
|
||||
})
|
||||
})
|
||||
|
||||
describe('itemListNode', () => {
|
||||
it('counts items and omits per-item names when not supplied', () => {
|
||||
const node = itemListNode('https://comfy.org/careers/', 'Careers', [
|
||||
{ url: 'https://jobs.example/1' },
|
||||
{ url: 'https://jobs.example/2', name: 'Designer' }
|
||||
])
|
||||
expect(node.numberOfItems).toBe(2)
|
||||
const items = node.itemListElement as Record<string, unknown>[]
|
||||
expect('name' in items[0]).toBe(false)
|
||||
expect(items[1].name).toBe('Designer')
|
||||
})
|
||||
})
|
||||
|
||||
describe('softwareApplicationNode', () => {
|
||||
it('claims Comfy Org as author and publisher only when first-party', () => {
|
||||
const node = softwareApplicationNode({
|
||||
siteUrl,
|
||||
id: jsonLdId(siteUrl, 'software'),
|
||||
name: 'ComfyUI',
|
||||
url: siteUrl,
|
||||
firstParty: true,
|
||||
applicationCategory: 'MultimediaApplication',
|
||||
isFree: true
|
||||
})
|
||||
const orgRef = { '@id': organizationId(siteUrl) }
|
||||
expect(node.author).toEqual(orgRef)
|
||||
expect(node.publisher).toEqual(orgRef)
|
||||
expect(node.offers).toEqual({
|
||||
'@type': 'Offer',
|
||||
price: 0,
|
||||
priceCurrency: 'USD',
|
||||
seller: orgRef
|
||||
})
|
||||
})
|
||||
|
||||
it('does not name Comfy Org as seller on a third-party free offer', () => {
|
||||
const node = softwareApplicationNode({
|
||||
siteUrl,
|
||||
id: 'https://comfy.org/cloud/supported-nodes/foo/#software',
|
||||
name: 'Foo Pack',
|
||||
url: 'https://comfy.org/cloud/supported-nodes/foo/',
|
||||
applicationCategory: 'DeveloperApplication',
|
||||
isFree: true
|
||||
})
|
||||
expect((node.offers as Record<string, unknown>).seller).toBeUndefined()
|
||||
})
|
||||
|
||||
it('credits a known third-party author without claiming to publish it', () => {
|
||||
const node = softwareApplicationNode({
|
||||
siteUrl,
|
||||
id: 'https://comfy.org/cloud/supported-nodes/foo/#software',
|
||||
name: 'Foo Pack',
|
||||
url: 'https://comfy.org/cloud/supported-nodes/foo/',
|
||||
applicationCategory: 'DeveloperApplication',
|
||||
authorName: 'Jane Dev'
|
||||
})
|
||||
expect(node.author).toEqual({ '@type': 'Person', name: 'Jane Dev' })
|
||||
expect(node.publisher).toBeUndefined()
|
||||
})
|
||||
|
||||
it('claims no author or publisher for third-party software with no author', () => {
|
||||
const node = softwareApplicationNode({
|
||||
siteUrl,
|
||||
id: 'https://comfy.org/p/supported-models/foo/#software',
|
||||
name: 'Foo Model',
|
||||
url: 'https://comfy.org/p/supported-models/foo/',
|
||||
applicationCategory: 'MultimediaApplication'
|
||||
})
|
||||
expect(node.author).toBeUndefined()
|
||||
expect(node.publisher).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('sameAs encyclopedic references', () => {
|
||||
it('links the organization to its Wikidata entity', () => {
|
||||
const graph = buildPageGraph(
|
||||
{ siteUrl, locale: 'en' },
|
||||
{ url: `${siteUrl}/`, name: 'Home' }
|
||||
)
|
||||
const org = graph['@graph'].find((node) => node['@type'] === 'Organization')
|
||||
expect(org?.sameAs).toContain(externalLinks.wikidataComfyOrg)
|
||||
})
|
||||
|
||||
it('links the ComfyUI application to its Wikidata, Wikipedia and G2 entities', () => {
|
||||
const node = comfyUiApplicationNode(siteUrl)
|
||||
expect(node.sameAs).toEqual([
|
||||
externalLinks.wikidataComfyUi,
|
||||
externalLinks.wikipediaComfyUi,
|
||||
externalLinks.g2ComfyUi
|
||||
])
|
||||
})
|
||||
|
||||
it('omits sameAs for third-party software', () => {
|
||||
const node = softwareApplicationNode({
|
||||
siteUrl,
|
||||
id: 'https://comfy.org/p/supported-models/foo/#software',
|
||||
name: 'Foo Model',
|
||||
url: 'https://comfy.org/p/supported-models/foo/',
|
||||
applicationCategory: 'MultimediaApplication'
|
||||
})
|
||||
expect(node.sameAs).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('productNode', () => {
|
||||
it('gives every offer a currency and price', () => {
|
||||
const node = productNode({
|
||||
siteUrl,
|
||||
id: 'https://comfy.org/cloud/pricing/#product',
|
||||
name: 'Comfy Cloud',
|
||||
url: 'https://comfy.org/cloud/pricing/',
|
||||
offers: [{ name: 'Standard', price: '20' }]
|
||||
})
|
||||
const offers = node.offers as Record<string, unknown>[]
|
||||
expect(offers[0].price).toBe('20')
|
||||
expect(offers[0].priceCurrency).toBe('USD')
|
||||
expect(offers[0].seller).toEqual({ '@id': organizationId(siteUrl) })
|
||||
})
|
||||
})
|
||||
|
||||
describe('comfyUiSourceCodeNode', () => {
|
||||
it('links the source code to the ComfyUI application via targetProduct', () => {
|
||||
const node = comfyUiSourceCodeNode(siteUrl)
|
||||
expect(node.targetProduct).toEqual({ '@id': comfyUiSoftwareId(siteUrl) })
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildPageGraph', () => {
|
||||
const url = 'https://comfy.org/cloud/pricing/'
|
||||
const graph = buildPageGraph(
|
||||
{ siteUrl, locale: 'en' },
|
||||
{
|
||||
url,
|
||||
name: 'Pricing',
|
||||
type: 'CollectionPage',
|
||||
mainEntityId: jsonLdId(url, 'itemlist'),
|
||||
crumbs: [{ name: 'Home', url: `${siteUrl}/` }, { name: 'Pricing' }]
|
||||
},
|
||||
itemListNode(url, 'Plans', [{ url: `${siteUrl}/one/` }])
|
||||
)
|
||||
|
||||
it('always includes the site-wide organization, website and page entity', () => {
|
||||
expect(typeNames(graph)).toContain('Organization')
|
||||
expect(typeNames(graph)).toContain('WebSite')
|
||||
expect(typeNames(graph)).toContain('CollectionPage')
|
||||
})
|
||||
|
||||
it('produces a graph where every @id reference resolves', () => {
|
||||
const { defined, references } = collectGraphIds(graph)
|
||||
for (const reference of references) {
|
||||
expect(defined.has(reference)).toBe(true)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('escapeJsonLd on a built graph', () => {
|
||||
it('neutralizes a </script> breakout in a page name', () => {
|
||||
const graph = buildPageGraph(
|
||||
{ siteUrl, locale: 'en' },
|
||||
{ url: `${siteUrl}/x/`, name: '</script><script>alert(1)</script>' }
|
||||
)
|
||||
const serialized = escapeJsonLd(graph)
|
||||
expect(serialized).not.toContain('</script>')
|
||||
expect(serialized).toContain('\\u003c')
|
||||
})
|
||||
})
|
||||
@@ -1,377 +0,0 @@
|
||||
import { externalLinks } from '../config/routes'
|
||||
import type { Locale } from '../i18n/translations'
|
||||
|
||||
export type JsonLdNode = Record<string, unknown> & { '@type': string }
|
||||
|
||||
export interface JsonLdGraph {
|
||||
'@context': 'https://schema.org'
|
||||
'@graph': JsonLdNode[]
|
||||
}
|
||||
|
||||
export interface PageContext {
|
||||
siteUrl: string
|
||||
locale: Locale
|
||||
}
|
||||
|
||||
export type WebPageType =
|
||||
| 'WebPage'
|
||||
| 'AboutPage'
|
||||
| 'ContactPage'
|
||||
| 'CollectionPage'
|
||||
|
||||
export interface Crumb {
|
||||
name: string
|
||||
url?: string
|
||||
}
|
||||
|
||||
const sameAs = [
|
||||
externalLinks.github,
|
||||
externalLinks.x,
|
||||
externalLinks.youtube,
|
||||
externalLinks.discord,
|
||||
externalLinks.instagram,
|
||||
externalLinks.reddit,
|
||||
externalLinks.linkedin,
|
||||
// Wikidata entity for the organization, so the Knowledge Graph can resolve it.
|
||||
externalLinks.wikidataComfyOrg
|
||||
]
|
||||
|
||||
// Authoritative encyclopedic and review-platform references for the ComfyUI software entity.
|
||||
const comfyUiSameAs = [
|
||||
externalLinks.wikidataComfyUi,
|
||||
externalLinks.wikipediaComfyUi,
|
||||
externalLinks.g2ComfyUi
|
||||
]
|
||||
|
||||
function siteUrlFrom(site: URL | undefined): string {
|
||||
return (site?.href ?? 'https://comfy.org/').replace(/\/$/, '')
|
||||
}
|
||||
|
||||
export function absoluteUrl(site: URL | undefined, path: string): string {
|
||||
const resolved = new URL(path, site ?? 'https://comfy.org').href
|
||||
return resolved.endsWith('/') ? resolved : `${resolved}/`
|
||||
}
|
||||
|
||||
export function pageContext(
|
||||
site: URL | undefined,
|
||||
pathname: string,
|
||||
currentLocale: string | undefined
|
||||
): PageContext & { url: string } {
|
||||
return {
|
||||
siteUrl: siteUrlFrom(site),
|
||||
locale: currentLocale === 'zh-CN' ? 'zh-CN' : 'en',
|
||||
url: absoluteUrl(site, pathname)
|
||||
}
|
||||
}
|
||||
|
||||
export function jsonLdId(pageUrl: string, fragment: string): string {
|
||||
return `${pageUrl}#${fragment}`
|
||||
}
|
||||
|
||||
export function organizationId(siteUrl: string): string {
|
||||
return `${siteUrl}/#organization`
|
||||
}
|
||||
|
||||
function websiteId(siteUrl: string): string {
|
||||
return `${siteUrl}/#website`
|
||||
}
|
||||
|
||||
function buildGraph(...nodes: (JsonLdNode | null | undefined)[]): JsonLdGraph {
|
||||
return {
|
||||
'@context': 'https://schema.org',
|
||||
'@graph': nodes.filter((node): node is JsonLdNode => Boolean(node))
|
||||
}
|
||||
}
|
||||
|
||||
function organizationNode(siteUrl: string): JsonLdNode {
|
||||
return {
|
||||
'@type': 'Organization',
|
||||
'@id': organizationId(siteUrl),
|
||||
name: 'Comfy Org',
|
||||
url: siteUrl,
|
||||
logo: {
|
||||
'@type': 'ImageObject',
|
||||
url: `${siteUrl}/web-app-manifest-512x512.png`,
|
||||
width: 512,
|
||||
height: 512
|
||||
},
|
||||
sameAs
|
||||
}
|
||||
}
|
||||
|
||||
function websiteNode(siteUrl: string): JsonLdNode {
|
||||
return {
|
||||
'@type': 'WebSite',
|
||||
'@id': websiteId(siteUrl),
|
||||
name: 'Comfy',
|
||||
url: siteUrl,
|
||||
publisher: { '@id': organizationId(siteUrl) }
|
||||
}
|
||||
}
|
||||
|
||||
function breadcrumbNode(pageUrl: string, crumbs: Crumb[]): JsonLdNode {
|
||||
return {
|
||||
'@type': 'BreadcrumbList',
|
||||
'@id': jsonLdId(pageUrl, 'breadcrumb'),
|
||||
itemListElement: crumbs.map((crumb, index) => {
|
||||
const isLast = index === crumbs.length - 1
|
||||
return isLast || !crumb.url
|
||||
? { '@type': 'ListItem', position: index + 1, name: crumb.name }
|
||||
: {
|
||||
'@type': 'ListItem',
|
||||
position: index + 1,
|
||||
name: crumb.name,
|
||||
item: crumb.url
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export function itemListNode(
|
||||
pageUrl: string,
|
||||
name: string,
|
||||
items: { url: string; name?: string }[]
|
||||
): JsonLdNode {
|
||||
return {
|
||||
'@type': 'ItemList',
|
||||
'@id': jsonLdId(pageUrl, 'itemlist'),
|
||||
name,
|
||||
numberOfItems: items.length,
|
||||
itemListElement: items.map((item, index) => ({
|
||||
'@type': 'ListItem',
|
||||
position: index + 1,
|
||||
url: item.url,
|
||||
...(item.name ? { name: item.name } : {})
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
interface WebPageInput {
|
||||
siteUrl: string
|
||||
locale: Locale
|
||||
url: string
|
||||
name: string
|
||||
description?: string
|
||||
imageUrl?: string
|
||||
crumbs?: Crumb[]
|
||||
mainEntityId?: string
|
||||
}
|
||||
|
||||
function webPageNode(input: WebPageInput, type: WebPageType): JsonLdNode {
|
||||
const hasCrumbs = Boolean(input.crumbs && input.crumbs.length > 0)
|
||||
return {
|
||||
'@type': type,
|
||||
'@id': jsonLdId(input.url, 'webpage'),
|
||||
url: input.url,
|
||||
name: input.name,
|
||||
description: input.description,
|
||||
isPartOf: { '@id': websiteId(input.siteUrl) },
|
||||
primaryImageOfPage: input.imageUrl
|
||||
? { '@type': 'ImageObject', url: input.imageUrl }
|
||||
: undefined,
|
||||
breadcrumb: hasCrumbs
|
||||
? { '@id': jsonLdId(input.url, 'breadcrumb') }
|
||||
: undefined,
|
||||
mainEntity: input.mainEntityId ? { '@id': input.mainEntityId } : undefined,
|
||||
inLanguage: input.locale
|
||||
}
|
||||
}
|
||||
|
||||
export interface SoftwareAppInput {
|
||||
siteUrl: string
|
||||
id: string
|
||||
name: string
|
||||
url: string
|
||||
applicationCategory: string
|
||||
firstParty?: boolean
|
||||
applicationSubCategory?: string
|
||||
description?: string
|
||||
operatingSystem?: string
|
||||
image?: string
|
||||
softwareVersion?: string
|
||||
license?: string
|
||||
codeRepository?: string
|
||||
authorName?: string
|
||||
isFree?: boolean
|
||||
sameAs?: string[]
|
||||
}
|
||||
|
||||
export function softwareApplicationNode(input: SoftwareAppInput): JsonLdNode {
|
||||
const orgRef = { '@id': organizationId(input.siteUrl) }
|
||||
const author = input.firstParty
|
||||
? orgRef
|
||||
: input.authorName
|
||||
? { '@type': 'Person', name: input.authorName }
|
||||
: undefined
|
||||
return {
|
||||
'@type': 'SoftwareApplication',
|
||||
'@id': input.id,
|
||||
name: input.name,
|
||||
url: input.url,
|
||||
applicationCategory: input.applicationCategory,
|
||||
applicationSubCategory: input.applicationSubCategory,
|
||||
description: input.description,
|
||||
operatingSystem: input.operatingSystem,
|
||||
image: input.image,
|
||||
softwareVersion: input.softwareVersion,
|
||||
license: input.license,
|
||||
codeRepository: input.codeRepository,
|
||||
author,
|
||||
publisher: input.firstParty ? orgRef : undefined,
|
||||
sameAs: input.sameAs,
|
||||
offers: input.isFree
|
||||
? {
|
||||
'@type': 'Offer',
|
||||
price: 0,
|
||||
priceCurrency: 'USD',
|
||||
seller: input.firstParty ? orgRef : undefined
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
}
|
||||
|
||||
interface SourceCodeInput {
|
||||
siteUrl: string
|
||||
id: string
|
||||
name: string
|
||||
codeRepository: string
|
||||
programmingLanguage?: string
|
||||
targetProductId?: string
|
||||
}
|
||||
|
||||
function softwareSourceCodeNode(input: SourceCodeInput): JsonLdNode {
|
||||
return {
|
||||
'@type': 'SoftwareSourceCode',
|
||||
'@id': input.id,
|
||||
name: input.name,
|
||||
codeRepository: input.codeRepository,
|
||||
programmingLanguage: input.programmingLanguage,
|
||||
targetProduct: input.targetProductId
|
||||
? { '@id': input.targetProductId }
|
||||
: undefined,
|
||||
author: { '@id': organizationId(input.siteUrl) }
|
||||
}
|
||||
}
|
||||
|
||||
export function comfyUiSoftwareId(siteUrl: string): string {
|
||||
return `${siteUrl}/#software`
|
||||
}
|
||||
|
||||
export function comfyUiApplicationNode(siteUrl: string): JsonLdNode {
|
||||
return softwareApplicationNode({
|
||||
siteUrl,
|
||||
id: comfyUiSoftwareId(siteUrl),
|
||||
name: 'ComfyUI',
|
||||
url: siteUrl,
|
||||
firstParty: true,
|
||||
applicationCategory: 'MultimediaApplication',
|
||||
operatingSystem: 'Windows, macOS, Linux',
|
||||
isFree: true,
|
||||
sameAs: comfyUiSameAs
|
||||
})
|
||||
}
|
||||
|
||||
export function comfyUiSourceCodeNode(siteUrl: string): JsonLdNode {
|
||||
return softwareSourceCodeNode({
|
||||
siteUrl,
|
||||
id: `${siteUrl}/#sourcecode`,
|
||||
name: 'ComfyUI',
|
||||
codeRepository: externalLinks.github,
|
||||
programmingLanguage: 'Python',
|
||||
targetProductId: comfyUiSoftwareId(siteUrl)
|
||||
})
|
||||
}
|
||||
|
||||
interface OfferInput {
|
||||
name: string
|
||||
price: string | number
|
||||
url?: string
|
||||
}
|
||||
|
||||
export interface ProductInput {
|
||||
siteUrl: string
|
||||
id: string
|
||||
name: string
|
||||
url: string
|
||||
offers: OfferInput[]
|
||||
}
|
||||
|
||||
export function productNode(input: ProductInput): JsonLdNode {
|
||||
return {
|
||||
'@type': 'Product',
|
||||
'@id': input.id,
|
||||
name: input.name,
|
||||
url: input.url,
|
||||
brand: { '@id': organizationId(input.siteUrl) },
|
||||
offers: input.offers.map((offer) => ({
|
||||
'@type': 'Offer',
|
||||
name: offer.name,
|
||||
price: offer.price,
|
||||
priceCurrency: 'USD',
|
||||
url: offer.url,
|
||||
seller: { '@id': organizationId(input.siteUrl) },
|
||||
priceSpecification: {
|
||||
'@type': 'UnitPriceSpecification',
|
||||
price: offer.price,
|
||||
priceCurrency: 'USD',
|
||||
unitText: 'MONTH'
|
||||
}
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
export interface PageGraphInput {
|
||||
url: string
|
||||
name: string
|
||||
type?: WebPageType
|
||||
description?: string
|
||||
imageUrl?: string
|
||||
crumbs?: Crumb[]
|
||||
mainEntityId?: string
|
||||
}
|
||||
|
||||
export function buildPageGraph(
|
||||
ctx: PageContext,
|
||||
page: PageGraphInput,
|
||||
...extraNodes: (JsonLdNode | null | undefined)[]
|
||||
): JsonLdGraph {
|
||||
const { type = 'WebPage', ...rest } = page
|
||||
const input: WebPageInput = {
|
||||
...rest,
|
||||
siteUrl: ctx.siteUrl,
|
||||
locale: ctx.locale
|
||||
}
|
||||
const hasCrumbs = Boolean(page.crumbs && page.crumbs.length > 0)
|
||||
return buildGraph(
|
||||
organizationNode(ctx.siteUrl),
|
||||
websiteNode(ctx.siteUrl),
|
||||
webPageNode(input, type),
|
||||
hasCrumbs ? breadcrumbNode(page.url, page.crumbs!) : undefined,
|
||||
...extraNodes
|
||||
)
|
||||
}
|
||||
|
||||
export function collectGraphIds(value: unknown): {
|
||||
defined: Set<string>
|
||||
references: string[]
|
||||
} {
|
||||
const defined = new Set<string>()
|
||||
const references: string[] = []
|
||||
const walk = (node: unknown): void => {
|
||||
if (Array.isArray(node)) {
|
||||
node.forEach(walk)
|
||||
return
|
||||
}
|
||||
if (node && typeof node === 'object') {
|
||||
const record = node as Record<string, unknown>
|
||||
const id = record['@id']
|
||||
if (typeof id === 'string') {
|
||||
if (Object.keys(record).length === 1) references.push(id)
|
||||
else defined.add(id)
|
||||
}
|
||||
Object.values(record).forEach(walk)
|
||||
}
|
||||
}
|
||||
walk(value)
|
||||
return { defined, references }
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
{
|
||||
"last_node_id": 1,
|
||||
"last_link_id": 0,
|
||||
"nodes": [
|
||||
{
|
||||
"id": 1,
|
||||
"type": "LoadVideo",
|
||||
"pos": [50, 120],
|
||||
"size": [400, 200],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "VIDEO",
|
||||
"type": "VIDEO",
|
||||
"links": null
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "LoadVideo"
|
||||
},
|
||||
"widgets_values": ["video/cloud-video-hash.mp4 [output]", "image"]
|
||||
}
|
||||
],
|
||||
"links": [],
|
||||
"groups": [],
|
||||
"config": {},
|
||||
"extra": {
|
||||
"ds": {
|
||||
"offset": [0, 0],
|
||||
"scale": 1
|
||||
}
|
||||
},
|
||||
"version": 0.4
|
||||
}
|
||||
@@ -11,7 +11,6 @@ import {
|
||||
WORKSPACE_FEATURE_FLAG
|
||||
} from '@e2e/fixtures/data/cloudWorkspace'
|
||||
import { CloudAuthHelper } from '@e2e/fixtures/helpers/CloudAuthHelper'
|
||||
import { mockWorkspaceTokenMint } from '@e2e/fixtures/utils/workspaceMocks'
|
||||
|
||||
interface RoleChangeRequest {
|
||||
url: string
|
||||
@@ -93,7 +92,9 @@ export class CloudWorkspaceMockHelper {
|
||||
await page.route('**/api/auth/session', (r) =>
|
||||
r.fulfill(jsonRoute({ token: 'mock-workspace-token' }))
|
||||
)
|
||||
await mockWorkspaceTokenMint(page, TEAM_WORKSPACE)
|
||||
await page.route('**/api/auth/token', (r) =>
|
||||
r.fulfill(jsonRoute({ token: 'mock-workspace-token' }))
|
||||
)
|
||||
await page.route('**/releases**', (r) => r.fulfill(jsonRoute([])))
|
||||
|
||||
await page.route('**/api/workspaces', (r) =>
|
||||
|
||||
@@ -110,8 +110,7 @@ export const TestIds = {
|
||||
},
|
||||
propertiesPanel: {
|
||||
root: 'properties-panel',
|
||||
errorsTab: 'panel-tab-errors',
|
||||
selectionContextStrip: 'selection-context-strip'
|
||||
errorsTab: 'panel-tab-errors'
|
||||
},
|
||||
assets: {
|
||||
browserModal: 'asset-browser-modal',
|
||||
|
||||
@@ -33,27 +33,6 @@ export function member(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stub `POST /api/auth/token` with a valid workspace token for `ws`. Without
|
||||
* this the mint fails and auth cannot resolve the active workspace.
|
||||
*/
|
||||
export async function mockWorkspaceTokenMint(
|
||||
page: Page,
|
||||
ws: Pick<WorkspaceWithRole, 'id' | 'name' | 'type' | 'role'>
|
||||
) {
|
||||
await page.route('**/api/auth/token', (r) =>
|
||||
r.fulfill(
|
||||
jsonRoute({
|
||||
token: 'mock-workspace-token',
|
||||
expires_at: new Date(Date.now() + 60 * 60 * 1000).toISOString(),
|
||||
workspace: { id: ws.id, name: ws.name, type: ws.type },
|
||||
role: ws.role,
|
||||
permissions: []
|
||||
})
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Stub the workspace resolution + members list so the cloud app boots into the
|
||||
* given workspace with the given roster (drives the original-owner gate).
|
||||
@@ -67,7 +46,17 @@ export async function mockWorkspace(
|
||||
if (route.request().method() !== 'GET') return route.fallback()
|
||||
await route.fulfill(jsonRoute({ workspaces: [ws] }))
|
||||
})
|
||||
await mockWorkspaceTokenMint(page, ws)
|
||||
await page.route('**/api/auth/token', (r) =>
|
||||
r.fulfill(
|
||||
jsonRoute({
|
||||
token: 'mock-workspace-token',
|
||||
expires_at: new Date(Date.now() + 60 * 60 * 1000).toISOString(),
|
||||
workspace: { id: ws.id, name: ws.name, type: ws.type },
|
||||
role: ws.role,
|
||||
permissions: []
|
||||
})
|
||||
)
|
||||
)
|
||||
await page.route('**/api/workspace/members**', (r) =>
|
||||
r.fulfill(
|
||||
jsonRoute({
|
||||
|
||||
@@ -11,10 +11,6 @@ import type {
|
||||
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
|
||||
import { mockSystemStats } from '@e2e/fixtures/data/systemStats'
|
||||
import { CloudAuthHelper } from '@e2e/fixtures/helpers/CloudAuthHelper'
|
||||
import {
|
||||
mockWorkspaceTokenMint,
|
||||
workspace
|
||||
} from '@e2e/fixtures/utils/workspaceMocks'
|
||||
|
||||
/**
|
||||
* Billing facade consumers — FE-933 (B3) regression.
|
||||
@@ -85,7 +81,6 @@ async function mockCloudBoot(
|
||||
await page.route('**/api/auth/session', (r) =>
|
||||
r.fulfill(jsonRoute({ token: 'mock-workspace-token' }))
|
||||
)
|
||||
await mockWorkspaceTokenMint(page, workspace('personal', 'owner'))
|
||||
await page.route('**/releases**', (r) => r.fulfill(jsonRoute([])))
|
||||
|
||||
// Single personal workspace.
|
||||
|
||||
@@ -1,279 +0,0 @@
|
||||
import { expect } from '@playwright/test'
|
||||
import type { Page, Route } from '@playwright/test'
|
||||
|
||||
import type { RemoteConfig } from '@/platform/remoteConfig/types'
|
||||
|
||||
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
|
||||
import { bootCloud, mockCloudBoot } from '@e2e/fixtures/utils/cloudBootMocks'
|
||||
import { jsonRoute } from '@e2e/fixtures/utils/jsonRoute'
|
||||
|
||||
/**
|
||||
* End-to-end coverage for the user-secrets (API keys) surface in the cloud app:
|
||||
* add a provider key, see it listed, delete it — the full CRUD round-trip —
|
||||
* plus the entitlement contract that a non-entitled account never sees the
|
||||
* gated providers.
|
||||
*
|
||||
* Drives a raw `page` against fully-mocked endpoints (the `comfyPage` fixture
|
||||
* would reach the OSS devtools backend during setup); `mockCloudBoot` +
|
||||
* `bootCloud` boot the app signed-in, and this spec layers a stateful in-memory
|
||||
* `/secrets` backend on top so the flow is deterministic and never touches a
|
||||
* real server.
|
||||
*/
|
||||
const APP_URL = process.env.PLAYWRIGHT_TEST_URL || 'http://localhost:8188'
|
||||
|
||||
// `/api/features` is the remote-config source. Enabling user secrets is what
|
||||
// surfaces the Secrets settings panel for a signed-in user.
|
||||
const BOOT_FEATURES = {
|
||||
user_secrets_enabled: true
|
||||
} satisfies RemoteConfig
|
||||
|
||||
// TutorialCompleted suppresses the new-user template browser, whose modal
|
||||
// overlay (z-1700) would otherwise intercept clicks on the settings dialog.
|
||||
const BOOT_SETTINGS = { 'Comfy.TutorialCompleted': true }
|
||||
|
||||
// The plaintext key a user types in. It must be sent on create but NEVER echoed
|
||||
// back by the API or rendered anywhere in the UI.
|
||||
const RUNWAY_KEY_VALUE = 'sk-runway-do-not-echo-0xDEADBEEF'
|
||||
|
||||
interface SecretRecord {
|
||||
id: string
|
||||
name: string
|
||||
provider?: string
|
||||
created_at: string
|
||||
updated_at: string
|
||||
last_used_at?: string
|
||||
}
|
||||
|
||||
interface CreateCapture {
|
||||
name?: string
|
||||
provider?: string
|
||||
secret_value?: string
|
||||
}
|
||||
|
||||
interface SecretsBackend {
|
||||
/** Bodies received by POST /secrets, in order — for asserting what was sent. */
|
||||
createRequests: CreateCapture[]
|
||||
/** Current server-side store — for asserting delete actually removed a row. */
|
||||
store: SecretRecord[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Stateful mock of the ingest `/secrets` surface. A single route handler
|
||||
* branches on path + method so registration order can never make a specific
|
||||
* path (`/secrets/providers`, `/secrets/:id`) lose to the collection glob.
|
||||
*
|
||||
* `providerIds` models entitlement: an entitled account sees runway/gemini,
|
||||
* a non-entitled account gets an empty list (the server omits them).
|
||||
*/
|
||||
async function mockSecretsBackend(
|
||||
page: Page,
|
||||
providerIds: string[]
|
||||
): Promise<SecretsBackend> {
|
||||
const backend: SecretsBackend = { createRequests: [], store: [] }
|
||||
let idSeq = 0
|
||||
|
||||
const respondList = (route: Route) =>
|
||||
route.fulfill(jsonRoute({ data: backend.store }))
|
||||
|
||||
await page.route('**/api/secrets**', async (route) => {
|
||||
const request = route.request()
|
||||
const { pathname } = new URL(request.url())
|
||||
const method = request.method()
|
||||
|
||||
// The glob `**/api/secrets**` also matches the panel's own lazy-loaded
|
||||
// source module (`/src/platform/secrets/api/secretsApi.ts`), whose path
|
||||
// contains the `/api/secrets` substring. Fulfilling that dev-server module
|
||||
// request with JSON breaks the dynamic import and the panel never mounts.
|
||||
// Anchor to the start of the pathname so only genuine `/api/secrets…` API
|
||||
// routes are handled; everything else falls through to the real Vite server.
|
||||
if (!/^\/api\/secrets(\/|$)/.test(pathname)) {
|
||||
return route.continue()
|
||||
}
|
||||
|
||||
// GET /secrets/providers — the entitlement-gated provider allowlist.
|
||||
if (pathname.endsWith('/secrets/providers')) {
|
||||
return route.fulfill(
|
||||
jsonRoute({ data: providerIds.map((id) => ({ id })) })
|
||||
)
|
||||
}
|
||||
|
||||
// /secrets/:id — item routes (only DELETE is exercised by this flow).
|
||||
const itemMatch = pathname.match(/\/secrets\/([^/]+)$/)
|
||||
if (itemMatch) {
|
||||
const id = itemMatch[1]
|
||||
if (method === 'DELETE') {
|
||||
backend.store = backend.store.filter((s) => s.id !== id)
|
||||
return route.fulfill({ status: 204, body: '' })
|
||||
}
|
||||
return respondList(route)
|
||||
}
|
||||
|
||||
// /secrets — collection routes.
|
||||
if (method === 'POST') {
|
||||
const body = (request.postDataJSON() ?? {}) as CreateCapture
|
||||
backend.createRequests.push(body)
|
||||
idSeq += 1
|
||||
const created: SecretRecord = {
|
||||
id: `00000000-0000-4000-8000-${String(idSeq).padStart(12, '0')}`,
|
||||
name: body.name ?? '',
|
||||
provider: body.provider,
|
||||
created_at: '2026-07-08T00:00:00Z',
|
||||
updated_at: '2026-07-08T00:00:00Z'
|
||||
}
|
||||
backend.store.push(created)
|
||||
// Response echoes metadata ONLY — the schema has no secret_value field.
|
||||
return route.fulfill(jsonRoute(created))
|
||||
}
|
||||
|
||||
// GET /secrets (list).
|
||||
return respondList(route)
|
||||
})
|
||||
|
||||
return backend
|
||||
}
|
||||
|
||||
/**
|
||||
* Open the settings dialog and land on the Secrets panel, waiting for both the
|
||||
* provider allowlist and the secret list to resolve so subsequent assertions
|
||||
* are not racing the panel's on-mount fetches.
|
||||
*/
|
||||
async function openSecretsPanel(page: Page) {
|
||||
const settingsDialog = page.getByTestId('settings-dialog')
|
||||
|
||||
await page.evaluate(() => {
|
||||
const app = window.app
|
||||
if (!app) throw new Error('window.app is not available')
|
||||
return app.extensionManager.command.execute('Comfy.ShowSettingsDialog')
|
||||
})
|
||||
await settingsDialog.waitFor({ state: 'visible' })
|
||||
|
||||
const providersResolved = page.waitForResponse((r) =>
|
||||
r.url().includes('/api/secrets/providers')
|
||||
)
|
||||
const listResolved = page.waitForResponse(
|
||||
(r) =>
|
||||
/\/api\/secrets(\?|$)/.test(r.url()) && r.request().method() === 'GET'
|
||||
)
|
||||
|
||||
await settingsDialog
|
||||
.locator('nav')
|
||||
.getByRole('button', { name: 'Secrets' })
|
||||
.click()
|
||||
|
||||
await Promise.all([providersResolved, listResolved])
|
||||
return settingsDialog
|
||||
}
|
||||
|
||||
test.describe('Cloud user secrets (API keys)', { tag: '@cloud' }, () => {
|
||||
test('an entitled account can add, list, and delete a provider key', async ({
|
||||
page
|
||||
}) => {
|
||||
test.slow()
|
||||
|
||||
await mockCloudBoot(page, {
|
||||
features: BOOT_FEATURES,
|
||||
settings: BOOT_SETTINGS
|
||||
})
|
||||
await bootCloud(page)
|
||||
const backend = await mockSecretsBackend(page, ['runway', 'gemini'])
|
||||
|
||||
await page.goto(APP_URL)
|
||||
await page.waitForFunction(() => !!window.app?.extensionManager, null, {
|
||||
timeout: 45_000
|
||||
})
|
||||
|
||||
const settingsDialog = await openSecretsPanel(page)
|
||||
|
||||
// Empty state before anything is added.
|
||||
await expect(settingsDialog.getByText(/No secrets stored/)).toBeVisible()
|
||||
|
||||
// --- ADD -------------------------------------------------------------
|
||||
await settingsDialog.getByRole('button', { name: 'Add Secret' }).click()
|
||||
|
||||
const formDialog = page
|
||||
.getByRole('dialog')
|
||||
.filter({ hasText: 'Secret Value' })
|
||||
await expect(formDialog).toBeVisible()
|
||||
|
||||
// Pick the entitled Runway provider from the server-driven dropdown.
|
||||
await formDialog.locator('#secret-provider').click()
|
||||
await page.getByRole('option', { name: 'Runway' }).click()
|
||||
|
||||
await formDialog.locator('#secret-name').fill('My Runway Key')
|
||||
await formDialog.locator('input[type="password"]').fill(RUNWAY_KEY_VALUE)
|
||||
|
||||
await formDialog.getByRole('button', { name: 'Save', exact: true }).click()
|
||||
await expect(formDialog).toBeHidden()
|
||||
|
||||
// --- LIST ------------------------------------------------------------
|
||||
await expect(settingsDialog.getByText('My Runway Key')).toBeVisible()
|
||||
await expect(settingsDialog.getByText(/No secrets stored/)).toBeHidden()
|
||||
|
||||
// The create request carried the plaintext value + provider...
|
||||
expect(backend.createRequests).toHaveLength(1)
|
||||
expect(backend.createRequests[0]).toMatchObject({
|
||||
name: 'My Runway Key',
|
||||
provider: 'runway',
|
||||
secret_value: RUNWAY_KEY_VALUE
|
||||
})
|
||||
// ...but the value must never be echoed back into the list — the API
|
||||
// response carries metadata only, so nothing should render it as text.
|
||||
await expect(page.getByText(RUNWAY_KEY_VALUE)).toHaveCount(0)
|
||||
|
||||
// --- DELETE ----------------------------------------------------------
|
||||
await settingsDialog
|
||||
.getByRole('button', { name: 'Delete', exact: true })
|
||||
.click()
|
||||
|
||||
const confirmDialog = page
|
||||
.getByRole('dialog')
|
||||
.filter({ hasText: 'Delete Secret' })
|
||||
await confirmDialog
|
||||
.getByRole('button', { name: 'Delete', exact: true })
|
||||
.click()
|
||||
|
||||
await expect(settingsDialog.getByText('My Runway Key')).toBeHidden()
|
||||
await expect(settingsDialog.getByText(/No secrets stored/)).toBeVisible()
|
||||
expect(backend.store).toHaveLength(0)
|
||||
})
|
||||
|
||||
test('a non-entitled account never sees the gated providers', async ({
|
||||
page
|
||||
}) => {
|
||||
test.slow()
|
||||
|
||||
await mockCloudBoot(page, {
|
||||
features: BOOT_FEATURES,
|
||||
settings: BOOT_SETTINGS
|
||||
})
|
||||
await bootCloud(page)
|
||||
// Non-entitled: the server omits runway/gemini from the allowlist.
|
||||
await mockSecretsBackend(page, [])
|
||||
|
||||
await page.goto(APP_URL)
|
||||
await page.waitForFunction(() => !!window.app?.extensionManager, null, {
|
||||
timeout: 45_000
|
||||
})
|
||||
|
||||
const settingsDialog = await openSecretsPanel(page)
|
||||
await expect(settingsDialog.getByText(/No secrets stored/)).toBeVisible()
|
||||
|
||||
// The add form opens, but its provider dropdown is empty — the gated
|
||||
// providers must not appear anywhere.
|
||||
await settingsDialog.getByRole('button', { name: 'Add Secret' }).click()
|
||||
const formDialog = page
|
||||
.getByRole('dialog')
|
||||
.filter({ hasText: 'Secret Value' })
|
||||
await expect(formDialog).toBeVisible()
|
||||
|
||||
await formDialog.locator('#secret-provider').click()
|
||||
// Anchor on the opened listbox so the absence assertions below can't pass
|
||||
// vacuously against a dropdown that never opened.
|
||||
const providerListbox = page.getByRole('listbox')
|
||||
await expect(providerListbox).toBeVisible()
|
||||
// An empty allowlist must yield an empty dropdown. Asserting zero options
|
||||
// (not just runway/gemini absent) also rejects the fetch-failure fallback,
|
||||
// where `availableProviders` is null and the default providers would show.
|
||||
await expect(providerListbox.getByRole('option')).toHaveCount(0)
|
||||
})
|
||||
})
|
||||
@@ -7,10 +7,6 @@ import type { BillingStatusResponse } from '@/platform/workspace/api/workspaceAp
|
||||
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
|
||||
import { mockSystemStats } from '@e2e/fixtures/data/systemStats'
|
||||
import { CloudAuthHelper } from '@e2e/fixtures/helpers/CloudAuthHelper'
|
||||
import {
|
||||
mockWorkspaceTokenMint,
|
||||
workspace
|
||||
} from '@e2e/fixtures/utils/workspaceMocks'
|
||||
|
||||
// Drives a raw `page` (not the `comfyPage` fixture) so the cloud app boots
|
||||
// against fully mocked endpoints; `comfyPage` would try to reach the OSS
|
||||
@@ -101,7 +97,6 @@ async function mockCloudBoot(page: Page) {
|
||||
await page.route('**/api/auth/session', (r) =>
|
||||
r.fulfill(jsonRoute({ token: 'mock-workspace-token' }))
|
||||
)
|
||||
await mockWorkspaceTokenMint(page, workspace('personal', 'owner'))
|
||||
await page.route('**/releases**', (r) => r.fulfill(jsonRoute([])))
|
||||
|
||||
// Single personal workspace.
|
||||
|
||||
@@ -476,37 +476,6 @@ test.describe('Minimap', { tag: '@canvas' }, () => {
|
||||
})
|
||||
.toBe(true)
|
||||
})
|
||||
|
||||
test(
|
||||
'Closing minimap after subgraph navigation keeps Vue render in sync',
|
||||
{ tag: '@vue-nodes' },
|
||||
async ({ comfyPage }) => {
|
||||
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
|
||||
|
||||
const subgraphNodeId = await comfyPage.subgraph.findSubgraphNodeId()
|
||||
|
||||
// Round-trip layers Vue's onNodeAdded wrapper on top of the minimap's.
|
||||
await comfyPage.vueNodes.enterSubgraph(subgraphNodeId)
|
||||
await comfyPage.subgraph.exitViaBreadcrumb()
|
||||
|
||||
// Minimap unmount must not clobber the Vue wrapper layered above it.
|
||||
await comfyPage.page
|
||||
.getByTestId(TestIds.canvas.closeMinimapButton)
|
||||
.click()
|
||||
|
||||
const subgraphFixture =
|
||||
await comfyPage.vueNodes.getFixtureByTitle('New Subgraph')
|
||||
await comfyPage.contextMenu.openForVueNode(subgraphFixture.header)
|
||||
await comfyPage.contextMenu.clickMenuItemExact('Unpack Subgraph')
|
||||
await comfyPage.contextMenu.waitForHidden()
|
||||
|
||||
await expect.poll(() => comfyPage.nodeOps.getGraphNodesCount()).toBe(2)
|
||||
await expect.poll(() => comfyPage.vueNodes.getNodeCount()).toBe(2)
|
||||
await expect(
|
||||
comfyPage.vueNodes.getNodeLocator(subgraphNodeId)
|
||||
).toHaveCount(0)
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
test.describe('Minimap mobile', { tag: ['@mobile', '@canvas'] }, () => {
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 21 KiB After Width: | Height: | Size: 21 KiB |
@@ -1,13 +1,7 @@
|
||||
import { mergeTests } from '@playwright/test'
|
||||
|
||||
import {
|
||||
comfyPageFixture as test,
|
||||
comfyExpect as expect
|
||||
} from '@e2e/fixtures/ComfyPage'
|
||||
import { ExecutionHelper } from '@e2e/fixtures/helpers/ExecutionHelper'
|
||||
import { webSocketFixture } from '@e2e/fixtures/ws'
|
||||
|
||||
const wstest = mergeTests(test, webSocketFixture)
|
||||
|
||||
test.describe('Preview as Text node', () => {
|
||||
test('does not include preview widget values in the API prompt', async ({
|
||||
@@ -45,34 +39,4 @@ test.describe('Preview as Text node', () => {
|
||||
expect(previewEntry!.inputs).not.toHaveProperty('preview_text')
|
||||
expect(previewEntry!.inputs).not.toHaveProperty('previewMode')
|
||||
})
|
||||
|
||||
wstest(
|
||||
'restoring workflow restores state',
|
||||
{ tag: '@vue-nodes' },
|
||||
async ({ comfyPage, getWebSocket }) => {
|
||||
const execution = new ExecutionHelper(comfyPage, await getWebSocket())
|
||||
|
||||
await comfyPage.menu.topbar.newWorkflowButton.click()
|
||||
await comfyPage.searchBoxV2.addNode('Preview as Text')
|
||||
const node = await comfyPage.vueNodes.getFixtureByTitle('Preview as Text')
|
||||
const preview = node.root.locator('textarea')
|
||||
|
||||
await test.step('node previews execution result', async () => {
|
||||
const id = await comfyPage.vueNodes.getNodeIdByTitle('Preview as Text')
|
||||
execution.executed('', id, { text: 'massive fennec ears' })
|
||||
await expect(preview).toHaveValue('massive fennec ears')
|
||||
})
|
||||
|
||||
await test.step('swap to a different workflow and back', async () => {
|
||||
await comfyPage.menu.topbar.getTab(0).click()
|
||||
await expect(node.root).toBeHidden()
|
||||
await comfyPage.menu.topbar.getTab(1).click()
|
||||
await expect(node.root).toBeVisible()
|
||||
})
|
||||
|
||||
await expect(preview, 'previous output is restored').toHaveValue(
|
||||
'massive fennec ears'
|
||||
)
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
@@ -1,11 +1,6 @@
|
||||
import { expect, mergeTests } from '@playwright/test'
|
||||
import type { Page, Route } from '@playwright/test'
|
||||
import type {
|
||||
Asset,
|
||||
GetAllSettingsResponse,
|
||||
GetSettingByIdResponse,
|
||||
ListAssetsResponse
|
||||
} from '@comfyorg/ingest-types'
|
||||
import type { Asset, ListAssetsResponse } from '@comfyorg/ingest-types'
|
||||
|
||||
import {
|
||||
assetRequestIncludesTag,
|
||||
@@ -13,7 +8,6 @@ import {
|
||||
} from '@e2e/fixtures/assetApiFixture'
|
||||
import { comfyPageFixture } from '@e2e/fixtures/ComfyPage'
|
||||
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
|
||||
import type { WorkspaceStore } from '@e2e/types/globals'
|
||||
import {
|
||||
routeObjectInfoFromSetupApi,
|
||||
setComboInputOptions
|
||||
@@ -29,11 +23,10 @@ import type { RawJobListItem } from '@/platform/remote/comfyui/jobs/jobTypes'
|
||||
const ossTest = mergeTests(comfyPageFixture, jobsRouteFixture)
|
||||
const outputHash =
|
||||
'147257c95a3e957e0deee73a077cfec89da2d906dd086ca70a2b0c897a9591d6e.png'
|
||||
const outputVideoHash = 'cloud-video-hash.mp4'
|
||||
const plainVideoFileName = 'plain_video.mp4'
|
||||
const graphDropPosition = { x: 500, y: 300 }
|
||||
const missingMediaObservationMs = 1_000
|
||||
const missingMediaPollMs = 100
|
||||
const missingMediaUploadObservationMs = 1_000
|
||||
const missingMediaUploadPollMs = 100
|
||||
const emptyMediaLoaderNodes = [
|
||||
{
|
||||
nodeType: 'LoadImage',
|
||||
@@ -67,18 +60,6 @@ const cloudOutputAsset: Asset & { hash?: string } = {
|
||||
last_access_time: '2026-05-01T00:00:00Z'
|
||||
}
|
||||
|
||||
const cloudOutputVideoAsset: Asset & { hash?: string } = {
|
||||
id: 'test-output-video-hash-001',
|
||||
name: 'ComfyUI_00001_.mp4',
|
||||
hash: outputVideoHash,
|
||||
size: 4_194_304,
|
||||
mime_type: 'video/mp4',
|
||||
tags: ['output'],
|
||||
created_at: '2026-05-01T00:00:00Z',
|
||||
updated_at: '2026-05-01T00:00:00Z',
|
||||
last_access_time: '2026-05-01T00:00:00Z'
|
||||
}
|
||||
|
||||
const cloudUploadedVideoAsset: Asset & { hash?: string } = {
|
||||
id: 'test-uploaded-video-001',
|
||||
name: plainVideoFileName,
|
||||
@@ -111,21 +92,10 @@ interface CloudUploadAssetState {
|
||||
|
||||
async function routeCloudBootstrapApis(page: Page) {
|
||||
await page.route('**/api/settings**', async (route) => {
|
||||
const completedSurveySetting: GetSettingByIdResponse = {
|
||||
value: { usage: 'personal' }
|
||||
}
|
||||
const allSettings: GetAllSettingsResponse = {}
|
||||
const body = route
|
||||
.request()
|
||||
.url()
|
||||
.includes('/api/settings/onboarding_survey')
|
||||
? completedSurveySetting
|
||||
: allSettings
|
||||
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(body)
|
||||
body: JSON.stringify({})
|
||||
})
|
||||
})
|
||||
await page.route('**/api/userdata**', async (route) => {
|
||||
@@ -151,10 +121,7 @@ async function routeCloudBootstrapApis(page: Page) {
|
||||
})
|
||||
}
|
||||
|
||||
const cloudOutputTest = createCloudAssetsFixture([
|
||||
cloudOutputAsset,
|
||||
cloudOutputVideoAsset
|
||||
]).extend({
|
||||
const cloudOutputTest = createCloudAssetsFixture([cloudOutputAsset]).extend({
|
||||
page: async ({ page }, use) => {
|
||||
await routeCloudBootstrapApis(page)
|
||||
const unrouteObjectInfo = await routeObjectInfoFromSetupApi(page)
|
||||
@@ -258,33 +225,6 @@ function getErrorOverlay(comfyPage: ComfyPage) {
|
||||
return comfyPage.page.getByTestId(TestIds.dialogs.errorOverlay)
|
||||
}
|
||||
|
||||
function isOutputAssetsRequest(url: string) {
|
||||
return url.includes('/api/assets') && assetRequestIncludesTag(url, 'output')
|
||||
}
|
||||
|
||||
async function waitForOutputAssetsResponse(comfyPage: ComfyPage) {
|
||||
await comfyPage.page.waitForResponse(
|
||||
(response) =>
|
||||
response.status() === 200 && isOutputAssetsRequest(response.url())
|
||||
)
|
||||
}
|
||||
|
||||
async function getCachedMissingMediaWarningNames(
|
||||
comfyPage: ComfyPage
|
||||
): Promise<string[] | null> {
|
||||
return await comfyPage.page.evaluate(() => {
|
||||
const workflow = (window.app!.extensionManager as WorkspaceStore).workflow
|
||||
.activeWorkflow
|
||||
if (!workflow) return null
|
||||
|
||||
return (
|
||||
workflow.pendingWarnings?.missingMediaCandidates?.map(
|
||||
(candidate) => candidate.name
|
||||
) ?? []
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
async function expectNoErrorsTab(comfyPage: ComfyPage) {
|
||||
await expect(getErrorOverlay(comfyPage)).toBeHidden()
|
||||
|
||||
@@ -387,31 +327,25 @@ async function expectLoadVideoUploading(comfyPage: ComfyPage) {
|
||||
.toBe(true)
|
||||
}
|
||||
|
||||
async function expectNoMissingMediaForObservationWindow(comfyPage: ComfyPage) {
|
||||
async function expectNoMissingMediaDuringUpload(comfyPage: ComfyPage) {
|
||||
await comfyPage.nextFrame()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
let sawErrorOverlay = false
|
||||
let sawCachedMissingMedia = false
|
||||
const startedAt = Date.now()
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const cachedMissingMedia =
|
||||
await getCachedMissingMediaWarningNames(comfyPage)
|
||||
sawCachedMissingMedia =
|
||||
sawCachedMissingMedia || !!cachedMissingMedia?.length
|
||||
sawErrorOverlay =
|
||||
sawErrorOverlay || (await getErrorOverlay(comfyPage).isVisible())
|
||||
return (
|
||||
!sawErrorOverlay &&
|
||||
!sawCachedMissingMedia &&
|
||||
Date.now() - startedAt >= missingMediaObservationMs
|
||||
Date.now() - startedAt >= missingMediaUploadObservationMs
|
||||
)
|
||||
},
|
||||
{
|
||||
timeout: missingMediaObservationMs + missingMediaPollMs * 5,
|
||||
intervals: [missingMediaPollMs]
|
||||
timeout: missingMediaUploadObservationMs + missingMediaUploadPollMs * 5,
|
||||
intervals: [missingMediaUploadPollMs]
|
||||
}
|
||||
)
|
||||
.toBe(true)
|
||||
@@ -490,7 +424,7 @@ ossTest.describe(
|
||||
})
|
||||
|
||||
await expectLoadVideoUploading(comfyPage)
|
||||
await expectNoMissingMediaForObservationWindow(comfyPage)
|
||||
await expectNoMissingMediaDuringUpload(comfyPage)
|
||||
|
||||
await delayedUpload.finishUpload()
|
||||
await expect(getErrorOverlay(comfyPage)).toBeHidden()
|
||||
@@ -548,30 +482,18 @@ cloudOutputTest.describe(
|
||||
|
||||
cloudOutputTest(
|
||||
'resolves compact annotated output media from output assets',
|
||||
async ({ comfyPage }) => {
|
||||
const outputAssetsResponse = waitForOutputAssetsResponse(comfyPage)
|
||||
|
||||
async ({ cloudAssetRequests, comfyPage }) => {
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'missing/missing_media_cloud_output_annotation'
|
||||
)
|
||||
|
||||
await outputAssetsResponse
|
||||
await expectNoMissingMediaForObservationWindow(comfyPage)
|
||||
await expectNoErrorsTab(comfyPage)
|
||||
}
|
||||
)
|
||||
|
||||
cloudOutputTest(
|
||||
'resolves subfoldered output video media from flat output asset hashes',
|
||||
async ({ comfyPage }) => {
|
||||
const outputAssetsResponse = waitForOutputAssetsResponse(comfyPage)
|
||||
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'missing/missing_media_cloud_output_video_subfolder'
|
||||
)
|
||||
|
||||
await outputAssetsResponse
|
||||
await expectNoMissingMediaForObservationWindow(comfyPage)
|
||||
await expect
|
||||
.poll(() =>
|
||||
cloudAssetRequests.some((url) =>
|
||||
assetRequestIncludesTag(url, 'output')
|
||||
)
|
||||
)
|
||||
.toBe(true)
|
||||
await expectNoErrorsTab(comfyPage)
|
||||
}
|
||||
)
|
||||
@@ -607,7 +529,7 @@ cloudUploadRaceTest.describe(
|
||||
})
|
||||
|
||||
await expectLoadVideoUploading(comfyPage)
|
||||
await expectNoMissingMediaForObservationWindow(comfyPage)
|
||||
await expectNoMissingMediaDuringUpload(comfyPage)
|
||||
|
||||
markUploadedCloudAssetAvailable()
|
||||
await delayedUpload.finishUpload()
|
||||
|
||||
@@ -286,7 +286,7 @@ test.describe('Errors tab - Mode-aware errors', { tag: '@ui' }, () => {
|
||||
await expect(missingModelGroup).toBeHidden()
|
||||
})
|
||||
|
||||
test('Selecting a node keeps all errors visible and shows selection context', async ({
|
||||
test('Selecting a node filters errors tab to only that node', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await loadWorkflowAndOpenErrorsTab(
|
||||
@@ -301,25 +301,14 @@ test.describe('Errors tab - Mode-aware errors', { tag: '@ui' }, () => {
|
||||
|
||||
const node1 = await comfyPage.nodeOps.getNodeRefById('1')
|
||||
await node1.click('title')
|
||||
|
||||
await expect(
|
||||
getMissingModelLabel(missingModelGroup, FAKE_MODEL_NAME)
|
||||
).toBeVisible()
|
||||
await expectReferenceBadge(missingModelGroup, 2)
|
||||
const strip = comfyPage.page.getByTestId(
|
||||
TestIds.propertiesPanel.selectionContextStrip
|
||||
)
|
||||
await expect(strip).toBeVisible()
|
||||
await expect(
|
||||
strip,
|
||||
'The strip count is scoped to the selection, diverging from the global reference badge'
|
||||
).toContainText('1 error')
|
||||
missingModelGroup.getByTestId(TestIds.dialogs.missingModelLocate)
|
||||
).toHaveCount(1)
|
||||
|
||||
await comfyPage.canvas.click()
|
||||
await expect(
|
||||
strip,
|
||||
'Deselecting swaps the always-visible strip back to the summary'
|
||||
).toContainText('2 nodes — 1 error')
|
||||
await expectReferenceBadge(missingModelGroup, 2)
|
||||
})
|
||||
})
|
||||
@@ -392,7 +381,7 @@ test.describe('Errors tab - Mode-aware errors', { tag: '@ui' }, () => {
|
||||
await expect(missingMediaGroup).toBeHidden()
|
||||
})
|
||||
|
||||
test('Selecting a node keeps all media rows visible and shows selection context', async ({
|
||||
test('Selecting a node filters errors tab to only that node', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow('missing/missing_media_multiple')
|
||||
@@ -414,66 +403,13 @@ test.describe('Errors tab - Mode-aware errors', { tag: '@ui' }, () => {
|
||||
|
||||
const node = await comfyPage.nodeOps.getNodeRefById('10')
|
||||
await node.click('title')
|
||||
|
||||
// Selection no longer filters the list — rows stay global and the
|
||||
// selection is surfaced via the context strip instead.
|
||||
const strip = comfyPage.page.getByTestId(
|
||||
TestIds.propertiesPanel.selectionContextStrip
|
||||
)
|
||||
await expect(strip).toBeVisible()
|
||||
await expect(strip).toContainText('1 error')
|
||||
await expect(mediaRows).toHaveCount(2)
|
||||
await expect(mediaRows).toHaveCount(1)
|
||||
|
||||
await comfyPage.canvas.click({ position: { x: 400, y: 600 } })
|
||||
// Deselecting swaps the always-visible strip back to the summary
|
||||
await expect(strip).toContainText('2 nodes — 2 errors')
|
||||
await expect(mediaRows).toHaveCount(2)
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Selection emphasis', () => {
|
||||
test('Selecting a node collapses unrelated groups and highlights its rows', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await loadWorkflowAndOpenErrorsTab(
|
||||
comfyPage,
|
||||
'missing/missing_nodes_and_media'
|
||||
)
|
||||
|
||||
const missingNodeCard = comfyPage.page.getByTestId(
|
||||
TestIds.dialogs.missingNodeCard
|
||||
)
|
||||
const mediaRow = comfyPage.page.getByTestId(
|
||||
TestIds.dialogs.missingMediaRow
|
||||
)
|
||||
const strip = comfyPage.page.getByTestId(
|
||||
TestIds.propertiesPanel.selectionContextStrip
|
||||
)
|
||||
await expect(missingNodeCard).toBeVisible()
|
||||
await expect(mediaRow).toBeVisible()
|
||||
await expect(strip).toContainText('2 nodes — 2 errors')
|
||||
|
||||
const mediaNode = await comfyPage.nodeOps.getNodeRefById('10')
|
||||
// The node sits near the canvas top where overlays intercept clicks
|
||||
await mediaNode.centerOnNode()
|
||||
await mediaNode.click('title')
|
||||
|
||||
// The unrelated missing-node group auto-collapses while the matched
|
||||
// media row stays visible and is marked as part of the selection
|
||||
await expect(missingNodeCard).toBeHidden()
|
||||
await expect(mediaRow).toBeVisible()
|
||||
await expect(mediaRow).toHaveAttribute('aria-current', 'true')
|
||||
await expect(strip).toContainText('1 error')
|
||||
|
||||
await comfyPage.canvas.click({ position: { x: 400, y: 600 } })
|
||||
// Emphasis ends: the collapsed group re-expands and the strip
|
||||
// returns to the workflow summary
|
||||
await expect(missingNodeCard).toBeVisible()
|
||||
await expect(mediaRow).not.toHaveAttribute('aria-current', 'true')
|
||||
await expect(strip).toContainText('2 nodes — 2 errors')
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Subgraph', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await cleanupFakeModel(comfyPage)
|
||||
|
||||
@@ -1,120 +0,0 @@
|
||||
# 11. Derived Credential Lifecycle for Cloud Auth
|
||||
|
||||
Date: 2026-07-09
|
||||
|
||||
## Status
|
||||
|
||||
Proposed
|
||||
|
||||
<!-- [Proposed | Accepted | Rejected | Deprecated | Superseded by [ADR-NNNN](NNNN-title.md)] -->
|
||||
|
||||
## Context
|
||||
|
||||
Cloud authentication derives several short-lived credentials from a single
|
||||
source of truth — the Firebase identity (ID token):
|
||||
|
||||
- the **workspace JWT** minted by exchanging the Firebase token (`workspaceAuthStore`),
|
||||
- the **session cookie** created by POSTing the Firebase token to `/auth/session`
|
||||
(`useSessionCookie`),
|
||||
- and consumer state gated on those credentials, such as **subscription status**
|
||||
(`useSubscription`).
|
||||
|
||||
A recurring class of production bugs traces back to how these derived credentials
|
||||
are kept fresh rather than to any single code path:
|
||||
|
||||
- **FE-613** — workspace token exchange is not reactive to Firebase auth state.
|
||||
Its refresh relies on a `setTimeout` timer that browsers throttle in background
|
||||
tabs, so a backgrounded session serves an expired workspace JWT and every cloud
|
||||
call 401s until reload.
|
||||
- **Workspace/personal oscillation** (PR #13511) — when a valid workspace token is
|
||||
momentarily absent, `getAuthHeader`/`getAuthToken` silently downgraded to the
|
||||
personal Firebase token, so requests authenticated as the wrong identity.
|
||||
- **Run-button toggle loop** (Slack, related to FE-1072) — a Firebase token-refresh
|
||||
burst on wake/network-swap fans out into concurrent, undeduped subscription
|
||||
fetches racing an in-flight session-cookie rotation; some land pre-rotation and
|
||||
return 401/empty, flapping `subscriptionStatus` and the run button.
|
||||
|
||||
These are not independent defects. They are symptoms of one design shape: **each
|
||||
derived credential has its own ad-hoc refresh lifecycle, driven by timers or
|
||||
one-shot events rather than the source identity, with no coalescing of concurrent
|
||||
refreshes and with silent fallback to a different identity or a stale value on
|
||||
failure.** Any credential built this way can go stale, stampede, or downgrade.
|
||||
|
||||
## Decision
|
||||
|
||||
Treat every derived credential as a pure function of the Firebase identity, and
|
||||
require all of them to obey the same lifecycle invariants. New auth code must
|
||||
satisfy these; existing code migrates toward them incrementally.
|
||||
|
||||
1. **Single source of truth.** The Firebase identity is authoritative. Workspace
|
||||
JWT and session cookie are derivations of it, never independent state that can
|
||||
drift from it.
|
||||
|
||||
2. **Valid-on-read.** A caller asking for a credential gets a currently-valid one
|
||||
or a definitive failure — never a known-expired one. Validity is checked at the
|
||||
point of use (expiry-aware), not assumed because a background timer _should_
|
||||
have refreshed. Timers may be an optimization, never the guarantee.
|
||||
|
||||
3. **Single-flight.** Concurrent requests for the same credential share one
|
||||
in-flight mint/refresh. A refresh burst collapses to a single network call.
|
||||
|
||||
4. **Fail-closed, never downgrade.** If the correct-scope credential cannot be
|
||||
obtained, fail the request. Never silently substitute a different identity or
|
||||
scope (e.g. personal token for a workspace request).
|
||||
|
||||
5. **Bounded reactive retry.** Invalidation is driven by the source identity
|
||||
(`onIdTokenChanged`), not by polling or wall-clock timers alone. A `401` on a
|
||||
derived credential triggers at most one re-mint and one retry, then surfaces
|
||||
the error.
|
||||
|
||||
6. **Explicit scope.** A credential names the identity/workspace it is for.
|
||||
Coalesced results are verified against the requested scope before use.
|
||||
|
||||
PR #13511 is the first increment: workspace-token recovery is now valid-on-read,
|
||||
single-flight, fail-closed, and reconciles a revoked workspace instead of
|
||||
downgrading; subscription-status and session-cookie creation are now
|
||||
single-flight so a refresh burst can no longer flap them. It intentionally does
|
||||
**not** yet add the `onIdTokenChanged` subscription FE-613 proposes — recovery is
|
||||
lazy (on read) rather than reactive (on refresh). Invariant 5 is the remaining
|
||||
gap and is tracked by FE-950 (Unified Cloud Auth) and FE-963 (reactive 401
|
||||
re-mint + single retry).
|
||||
|
||||
Alternatives considered:
|
||||
|
||||
- **Layer more defensive checks per call site.** Rejected: this is what produced
|
||||
the current state — correctness that depends on every caller remembering to
|
||||
guard is the defect, not the fix.
|
||||
- **A single reactive credential store subscribing to Firebase, replacing all
|
||||
three ad-hoc lifecycles at once.** Deferred, not rejected: it is the target
|
||||
end-state, but a big-bang rewrite of live auth is too risky. We migrate under
|
||||
these invariants incrementally instead.
|
||||
|
||||
## Consequences
|
||||
|
||||
### Positive
|
||||
|
||||
- Whole categories of failure become structurally hard rather than individually
|
||||
patched: stale-on-wake (invariant 2), refresh stampede (3), wrong-identity
|
||||
requests (4).
|
||||
- New auth code has a single checklist to satisfy, and reviewers a single rubric
|
||||
to apply.
|
||||
- Establishes a shared vocabulary (valid-on-read, single-flight, fail-closed) for
|
||||
reasoning about auth changes.
|
||||
|
||||
### Negative
|
||||
|
||||
- Fail-closed surfaces auth failures that silent downgrade previously masked; some
|
||||
transient conditions now show errors instead of degrading quietly, so
|
||||
transient-vs-permanent classification must be correct.
|
||||
- The invariants are not yet fully realized. Until invariant 5 lands, recovery is
|
||||
lazy and a backgrounded tab still relies on the next read to heal, leaving a
|
||||
visible gap against FE-613's reactive ideal.
|
||||
- Existing lifecycles remain non-uniform during migration, so the mental model is
|
||||
"target vs. current" until the reactive credential store exists.
|
||||
|
||||
## Notes
|
||||
|
||||
- Related: [ADR-0003](0003-crdt-based-layout-system.md) is unrelated in domain but
|
||||
shares the philosophy of designing invariants that make illegal states
|
||||
unrepresentable rather than guarding against them per call site.
|
||||
- Tickets: FE-613, FE-950, FE-963, FE-1072. PR: #13511.
|
||||
@@ -20,7 +20,6 @@ An Architecture Decision Record captures an important architectural decision mad
|
||||
| [0008](0008-entity-component-system.md) | Entity Component System | Proposed | 2026-03-23 |
|
||||
| [0009](0009-subgraph-promoted-widgets-use-linked-inputs.md) | Subgraph Promoted Widgets Use Linked Inputs | Proposed | 2026-05-05 |
|
||||
| [0010](0010-remove-nx-orchestration.md) | Remove Nx Orchestration | Accepted | 2026-05-19 |
|
||||
| [0011](0011-derived-credential-lifecycle.md) | Derived Credential Lifecycle for Cloud Auth | Proposed | 2026-07-09 |
|
||||
|
||||
## Creating a New ADR
|
||||
|
||||
|
||||
@@ -4,12 +4,11 @@ This guide provides an overview of testing approaches used in the ComfyUI Fronte
|
||||
|
||||
## Testing Documentation
|
||||
|
||||
Documentation for unit tests is organized into four guides:
|
||||
Documentation for unit tests is organized into three guides:
|
||||
|
||||
- [Component Testing](./component-testing.md) - How to test Vue components
|
||||
- [Unit Testing](./unit-testing.md) - How to test utility functions, composables, and other non-component code
|
||||
- [Store Testing](./store-testing.md) - How to test Pinia stores specifically
|
||||
- [LiteGraph Testing](./litegraph-testing.md) - How to test LiteGraph graph, node, link, and workflow behavior
|
||||
|
||||
## Testing Structure
|
||||
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
# LiteGraph Testing Guide
|
||||
|
||||
This guide covers test patterns for LiteGraph graph, node, link, subgraph, and workflow behavior in ComfyUI Frontend.
|
||||
|
||||
## Shared Factories
|
||||
|
||||
Reuse shared factories in `src/utils/__tests__/litegraphTestUtils.ts` instead of hand-rolling LiteGraph node, canvas, graph, subgraph, or workflow builders.
|
||||
|
||||
Use real LiteGraph instances or shared factories when they exercise behavior directly. Avoid mocking LiteGraph classes unless the test is intentionally checking a seam outside LiteGraph itself.
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@comfyorg/comfyui-frontend",
|
||||
"version": "1.48.2",
|
||||
"version": "1.47.6",
|
||||
"private": true,
|
||||
"description": "Official front-end implementation of ComfyUI",
|
||||
"homepage": "https://comfy.org",
|
||||
@@ -19,6 +19,8 @@
|
||||
"size:collect": "node scripts/size-collect.js",
|
||||
"size:report": "node scripts/size-report.js",
|
||||
"collect-i18n": "pnpm exec playwright test --config=playwright.i18n.config.ts",
|
||||
"coverage:critical:compare": "tsx scripts/critical-coverage/compareCriticalCoverage.ts",
|
||||
"coverage:critical:extract": "tsx scripts/critical-coverage/extractCriticalCoverage.ts",
|
||||
"dev:cloud": "pnpm dev:cloud:test",
|
||||
"dev:cloud:test": "cross-env DEV_SERVER_COMFYUI_URL=https://testcloud.comfy.org/ vite --config vite.config.mts",
|
||||
"dev:cloud:staging": "cross-env DEV_SERVER_COMFYUI_URL=https://stagingcloud.comfy.org/ vite --config vite.config.mts",
|
||||
@@ -114,7 +116,6 @@
|
||||
"jsonata": "catalog:",
|
||||
"loglevel": "^1.9.2",
|
||||
"marked": "^15.0.11",
|
||||
"minisearch": "catalog:",
|
||||
"pinia": "catalog:",
|
||||
"posthog-js": "catalog:",
|
||||
"primeicons": "catalog:",
|
||||
|
||||
@@ -414,15 +414,15 @@ describe('formatUtil', () => {
|
||||
})
|
||||
|
||||
describe('isPreviewableMediaType', () => {
|
||||
it('returns true for image/video/audio/3D/text', () => {
|
||||
it('returns true for image/video/audio/3D', () => {
|
||||
expect(isPreviewableMediaType('image')).toBe(true)
|
||||
expect(isPreviewableMediaType('video')).toBe(true)
|
||||
expect(isPreviewableMediaType('audio')).toBe(true)
|
||||
expect(isPreviewableMediaType('3D')).toBe(true)
|
||||
expect(isPreviewableMediaType('text')).toBe(true)
|
||||
})
|
||||
|
||||
it('returns false for other', () => {
|
||||
it('returns false for text/other', () => {
|
||||
expect(isPreviewableMediaType('text')).toBe(false)
|
||||
expect(isPreviewableMediaType('other')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -677,7 +677,12 @@ export function getMediaTypeFromFilename(
|
||||
}
|
||||
|
||||
export function isPreviewableMediaType(mediaType: MediaType): boolean {
|
||||
return mediaType !== 'other'
|
||||
return (
|
||||
mediaType === 'image' ||
|
||||
mediaType === 'video' ||
|
||||
mediaType === 'audio' ||
|
||||
mediaType === '3D'
|
||||
)
|
||||
}
|
||||
|
||||
export function formatTime(seconds: number): string {
|
||||
|
||||
19
pnpm-lock.yaml
generated
19
pnpm-lock.yaml
generated
@@ -282,9 +282,6 @@ catalogs:
|
||||
markdown-table:
|
||||
specifier: ^3.0.4
|
||||
version: 3.0.4
|
||||
minisearch:
|
||||
specifier: ^7.2.0
|
||||
version: 7.2.0
|
||||
mixpanel-browser:
|
||||
specifier: ^2.71.0
|
||||
version: 2.71.0
|
||||
@@ -594,9 +591,6 @@ importers:
|
||||
marked:
|
||||
specifier: ^15.0.11
|
||||
version: 15.0.11
|
||||
minisearch:
|
||||
specifier: 'catalog:'
|
||||
version: 7.2.0
|
||||
pinia:
|
||||
specifier: 'catalog:'
|
||||
version: 3.0.4(typescript@5.9.3)(vue@3.5.34(typescript@5.9.3))
|
||||
@@ -7030,9 +7024,6 @@ packages:
|
||||
resolution: {integrity: sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==}
|
||||
engines: {node: '>=16 || 14 >=14.17'}
|
||||
|
||||
minisearch@7.2.0:
|
||||
resolution: {integrity: sha512-dqT2XBYUOZOiC5t2HRnwADjhNS2cecp9u+TJRiJ1Qp/f5qjkeT5APcGPjHw+bz89Ms8Jp+cG4AlE+QZ/QnDglg==}
|
||||
|
||||
mitt@3.0.1:
|
||||
resolution: {integrity: sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==}
|
||||
|
||||
@@ -8893,8 +8884,8 @@ packages:
|
||||
vue-component-type-helpers@3.3.2:
|
||||
resolution: {integrity: sha512-l4Z2Y34m7nFMlx8vrslJaVtXxUpzgDMSESC7TakG/c5kwjYT/do+E0NcT2/vWDzaoIhsShg/2OKwX7Q4nbzC0g==}
|
||||
|
||||
vue-component-type-helpers@3.3.6:
|
||||
resolution: {integrity: sha512-FkljacAwJ9BUoSUdpFe3VDy0sGigNlTH9+2zcXUWmZOjN8swiCkl3t48wOJun0OsUd2cEIda1l04tsxMiKIIrQ==}
|
||||
vue-component-type-helpers@3.3.5:
|
||||
resolution: {integrity: sha512-Fe1jyPJoUGpJOYKOri44jduR7My4yYINOMJISuMAbmrs+L5LbIDUc8NTWZYY3EJLK0yPLuCmcd5zoCsE4k2/KA==}
|
||||
|
||||
vue-demi@0.14.10:
|
||||
resolution: {integrity: sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==}
|
||||
@@ -11677,7 +11668,7 @@ snapshots:
|
||||
storybook: 10.2.10(@testing-library/dom@10.4.1)(prettier@3.7.4)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
type-fest: 2.19.0
|
||||
vue: 3.5.34(typescript@5.9.3)
|
||||
vue-component-type-helpers: 3.3.6
|
||||
vue-component-type-helpers: 3.3.5
|
||||
|
||||
'@swc/helpers@0.5.21':
|
||||
dependencies:
|
||||
@@ -15826,8 +15817,6 @@ snapshots:
|
||||
|
||||
minipass@7.1.3: {}
|
||||
|
||||
minisearch@7.2.0: {}
|
||||
|
||||
mitt@3.0.1: {}
|
||||
|
||||
mixpanel-browser@2.71.0:
|
||||
@@ -18149,7 +18138,7 @@ snapshots:
|
||||
|
||||
vue-component-type-helpers@3.3.2: {}
|
||||
|
||||
vue-component-type-helpers@3.3.6: {}
|
||||
vue-component-type-helpers@3.3.5: {}
|
||||
|
||||
vue-demi@0.14.10(vue@3.5.34(typescript@5.9.3)):
|
||||
dependencies:
|
||||
|
||||
@@ -103,7 +103,6 @@ catalog:
|
||||
lenis: ^1.3.21
|
||||
lint-staged: ^16.2.7
|
||||
markdown-table: ^3.0.4
|
||||
minisearch: ^7.2.0
|
||||
mixpanel-browser: ^2.71.0
|
||||
monocart-coverage-reports: ^2.12.9
|
||||
oxfmt: ^0.54.0
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24" role="img" aria-label="Google Gemini">
|
||||
<path d="M12 1c.6 5.4 4.6 9.4 10 10-5.4.6-9.4 4.6-10 10-.6-5.4-4.6-9.4-10-10 5.4-.6 9.4-4.6 10-10z" fill="#4285F4"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 248 B |
@@ -1,4 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24" role="img" aria-label="Runway">
|
||||
<rect width="24" height="24" rx="5" fill="#6E56CF"/>
|
||||
<path d="M9.5 8.2v7.6l6.3-3.8z" fill="#ffffff"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 228 B |
@@ -1,151 +0,0 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { resolve } from 'node:path'
|
||||
import { runInNewContext } from 'node:vm'
|
||||
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
function workflow(name: string): string {
|
||||
return readFileSync(resolve('.github/workflows', name), 'utf8')
|
||||
}
|
||||
|
||||
function issueLabelScript(): string {
|
||||
const source = workflow('ci-issue-evidence.yaml')
|
||||
const marker = ' script: |\n'
|
||||
const scriptAndFollowingSteps = source.slice(
|
||||
source.indexOf(marker) + marker.length
|
||||
)
|
||||
return scriptAndFollowingSteps
|
||||
.split('\n - name:')[0]
|
||||
.replace(/^ {12}/gm, '')
|
||||
}
|
||||
|
||||
const automatedFrontendPrWorkflows = [
|
||||
'api-update-electron-api-types.yaml',
|
||||
'api-update-manager-api-types.yaml',
|
||||
'i18n-update-nodes.yaml',
|
||||
'release-version-bump.yaml',
|
||||
'release-website.yaml',
|
||||
'version-bump-desktop-ui.yaml',
|
||||
'weekly-docs-check.yaml'
|
||||
]
|
||||
|
||||
describe('evidence workflows', () => {
|
||||
it('runs the trusted validator from the workflow commit', () => {
|
||||
const source = workflow('ci-pr-evidence.yaml')
|
||||
expect(source).toContain('ref: ${{ github.workflow_sha }}')
|
||||
expect(source).toContain(' assigned,\n unassigned')
|
||||
expect(source).toContain(
|
||||
'COMFY_ORG_MEMBERS_READ_TOKEN: ${{ secrets.COMFY_ORG_MEMBERS_READ_TOKEN }}'
|
||||
)
|
||||
expect(source).toContain('node scripts/cicd/validate-maintainer.ts')
|
||||
expect(source).toContain('steps.maintainer.outputs.valid')
|
||||
expect(source).toContain("github.event_name == 'merge_group'")
|
||||
expect(source).toContain('node scripts/cicd/validate-merge-group.ts')
|
||||
expect(source).not.toContain('Evidence was validated before')
|
||||
expect(source).toContain('cancel-in-progress: true')
|
||||
})
|
||||
|
||||
it('rechecks issue evidence and ownership when assignments change', () => {
|
||||
const source = workflow('ci-issue-evidence.yaml')
|
||||
expect(source).toContain('assigned, unassigned')
|
||||
expect(source).toContain('ref: ${{ github.workflow_sha }}')
|
||||
expect(source).toContain('blocked: needs-evidence')
|
||||
expect(source).toContain('blocked: needs-maintainer')
|
||||
expect(source).toContain('Fail blocked contribution')
|
||||
expect(source).toContain(
|
||||
'COMFY_ORG_MEMBERS_READ_TOKEN: ${{ secrets.COMFY_ORG_MEMBERS_READ_TOKEN }}'
|
||||
)
|
||||
})
|
||||
|
||||
it.for(automatedFrontendPrWorkflows)(
|
||||
'%s generates a structurally complete evidence body',
|
||||
(name) => {
|
||||
const source = workflow(name)
|
||||
expect(source).toContain('## Reproduction or validation steps')
|
||||
expect(source).toContain('## Evidence type')
|
||||
expect(source).toContain('## Non-visual rationale')
|
||||
expect(source).toContain('## Test or log evidence')
|
||||
}
|
||||
)
|
||||
|
||||
it('backports retain the validated source pull request evidence', () => {
|
||||
const source = workflow('pr-backport.yaml')
|
||||
expect(source).toContain('PR_SOURCE_BODY=')
|
||||
expect(source).toContain('## Source pull request evidence')
|
||||
expect(source).toContain('${PR_SOURCE_BODY}')
|
||||
})
|
||||
|
||||
it('adds the issue label after another run wins the creation race', async () => {
|
||||
const addLabels = vi.fn()
|
||||
const github = {
|
||||
rest: {
|
||||
issues: {
|
||||
addLabels,
|
||||
createLabel: vi.fn().mockRejectedValue({
|
||||
response: { data: { errors: [{ code: 'already_exists' }] } },
|
||||
status: 422
|
||||
}),
|
||||
getLabel: vi.fn().mockRejectedValue({ status: 404 }),
|
||||
removeLabel: vi.fn()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await runInNewContext(`(async () => {${issueLabelScript()}})()`, {
|
||||
context: {
|
||||
issue: { number: 42 },
|
||||
repo: { owner: 'Comfy-Org', repo: 'ComfyUI_frontend' }
|
||||
},
|
||||
github,
|
||||
process: {
|
||||
env: { EVIDENCE_VALID: 'false', MAINTAINER_VALID: 'true' }
|
||||
}
|
||||
})
|
||||
|
||||
expect(addLabels).toHaveBeenCalledWith({
|
||||
issue_number: 42,
|
||||
labels: ['blocked: needs-evidence'],
|
||||
owner: 'Comfy-Org',
|
||||
repo: 'ComfyUI_frontend'
|
||||
})
|
||||
})
|
||||
|
||||
it('tracks missing maintainers separately from missing evidence', async () => {
|
||||
const addLabels = vi.fn()
|
||||
const removeLabel = vi.fn().mockRejectedValue({ status: 404 })
|
||||
const github = {
|
||||
rest: {
|
||||
issues: {
|
||||
addLabels,
|
||||
createLabel: vi.fn().mockResolvedValue({}),
|
||||
getLabel: vi.fn().mockResolvedValue({}),
|
||||
removeLabel
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await runInNewContext(`(async () => {${issueLabelScript()}})()`, {
|
||||
context: {
|
||||
issue: { number: 42 },
|
||||
repo: { owner: 'Comfy-Org', repo: 'ComfyUI_frontend' }
|
||||
},
|
||||
github,
|
||||
process: {
|
||||
env: { EVIDENCE_VALID: 'true', MAINTAINER_VALID: 'false' }
|
||||
}
|
||||
})
|
||||
|
||||
expect(removeLabel).toHaveBeenCalledWith({
|
||||
issue_number: 42,
|
||||
name: 'blocked: needs-evidence',
|
||||
owner: 'Comfy-Org',
|
||||
repo: 'ComfyUI_frontend'
|
||||
})
|
||||
expect(addLabels).toHaveBeenCalledWith({
|
||||
issue_number: 42,
|
||||
labels: ['blocked: needs-maintainer'],
|
||||
owner: 'Comfy-Org',
|
||||
repo: 'ComfyUI_frontend'
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,684 +0,0 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import {
|
||||
probeGitHubScreencast,
|
||||
validateIssueEvidence,
|
||||
validateIssueEvidenceWithMedia,
|
||||
validatePullRequestEvidence,
|
||||
validatePullRequestEvidenceWithMedia
|
||||
} from './validate-evidence'
|
||||
import type { MediaResponse } from './validate-evidence'
|
||||
|
||||
function issueBody({
|
||||
evidence,
|
||||
heading = 'Steps to Reproduce',
|
||||
mode = 'Visual',
|
||||
reproduction = '1. Open ComfyUI\n2. Queue the workflow'
|
||||
}: {
|
||||
evidence: string
|
||||
heading?: string
|
||||
mode?: string
|
||||
reproduction?: string
|
||||
}): string {
|
||||
return `## ${heading}
|
||||
${reproduction}
|
||||
|
||||
## Evidence type
|
||||
${mode}
|
||||
|
||||
## Evidence
|
||||
${evidence}`
|
||||
}
|
||||
|
||||
function pullRequestBody({
|
||||
after = '',
|
||||
before = '',
|
||||
mode = 'Visual',
|
||||
rationale = '',
|
||||
screencast = '',
|
||||
steps = '1. Open the queue\n2. Reproduce the change',
|
||||
testEvidence = ''
|
||||
}: {
|
||||
after?: string
|
||||
before?: string
|
||||
mode?: 'Both' | 'Neither' | 'Non-visual' | 'Visual'
|
||||
rationale?: string
|
||||
screencast?: string
|
||||
steps?: string
|
||||
testEvidence?: string
|
||||
}): string {
|
||||
const visual = mode === 'Visual' || mode === 'Both' ? 'x' : ' '
|
||||
const nonVisual = mode === 'Non-visual' || mode === 'Both' ? 'x' : ' '
|
||||
return `## Reproduction or validation steps
|
||||
${steps}
|
||||
|
||||
## Evidence type
|
||||
- [${visual}] Visual
|
||||
- [${nonVisual}] Non-visual
|
||||
|
||||
## Before
|
||||
${before}
|
||||
|
||||
## After
|
||||
${after}
|
||||
|
||||
## Screencast
|
||||
${screencast}
|
||||
|
||||
## Non-visual rationale
|
||||
${rationale}
|
||||
|
||||
## Test or log evidence
|
||||
${testEvidence}`
|
||||
}
|
||||
|
||||
function mediaResponse(
|
||||
status: number,
|
||||
headers: Record<string, string> = {}
|
||||
): MediaResponse {
|
||||
const normalized = new Map(
|
||||
Object.entries(headers).map(([name, value]) => [name.toLowerCase(), value])
|
||||
)
|
||||
return {
|
||||
headers: { get: (name) => normalized.get(name.toLowerCase()) ?? null },
|
||||
ok: status >= 200 && status < 300,
|
||||
status
|
||||
}
|
||||
}
|
||||
|
||||
describe('validateIssueEvidence', () => {
|
||||
it('accepts visual evidence with reproduction steps', () => {
|
||||
const result = validateIssueEvidence(
|
||||
issueBody({
|
||||
evidence:
|
||||
'Before: https://github.com/user-attachments/assets/11111111-1111-4111-8111-111111111111\nAfter: https://github.com/user-attachments/assets/22222222-2222-4222-8222-222222222222'
|
||||
})
|
||||
)
|
||||
expect(result.valid).toBe(true)
|
||||
})
|
||||
|
||||
it('accepts standard GitHub markdown images for before and after', () => {
|
||||
const result = validateIssueEvidence(
|
||||
issueBody({
|
||||
evidence:
|
||||
'Before: \nAfter: '
|
||||
})
|
||||
)
|
||||
|
||||
expect(result.valid).toBe(true)
|
||||
})
|
||||
|
||||
it('accepts one visual screencast', () => {
|
||||
const result = validateIssueEvidence(
|
||||
issueBody({ evidence: 'https://example.com/change.webm' })
|
||||
)
|
||||
expect(result.valid).toBe(true)
|
||||
})
|
||||
|
||||
it('accepts a content-verified GitHub screencast attachment', async () => {
|
||||
const result = await validateIssueEvidenceWithMedia(
|
||||
issueBody({
|
||||
evidence:
|
||||
'Screencast: https://github.com/user-attachments/assets/77777777-7777-4777-8777-777777777777'
|
||||
}),
|
||||
vi.fn().mockResolvedValue(true)
|
||||
)
|
||||
|
||||
expect(result.valid).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects a screenshot attachment labeled as an issue screencast', async () => {
|
||||
const result = await validateIssueEvidenceWithMedia(
|
||||
issueBody({
|
||||
evidence:
|
||||
'Screencast: https://github.com/user-attachments/assets/77777777-7777-4777-8777-777777777777'
|
||||
}),
|
||||
vi.fn().mockResolvedValue(false)
|
||||
)
|
||||
|
||||
expect(result.valid).toBe(false)
|
||||
})
|
||||
|
||||
it('accepts a non-visual rationale with test evidence', () => {
|
||||
const result = validateIssueEvidence(
|
||||
issueBody({
|
||||
evidence:
|
||||
'N/A: The failure is an API response.\nTest/log evidence: `pnpm test` reproduces the timeout.',
|
||||
heading: 'Concrete example or steps',
|
||||
mode: 'Non-visual'
|
||||
})
|
||||
)
|
||||
expect(result.valid).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects empty numbered reproduction steps', () => {
|
||||
const result = validateIssueEvidence(
|
||||
issueBody({
|
||||
evidence:
|
||||
'https://github.com/user-attachments/assets/33333333-3333-4333-8333-333333333333',
|
||||
reproduction: '1.\n2.\n3.'
|
||||
})
|
||||
)
|
||||
expect(result.errors).toContain(
|
||||
'Add concrete reproduction steps or an example.'
|
||||
)
|
||||
})
|
||||
|
||||
it('rejects visual evidence without a link', () => {
|
||||
const result = validateIssueEvidence(
|
||||
issueBody({ evidence: 'The button is visibly broken.' })
|
||||
)
|
||||
expect(result.valid).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects a generic URL that is not visual media', () => {
|
||||
const result = validateIssueEvidence(
|
||||
issueBody({ evidence: 'https://example.com/evidence' })
|
||||
)
|
||||
expect(result.valid).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects one screenshot without before and after states', () => {
|
||||
const result = validateIssueEvidence(
|
||||
issueBody({
|
||||
evidence:
|
||||
'https://github.com/user-attachments/assets/44444444-4444-4444-8444-444444444444'
|
||||
})
|
||||
)
|
||||
expect(result.valid).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects two unlabeled screenshots because before and after are ambiguous', () => {
|
||||
const result = validateIssueEvidence(
|
||||
issueBody({
|
||||
evidence:
|
||||
'https://github.com/user-attachments/assets/55555555-5555-4555-8555-555555555555\nhttps://github.com/user-attachments/assets/66666666-6666-4666-8666-666666666666'
|
||||
})
|
||||
)
|
||||
expect(result.valid).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects non-visual evidence without tests or logs', () => {
|
||||
const result = validateIssueEvidence(
|
||||
issueBody({
|
||||
evidence: 'N/A: This changes internal state only.',
|
||||
mode: 'Non-visual'
|
||||
})
|
||||
)
|
||||
expect(result.valid).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects GitHub URLs outside the exact attachment asset path', () => {
|
||||
const result = validateIssueEvidence(
|
||||
issueBody({
|
||||
evidence:
|
||||
'Before: https://github.com/user-attachments/not-assets/before\nAfter: https://github.com/user-attachments/assets/after/extra'
|
||||
})
|
||||
)
|
||||
|
||||
expect(result.valid).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects encoded path separators in GitHub attachment IDs', () => {
|
||||
const result = validateIssueEvidence(
|
||||
issueBody({
|
||||
evidence:
|
||||
'Before: https://github.com/user-attachments/assets/11111111-1111-4111-8111-111111111111\nAfter: https://github.com/user-attachments/assets/22222222-2222-4222-8222-222222222222%2Fextra'
|
||||
})
|
||||
)
|
||||
|
||||
expect(result.valid).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects a fabricated non-UUID GitHub attachment asset', () => {
|
||||
const result = validateIssueEvidence(
|
||||
issueBody({
|
||||
evidence:
|
||||
'Before: https://github.com/user-attachments/assets/before\nAfter: https://github.com/user-attachments/assets/after'
|
||||
})
|
||||
)
|
||||
|
||||
expect(result.valid).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects an image URL labeled as a screencast', () => {
|
||||
const result = validateIssueEvidence(
|
||||
issueBody({ evidence: 'Screencast: https://example.com/change.png' })
|
||||
)
|
||||
|
||||
expect(result.valid).toBe(false)
|
||||
})
|
||||
|
||||
it('canonicalizes labeled before and after URLs before comparing them', () => {
|
||||
const result = validateIssueEvidence(
|
||||
issueBody({
|
||||
evidence:
|
||||
'Before: https://example.com/change.png?state=before\nAfter: https://EXAMPLE.com/change.png?state=after#result'
|
||||
})
|
||||
)
|
||||
|
||||
expect(result.valid).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects an HTTP alias for the same GitHub attachment', () => {
|
||||
const attachment =
|
||||
'github.com/user-attachments/assets/11111111-1111-4111-8111-111111111111'
|
||||
const result = validateIssueEvidence(
|
||||
issueBody({
|
||||
evidence: `Before: http://${attachment}\nAfter: https://${attachment}`
|
||||
})
|
||||
)
|
||||
|
||||
expect(result.valid).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('validatePullRequestEvidence', () => {
|
||||
it('ignores required headings hidden inside an HTML comment', () => {
|
||||
const hidden = `Context 🧪 <!--
|
||||
## Reproduction or validation steps
|
||||
1. Hidden valid step
|
||||
2. Hidden second step
|
||||
|
||||
## Evidence type
|
||||
- [x] Visual
|
||||
- [ ] Non-visual
|
||||
|
||||
## Screencast
|
||||
https://example.com/hidden.webm
|
||||
-->`
|
||||
const visible = pullRequestBody({
|
||||
mode: 'Non-visual',
|
||||
rationale: 'N/A: This changes CI metadata only.',
|
||||
testEvidence: '`pnpm test:unit` passed.'
|
||||
})
|
||||
|
||||
expect(validatePullRequestEvidence(`${hidden}\n${visible}`).valid).toBe(
|
||||
true
|
||||
)
|
||||
expect(validatePullRequestEvidence(hidden).valid).toBe(false)
|
||||
})
|
||||
|
||||
it('ignores required headings inside a fenced code sample', () => {
|
||||
const fenced = `\`\`\`markdown
|
||||
## Reproduction or validation steps
|
||||
1. Hidden valid step
|
||||
2. Hidden second step
|
||||
|
||||
## Evidence type
|
||||
- [x] Visual
|
||||
- [ ] Non-visual
|
||||
|
||||
## Screencast
|
||||
https://example.com/hidden.webm
|
||||
\`\`\``
|
||||
|
||||
expect(validatePullRequestEvidence(fenced).valid).toBe(false)
|
||||
})
|
||||
|
||||
it('preserves visible fenced test and log evidence', () => {
|
||||
const result = validatePullRequestEvidence(
|
||||
pullRequestBody({
|
||||
mode: 'Non-visual',
|
||||
rationale: 'N/A: This changes CI metadata only.',
|
||||
testEvidence: '```text\nStatus: success\n```'
|
||||
})
|
||||
)
|
||||
|
||||
expect(result.valid).toBe(true)
|
||||
})
|
||||
|
||||
it('accepts before and after links', () => {
|
||||
const result = validatePullRequestEvidence(
|
||||
pullRequestBody({
|
||||
after:
|
||||
'https://github.com/user-attachments/assets/22222222-2222-4222-8222-222222222222',
|
||||
before:
|
||||
'https://github.com/user-attachments/assets/11111111-1111-4111-8111-111111111111'
|
||||
})
|
||||
)
|
||||
expect(result.valid).toBe(true)
|
||||
})
|
||||
|
||||
it('accepts one content-verified GitHub screencast', async () => {
|
||||
const result = await validatePullRequestEvidenceWithMedia(
|
||||
pullRequestBody({
|
||||
screencast:
|
||||
'https://github.com/user-attachments/assets/77777777-7777-4777-8777-777777777777'
|
||||
}),
|
||||
vi.fn().mockResolvedValue(true)
|
||||
)
|
||||
expect(result.valid).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects a GitHub screencast when the media probe fails', async () => {
|
||||
const result = await validatePullRequestEvidenceWithMedia(
|
||||
pullRequestBody({
|
||||
screencast:
|
||||
'https://github.com/user-attachments/assets/77777777-7777-4777-8777-777777777777'
|
||||
}),
|
||||
vi.fn().mockRejectedValue(new Error('probe unavailable'))
|
||||
)
|
||||
|
||||
expect(result.valid).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects an image-markdown attachment in the screencast field', () => {
|
||||
const result = validatePullRequestEvidence(
|
||||
pullRequestBody({
|
||||
screencast:
|
||||
''
|
||||
})
|
||||
)
|
||||
|
||||
expect(result.valid).toBe(false)
|
||||
})
|
||||
|
||||
it('accepts a non-visual rationale with test evidence', () => {
|
||||
const result = validatePullRequestEvidence(
|
||||
pullRequestBody({
|
||||
mode: 'Non-visual',
|
||||
rationale: 'N/A: This only changes CI metadata.',
|
||||
testEvidence: '`pnpm test:unit` passed.'
|
||||
})
|
||||
)
|
||||
expect(result.valid).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects selecting both evidence modes', () => {
|
||||
const result = validatePullRequestEvidence(
|
||||
pullRequestBody({ mode: 'Both' })
|
||||
)
|
||||
expect(result.errors).toContain(
|
||||
'Check exactly one evidence type: Visual or Non-visual.'
|
||||
)
|
||||
})
|
||||
|
||||
it('rejects selecting neither evidence mode', () => {
|
||||
const result = validatePullRequestEvidence(
|
||||
pullRequestBody({ mode: 'Neither' })
|
||||
)
|
||||
expect(result.errors).toContain(
|
||||
'Check exactly one evidence type: Visual or Non-visual.'
|
||||
)
|
||||
})
|
||||
|
||||
it('rejects template placeholders', () => {
|
||||
const result = validatePullRequestEvidence(
|
||||
pullRequestBody({
|
||||
mode: 'Non-visual',
|
||||
rationale: 'N/A:',
|
||||
steps: '1.',
|
||||
testEvidence: 'TBD'
|
||||
})
|
||||
)
|
||||
expect(result.valid).toBe(false)
|
||||
expect(result.errors).toHaveLength(3)
|
||||
})
|
||||
|
||||
it('rejects single-word validation and log placeholders', () => {
|
||||
const result = validatePullRequestEvidence(
|
||||
pullRequestBody({
|
||||
mode: 'Non-visual',
|
||||
rationale: 'N/A: This only changes CI metadata.',
|
||||
steps: 'done',
|
||||
testEvidence: 'test'
|
||||
})
|
||||
)
|
||||
expect(result.errors).toContain('Add reproduction or validation steps.')
|
||||
expect(result.errors).toContain(
|
||||
'Non-visual pull requests require test or log evidence.'
|
||||
)
|
||||
})
|
||||
|
||||
it('rejects a generic all-tests-passed claim without a command or log', () => {
|
||||
const result = validatePullRequestEvidence(
|
||||
pullRequestBody({
|
||||
mode: 'Non-visual',
|
||||
rationale: 'N/A: This only changes CI metadata.',
|
||||
testEvidence: 'All tests passed.'
|
||||
})
|
||||
)
|
||||
|
||||
expect(result.errors).toContain(
|
||||
'Non-visual pull requests require test or log evidence.'
|
||||
)
|
||||
})
|
||||
|
||||
it('rejects generic URLs as before and after visual evidence', () => {
|
||||
const result = validatePullRequestEvidence(
|
||||
pullRequestBody({
|
||||
after: 'https://example.com/after',
|
||||
before: 'https://example.com/before'
|
||||
})
|
||||
)
|
||||
expect(result.valid).toBe(false)
|
||||
})
|
||||
|
||||
it('requires distinct before and after visual evidence', () => {
|
||||
const attachment =
|
||||
'https://github.com/user-attachments/assets/88888888-8888-4888-8888-888888888888'
|
||||
const result = validatePullRequestEvidence(
|
||||
pullRequestBody({ after: attachment, before: attachment })
|
||||
)
|
||||
expect(result.valid).toBe(false)
|
||||
})
|
||||
|
||||
it('canonicalizes before and after URLs before comparing them', () => {
|
||||
const result = validatePullRequestEvidence(
|
||||
pullRequestBody({
|
||||
after: 'https://EXAMPLE.com/change.png?state=after#result',
|
||||
before: 'https://example.com/change.png?state=before'
|
||||
})
|
||||
)
|
||||
|
||||
expect(result.valid).toBe(false)
|
||||
})
|
||||
|
||||
it('treats a trailing slash as the same visual evidence URL', () => {
|
||||
const result = validatePullRequestEvidence(
|
||||
pullRequestBody({
|
||||
after: 'https://www.loom.com/share/same-recording/',
|
||||
before: 'https://www.loom.com/share/same-recording'
|
||||
})
|
||||
)
|
||||
|
||||
expect(result.valid).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects an HTTP alias for the same before and after attachment', () => {
|
||||
const attachment =
|
||||
'github.com/user-attachments/assets/88888888-8888-4888-8888-888888888888'
|
||||
const result = validatePullRequestEvidence(
|
||||
pullRequestBody({
|
||||
after: `https://${attachment}`,
|
||||
before: `http://${attachment}`
|
||||
})
|
||||
)
|
||||
|
||||
expect(result.valid).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects an image-only URL in the screencast field', () => {
|
||||
const result = validatePullRequestEvidence(
|
||||
pullRequestBody({ screencast: 'https://example.com/change.png' })
|
||||
)
|
||||
|
||||
expect(result.valid).toBe(false)
|
||||
})
|
||||
|
||||
it('accepts an explicit video URL in the screencast field', () => {
|
||||
const result = validatePullRequestEvidence(
|
||||
pullRequestBody({ screencast: 'https://example.com/change.webm' })
|
||||
)
|
||||
|
||||
expect(result.valid).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('probeGitHubScreencast', () => {
|
||||
const attachment =
|
||||
'https://github.com/user-attachments/assets/77777777-7777-4777-8777-777777777777'
|
||||
|
||||
it('rejects non-GitHub attachment URLs without making a request', async () => {
|
||||
const request = vi.fn()
|
||||
|
||||
await expect(
|
||||
probeGitHubScreencast('https://example.com/demo.mp4', request)
|
||||
).resolves.toBe(false)
|
||||
expect(request).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('follows a manual GitHub redirect and accepts video content', async () => {
|
||||
const request = vi.fn().mockResolvedValueOnce(
|
||||
mediaResponse(302, {
|
||||
location:
|
||||
'https://github-production-user-asset-6210df.s3.amazonaws.com/assets/demo.mp4?response-content-type=video%2Fmp4'
|
||||
})
|
||||
)
|
||||
|
||||
await expect(probeGitHubScreencast(attachment, request)).resolves.toBe(true)
|
||||
expect(request).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
attachment,
|
||||
expect.objectContaining({ method: 'HEAD', redirect: 'manual' })
|
||||
)
|
||||
expect(request).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('accepts an animated GIF content type', async () => {
|
||||
const request = vi
|
||||
.fn()
|
||||
.mockResolvedValue(mediaResponse(200, { 'content-type': 'image/gif' }))
|
||||
|
||||
await expect(probeGitHubScreencast(attachment, request)).resolves.toBe(true)
|
||||
})
|
||||
|
||||
it('rejects a static screenshot attachment', async () => {
|
||||
const request = vi
|
||||
.fn()
|
||||
.mockResolvedValue(mediaResponse(200, { 'content-type': 'image/png' }))
|
||||
|
||||
await expect(probeGitHubScreencast(attachment, request)).resolves.toBe(
|
||||
false
|
||||
)
|
||||
})
|
||||
|
||||
it('rejects a GitHub redirect that identifies a static PNG', async () => {
|
||||
const request = vi.fn().mockResolvedValueOnce(
|
||||
mediaResponse(302, {
|
||||
location:
|
||||
'https://github-production-user-asset-6210df.s3.amazonaws.com/assets/demo.png?response-content-type=image%2Fpng'
|
||||
})
|
||||
)
|
||||
|
||||
await expect(probeGitHubScreencast(attachment, request)).resolves.toBe(
|
||||
false
|
||||
)
|
||||
expect(request).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('uses a range GET when an opaque signed redirect rejects HEAD', async () => {
|
||||
const request = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(
|
||||
mediaResponse(302, {
|
||||
location:
|
||||
'https://github-production-user-asset-6210df.s3.amazonaws.com/assets/demo'
|
||||
})
|
||||
)
|
||||
.mockResolvedValueOnce(mediaResponse(403))
|
||||
.mockResolvedValueOnce(
|
||||
mediaResponse(206, { 'content-type': 'video/mp4' })
|
||||
)
|
||||
|
||||
await expect(probeGitHubScreencast(attachment, request)).resolves.toBe(true)
|
||||
expect(request).toHaveBeenNthCalledWith(
|
||||
3,
|
||||
expect.stringContaining('github-production-user-asset-6210df'),
|
||||
expect.objectContaining({ method: 'GET', redirect: 'manual' })
|
||||
)
|
||||
})
|
||||
|
||||
it.for([405, 501])(
|
||||
'uses a range GET when the initial HEAD returns %i',
|
||||
async (status) => {
|
||||
const request = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(mediaResponse(status))
|
||||
.mockResolvedValueOnce(
|
||||
mediaResponse(206, { 'content-type': 'video/mp4' })
|
||||
)
|
||||
|
||||
await expect(probeGitHubScreencast(attachment, request)).resolves.toBe(
|
||||
true
|
||||
)
|
||||
expect(request).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
attachment,
|
||||
expect.objectContaining({ method: 'GET', redirect: 'manual' })
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
it('does not retry an initial 403 response with GET', async () => {
|
||||
const request = vi.fn().mockResolvedValue(mediaResponse(403))
|
||||
|
||||
await expect(probeGitHubScreencast(attachment, request)).resolves.toBe(
|
||||
false
|
||||
)
|
||||
expect(request).toHaveBeenCalledTimes(1)
|
||||
expect(request).toHaveBeenCalledWith(
|
||||
attachment,
|
||||
expect.objectContaining({ method: 'HEAD', redirect: 'manual' })
|
||||
)
|
||||
})
|
||||
|
||||
it('rejects a redirect without a location header', async () => {
|
||||
const request = vi.fn().mockResolvedValue(mediaResponse(302))
|
||||
|
||||
await expect(probeGitHubScreencast(attachment, request)).resolves.toBe(
|
||||
false
|
||||
)
|
||||
expect(request).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it.for([
|
||||
'http://github-production-user-asset-6210df.s3.amazonaws.com/assets/demo',
|
||||
'https://user:password@github-production-user-asset-6210df.s3.amazonaws.com/assets/demo'
|
||||
])('rejects an unsafe redirect target: %s', async (location) => {
|
||||
const request = vi.fn().mockResolvedValue(mediaResponse(302, { location }))
|
||||
|
||||
await expect(probeGitHubScreencast(attachment, request)).resolves.toBe(
|
||||
false
|
||||
)
|
||||
expect(request).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('rejects a redirect chain after five hops', async () => {
|
||||
const request = vi.fn().mockResolvedValue(
|
||||
mediaResponse(302, {
|
||||
location:
|
||||
'https://github-production-user-asset-6210df.s3.amazonaws.com/assets/next'
|
||||
})
|
||||
)
|
||||
|
||||
await expect(probeGitHubScreencast(attachment, request)).resolves.toBe(
|
||||
false
|
||||
)
|
||||
expect(request).toHaveBeenCalledTimes(5)
|
||||
})
|
||||
|
||||
it('rejects redirects outside GitHub media hosts', async () => {
|
||||
const request = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(
|
||||
mediaResponse(302, { location: 'https://example.com/video.mp4' })
|
||||
)
|
||||
|
||||
await expect(probeGitHubScreencast(attachment, request)).resolves.toBe(
|
||||
false
|
||||
)
|
||||
expect(request).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
@@ -1,709 +0,0 @@
|
||||
import { appendFileSync, readFileSync } from 'node:fs'
|
||||
import { pathToFileURL } from 'node:url'
|
||||
|
||||
type EvidenceKind = 'issue' | 'pr'
|
||||
|
||||
type EventPayload = {
|
||||
issue?: { body?: string | null }
|
||||
pull_request?: { body?: string | null }
|
||||
}
|
||||
|
||||
export type PullRequestEvidencePayload = {
|
||||
body?: string | null
|
||||
number?: number | null
|
||||
}
|
||||
|
||||
export type ScreencastProbe = (url: string) => Promise<boolean>
|
||||
|
||||
export type MediaResponse = {
|
||||
headers: { get(name: string): string | null }
|
||||
ok: boolean
|
||||
status: number
|
||||
}
|
||||
|
||||
export type MediaRequest = (
|
||||
url: string,
|
||||
init: {
|
||||
headers?: Record<string, string>
|
||||
method: 'GET' | 'HEAD'
|
||||
redirect: 'manual'
|
||||
}
|
||||
) => Promise<MediaResponse>
|
||||
|
||||
export type EvidenceValidation = {
|
||||
errors: string[]
|
||||
valid: boolean
|
||||
}
|
||||
|
||||
const EMPTY_VALUE = /^(?:-|n\/a|none|not applicable|tbd|todo)$/i
|
||||
|
||||
function clean(value: string): string {
|
||||
return stripHtmlCommentsOutsideFences(value).trim()
|
||||
}
|
||||
|
||||
function spaces(value: string): string {
|
||||
return value.replace(/[^\r\n]/g, ' ')
|
||||
}
|
||||
|
||||
function maskHtmlComments(
|
||||
value: string,
|
||||
startsInsideComment: boolean
|
||||
): { masked: string; remainsInsideComment: boolean } {
|
||||
const masked = value.split('')
|
||||
let inComment = startsInsideComment
|
||||
let offset = 0
|
||||
|
||||
while (offset < value.length) {
|
||||
if (inComment) {
|
||||
const end = value.indexOf('-->', offset)
|
||||
const stop = end === -1 ? value.length : end + 3
|
||||
masked.fill(' ', offset, stop)
|
||||
offset = stop
|
||||
if (end === -1) break
|
||||
inComment = false
|
||||
continue
|
||||
}
|
||||
|
||||
const start = value.indexOf('<!--', offset)
|
||||
if (start === -1) break
|
||||
masked.fill(' ', start, start + 4)
|
||||
offset = start + 4
|
||||
inComment = true
|
||||
}
|
||||
|
||||
return { masked: masked.join(''), remainsInsideComment: inComment }
|
||||
}
|
||||
|
||||
function maskHiddenHeadingRegions(body: string): string {
|
||||
let inComment = false
|
||||
let fence: { character: '`' | '~'; length: number } | null = null
|
||||
|
||||
return body
|
||||
.split(/(?<=\n)/)
|
||||
.map((line) => {
|
||||
const ending = line.endsWith('\r\n')
|
||||
? '\r\n'
|
||||
: line.endsWith('\n')
|
||||
? '\n'
|
||||
: ''
|
||||
const content = ending ? line.slice(0, -ending.length) : line
|
||||
|
||||
if (fence) {
|
||||
const closing = new RegExp(
|
||||
`^ {0,3}\\${fence.character}{${fence.length},}[ \\t]*$`
|
||||
).test(content)
|
||||
if (closing) fence = null
|
||||
return `${spaces(content)}${ending}`
|
||||
}
|
||||
|
||||
const commentResult = maskHtmlComments(content, inComment)
|
||||
inComment = commentResult.remainsInsideComment
|
||||
const opening = commentResult.masked.match(/^ {0,3}(`{3,}|~{3,})/)
|
||||
if (opening) {
|
||||
const marker = opening[1]
|
||||
fence = {
|
||||
character: marker[0] as '`' | '~',
|
||||
length: marker.length
|
||||
}
|
||||
return `${spaces(content)}${ending}`
|
||||
}
|
||||
|
||||
return `${commentResult.masked}${ending}`
|
||||
})
|
||||
.join('')
|
||||
}
|
||||
|
||||
function stripHtmlCommentsOutsideFences(body: string): string {
|
||||
let inComment = false
|
||||
let fence: { character: '`' | '~'; length: number } | null = null
|
||||
|
||||
return body
|
||||
.split(/(?<=\n)/)
|
||||
.map((line) => {
|
||||
const ending = line.endsWith('\r\n')
|
||||
? '\r\n'
|
||||
: line.endsWith('\n')
|
||||
? '\n'
|
||||
: ''
|
||||
const content = ending ? line.slice(0, -ending.length) : line
|
||||
|
||||
if (fence) {
|
||||
const closing = new RegExp(
|
||||
`^ {0,3}\\${fence.character}{${fence.length},}[ \\t]*$`
|
||||
).test(content)
|
||||
if (closing) fence = null
|
||||
return line
|
||||
}
|
||||
|
||||
const commentResult = maskHtmlComments(content, inComment)
|
||||
inComment = commentResult.remainsInsideComment
|
||||
const opening = commentResult.masked.match(/^ {0,3}(`{3,}|~{3,})/)
|
||||
if (opening) {
|
||||
const marker = opening[1]
|
||||
fence = {
|
||||
character: marker[0] as '`' | '~',
|
||||
length: marker.length
|
||||
}
|
||||
}
|
||||
return `${commentResult.masked}${ending}`
|
||||
})
|
||||
.join('')
|
||||
}
|
||||
|
||||
function section(body: string, heading: string): string {
|
||||
const escapedHeading = heading.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
||||
const headingSearch = maskHiddenHeadingRegions(body)
|
||||
const match = new RegExp(
|
||||
`^ {0,3}#{2,6}[ \\t]+${escapedHeading}[ \\t]*#*[ \\t]*(?:\\r?\\n|$)`,
|
||||
'im'
|
||||
).exec(headingSearch)
|
||||
if (!match) return ''
|
||||
|
||||
const contentStart = match.index + match[0].length
|
||||
const nextHeading = /^ {0,3}#{2,6}[ \t]+/gm
|
||||
nextHeading.lastIndex = contentStart
|
||||
const nextMatch = nextHeading.exec(headingSearch)
|
||||
return clean(body.slice(contentStart, nextMatch?.index ?? body.length))
|
||||
}
|
||||
|
||||
function hasContent(value: string): boolean {
|
||||
const content = clean(value)
|
||||
.replace(/^\s*(?:[-*+]\s*|\d+\.\s*)$/gm, '')
|
||||
.replace(/^\s*```[\w-]*\s*$/gm, '')
|
||||
.trim()
|
||||
|
||||
return content.length >= 4 && !EMPTY_VALUE.test(content)
|
||||
}
|
||||
|
||||
function hasProcedure(value: string): boolean {
|
||||
const content = clean(value)
|
||||
if (!hasContent(content)) return false
|
||||
|
||||
const listedSteps = content
|
||||
.split(/\r?\n/)
|
||||
.filter((line) => /^\s*(?:[-*+]\s+|\d+[.)]\s+)/.test(line))
|
||||
.map((line) => line.replace(/^\s*(?:[-*+]\s+|\d+[.)]\s+)/, '').trim())
|
||||
.filter((line) => line.length >= 4 && !EMPTY_VALUE.test(line))
|
||||
|
||||
if (listedSteps.length >= 2) return true
|
||||
|
||||
const words = content.match(/[\p{L}\p{N}_-]+/gu) ?? []
|
||||
return content.length >= 20 && words.length >= 5
|
||||
}
|
||||
|
||||
function isGitHubAttachmentUrl(host: string, path: string): boolean {
|
||||
return (
|
||||
host === 'github.com' &&
|
||||
/^\/user-attachments\/assets\/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/.test(
|
||||
path
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
function isOpaqueGitHubAttachmentUrl(rawUrl: string): boolean {
|
||||
try {
|
||||
const url = new URL(rawUrl)
|
||||
return (
|
||||
url.protocol === 'https:' &&
|
||||
!url.username &&
|
||||
!url.password &&
|
||||
isGitHubAttachmentUrl(
|
||||
url.hostname.toLowerCase(),
|
||||
url.pathname.toLowerCase()
|
||||
)
|
||||
)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
function isLoomUrl(host: string, path: string): boolean {
|
||||
return (
|
||||
(host === 'loom.com' || host === 'www.loom.com') &&
|
||||
path.startsWith('/share/')
|
||||
)
|
||||
}
|
||||
|
||||
function isVisualMediaUrl(rawUrl: string): boolean {
|
||||
try {
|
||||
const url = new URL(rawUrl)
|
||||
if (url.protocol !== 'https:' || url.username || url.password) return false
|
||||
const host = url.hostname.toLowerCase()
|
||||
const path = url.pathname.toLowerCase()
|
||||
|
||||
return (
|
||||
isGitHubAttachmentUrl(host, path) ||
|
||||
host === 'user-images.githubusercontent.com' ||
|
||||
/\.(?:gif|jpe?g|mov|mp4|png|webm|webp)$/.test(path) ||
|
||||
isLoomUrl(host, path)
|
||||
)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
function isExplicitScreencastUrl(rawUrl: string): boolean {
|
||||
try {
|
||||
const url = new URL(rawUrl)
|
||||
if (url.protocol !== 'https:' || url.username || url.password) return false
|
||||
const host = url.hostname.toLowerCase()
|
||||
const path = url.pathname.toLowerCase()
|
||||
return /\.(?:gif|mov|mp4|webm)$/.test(path) || isLoomUrl(host, path)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
function isAllowedGitHubMediaHost(host: string): boolean {
|
||||
return (
|
||||
host === 'github.com' ||
|
||||
host === 'user-images.githubusercontent.com' ||
|
||||
host.endsWith('.githubusercontent.com') ||
|
||||
/^github-production-user-asset-[a-z0-9-]+\.s3\.amazonaws\.com$/.test(host)
|
||||
)
|
||||
}
|
||||
|
||||
function isRedirectStatus(status: number): boolean {
|
||||
return [301, 302, 303, 307, 308].includes(status)
|
||||
}
|
||||
|
||||
function redirectMediaVerdict(url: URL): boolean | null {
|
||||
const hintedContentType =
|
||||
url.searchParams.get('response-content-type')?.toLowerCase() ?? ''
|
||||
if (
|
||||
hintedContentType.startsWith('video/') ||
|
||||
hintedContentType === 'image/gif'
|
||||
) {
|
||||
return true
|
||||
}
|
||||
if (hintedContentType.startsWith('image/')) return false
|
||||
|
||||
const path = url.pathname.toLowerCase()
|
||||
if (/\.(?:gif|mov|mp4|webm)$/.test(path)) return true
|
||||
if (/\.(?:jpe?g|png|webp)$/.test(path)) return false
|
||||
return null
|
||||
}
|
||||
|
||||
function isVerifiedScreencastResponse(
|
||||
url: URL,
|
||||
response: MediaResponse
|
||||
): boolean {
|
||||
const contentType =
|
||||
response.headers
|
||||
.get('content-type')
|
||||
?.split(';', 1)[0]
|
||||
.trim()
|
||||
.toLowerCase() ?? ''
|
||||
if (contentType.startsWith('video/') || contentType === 'image/gif') {
|
||||
return true
|
||||
}
|
||||
|
||||
return (
|
||||
(!contentType || contentType === 'application/octet-stream') &&
|
||||
/\.(?:gif|mov|mp4|webm)$/.test(url.pathname.toLowerCase())
|
||||
)
|
||||
}
|
||||
|
||||
export async function probeGitHubScreencast(
|
||||
rawUrl: string,
|
||||
request: MediaRequest = async (url, init) =>
|
||||
fetch(url, { ...init, signal: AbortSignal.timeout(15_000) })
|
||||
): Promise<boolean> {
|
||||
if (!isOpaqueGitHubAttachmentUrl(rawUrl)) return false
|
||||
|
||||
let current = new URL(rawUrl)
|
||||
for (let redirectCount = 0; redirectCount <= 4; redirectCount += 1) {
|
||||
if (
|
||||
current.protocol !== 'https:' ||
|
||||
current.username ||
|
||||
current.password ||
|
||||
!isAllowedGitHubMediaHost(current.hostname.toLowerCase())
|
||||
) {
|
||||
return false
|
||||
}
|
||||
|
||||
let response = await request(current.href, {
|
||||
headers: { Accept: '*/*' },
|
||||
method: 'HEAD',
|
||||
redirect: 'manual'
|
||||
})
|
||||
if (
|
||||
response.status === 405 ||
|
||||
response.status === 501 ||
|
||||
(response.status === 403 && redirectCount > 0)
|
||||
) {
|
||||
response = await request(current.href, {
|
||||
headers: { Accept: '*/*', Range: 'bytes=0-0' },
|
||||
method: 'GET',
|
||||
redirect: 'manual'
|
||||
})
|
||||
}
|
||||
|
||||
if (isRedirectStatus(response.status)) {
|
||||
const location = response.headers.get('location')
|
||||
if (!location) return false
|
||||
const redirected = new URL(location, current)
|
||||
if (
|
||||
redirected.protocol !== 'https:' ||
|
||||
redirected.username ||
|
||||
redirected.password ||
|
||||
!isAllowedGitHubMediaHost(redirected.hostname.toLowerCase())
|
||||
) {
|
||||
return false
|
||||
}
|
||||
const redirectVerdict = redirectMediaVerdict(redirected)
|
||||
if (redirectVerdict !== null) return redirectVerdict
|
||||
current = redirected
|
||||
continue
|
||||
}
|
||||
|
||||
return response.ok && isVerifiedScreencastResponse(current, response)
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
function isStandaloneScreencastLink(content: string, link: string): boolean {
|
||||
const escaped = link.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
||||
return new RegExp(
|
||||
`^\\s*(?:Screencast:\\s*)?(?:${escaped}|\\[[^\\]]+\\]\\(${escaped}\\))\\s*$`,
|
||||
'im'
|
||||
).test(content)
|
||||
}
|
||||
|
||||
function opaqueScreencastUrls(value: string): string[] {
|
||||
const content = clean(value)
|
||||
const links = content.match(/https?:\/\/[^\s)>]+/gi) ?? []
|
||||
return links
|
||||
.map((rawLink) => rawLink.replace(/[),.;]+$/, ''))
|
||||
.filter(
|
||||
(link) =>
|
||||
isOpaqueGitHubAttachmentUrl(link) &&
|
||||
isStandaloneScreencastLink(content, link)
|
||||
)
|
||||
}
|
||||
|
||||
function hasScreencastEvidence(
|
||||
value: string,
|
||||
verifiedOpaqueUrls: ReadonlySet<string> = new Set()
|
||||
): boolean {
|
||||
const content = clean(value)
|
||||
const links = content.match(/https?:\/\/[^\s)>]+/gi) ?? []
|
||||
for (const rawLink of links) {
|
||||
const link = rawLink.replace(/[),.;]+$/, '')
|
||||
if (isExplicitScreencastUrl(link)) return true
|
||||
try {
|
||||
const url = new URL(link)
|
||||
if (
|
||||
!isGitHubAttachmentUrl(
|
||||
url.hostname.toLowerCase(),
|
||||
url.pathname.toLowerCase()
|
||||
)
|
||||
) {
|
||||
continue
|
||||
}
|
||||
if (
|
||||
isStandaloneScreencastLink(content, link) &&
|
||||
verifiedOpaqueUrls.has(canonicalMediaUrl(link))
|
||||
) {
|
||||
return true
|
||||
}
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
function canonicalMediaUrl(rawUrl: string): string {
|
||||
const url = new URL(rawUrl)
|
||||
url.hash = ''
|
||||
url.search = ''
|
||||
url.hostname = url.hostname.toLowerCase()
|
||||
try {
|
||||
url.pathname = decodeURIComponent(url.pathname)
|
||||
} catch {
|
||||
// Keep malformed percent encoding unchanged; URL parsing already succeeded.
|
||||
}
|
||||
if (url.pathname.length > 1) {
|
||||
url.pathname = url.pathname.replace(/\/+$/, '')
|
||||
}
|
||||
return url.href
|
||||
}
|
||||
|
||||
function visualMediaUrls(value: string): string[] {
|
||||
const links = clean(value).match(/https?:\/\/[^\s)>]+/gi) ?? []
|
||||
return links.filter(isVisualMediaUrl)
|
||||
}
|
||||
|
||||
function hasIssueVisualEvidence(
|
||||
value: string,
|
||||
verifiedOpaqueUrls: ReadonlySet<string> = new Set()
|
||||
): boolean {
|
||||
const urls = visualMediaUrls(value)
|
||||
if (urls.some(isExplicitScreencastUrl)) return true
|
||||
|
||||
const labeledUrl = (
|
||||
label: string,
|
||||
accepts: (url: string) => boolean
|
||||
): string => {
|
||||
const match = clean(value).match(
|
||||
new RegExp(`^\\s*(?:Or\\s+)?${label}:\\s*(.+)$`, 'im')
|
||||
)
|
||||
const url = visualMediaUrls(match?.[1] ?? '')[0] ?? ''
|
||||
return accepts(url) ? url : ''
|
||||
}
|
||||
const labeledScreencast = clean(value).match(
|
||||
/^\s*(?:Or\s+)?Screencast:\s*(.+)$/im
|
||||
)?.[1]
|
||||
if (
|
||||
labeledScreencast &&
|
||||
hasScreencastEvidence(labeledScreencast, verifiedOpaqueUrls)
|
||||
) {
|
||||
return true
|
||||
}
|
||||
|
||||
const before = labeledUrl('Before', isVisualMediaUrl)
|
||||
const after = labeledUrl('After', isVisualMediaUrl)
|
||||
return Boolean(
|
||||
before && after && canonicalMediaUrl(before) !== canonicalMediaUrl(after)
|
||||
)
|
||||
}
|
||||
|
||||
function hasTestOrLogEvidence(value: string): boolean {
|
||||
const evidence = clean(value)
|
||||
if (!hasContent(evidence) || evidence.length < 16) return false
|
||||
|
||||
const command =
|
||||
/\b(?:bun|deno|npm|npx|playwright|pnpm|pytest|tox|vitest|yarn)\b/i.test(
|
||||
evidence
|
||||
)
|
||||
const outcome =
|
||||
/\b(?:exit(?:ed)?\s+(?:code\s+)?\d+|fail(?:ed|ure)?|pass(?:ed)?|success(?:ful)?|timeout)\b/i.test(
|
||||
evidence
|
||||
)
|
||||
const logExcerpt =
|
||||
/(?:^|\n)\s*(?:error|exception|failure|status)\s*[:=]|\b(?:stack trace|status\s+[1-5]\d\d)\b/i.test(
|
||||
evidence
|
||||
)
|
||||
const linkedEvidence =
|
||||
/\b(?:ci|log|test)\b/i.test(evidence) && /https?:\/\/\S+/i.test(evidence)
|
||||
|
||||
return (command && outcome) || logExcerpt || linkedEvidence
|
||||
}
|
||||
|
||||
function issueReproduction(body: string): string {
|
||||
return (
|
||||
section(body, 'Steps to Reproduce') ||
|
||||
section(body, 'Concrete example or steps')
|
||||
)
|
||||
}
|
||||
|
||||
function nonVisualIssueEvidence(value: string): boolean {
|
||||
const evidence = clean(value)
|
||||
const reason = evidence.match(/^N\/A:\s*(.+)$/im)?.[1] ?? ''
|
||||
const testOrLog =
|
||||
evidence.match(/^Test\/log evidence:\s*([\s\S]*)$/im)?.[1] ?? ''
|
||||
return hasContent(reason) && hasTestOrLogEvidence(testOrLog)
|
||||
}
|
||||
|
||||
function validateIssueEvidenceWithVerifiedMedia(
|
||||
body: string,
|
||||
verifiedOpaqueUrls: ReadonlySet<string>
|
||||
): EvidenceValidation {
|
||||
const errors: string[] = []
|
||||
const mode = section(body, 'Evidence type').toLowerCase()
|
||||
const evidence = section(body, 'Evidence')
|
||||
|
||||
if (!hasProcedure(issueReproduction(body))) {
|
||||
errors.push('Add concrete reproduction steps or an example.')
|
||||
}
|
||||
|
||||
if (mode !== 'visual' && mode !== 'non-visual') {
|
||||
errors.push('Select exactly one evidence type: Visual or Non-visual.')
|
||||
} else if (
|
||||
mode === 'visual' &&
|
||||
!hasIssueVisualEvidence(evidence, verifiedOpaqueUrls)
|
||||
) {
|
||||
errors.push(
|
||||
'Visual issues require distinct before and after links or a screencast.'
|
||||
)
|
||||
} else if (mode === 'non-visual' && !nonVisualIssueEvidence(evidence)) {
|
||||
errors.push(
|
||||
'Non-visual issues require an N/A rationale and test/log evidence.'
|
||||
)
|
||||
}
|
||||
|
||||
return { errors, valid: errors.length === 0 }
|
||||
}
|
||||
|
||||
export function validateIssueEvidence(body: string): EvidenceValidation {
|
||||
return validateIssueEvidenceWithVerifiedMedia(body, new Set())
|
||||
}
|
||||
|
||||
function selectedPrModes(body: string): string[] {
|
||||
const mode = section(body, 'Evidence type')
|
||||
return ['Visual', 'Non-visual'].filter((value) =>
|
||||
new RegExp(`^\\s*[-*]\\s+\\[[xX]\\]\\s+${value}\\s*$`, 'im').test(mode)
|
||||
)
|
||||
}
|
||||
|
||||
function validatePullRequestEvidenceWithVerifiedMedia(
|
||||
body: string,
|
||||
verifiedOpaqueUrls: ReadonlySet<string>
|
||||
): EvidenceValidation {
|
||||
const errors: string[] = []
|
||||
const selectedModes = selectedPrModes(body)
|
||||
|
||||
if (!hasProcedure(section(body, 'Reproduction or validation steps'))) {
|
||||
errors.push('Add reproduction or validation steps.')
|
||||
}
|
||||
|
||||
if (selectedModes.length !== 1) {
|
||||
errors.push('Check exactly one evidence type: Visual or Non-visual.')
|
||||
} else if (selectedModes[0] === 'Visual') {
|
||||
const before = visualMediaUrls(section(body, 'Before'))[0]
|
||||
const after = visualMediaUrls(section(body, 'After'))[0]
|
||||
const hasBeforeAndAfter = Boolean(
|
||||
before && after && canonicalMediaUrl(before) !== canonicalMediaUrl(after)
|
||||
)
|
||||
const hasScreencast = hasScreencastEvidence(
|
||||
section(body, 'Screencast'),
|
||||
verifiedOpaqueUrls
|
||||
)
|
||||
if (!hasBeforeAndAfter && !hasScreencast) {
|
||||
errors.push(
|
||||
'Visual pull requests require before and after links or a screencast.'
|
||||
)
|
||||
}
|
||||
} else {
|
||||
const rationale = section(body, 'Non-visual rationale')
|
||||
const reason = rationale.match(/^N\/A:\s*([\s\S]*)$/i)?.[1] ?? ''
|
||||
if (!hasContent(reason)) {
|
||||
errors.push('Non-visual pull requests require an N/A rationale.')
|
||||
}
|
||||
if (!hasTestOrLogEvidence(section(body, 'Test or log evidence'))) {
|
||||
errors.push('Non-visual pull requests require test or log evidence.')
|
||||
}
|
||||
}
|
||||
|
||||
return { errors, valid: errors.length === 0 }
|
||||
}
|
||||
|
||||
export function validatePullRequestEvidence(body: string): EvidenceValidation {
|
||||
return validatePullRequestEvidenceWithVerifiedMedia(body, new Set())
|
||||
}
|
||||
|
||||
async function verifiedOpaqueScreencasts(
|
||||
value: string,
|
||||
probe: ScreencastProbe
|
||||
): Promise<Set<string>> {
|
||||
const verified = new Set<string>()
|
||||
for (const url of opaqueScreencastUrls(value)) {
|
||||
try {
|
||||
if (await probe(url)) verified.add(canonicalMediaUrl(url))
|
||||
} catch {
|
||||
// Probe errors fail closed by leaving the attachment unverified.
|
||||
}
|
||||
}
|
||||
return verified
|
||||
}
|
||||
|
||||
export async function validateIssueEvidenceWithMedia(
|
||||
body: string,
|
||||
probe: ScreencastProbe = probeGitHubScreencast
|
||||
): Promise<EvidenceValidation> {
|
||||
const verified = await verifiedOpaqueScreencasts(
|
||||
section(body, 'Evidence'),
|
||||
probe
|
||||
)
|
||||
return validateIssueEvidenceWithVerifiedMedia(body, verified)
|
||||
}
|
||||
|
||||
export async function validatePullRequestEvidenceWithMedia(
|
||||
body: string,
|
||||
probe: ScreencastProbe = probeGitHubScreencast
|
||||
): Promise<EvidenceValidation> {
|
||||
const verified = await verifiedOpaqueScreencasts(
|
||||
section(body, 'Screencast'),
|
||||
probe
|
||||
)
|
||||
return validatePullRequestEvidenceWithVerifiedMedia(body, verified)
|
||||
}
|
||||
|
||||
export async function validatePullRequestEvidenceBatch(
|
||||
pullRequests: PullRequestEvidencePayload[],
|
||||
probe: ScreencastProbe = probeGitHubScreencast
|
||||
): Promise<EvidenceValidation> {
|
||||
if (pullRequests.length === 0) {
|
||||
return {
|
||||
errors: [
|
||||
'No pull request metadata was available for evidence validation.'
|
||||
],
|
||||
valid: false
|
||||
}
|
||||
}
|
||||
|
||||
const errors: string[] = []
|
||||
for (const pullRequest of pullRequests) {
|
||||
const result = await validatePullRequestEvidenceWithMedia(
|
||||
pullRequest.body ?? '',
|
||||
probe
|
||||
)
|
||||
if (!result.valid) {
|
||||
const prefix = pullRequest.number ? `PR #${pullRequest.number}: ` : ''
|
||||
errors.push(...result.errors.map((error) => `${prefix}${error}`))
|
||||
}
|
||||
}
|
||||
return { errors, valid: errors.length === 0 }
|
||||
}
|
||||
|
||||
async function runCli(): Promise<void> {
|
||||
const [kind, eventPath] = process.argv.slice(2)
|
||||
if ((kind !== 'issue' && kind !== 'pr') || !eventPath) {
|
||||
throw new Error('Usage: validate-evidence.ts <issue|pr> <event.json>')
|
||||
}
|
||||
|
||||
const payload = JSON.parse(readFileSync(eventPath, 'utf8')) as EventPayload
|
||||
const body =
|
||||
kind === 'issue' ? payload.issue?.body : payload.pull_request?.body
|
||||
const result = await validateEvidence(kind, body ?? '')
|
||||
|
||||
if (process.env.GITHUB_OUTPUT) {
|
||||
appendFileSync(
|
||||
process.env.GITHUB_OUTPUT,
|
||||
`valid=${result.valid}\nerrors=${JSON.stringify(result.errors)}\n`
|
||||
)
|
||||
}
|
||||
|
||||
if (result.valid) {
|
||||
process.stdout.write('Evidence contract satisfied.\n')
|
||||
return
|
||||
}
|
||||
|
||||
result.errors.forEach((error) => console.error(`::error::${error}`))
|
||||
process.exitCode = 1
|
||||
}
|
||||
|
||||
async function validateEvidence(
|
||||
kind: EvidenceKind,
|
||||
body: string
|
||||
): Promise<EvidenceValidation> {
|
||||
return kind === 'issue'
|
||||
? validateIssueEvidenceWithMedia(body)
|
||||
: validatePullRequestEvidenceWithMedia(body)
|
||||
}
|
||||
|
||||
if (
|
||||
process.argv[1] &&
|
||||
import.meta.url === pathToFileURL(process.argv[1]).href
|
||||
) {
|
||||
runCli().catch((error: unknown) => {
|
||||
if (process.env.GITHUB_OUTPUT) {
|
||||
appendFileSync(process.env.GITHUB_OUTPUT, 'valid=false\nerrors=[]\n')
|
||||
}
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
console.error(`::error::${message}`)
|
||||
process.exitCode = 1
|
||||
})
|
||||
}
|
||||
@@ -1,217 +0,0 @@
|
||||
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { spawnSync } from 'node:child_process'
|
||||
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import {
|
||||
createGitHubMembershipLookup,
|
||||
validateMaintainerAssignees
|
||||
} from './validate-maintainer'
|
||||
|
||||
function activeMember(login = 'maintainer') {
|
||||
return {
|
||||
membership: { state: 'active' },
|
||||
teamMembership: { state: 'active' },
|
||||
user: { login, suspended_at: null, type: 'User' }
|
||||
}
|
||||
}
|
||||
|
||||
describe('validateMaintainerAssignees', () => {
|
||||
it('rejects an empty assignee list', async () => {
|
||||
const membershipFor = vi.fn()
|
||||
const result = await validateMaintainerAssignees([], membershipFor)
|
||||
|
||||
expect(result.valid).toBe(false)
|
||||
expect(membershipFor).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('accepts an active human Comfy-Org member', async () => {
|
||||
const result = await validateMaintainerAssignees(
|
||||
[{ login: 'maintainer', type: 'User' }],
|
||||
vi.fn().mockResolvedValue(activeMember())
|
||||
)
|
||||
|
||||
expect(result.valid).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects a pending organization membership', async () => {
|
||||
const result = await validateMaintainerAssignees(
|
||||
[{ login: 'maintainer', type: 'User' }],
|
||||
vi.fn().mockResolvedValue({
|
||||
membership: { state: 'pending' },
|
||||
teamMembership: { state: 'active' },
|
||||
user: { login: 'maintainer', suspended_at: null, type: 'User' }
|
||||
})
|
||||
)
|
||||
|
||||
expect(result.valid).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects GitHub bot accounts even when they are organization members', async () => {
|
||||
const membershipFor = vi.fn().mockResolvedValue(activeMember())
|
||||
const result = await validateMaintainerAssignees(
|
||||
[{ login: 'automation[bot]', type: 'Bot' }],
|
||||
membershipFor
|
||||
)
|
||||
|
||||
expect(result.valid).toBe(false)
|
||||
expect(membershipFor).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects bot-like service users even when GitHub reports User', async () => {
|
||||
const membershipFor = vi
|
||||
.fn()
|
||||
.mockResolvedValue(activeMember('comfy-pr-bot'))
|
||||
const result = await validateMaintainerAssignees(
|
||||
[{ login: 'comfy-pr-bot', type: 'User' }],
|
||||
membershipFor
|
||||
)
|
||||
|
||||
expect(result.valid).toBe(false)
|
||||
expect(membershipFor).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects a human who is not an organization member', async () => {
|
||||
const result = await validateMaintainerAssignees(
|
||||
[{ login: 'contributor', type: 'User' }],
|
||||
vi.fn().mockResolvedValue({
|
||||
membership: null,
|
||||
teamMembership: null,
|
||||
user: { login: 'contributor', suspended_at: null, type: 'User' }
|
||||
})
|
||||
)
|
||||
|
||||
expect(result.valid).toBe(false)
|
||||
expect(result.errors).toContain(
|
||||
'Assign at least one active human member of the Comfy-Org/comfy_frontend_devs team.'
|
||||
)
|
||||
})
|
||||
|
||||
it('rejects an organization service user outside the human-only team', async () => {
|
||||
const result = await validateMaintainerAssignees(
|
||||
[{ login: 'comfyui-wiki', type: 'User' }],
|
||||
vi.fn().mockResolvedValue({
|
||||
membership: { state: 'active' },
|
||||
teamMembership: null,
|
||||
user: { login: 'comfyui-wiki', suspended_at: null, type: 'User' }
|
||||
})
|
||||
)
|
||||
|
||||
expect(result.valid).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects a suspended organization member', async () => {
|
||||
const result = await validateMaintainerAssignees(
|
||||
[{ login: 'maintainer', type: 'User' }],
|
||||
vi.fn().mockResolvedValue({
|
||||
membership: { state: 'active' },
|
||||
teamMembership: { state: 'active' },
|
||||
user: {
|
||||
login: 'maintainer',
|
||||
suspended_at: '2026-07-11T00:00:00Z',
|
||||
type: 'User'
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
expect(result.valid).toBe(false)
|
||||
})
|
||||
|
||||
it('checks later human assignees after rejecting an ineligible one', async () => {
|
||||
const membershipFor = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({
|
||||
membership: null,
|
||||
teamMembership: null,
|
||||
user: { login: 'contributor', suspended_at: null, type: 'User' }
|
||||
})
|
||||
.mockResolvedValueOnce(activeMember('owner'))
|
||||
const result = await validateMaintainerAssignees(
|
||||
[
|
||||
{ login: 'contributor', type: 'User' },
|
||||
{ login: 'owner', type: 'User' }
|
||||
],
|
||||
membershipFor
|
||||
)
|
||||
|
||||
expect(result.valid).toBe(true)
|
||||
expect(membershipFor).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('propagates a membership API failure so callers fail closed', async () => {
|
||||
await expect(
|
||||
validateMaintainerAssignees(
|
||||
[{ login: 'maintainer', type: 'User' }],
|
||||
vi.fn().mockRejectedValue(new Error('GitHub API unavailable'))
|
||||
)
|
||||
).rejects.toThrow('GitHub API unavailable')
|
||||
})
|
||||
|
||||
it('fails closed when the human-team membership API fails', async () => {
|
||||
const fetchMock = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({
|
||||
json: vi.fn().mockResolvedValue({ login: 'maintainer', type: 'User' }),
|
||||
ok: true,
|
||||
status: 200
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
json: vi.fn().mockResolvedValue({ state: 'active' }),
|
||||
ok: true,
|
||||
status: 200
|
||||
})
|
||||
.mockResolvedValueOnce({ ok: false, status: 503 })
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
|
||||
try {
|
||||
await expect(
|
||||
createGitHubMembershipLookup('token')('maintainer')
|
||||
).rejects.toThrow('GitHub human-team membership lookup failed')
|
||||
expect(fetchMock.mock.calls[2][0]).toContain(
|
||||
'/teams/comfy_frontend_devs/memberships/maintainer'
|
||||
)
|
||||
} finally {
|
||||
vi.unstubAllGlobals()
|
||||
}
|
||||
})
|
||||
|
||||
it('writes valid=false and exits nonzero when CLI setup fails', () => {
|
||||
const directory = mkdtempSync(join(tmpdir(), 'maintainer-policy-'))
|
||||
const eventPath = join(directory, 'event.json')
|
||||
const outputPath = join(directory, 'github-output.txt')
|
||||
writeFileSync(
|
||||
eventPath,
|
||||
JSON.stringify({
|
||||
pull_request: {
|
||||
assignees: [{ login: 'maintainer', type: 'User' }]
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
try {
|
||||
const result = spawnSync(
|
||||
process.execPath,
|
||||
['scripts/cicd/validate-maintainer.ts', eventPath],
|
||||
{
|
||||
cwd: process.cwd(),
|
||||
encoding: 'utf8',
|
||||
env: {
|
||||
...process.env,
|
||||
COMFY_ORG_MEMBERS_READ_TOKEN: '',
|
||||
GITHUB_OUTPUT: outputPath
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
expect(result.status).toBe(1)
|
||||
expect(result.stderr).toContain(
|
||||
'::error::COMFY_ORG_MEMBERS_READ_TOKEN is required.'
|
||||
)
|
||||
expect(readFileSync(outputPath, 'utf8')).toBe('valid=false\n')
|
||||
} finally {
|
||||
rmSync(directory, { force: true, recursive: true })
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -1,220 +0,0 @@
|
||||
import { appendFileSync, readFileSync } from 'node:fs'
|
||||
import { pathToFileURL } from 'node:url'
|
||||
|
||||
type Assignee = {
|
||||
login?: string | null
|
||||
type?: string | null
|
||||
}
|
||||
|
||||
type EventPayload = {
|
||||
issue?: { assignees?: Assignee[] | null }
|
||||
pull_request?: { assignees?: Assignee[] | null }
|
||||
}
|
||||
|
||||
type GitHubUser = {
|
||||
login?: string | null
|
||||
suspended_at?: string | null
|
||||
type?: string | null
|
||||
}
|
||||
|
||||
type OrgMembership = {
|
||||
state?: string | null
|
||||
}
|
||||
|
||||
export type MembershipRecord = {
|
||||
membership: OrgMembership | null
|
||||
teamMembership: OrgMembership | null
|
||||
user: GitHubUser | null
|
||||
}
|
||||
|
||||
export type MembershipLookup = (login: string) => Promise<MembershipRecord>
|
||||
|
||||
export type MaintainerValidation = {
|
||||
errors: string[]
|
||||
valid: boolean
|
||||
}
|
||||
|
||||
export type PullRequestAssigneePayload = {
|
||||
assignees?: Assignee[] | null
|
||||
number?: number | null
|
||||
}
|
||||
|
||||
const BOT_LOGIN = /(?:\[bot\]|[-_]bot)$/i
|
||||
const COMFY_ORG = 'Comfy-Org'
|
||||
const HUMAN_TEAM = 'comfy_frontend_devs'
|
||||
|
||||
function isHumanAccount(assignee: Assignee): assignee is Assignee & {
|
||||
login: string
|
||||
} {
|
||||
const login = assignee.login?.trim() ?? ''
|
||||
return (
|
||||
Boolean(login) &&
|
||||
assignee.type?.toLowerCase() === 'user' &&
|
||||
!BOT_LOGIN.test(login)
|
||||
)
|
||||
}
|
||||
|
||||
function isActiveOrgHuman(record: MembershipRecord, login: string): boolean {
|
||||
const userLogin = record.user?.login?.trim() ?? ''
|
||||
return (
|
||||
userLogin.toLowerCase() === login.toLowerCase() &&
|
||||
record.user?.type?.toLowerCase() === 'user' &&
|
||||
!record.user.suspended_at &&
|
||||
record.membership?.state?.toLowerCase() === 'active' &&
|
||||
record.teamMembership?.state?.toLowerCase() === 'active'
|
||||
)
|
||||
}
|
||||
|
||||
export async function validateMaintainerAssignees(
|
||||
assignees: Assignee[],
|
||||
membershipFor: MembershipLookup
|
||||
): Promise<MaintainerValidation> {
|
||||
for (const assignee of assignees) {
|
||||
if (!isHumanAccount(assignee)) continue
|
||||
if (isActiveOrgHuman(await membershipFor(assignee.login), assignee.login)) {
|
||||
return { errors: [], valid: true }
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
errors: [
|
||||
'Assign at least one active human member of the Comfy-Org/comfy_frontend_devs team.'
|
||||
],
|
||||
valid: false
|
||||
}
|
||||
}
|
||||
|
||||
export async function validatePullRequestMaintainers(
|
||||
pullRequests: PullRequestAssigneePayload[],
|
||||
membershipFor: MembershipLookup
|
||||
): Promise<MaintainerValidation> {
|
||||
if (pullRequests.length === 0) {
|
||||
return {
|
||||
errors: ['No pull request metadata was available for owner validation.'],
|
||||
valid: false
|
||||
}
|
||||
}
|
||||
|
||||
const errors: string[] = []
|
||||
for (const pullRequest of pullRequests) {
|
||||
const result = await validateMaintainerAssignees(
|
||||
pullRequest.assignees ?? [],
|
||||
membershipFor
|
||||
)
|
||||
if (!result.valid) {
|
||||
const prefix = pullRequest.number ? `PR #${pullRequest.number}: ` : ''
|
||||
errors.push(...result.errors.map((error) => `${prefix}${error}`))
|
||||
}
|
||||
}
|
||||
|
||||
return { errors, valid: errors.length === 0 }
|
||||
}
|
||||
|
||||
export function createGitHubMembershipLookup(token: string): MembershipLookup {
|
||||
return async (login): Promise<MembershipRecord> => {
|
||||
const headers = {
|
||||
Accept: 'application/vnd.github+json',
|
||||
Authorization: `Bearer ${token}`,
|
||||
'X-GitHub-Api-Version': '2026-03-10'
|
||||
}
|
||||
const userResponse = await fetch(
|
||||
`https://api.github.com/users/${encodeURIComponent(login)}`,
|
||||
{ headers }
|
||||
)
|
||||
if (userResponse.status === 404) {
|
||||
return { membership: null, teamMembership: null, user: null }
|
||||
}
|
||||
if (!userResponse.ok) {
|
||||
throw new Error(
|
||||
`GitHub user lookup failed for ${login}: ${userResponse.status}`
|
||||
)
|
||||
}
|
||||
|
||||
const membershipResponse = await fetch(
|
||||
`https://api.github.com/orgs/${COMFY_ORG}/memberships/${encodeURIComponent(login)}`,
|
||||
{ headers }
|
||||
)
|
||||
if (membershipResponse.status === 404) {
|
||||
return {
|
||||
membership: null,
|
||||
teamMembership: null,
|
||||
user: (await userResponse.json()) as GitHubUser
|
||||
}
|
||||
}
|
||||
if (!membershipResponse.ok) {
|
||||
throw new Error(
|
||||
`GitHub organization membership lookup failed for ${login}: ${membershipResponse.status}`
|
||||
)
|
||||
}
|
||||
|
||||
const teamResponse = await fetch(
|
||||
`https://api.github.com/orgs/${COMFY_ORG}/teams/${HUMAN_TEAM}/memberships/${encodeURIComponent(login)}`,
|
||||
{ headers }
|
||||
)
|
||||
if (teamResponse.status === 404) {
|
||||
return {
|
||||
membership: (await membershipResponse.json()) as OrgMembership,
|
||||
teamMembership: null,
|
||||
user: (await userResponse.json()) as GitHubUser
|
||||
}
|
||||
}
|
||||
if (!teamResponse.ok) {
|
||||
throw new Error(
|
||||
`GitHub human-team membership lookup failed for ${login}: ${teamResponse.status}`
|
||||
)
|
||||
}
|
||||
|
||||
return {
|
||||
membership: (await membershipResponse.json()) as OrgMembership,
|
||||
teamMembership: (await teamResponse.json()) as OrgMembership,
|
||||
user: (await userResponse.json()) as GitHubUser
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function writeOutput(valid: boolean): void {
|
||||
if (process.env.GITHUB_OUTPUT) {
|
||||
appendFileSync(process.env.GITHUB_OUTPUT, `valid=${valid}\n`)
|
||||
}
|
||||
}
|
||||
|
||||
async function runCli(): Promise<void> {
|
||||
const [eventPath] = process.argv.slice(2)
|
||||
if (!eventPath) {
|
||||
throw new Error('Usage: validate-maintainer.ts <event.json>')
|
||||
}
|
||||
|
||||
const token = process.env.COMFY_ORG_MEMBERS_READ_TOKEN?.trim()
|
||||
if (!token) {
|
||||
throw new Error('COMFY_ORG_MEMBERS_READ_TOKEN is required.')
|
||||
}
|
||||
|
||||
const payload = JSON.parse(readFileSync(eventPath, 'utf8')) as EventPayload
|
||||
const assignees =
|
||||
payload.pull_request?.assignees ?? payload.issue?.assignees ?? []
|
||||
const result = await validateMaintainerAssignees(
|
||||
assignees,
|
||||
createGitHubMembershipLookup(token)
|
||||
)
|
||||
|
||||
writeOutput(result.valid)
|
||||
if (result.valid) {
|
||||
process.stdout.write('Human Comfy maintainer assignment satisfied.\n')
|
||||
return
|
||||
}
|
||||
|
||||
result.errors.forEach((error) => console.error(`::error::${error}`))
|
||||
process.exitCode = 1
|
||||
}
|
||||
|
||||
if (
|
||||
process.argv[1] &&
|
||||
import.meta.url === pathToFileURL(process.argv[1]).href
|
||||
) {
|
||||
runCli().catch((error: unknown) => {
|
||||
writeOutput(false)
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
console.error(`::error::${message}`)
|
||||
process.exitCode = 1
|
||||
})
|
||||
}
|
||||
@@ -1,239 +0,0 @@
|
||||
import { spawnSync } from 'node:child_process'
|
||||
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import {
|
||||
loadMergeGroupPullRequests,
|
||||
validateMergeGroupPolicy
|
||||
} from './validate-merge-group'
|
||||
import type { RepoRequest } from './validate-merge-group'
|
||||
|
||||
const HEAD_SHA = 'a'.repeat(40)
|
||||
|
||||
function validBody(): string {
|
||||
return `## Reproduction or validation steps
|
||||
1. Run the metadata check
|
||||
2. Inspect the generated result
|
||||
|
||||
## Evidence type
|
||||
- [ ] Visual
|
||||
- [x] Non-visual
|
||||
|
||||
## Non-visual rationale
|
||||
N/A: This changes CI metadata only.
|
||||
|
||||
## Test or log evidence
|
||||
\`pnpm test:unit\` passed.`
|
||||
}
|
||||
|
||||
function activeHuman() {
|
||||
return {
|
||||
membership: { state: 'active' },
|
||||
teamMembership: { state: 'active' },
|
||||
user: { login: 'maintainer', suspended_at: null, type: 'User' }
|
||||
}
|
||||
}
|
||||
|
||||
describe('loadMergeGroupPullRequests', () => {
|
||||
it.for([null, 'not-a-sha'])(
|
||||
'fails before requesting metadata when head_sha is %s',
|
||||
async (headSha) => {
|
||||
const request: RepoRequest = vi.fn()
|
||||
|
||||
await expect(
|
||||
loadMergeGroupPullRequests(
|
||||
{ merge_group: { head_ref: 'unused', head_sha: headSha } },
|
||||
request
|
||||
)
|
||||
).rejects.toThrow('Merge group head SHA is missing or malformed.')
|
||||
expect(request).not.toHaveBeenCalled()
|
||||
}
|
||||
)
|
||||
|
||||
it('uses associated PRs and refetches current metadata for every PR', async () => {
|
||||
const request: RepoRequest = vi.fn(async (path) => {
|
||||
if (path === `commits/${HEAD_SHA}/pulls`) {
|
||||
return [
|
||||
{ body: 'stale', number: 41 },
|
||||
{ body: 'stale', number: 42 }
|
||||
]
|
||||
}
|
||||
if (path === 'pulls/41') {
|
||||
return {
|
||||
assignees: [{ login: 'maintainer', type: 'User' }],
|
||||
body: 'current 41',
|
||||
number: 41
|
||||
}
|
||||
}
|
||||
if (path === 'pulls/42') {
|
||||
return {
|
||||
assignees: [{ login: 'owner', type: 'User' }],
|
||||
body: 'current 42',
|
||||
number: 42
|
||||
}
|
||||
}
|
||||
throw new Error(`Unexpected path: ${path}`)
|
||||
})
|
||||
|
||||
const result = await loadMergeGroupPullRequests(
|
||||
{ merge_group: { head_ref: 'unused', head_sha: HEAD_SHA } },
|
||||
request
|
||||
)
|
||||
|
||||
expect(result.map(({ body, number }) => ({ body, number }))).toEqual([
|
||||
{ body: 'current 41', number: 41 },
|
||||
{ body: 'current 42', number: 42 }
|
||||
])
|
||||
})
|
||||
|
||||
it('falls back to the exact queue head_ref PR token when associations are empty', async () => {
|
||||
const request: RepoRequest = vi.fn(async (path) => {
|
||||
if (path === `commits/${HEAD_SHA}/pulls`) return []
|
||||
if (path === 'pulls/13556') {
|
||||
return { assignees: [], body: validBody(), number: 13556 }
|
||||
}
|
||||
throw new Error(`Unexpected path: ${path}`)
|
||||
})
|
||||
|
||||
const result = await loadMergeGroupPullRequests(
|
||||
{
|
||||
merge_group: {
|
||||
head_ref: `refs/heads/gh-readonly-queue/main/pr-13556-${HEAD_SHA}`,
|
||||
head_sha: HEAD_SHA
|
||||
}
|
||||
},
|
||||
request
|
||||
)
|
||||
|
||||
expect(result).toHaveLength(1)
|
||||
expect(result[0].number).toBe(13556)
|
||||
})
|
||||
|
||||
it('fails when neither API associations nor an exact head_ref token exist', async () => {
|
||||
const request: RepoRequest = vi.fn().mockResolvedValue([])
|
||||
|
||||
await expect(
|
||||
loadMergeGroupPullRequests(
|
||||
{
|
||||
merge_group: {
|
||||
head_ref: `refs/heads/gh-readonly-queue/main/not-a-pr-${HEAD_SHA}`,
|
||||
head_sha: HEAD_SHA
|
||||
}
|
||||
},
|
||||
request
|
||||
)
|
||||
).rejects.toThrow('No pull request was associated with the merge group')
|
||||
})
|
||||
|
||||
it('propagates associated-pulls API failures', async () => {
|
||||
await expect(
|
||||
loadMergeGroupPullRequests(
|
||||
{ merge_group: { head_ref: 'unused', head_sha: HEAD_SHA } },
|
||||
vi.fn().mockRejectedValue(new Error('GitHub API unavailable'))
|
||||
)
|
||||
).rejects.toThrow('GitHub API unavailable')
|
||||
})
|
||||
|
||||
it('rejects malformed refetched pull request metadata', async () => {
|
||||
const request: RepoRequest = vi.fn(async (path) => {
|
||||
if (path === `commits/${HEAD_SHA}/pulls`) return [{ number: 41 }]
|
||||
if (path === 'pulls/41') return { body: validBody() }
|
||||
throw new Error(`Unexpected path: ${path}`)
|
||||
})
|
||||
|
||||
await expect(
|
||||
loadMergeGroupPullRequests(
|
||||
{ merge_group: { head_ref: 'unused', head_sha: HEAD_SHA } },
|
||||
request
|
||||
)
|
||||
).rejects.toThrow('GitHub returned malformed pull request metadata.')
|
||||
})
|
||||
})
|
||||
|
||||
describe('validateMergeGroupPolicy', () => {
|
||||
it('validates current evidence and human-team ownership for every PR', async () => {
|
||||
const request: RepoRequest = vi.fn(async (path) => {
|
||||
if (path === `commits/${HEAD_SHA}/pulls`) {
|
||||
return [{ number: 41 }, { number: 42 }]
|
||||
}
|
||||
const number = Number(path.replace('pulls/', ''))
|
||||
return {
|
||||
assignees: [{ login: 'maintainer', type: 'User' }],
|
||||
body: validBody(),
|
||||
number
|
||||
}
|
||||
})
|
||||
|
||||
const result = await validateMergeGroupPolicy(
|
||||
{ merge_group: { head_ref: 'unused', head_sha: HEAD_SHA } },
|
||||
request,
|
||||
vi.fn().mockResolvedValue(activeHuman())
|
||||
)
|
||||
|
||||
expect(result.valid).toBe(true)
|
||||
})
|
||||
|
||||
it('prefixes failures from any current PR in the group', async () => {
|
||||
const request: RepoRequest = vi.fn(async (path) => {
|
||||
if (path === `commits/${HEAD_SHA}/pulls`) {
|
||||
return [{ number: 41 }, { number: 42 }]
|
||||
}
|
||||
const number = Number(path.replace('pulls/', ''))
|
||||
return {
|
||||
assignees: [{ login: 'maintainer', type: 'User' }],
|
||||
body: number === 41 ? validBody() : '',
|
||||
number
|
||||
}
|
||||
})
|
||||
|
||||
const result = await validateMergeGroupPolicy(
|
||||
{ merge_group: { head_ref: 'unused', head_sha: HEAD_SHA } },
|
||||
request,
|
||||
vi.fn().mockResolvedValue(activeHuman())
|
||||
)
|
||||
|
||||
expect(result.valid).toBe(false)
|
||||
expect(result.errors.some((error) => error.startsWith('PR #42:'))).toBe(
|
||||
true
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('validate-merge-group CLI', () => {
|
||||
it('loads in Node before failing closed on missing setup', () => {
|
||||
const directory = mkdtempSync(join(tmpdir(), 'merge-group-policy-'))
|
||||
const eventPath = join(directory, 'event.json')
|
||||
const outputPath = join(directory, 'github-output.txt')
|
||||
writeFileSync(eventPath, JSON.stringify({ merge_group: {} }))
|
||||
|
||||
try {
|
||||
const result = spawnSync(
|
||||
process.execPath,
|
||||
['scripts/cicd/validate-merge-group.ts', eventPath],
|
||||
{
|
||||
cwd: process.cwd(),
|
||||
encoding: 'utf8',
|
||||
env: {
|
||||
...process.env,
|
||||
COMFY_ORG_MEMBERS_READ_TOKEN: '',
|
||||
GITHUB_OUTPUT: outputPath,
|
||||
GITHUB_REPOSITORY: '',
|
||||
GITHUB_TOKEN: ''
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
expect(result.status).toBe(1)
|
||||
expect(result.stderr).toContain(
|
||||
'::error::Event path, GITHUB_TOKEN, GITHUB_REPOSITORY, and COMFY_ORG_MEMBERS_READ_TOKEN are required.'
|
||||
)
|
||||
expect(result.stderr).not.toContain('ERR_MODULE_NOT_FOUND')
|
||||
expect(readFileSync(outputPath, 'utf8')).toBe('valid=false\n')
|
||||
} finally {
|
||||
rmSync(directory, { force: true, recursive: true })
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -1,202 +0,0 @@
|
||||
import { appendFileSync, readFileSync } from 'node:fs'
|
||||
import { pathToFileURL } from 'node:url'
|
||||
|
||||
import { validatePullRequestEvidenceBatch } from './validate-evidence.ts'
|
||||
import type {
|
||||
EvidenceValidation,
|
||||
PullRequestEvidencePayload,
|
||||
ScreencastProbe
|
||||
} from './validate-evidence.ts'
|
||||
import {
|
||||
createGitHubMembershipLookup,
|
||||
validatePullRequestMaintainers
|
||||
} from './validate-maintainer.ts'
|
||||
import type {
|
||||
MembershipLookup,
|
||||
PullRequestAssigneePayload
|
||||
} from './validate-maintainer.ts'
|
||||
|
||||
type MergeGroupEvent = {
|
||||
merge_group?: {
|
||||
head_ref?: string | null
|
||||
head_sha?: string | null
|
||||
} | null
|
||||
}
|
||||
|
||||
type PullRequestPayload = PullRequestEvidencePayload &
|
||||
PullRequestAssigneePayload & {
|
||||
number: number
|
||||
}
|
||||
|
||||
export type RepoRequest = (path: string) => Promise<unknown>
|
||||
|
||||
function pullRequestNumber(value: unknown): number | null {
|
||||
if (!value || typeof value !== 'object' || !('number' in value)) return null
|
||||
const number = value.number
|
||||
return typeof number === 'number' && Number.isInteger(number) && number > 0
|
||||
? number
|
||||
: null
|
||||
}
|
||||
|
||||
function currentPullRequest(value: unknown): PullRequestPayload {
|
||||
const number = pullRequestNumber(value)
|
||||
if (!number || !value || typeof value !== 'object') {
|
||||
throw new Error('GitHub returned malformed pull request metadata.')
|
||||
}
|
||||
|
||||
const body =
|
||||
'body' in value && typeof value.body === 'string' ? value.body : ''
|
||||
const assignees =
|
||||
'assignees' in value && Array.isArray(value.assignees)
|
||||
? value.assignees
|
||||
.filter(
|
||||
(assignee): assignee is { login?: string; type?: string } =>
|
||||
Boolean(assignee) && typeof assignee === 'object'
|
||||
)
|
||||
.map((assignee) => ({
|
||||
login:
|
||||
typeof assignee.login === 'string' ? assignee.login : undefined,
|
||||
type: typeof assignee.type === 'string' ? assignee.type : undefined
|
||||
}))
|
||||
: []
|
||||
|
||||
return { assignees, body, number }
|
||||
}
|
||||
|
||||
function fallbackPullRequestNumber(headRef: string): number | null {
|
||||
const match = headRef.match(/\/pr-(\d+)-[0-9a-f]{40}$/i)
|
||||
if (!match) return null
|
||||
const number = Number(match[1])
|
||||
return Number.isSafeInteger(number) && number > 0 ? number : null
|
||||
}
|
||||
|
||||
export async function loadMergeGroupPullRequests(
|
||||
event: MergeGroupEvent,
|
||||
request: RepoRequest
|
||||
): Promise<PullRequestPayload[]> {
|
||||
const headSha = event.merge_group?.head_sha?.trim() ?? ''
|
||||
const headRef = event.merge_group?.head_ref?.trim() ?? ''
|
||||
if (!/^[0-9a-f]{40}$/i.test(headSha)) {
|
||||
throw new Error('Merge group head SHA is missing or malformed.')
|
||||
}
|
||||
|
||||
const associated = await request(
|
||||
`commits/${encodeURIComponent(headSha)}/pulls`
|
||||
)
|
||||
if (!Array.isArray(associated)) {
|
||||
throw new Error('GitHub returned malformed associated pull request data.')
|
||||
}
|
||||
|
||||
let numbers: number[]
|
||||
if (associated.length > 0) {
|
||||
numbers = associated.map((pullRequest) => {
|
||||
const number = pullRequestNumber(pullRequest)
|
||||
if (!number) {
|
||||
throw new Error('GitHub returned a malformed associated pull request.')
|
||||
}
|
||||
return number
|
||||
})
|
||||
} else {
|
||||
const fallback = fallbackPullRequestNumber(headRef)
|
||||
if (!fallback) {
|
||||
throw new Error(
|
||||
'No pull request was associated with the merge group and head_ref did not identify one.'
|
||||
)
|
||||
}
|
||||
numbers = [fallback]
|
||||
}
|
||||
|
||||
const uniqueNumbers = [...new Set(numbers)]
|
||||
return Promise.all(
|
||||
uniqueNumbers.map(async (number) =>
|
||||
currentPullRequest(await request(`pulls/${number}`))
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
export async function validateMergeGroupPolicy(
|
||||
event: MergeGroupEvent,
|
||||
request: RepoRequest,
|
||||
membershipFor: MembershipLookup,
|
||||
screencastProbe?: ScreencastProbe
|
||||
): Promise<EvidenceValidation> {
|
||||
const pullRequests = await loadMergeGroupPullRequests(event, request)
|
||||
const [evidence, maintainers] = await Promise.all([
|
||||
validatePullRequestEvidenceBatch(pullRequests, screencastProbe),
|
||||
validatePullRequestMaintainers(pullRequests, membershipFor)
|
||||
])
|
||||
const errors = [...evidence.errors, ...maintainers.errors]
|
||||
return { errors, valid: errors.length === 0 }
|
||||
}
|
||||
|
||||
function createRepoRequest(repository: string, token: string): RepoRequest {
|
||||
const [owner, repo, ...extra] = repository.split('/')
|
||||
if (!owner || !repo || extra.length > 0) {
|
||||
throw new Error('GITHUB_REPOSITORY must be owner/repo.')
|
||||
}
|
||||
|
||||
return async (path): Promise<unknown> => {
|
||||
const response = await fetch(
|
||||
`https://api.github.com/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/${path}`,
|
||||
{
|
||||
headers: {
|
||||
Accept: 'application/vnd.github+json',
|
||||
Authorization: `Bearer ${token}`,
|
||||
'X-GitHub-Api-Version': '2026-03-10'
|
||||
},
|
||||
signal: AbortSignal.timeout(30_000)
|
||||
}
|
||||
)
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
`GitHub repository metadata lookup failed: ${response.status}`
|
||||
)
|
||||
}
|
||||
return response.json()
|
||||
}
|
||||
}
|
||||
|
||||
function writeOutput(valid: boolean): void {
|
||||
if (process.env.GITHUB_OUTPUT) {
|
||||
appendFileSync(process.env.GITHUB_OUTPUT, `valid=${valid}\n`)
|
||||
}
|
||||
}
|
||||
|
||||
async function runCli(): Promise<void> {
|
||||
const [eventPath] = process.argv.slice(2)
|
||||
const repoToken = process.env.GITHUB_TOKEN?.trim()
|
||||
const membershipToken = process.env.COMFY_ORG_MEMBERS_READ_TOKEN?.trim()
|
||||
const repository = process.env.GITHUB_REPOSITORY?.trim()
|
||||
if (!eventPath || !repoToken || !membershipToken || !repository) {
|
||||
throw new Error(
|
||||
'Event path, GITHUB_TOKEN, GITHUB_REPOSITORY, and COMFY_ORG_MEMBERS_READ_TOKEN are required.'
|
||||
)
|
||||
}
|
||||
|
||||
const event = JSON.parse(readFileSync(eventPath, 'utf8')) as MergeGroupEvent
|
||||
const result = await validateMergeGroupPolicy(
|
||||
event,
|
||||
createRepoRequest(repository, repoToken),
|
||||
createGitHubMembershipLookup(membershipToken)
|
||||
)
|
||||
writeOutput(result.valid)
|
||||
if (result.valid) {
|
||||
process.stdout.write('Merge group contribution policy satisfied.\n')
|
||||
return
|
||||
}
|
||||
|
||||
result.errors.forEach((error) => console.error(`::error::${error}`))
|
||||
process.exitCode = 1
|
||||
}
|
||||
|
||||
if (
|
||||
process.argv[1] &&
|
||||
import.meta.url === pathToFileURL(process.argv[1]).href
|
||||
) {
|
||||
runCli().catch((error: unknown) => {
|
||||
writeOutput(false)
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
console.error(`::error::${message}`)
|
||||
process.exitCode = 1
|
||||
})
|
||||
}
|
||||
120
scripts/critical-coverage/compareCriticalCoverage.ts
Normal file
120
scripts/critical-coverage/compareCriticalCoverage.ts
Normal file
@@ -0,0 +1,120 @@
|
||||
import { appendFileSync } from 'node:fs'
|
||||
|
||||
import {
|
||||
compareCriticalCoverageReports,
|
||||
readCriticalCoverageReport
|
||||
} from './criticalCoverageReport'
|
||||
import type { CriticalCoverageComparison } from './criticalCoverageReport'
|
||||
|
||||
interface Options {
|
||||
base: string
|
||||
head: string
|
||||
}
|
||||
|
||||
const options = parseOptions(process.argv.slice(2))
|
||||
const base = readCriticalCoverageReport(options.base)
|
||||
const head = readCriticalCoverageReport(options.head)
|
||||
const comparison = compareCriticalCoverageReports(base, head)
|
||||
const summary = formatComparison(comparison)
|
||||
|
||||
process.stdout.write(`${summary}\n`)
|
||||
|
||||
if (process.env.GITHUB_STEP_SUMMARY) {
|
||||
appendFileSync(process.env.GITHUB_STEP_SUMMARY, `${summary}\n`)
|
||||
}
|
||||
|
||||
if (comparison.commonBranches === 0) {
|
||||
process.stderr.write('No comparable critical unit branches found.\n')
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
if (comparison.coveredBranchDelta < 0) {
|
||||
process.stderr.write(
|
||||
`Critical unit coverage dropped by ${Math.abs(comparison.coveredBranchDelta)} covered branches on the shared branch set.\n`
|
||||
)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
function parseOptions(args: string[]): Options {
|
||||
const options: Partial<Options> = {}
|
||||
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
const arg = args[i]
|
||||
const next = args[i + 1]
|
||||
|
||||
if (arg === '--base' && next) {
|
||||
options.base = next
|
||||
i++
|
||||
} else if (arg.startsWith('--base=')) {
|
||||
options.base = arg.slice('--base='.length)
|
||||
} else if (arg === '--head' && next) {
|
||||
options.head = next
|
||||
i++
|
||||
} else if (arg.startsWith('--head=')) {
|
||||
options.head = arg.slice('--head='.length)
|
||||
}
|
||||
}
|
||||
|
||||
if (!options.base || !options.head) {
|
||||
throw new Error(
|
||||
'Usage: compareCriticalCoverage --base <json> --head <json>'
|
||||
)
|
||||
}
|
||||
|
||||
return {
|
||||
base: options.base,
|
||||
head: options.head
|
||||
}
|
||||
}
|
||||
|
||||
function formatComparison(comparison: CriticalCoverageComparison): string {
|
||||
const passed = comparison.coveredBranchDelta >= 0
|
||||
const lines = [
|
||||
`## Critical Unit Coverage Gate: ${passed ? 'PASS' : 'FAIL'}`,
|
||||
'',
|
||||
`Base tested commit: \`${comparison.baseSha}\``,
|
||||
`PR tested commit: \`${comparison.headSha}\``,
|
||||
'',
|
||||
'| Metric | Count |',
|
||||
'|---|--:|',
|
||||
`| Comparable critical branches | ${comparison.commonBranches} |`,
|
||||
`| Covered in base | ${comparison.commonCoveredBranchesInBase} |`,
|
||||
`| Covered in head | ${comparison.commonCoveredBranchesInHead} |`,
|
||||
`| Covered branch delta | ${formatSignedCount(comparison.coveredBranchDelta)} |`,
|
||||
`| Base-only branches | ${comparison.baseOnlyBranches} |`,
|
||||
`| Head-only branches | ${comparison.headOnlyBranches} |`,
|
||||
`| Covered-to-uncovered branches | ${comparison.regressions.length} |`,
|
||||
''
|
||||
]
|
||||
|
||||
if (passed) {
|
||||
lines.push('PASS: Critical branch coverage did not decrease.')
|
||||
return lines.join('\n')
|
||||
}
|
||||
|
||||
lines.push('| File | Line | Branch | Base | Head |')
|
||||
lines.push('|---|--:|---|--:|--:|')
|
||||
|
||||
for (const regression of comparison.regressions.slice(0, 25)) {
|
||||
lines.push(
|
||||
`| \`${regression.file}\` | ${regression.line} | ${regression.block}:${regression.branch} | ${formatTaken(regression.baseTaken)} | ${formatTaken(regression.headTaken)} |`
|
||||
)
|
||||
}
|
||||
|
||||
if (comparison.regressions.length > 25) {
|
||||
lines.push('')
|
||||
lines.push(
|
||||
`${comparison.regressions.length - 25} additional regressions omitted from this summary.`
|
||||
)
|
||||
}
|
||||
|
||||
return lines.join('\n')
|
||||
}
|
||||
|
||||
function formatTaken(value: number | null): string {
|
||||
return value === null ? '0' : String(value)
|
||||
}
|
||||
|
||||
function formatSignedCount(value: number): string {
|
||||
return value > 0 ? `+${value}` : String(value)
|
||||
}
|
||||
45
scripts/critical-coverage/criticalCoverageDirs.ts
Normal file
45
scripts/critical-coverage/criticalCoverageDirs.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
export const CRITICAL_COVERAGE_DIRS = [
|
||||
'src/base',
|
||||
'src/composables',
|
||||
'src/core',
|
||||
'src/lib/litegraph/src/node',
|
||||
'src/lib/litegraph/src/subgraph',
|
||||
'src/lib/litegraph/src/utils',
|
||||
'src/platform/assets/composables',
|
||||
'src/platform/assets/mappings',
|
||||
'src/platform/assets/schemas',
|
||||
'src/platform/assets/services',
|
||||
'src/platform/assets/utils',
|
||||
'src/platform/errorCatalog',
|
||||
'src/platform/keybindings',
|
||||
'src/platform/missingMedia',
|
||||
'src/platform/missingModel',
|
||||
'src/platform/navigation',
|
||||
'src/platform/nodeReplacement',
|
||||
'src/platform/remote',
|
||||
'src/platform/remoteConfig',
|
||||
'src/platform/secrets',
|
||||
'src/platform/settings',
|
||||
'src/platform/workflow',
|
||||
'src/platform/workspace/api',
|
||||
'src/platform/workspace/auth',
|
||||
'src/platform/workspace/composables',
|
||||
'src/platform/workspace/stores',
|
||||
'src/platform/workspace/utils',
|
||||
'src/schemas',
|
||||
'src/scripts',
|
||||
'src/services',
|
||||
'src/stores',
|
||||
'src/utils',
|
||||
'src/workbench/extensions/manager/composables',
|
||||
'src/workbench/extensions/manager/services',
|
||||
'src/workbench/extensions/manager/stores',
|
||||
'src/workbench/extensions/manager/utils',
|
||||
'src/workbench/utils'
|
||||
] as const
|
||||
|
||||
export function isCriticalCoveragePath(filePath: string): boolean {
|
||||
return CRITICAL_COVERAGE_DIRS.some(
|
||||
(dir) => filePath === dir || filePath.startsWith(`${dir}/`)
|
||||
)
|
||||
}
|
||||
322
scripts/critical-coverage/criticalCoverageReport.ts
Normal file
322
scripts/critical-coverage/criticalCoverageReport.ts
Normal file
@@ -0,0 +1,322 @@
|
||||
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'
|
||||
import { dirname, isAbsolute, relative } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
import {
|
||||
CRITICAL_COVERAGE_DIRS,
|
||||
isCriticalCoveragePath
|
||||
} from './criticalCoverageDirs'
|
||||
|
||||
export interface CriticalBranchCoverage {
|
||||
key: string
|
||||
file: string
|
||||
line: number
|
||||
block: string
|
||||
branch: string
|
||||
taken: number | null
|
||||
covered: boolean
|
||||
}
|
||||
|
||||
export interface CriticalCoverageReport {
|
||||
schemaVersion: 1
|
||||
source: 'lcov'
|
||||
sha: string
|
||||
generatedAt: string
|
||||
inputPath: string
|
||||
criticalDirs: readonly string[]
|
||||
totals: {
|
||||
files: number
|
||||
branches: number
|
||||
coveredBranches: number
|
||||
}
|
||||
branches: CriticalBranchCoverage[]
|
||||
}
|
||||
|
||||
export interface CriticalCoverageRegression extends CriticalBranchCoverage {
|
||||
baseTaken: number | null
|
||||
headTaken: number | null
|
||||
}
|
||||
|
||||
export interface CriticalCoverageComparison {
|
||||
baseSha: string
|
||||
headSha: string
|
||||
commonBranches: number
|
||||
baseOnlyBranches: number
|
||||
headOnlyBranches: number
|
||||
commonCoveredBranchesInBase: number
|
||||
commonCoveredBranchesInHead: number
|
||||
coveredBranchDelta: number
|
||||
regressions: CriticalCoverageRegression[]
|
||||
}
|
||||
|
||||
interface CreateReportOptions {
|
||||
inputPath: string
|
||||
sha: string
|
||||
generatedAt?: string
|
||||
cwd?: string
|
||||
}
|
||||
|
||||
export function createCriticalCoverageReport({
|
||||
inputPath,
|
||||
sha,
|
||||
generatedAt = new Date().toISOString(),
|
||||
cwd = process.cwd()
|
||||
}: CreateReportOptions): CriticalCoverageReport {
|
||||
const lcov = readFileSync(inputPath, 'utf-8')
|
||||
const branches = parseCriticalBranches(lcov, cwd)
|
||||
const files = new Set(branches.map((branch) => branch.file))
|
||||
const coveredBranches = branches.filter((branch) => branch.covered).length
|
||||
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
source: 'lcov',
|
||||
sha,
|
||||
generatedAt,
|
||||
inputPath,
|
||||
criticalDirs: CRITICAL_COVERAGE_DIRS,
|
||||
totals: {
|
||||
files: files.size,
|
||||
branches: branches.length,
|
||||
coveredBranches
|
||||
},
|
||||
branches
|
||||
}
|
||||
}
|
||||
|
||||
export function writeCriticalCoverageReport(
|
||||
report: CriticalCoverageReport,
|
||||
outputPath: string
|
||||
): void {
|
||||
mkdirSync(dirname(outputPath), { recursive: true })
|
||||
writeFileSync(outputPath, `${JSON.stringify(report, null, 2)}\n`)
|
||||
}
|
||||
|
||||
export function readCriticalCoverageReport(
|
||||
inputPath: string
|
||||
): CriticalCoverageReport {
|
||||
const parsed: unknown = JSON.parse(readFileSync(inputPath, 'utf-8'))
|
||||
|
||||
if (!isCriticalCoverageReport(parsed)) {
|
||||
throw new Error(`Invalid critical coverage report: ${inputPath}`)
|
||||
}
|
||||
|
||||
return parsed
|
||||
}
|
||||
|
||||
export function compareCriticalCoverageReports(
|
||||
base: CriticalCoverageReport,
|
||||
head: CriticalCoverageReport
|
||||
): CriticalCoverageComparison {
|
||||
const baseBranches = new Map(
|
||||
base.branches.map((branch) => [branch.key, branch])
|
||||
)
|
||||
const headBranches = new Map(
|
||||
head.branches.map((branch) => [branch.key, branch])
|
||||
)
|
||||
const regressions: CriticalCoverageRegression[] = []
|
||||
let commonBranches = 0
|
||||
let commonCoveredBranchesInBase = 0
|
||||
let commonCoveredBranchesInHead = 0
|
||||
let baseOnlyBranches = 0
|
||||
|
||||
for (const [key, baseBranch] of baseBranches) {
|
||||
const headBranch = headBranches.get(key)
|
||||
|
||||
if (!headBranch) {
|
||||
baseOnlyBranches++
|
||||
continue
|
||||
}
|
||||
|
||||
commonBranches++
|
||||
|
||||
if (baseBranch.covered) {
|
||||
commonCoveredBranchesInBase++
|
||||
}
|
||||
|
||||
if (headBranch.covered) {
|
||||
commonCoveredBranchesInHead++
|
||||
}
|
||||
|
||||
if (baseBranch.covered && !headBranch.covered) {
|
||||
regressions.push({
|
||||
...baseBranch,
|
||||
baseTaken: baseBranch.taken,
|
||||
headTaken: headBranch.taken
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
baseSha: base.sha,
|
||||
headSha: head.sha,
|
||||
commonBranches,
|
||||
baseOnlyBranches,
|
||||
headOnlyBranches: [...headBranches.keys()].filter(
|
||||
(key) => !baseBranches.has(key)
|
||||
).length,
|
||||
commonCoveredBranchesInBase,
|
||||
commonCoveredBranchesInHead,
|
||||
coveredBranchDelta:
|
||||
commonCoveredBranchesInHead - commonCoveredBranchesInBase,
|
||||
regressions: regressions.sort(compareBranches)
|
||||
}
|
||||
}
|
||||
|
||||
function parseCriticalBranches(
|
||||
lcov: string,
|
||||
cwd: string
|
||||
): CriticalBranchCoverage[] {
|
||||
let currentFile = ''
|
||||
const branches = new Map<string, CriticalBranchCoverage>()
|
||||
|
||||
for (const line of lcov.split('\n')) {
|
||||
if (line.startsWith('SF:')) {
|
||||
currentFile = normalizeCoveragePath(line.slice(3), cwd)
|
||||
continue
|
||||
}
|
||||
|
||||
if (!line.startsWith('BRDA:') || !isCriticalCoveragePath(currentFile)) {
|
||||
continue
|
||||
}
|
||||
|
||||
const branch = parseBranchData(currentFile, line.slice(5))
|
||||
if (!branch) {
|
||||
continue
|
||||
}
|
||||
|
||||
const existing = branches.get(branch.key)
|
||||
if (!existing) {
|
||||
branches.set(branch.key, branch)
|
||||
continue
|
||||
}
|
||||
|
||||
branches.set(branch.key, mergeBranchCoverage(existing, branch))
|
||||
}
|
||||
|
||||
return [...branches.values()].sort(compareBranches)
|
||||
}
|
||||
|
||||
function parseBranchData(
|
||||
file: string,
|
||||
data: string
|
||||
): CriticalBranchCoverage | null {
|
||||
const [lineValue, block, branch, takenValue] = data.split(',')
|
||||
const line = Number(lineValue)
|
||||
|
||||
if (
|
||||
!Number.isInteger(line) ||
|
||||
!block ||
|
||||
!branch ||
|
||||
takenValue === undefined
|
||||
) {
|
||||
return null
|
||||
}
|
||||
|
||||
const taken = takenValue === '-' ? null : Number(takenValue)
|
||||
const covered = taken !== null && Number.isFinite(taken) && taken > 0
|
||||
const key = `${file}:${line}:${block}:${branch}`
|
||||
|
||||
return {
|
||||
key,
|
||||
file,
|
||||
line,
|
||||
block,
|
||||
branch,
|
||||
taken: Number.isFinite(taken) ? taken : null,
|
||||
covered
|
||||
}
|
||||
}
|
||||
|
||||
function mergeBranchCoverage(
|
||||
left: CriticalBranchCoverage,
|
||||
right: CriticalBranchCoverage
|
||||
): CriticalBranchCoverage {
|
||||
const taken =
|
||||
left.taken === null || right.taken === null
|
||||
? null
|
||||
: left.taken + right.taken
|
||||
|
||||
return {
|
||||
...left,
|
||||
taken,
|
||||
covered: left.covered || right.covered
|
||||
}
|
||||
}
|
||||
|
||||
function compareBranches(
|
||||
left: CriticalBranchCoverage,
|
||||
right: CriticalBranchCoverage
|
||||
): number {
|
||||
return (
|
||||
left.file.localeCompare(right.file) ||
|
||||
left.line - right.line ||
|
||||
left.block.localeCompare(right.block) ||
|
||||
left.branch.localeCompare(right.branch)
|
||||
)
|
||||
}
|
||||
|
||||
function normalizeCoveragePath(filePath: string, cwd: string): string {
|
||||
const decodedPath = filePath.startsWith('file://')
|
||||
? fileURLToPath(filePath)
|
||||
: filePath
|
||||
|
||||
const relativePath = isAbsolute(decodedPath)
|
||||
? relative(cwd, decodedPath)
|
||||
: decodedPath
|
||||
|
||||
const normalizedPath = relativePath.replace(/\\/g, '/').replace(/^\.\//, '')
|
||||
|
||||
if (!normalizedPath.startsWith('../')) {
|
||||
return normalizedPath
|
||||
}
|
||||
|
||||
const srcIndex = normalizedPath.indexOf('/src/')
|
||||
return srcIndex === -1 ? normalizedPath : normalizedPath.slice(srcIndex + 1)
|
||||
}
|
||||
|
||||
function isCriticalCoverageReport(
|
||||
value: unknown
|
||||
): value is CriticalCoverageReport {
|
||||
if (!isRecord(value)) {
|
||||
return false
|
||||
}
|
||||
|
||||
const totals = value.totals
|
||||
|
||||
return (
|
||||
value.schemaVersion === 1 &&
|
||||
value.source === 'lcov' &&
|
||||
typeof value.sha === 'string' &&
|
||||
typeof value.generatedAt === 'string' &&
|
||||
typeof value.inputPath === 'string' &&
|
||||
Array.isArray(value.criticalDirs) &&
|
||||
isRecord(totals) &&
|
||||
typeof totals.files === 'number' &&
|
||||
typeof totals.branches === 'number' &&
|
||||
typeof totals.coveredBranches === 'number' &&
|
||||
Array.isArray(value.branches) &&
|
||||
value.branches.every(isCriticalBranchCoverage)
|
||||
)
|
||||
}
|
||||
|
||||
function isCriticalBranchCoverage(
|
||||
value: unknown
|
||||
): value is CriticalBranchCoverage {
|
||||
if (!isRecord(value)) {
|
||||
return false
|
||||
}
|
||||
|
||||
return (
|
||||
typeof value.key === 'string' &&
|
||||
typeof value.file === 'string' &&
|
||||
typeof value.line === 'number' &&
|
||||
typeof value.block === 'string' &&
|
||||
typeof value.branch === 'string' &&
|
||||
(typeof value.taken === 'number' || value.taken === null) &&
|
||||
typeof value.covered === 'boolean'
|
||||
)
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null
|
||||
}
|
||||
58
scripts/critical-coverage/extractCriticalCoverage.ts
Normal file
58
scripts/critical-coverage/extractCriticalCoverage.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import {
|
||||
createCriticalCoverageReport,
|
||||
writeCriticalCoverageReport
|
||||
} from './criticalCoverageReport'
|
||||
|
||||
interface Options {
|
||||
input: string
|
||||
output: string
|
||||
sha: string
|
||||
}
|
||||
|
||||
const options = parseOptions(process.argv.slice(2))
|
||||
const report = createCriticalCoverageReport({
|
||||
inputPath: options.input,
|
||||
sha: options.sha
|
||||
})
|
||||
|
||||
writeCriticalCoverageReport(report, options.output)
|
||||
|
||||
process.stdout.write(
|
||||
[
|
||||
`Critical coverage branches: ${report.totals.coveredBranches}/${report.totals.branches}`,
|
||||
`Critical coverage files: ${report.totals.files}`,
|
||||
`Wrote ${options.output}`
|
||||
].join('\n') + '\n'
|
||||
)
|
||||
|
||||
function parseOptions(args: string[]): Options {
|
||||
const options: Options = {
|
||||
input: 'coverage/lcov.info',
|
||||
output: 'coverage/critical-unit-coverage.json',
|
||||
sha: process.env.GITHUB_SHA ?? 'unknown'
|
||||
}
|
||||
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
const arg = args[i]
|
||||
const next = args[i + 1]
|
||||
|
||||
if (arg === '--input' && next) {
|
||||
options.input = next
|
||||
i++
|
||||
} else if (arg.startsWith('--input=')) {
|
||||
options.input = arg.slice('--input='.length)
|
||||
} else if (arg === '--output' && next) {
|
||||
options.output = next
|
||||
i++
|
||||
} else if (arg.startsWith('--output=')) {
|
||||
options.output = arg.slice('--output='.length)
|
||||
} else if (arg === '--sha' && next) {
|
||||
options.sha = next
|
||||
i++
|
||||
} else if (arg.startsWith('--sha=')) {
|
||||
options.sha = arg.slice('--sha='.length)
|
||||
}
|
||||
}
|
||||
|
||||
return options
|
||||
}
|
||||
@@ -4,67 +4,37 @@
|
||||
data-testid="bounding-boxes"
|
||||
@pointerdown.stop
|
||||
>
|
||||
<div class="flex flex-col">
|
||||
<div
|
||||
class="flex h-9 items-center gap-1 rounded-t-sm border border-b-0 border-component-node-border bg-component-node-widget-background px-2"
|
||||
>
|
||||
<Button
|
||||
variant="textonly"
|
||||
size="unset"
|
||||
:aria-pressed="grid"
|
||||
:class="
|
||||
cn(
|
||||
actionBtnClass,
|
||||
grid && 'bg-component-node-widget-background-selected'
|
||||
)
|
||||
"
|
||||
@click="grid = !grid"
|
||||
>
|
||||
<i class="icon-[lucide--grid-3x3] size-4" />
|
||||
<span>{{ $t('boundingBoxes.grid') }}</span>
|
||||
</Button>
|
||||
<Button
|
||||
variant="textonly"
|
||||
size="unset"
|
||||
:class="cn(actionBtnClass, 'ml-auto')"
|
||||
@click="clearAll"
|
||||
>
|
||||
<i class="icon-[lucide--undo-2] size-4" />
|
||||
<span>{{ $t('boundingBoxes.clearAll') }}</span>
|
||||
</Button>
|
||||
</div>
|
||||
<div
|
||||
ref="canvasContainer"
|
||||
class="relative w-full shrink-0 overflow-hidden rounded-b-sm border border-t-0 border-component-node-border bg-base-background"
|
||||
:style="canvasStyle"
|
||||
>
|
||||
<canvas
|
||||
ref="canvasEl"
|
||||
tabindex="0"
|
||||
class="absolute inset-0 size-full rounded-sm text-node-component-slot-text outline-none"
|
||||
:style="{ cursor: canvasCursor }"
|
||||
@pointerdown="onPointerDown"
|
||||
@pointermove="onCanvasPointerMove"
|
||||
@pointerup="onDocPointerUp"
|
||||
@pointercancel="onDocPointerUp"
|
||||
@pointerleave="onPointerLeave"
|
||||
@lostpointercapture="onDocPointerUp"
|
||||
@dblclick="onDoubleClick"
|
||||
@keydown="onCanvasKeyDown"
|
||||
@focus="focused = true"
|
||||
@blur="focused = false"
|
||||
/>
|
||||
<textarea
|
||||
v-if="inlineEditor"
|
||||
ref="inlineEditorEl"
|
||||
v-model="inlineEditor.value"
|
||||
class="absolute box-border resize-none rounded-sm border-2 bg-black/90 p-1 font-mono text-xs text-white outline-none"
|
||||
:style="inlineEditor.style"
|
||||
data-capture-wheel="true"
|
||||
@keydown.stop="onInlineKeyDown"
|
||||
@blur="commitInlineEditor"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
ref="canvasContainer"
|
||||
class="relative w-full shrink-0 overflow-hidden rounded-sm border border-component-node-border bg-node-component-surface"
|
||||
:style="canvasStyle"
|
||||
>
|
||||
<canvas
|
||||
ref="canvasEl"
|
||||
tabindex="0"
|
||||
class="absolute inset-0 size-full rounded-sm outline-none"
|
||||
:style="{ cursor: canvasCursor }"
|
||||
@pointerdown="onPointerDown"
|
||||
@pointermove="onCanvasPointerMove"
|
||||
@pointerup="onDocPointerUp"
|
||||
@pointercancel="onDocPointerUp"
|
||||
@pointerleave="onPointerLeave"
|
||||
@lostpointercapture="onDocPointerUp"
|
||||
@dblclick="onDoubleClick"
|
||||
@keydown="onCanvasKeyDown"
|
||||
@focus="focused = true"
|
||||
@blur="focused = false"
|
||||
/>
|
||||
<textarea
|
||||
v-if="inlineEditor"
|
||||
ref="inlineEditorEl"
|
||||
v-model="inlineEditor.value"
|
||||
class="absolute box-border resize-none rounded-sm border-2 bg-black/90 p-1 font-mono text-xs text-white outline-none"
|
||||
:style="inlineEditor.style"
|
||||
data-capture-wheel="true"
|
||||
@keydown.stop="onInlineKeyDown"
|
||||
@blur="commitInlineEditor"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
@@ -152,6 +122,16 @@
|
||||
<div v-else-if="hasRegions" class="text-node-text-muted px-1 text-xs">
|
||||
{{ $t('boundingBoxes.clickRegionToEdit') }}
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="md"
|
||||
class="gap-2 rounded-lg border border-component-node-border bg-component-node-background text-xs text-muted-foreground hover:text-base-foreground"
|
||||
@click="clearAll"
|
||||
>
|
||||
<i class="icon-[lucide--undo-2]" />
|
||||
{{ $t('boundingBoxes.clearAll') }}
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -167,9 +147,6 @@ import { useBoundingBoxes } from '@/composables/boundingBoxes/useBoundingBoxes'
|
||||
import type { BoundingBox } from '@/types/boundingBoxes'
|
||||
import type { NodeId } from '@/types/nodeId'
|
||||
|
||||
const actionBtnClass =
|
||||
'flex shrink-0 items-center gap-1.5 rounded-md border-0 bg-transparent px-2 py-1 text-sm text-base-foreground outline-none transition-colors hover:bg-component-node-widget-background-hovered'
|
||||
|
||||
const { nodeId } = defineProps<{ nodeId: NodeId }>()
|
||||
const modelValue = defineModel<BoundingBox[]>({ default: () => [] })
|
||||
|
||||
@@ -195,8 +172,7 @@ const {
|
||||
commitInlineEditor,
|
||||
setActiveType,
|
||||
clearAll,
|
||||
syncState,
|
||||
grid
|
||||
syncState
|
||||
} = useBoundingBoxes(nodeId, {
|
||||
canvasEl,
|
||||
canvasContainer,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user