Compare commits

..

2 Commits

Author SHA1 Message Date
huang47
215550ae3d fix: keep Vitest guidance out of Playwright reviews 2026-07-10 23:10:49 -07:00
huang47
8edfe454eb ci: route reviews to repository guidance 2026-07-10 10:47:55 -07:00
18 changed files with 184 additions and 2334 deletions

View File

@@ -65,12 +65,39 @@ reviews:
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: '**/*.ts'
instructions: |
Treat `docs/guidance/typescript.md` as required review context.
- path: '**/*.vue'
instructions: |
Treat `docs/guidance/typescript.md` and
`docs/guidance/vue-components.md` as required review context. For
changed components or views under `src/components/` or `src/views/`,
also apply `docs/guidance/design-standards.md` and assess accessibility.
- path: '**/*.stories.ts'
instructions: |
Treat `docs/guidance/storybook.md` as required review context.
- path: 'src/lib/litegraph/**'
instructions: |
Treat `docs/adr/README.md` as required review context. For widget
serialization changes, also read
`docs/WIDGET_SERIALIZATION.md`.
- 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.
Treat `.agents/checks/test-quality.md`, `docs/testing/README.md`,
`docs/guidance/vitest.md`, and `docs/testing/vitest-patterns.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.
Treat `.agents/checks/test-quality.md`, `docs/testing/README.md`,
`docs/guidance/vitest.md`, `docs/testing/vitest-patterns.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.
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. For
`browser_tests/`, also read `browser_tests/README.md` and
`browser_tests/AGENTS.md`, and apply
`.agents/checks/playwright-e2e.md`.

View File

@@ -8,25 +8,17 @@ 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 matching run exists after polling
description: Status to output when no run exists
required: false
default: pending
wait-seconds:
description: Maximum time to poll for a matching successful run
required: false
default: '0'
token:
description: GitHub token for API access
required: true
outputs:
status:
description: One of 'ready', 'pending', 'failed', or the configured not-found status
description: One of 'ready', 'pending', 'failed', or the not-found-status value
value: ${{ steps.find.outputs.status }}
run-id:
description: The workflow run ID (only set when status is 'ready')
@@ -41,94 +33,33 @@ runs:
env:
WORKFLOW_ID: ${{ inputs.workflow-id }}
HEAD_SHA: ${{ inputs.head-sha }}
EVENT_NAME: ${{ inputs.event }}
NOT_FOUND_STATUS: ${{ inputs.not-found-status }}
WAIT_SECONDS: ${{ inputs.wait-seconds }}
with:
github-token: ${{ inputs.token }}
script: |
const waitSeconds = Number(process.env.WAIT_SECONDS);
if (!Number.isSafeInteger(waitSeconds) || waitSeconds < 0) {
core.setFailed('wait-seconds must be a non-negative integer');
const { data: runs } = await github.rest.actions.listWorkflowRuns({
owner: context.repo.owner,
repo: context.repo.repo,
workflow_id: process.env.WORKFLOW_ID,
head_sha: process.env.HEAD_SHA,
per_page: 1,
});
const run = runs.workflow_runs[0];
if (!run) {
core.setOutput('status', process.env.NOT_FOUND_STATUS);
return;
}
const deadline = Date.now() + waitSeconds * 1000;
while (true) {
let run;
let response;
let requestFailed = false;
try {
response = await github.rest.actions.listWorkflowRuns({
owner: context.repo.owner,
repo: context.repo.repo,
workflow_id: process.env.WORKFLOW_ID,
head_sha: process.env.HEAD_SHA,
event: process.env.EVENT_NAME || undefined,
per_page: 10,
});
} catch (error) {
const status = error?.status;
const retryable =
typeof status !== 'number' ||
status === 408 ||
status === 429 ||
status >= 500;
if (!retryable) {
core.setFailed(
`listWorkflowRuns failed with status ${status}: ${String(error)}`
);
return;
}
requestFailed = true;
core.warning(`listWorkflowRuns failed: ${String(error)}`);
}
if (response) {
const workflowRuns = response.data.workflow_runs;
const successfulRun = workflowRuns.find(
({ status, conclusion }) =>
status === 'completed' && conclusion === 'success'
);
if (successfulRun) {
core.setOutput('status', 'ready');
core.setOutput('run-id', String(successfulRun.id));
return;
}
run =
workflowRuns.find(({ status }) => status !== 'completed') ??
workflowRuns[0];
}
if (run?.status === 'completed') {
if (run.conclusion === 'success') {
core.setOutput('status', 'ready');
core.setOutput('run-id', String(run.id));
} else {
core.setOutput('status', 'failed');
}
return;
}
const remainingMilliseconds = deadline - Date.now();
if (remainingMilliseconds <= 0) {
core.setOutput(
'status',
requestFailed
? 'failed'
: run
? 'pending'
: process.env.NOT_FOUND_STATUS
);
return;
}
await new Promise((resolve) =>
setTimeout(resolve, Math.min(15_000, remainingMilliseconds))
);
if (run.status !== 'completed') {
core.setOutput('status', 'pending');
return;
}
if (run.conclusion !== 'success') {
core.setOutput('status', 'failed');
return;
}
core.setOutput('status', 'ready');
core.setOutput('run-id', String(run.id));

View File

@@ -9,8 +9,8 @@ on:
merge_group:
concurrency:
group: ${{ github.workflow }}-${{ github.event_name == 'push' && github.sha || github.ref }}
cancel-in-progress: ${{ github.event_name != 'push' }}
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
changes:
@@ -38,19 +38,14 @@ jobs:
- name: Run Vitest tests with coverage
run: pnpm test:coverage
- name: Extract critical unit coverage
run: pnpm coverage:critical:extract --sha="${{ github.sha }}"
- name: Upload unit coverage artifact
if: always() && github.event_name != 'merge_group'
if: always() && github.event_name == 'push'
uses: actions/upload-artifact@v6
with:
name: unit-coverage
path: |
coverage/lcov.info
coverage/critical-unit-coverage.json
path: coverage/lcov.info
retention-days: 30
if-no-files-found: error
if-no-files-found: warn
- name: Upload coverage to Codecov
if: always()
@@ -60,111 +55,3 @@ 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' &&
(
github.base_ref == 'main' ||
github.base_ref == 'master' ||
startsWith(github.base_ref, 'dev') ||
startsWith(github.base_ref, 'core/') ||
startsWith(github.base_ref, 'desktop/')
)
}}
runs-on: ubuntu-latest
timeout-minutes: 30
permissions:
actions: read
contents: read
steps:
- uses: actions/checkout@v6
with:
fetch-depth: 2
- name: Resolve tested base commit
id: tested-base
run: echo "sha=$(git rev-parse HEAD^1)" >> "$GITHUB_OUTPUT"
- name: Setup frontend
uses: ./.github/actions/setup-frontend
- name: Download head unit coverage
uses: actions/download-artifact@v7
with:
name: unit-coverage
path: temp/head-unit-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: ${{ steps.tested-base.outputs.sha }}
event: push
wait-seconds: 600
token: ${{ secrets.GITHUB_TOKEN }}
- name: Download base unit coverage
id: download-base-unit
if: steps.find-base-unit.outputs.status == 'ready'
continue-on-error: true
uses: actions/download-artifact@v7
with:
name: unit-coverage
run-id: ${{ steps.find-base-unit.outputs.run-id }}
github-token: ${{ secrets.GITHUB_TOKEN }}
path: temp/base-unit-coverage
- name: Regenerate missing base unit coverage
if: steps.find-base-unit.outputs.status != 'ready' || steps.download-base-unit.outcome != 'success'
env:
BASE_SHA: ${{ steps.tested-base.outputs.sha }}
run: |
base_dir="$RUNNER_TEMP/coverage-base"
cleanup() {
git worktree remove --force "$base_dir" || true
}
trap cleanup EXIT
git worktree add "$base_dir" "$BASE_SHA"
(
cd "$base_dir"
pnpm install --frozen-lockfile
pnpm test:coverage
if [ -f scripts/critical-coverage/extractCriticalCoverage.ts ]; then
pnpm coverage:critical:extract --sha="$BASE_SHA"
fi
)
mkdir -p temp/base-unit-coverage
cp "$base_dir/coverage/lcov.info" temp/base-unit-coverage/lcov.info
if [ -f "$base_dir/coverage/critical-unit-coverage.json" ]; then
cp "$base_dir/coverage/critical-unit-coverage.json" \
temp/base-unit-coverage/
fi
- name: Prepare critical unit coverage reports
env:
BASE_SHA: ${{ steps.tested-base.outputs.sha }}
run: |
mkdir -p temp/base-critical-coverage temp/head-critical-coverage
if [ -f temp/base-unit-coverage/critical-unit-coverage.json ]; then
cp temp/base-unit-coverage/critical-unit-coverage.json \
temp/base-critical-coverage/
else
pnpm coverage:critical:extract \
--input temp/base-unit-coverage/lcov.info \
--output temp/base-critical-coverage/critical-unit-coverage.json \
--sha="$BASE_SHA"
fi
cp temp/head-unit-coverage/critical-unit-coverage.json \
temp/head-critical-coverage/
- 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

View File

@@ -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"

View File

@@ -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::"
@@ -401,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>
@@ -437,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
@@ -483,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

View File

@@ -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'] }, () => {

View File

@@ -19,8 +19,6 @@
"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",

View File

@@ -1,494 +0,0 @@
import { execFileSync, spawnSync } from 'node:child_process'
import { createRequire } from 'node:module'
import {
mkdirSync,
mkdtempSync,
readFileSync,
rmSync,
writeFileSync
} from 'node:fs'
import { tmpdir } from 'node:os'
import { dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
import { describe, expect, it, onTestFinished } from 'vitest'
import { CRITICAL_COVERAGE_DIRS } from './criticalCoverageDirs'
import {
readCriticalCoverageReport,
writeCriticalCoverageReport
} from './criticalCoverageReport'
import type {
CriticalBranchCoverage,
CriticalCoverageReport
} from './criticalCoverageReport'
const require = createRequire(import.meta.url)
const TSX_CLI = require.resolve('tsx/cli')
const SCRIPT_PATH = join(
dirname(fileURLToPath(import.meta.url)),
'compareCriticalCoverage.ts'
)
interface SourceChange {
base: string
head: string
headPath?: string
}
const BASE_SHA = 'a'.repeat(40)
const HEAD_SHA = 'b'.repeat(40)
describe('compareCriticalCoverage CLI', () => {
it('passes a non-negative delta and writes the job summary', () => {
const directory = createTempDirectory()
const summaryPath = join(directory, 'summary.md')
const shared = createBranch('src/stores/a.ts', true)
const paths = writeReports(
directory,
createReport(BASE_SHA, [shared]),
createReport(HEAD_SHA, [shared])
)
const result = runComparison(paths, { GITHUB_STEP_SUMMARY: summaryPath })
expect(result.status).toBe(0)
const summary = readFileSync(summaryPath, 'utf-8')
expect(summary).toContain('Critical Unit Coverage Gate: PASS')
expect(summary).toContain('| Covered branch delta | 0 |')
})
it('fails a negative delta and lists the regressed branch', () => {
const directory = createTempDirectory()
const covered = createBranch('src/stores/a.ts', true)
const paths = writeReports(
directory,
createReport(BASE_SHA, [covered]),
createReport(HEAD_SHA, [{ ...covered, taken: 0, covered: false }])
)
const result = runComparison(paths)
expect(result.status).toBe(1)
expect(result.stdout).toContain('Critical Unit Coverage Gate: FAIL')
expect(result.stdout).toContain('| Covered branch delta | -1 |')
expect(result.stdout).toContain('| `src/stores/a.ts` | 1 | 0:0 | 1 | 0 |')
expect(result.stderr).toContain('regressed on 1 previously covered branch')
})
it('renders an unknown taken count as unavailable', () => {
const directory = createTempDirectory()
const covered = createBranch('src/stores/a.ts', true)
const paths = writeReports(
directory,
createReport(BASE_SHA, [covered]),
createReport(HEAD_SHA, [{ ...covered, taken: null, covered: false }])
)
const result = runComparison(paths)
expect(result.status).toBe(1)
expect(result.stdout).toContain('| `src/stores/a.ts` | 1 | 0:0 | 1 | - |')
})
it('fails when newly covered branches offset a regression', () => {
const directory = createTempDirectory()
const regressed = createBranch('src/stores/regressed.ts', true)
const improved = createBranch('src/stores/improved.ts', false)
const paths = writeReports(
directory,
createReport(BASE_SHA, [regressed, improved]),
createReport(HEAD_SHA, [
{ ...regressed, taken: 0, covered: false },
{ ...improved, taken: 1, covered: true }
])
)
const result = runComparison(paths)
expect(result.status).toBe(1)
expect(result.stdout).toContain('| Covered branch delta | 0 |')
expect(result.stderr).toContain('regressed on 1 previously covered branch')
})
it('fails when a covered branch disappears from unchanged source', () => {
const directory = createTempDirectory()
const shared = createBranch('src/stores/shared.ts', true)
const missing = createBranch('src/stores/missing.ts', true)
const paths = writeReports(
directory,
createReport(BASE_SHA, [shared, missing]),
createReport(HEAD_SHA, [shared])
)
const result = runComparison(paths)
expect(result.status).toBe(1)
expect(result.stderr).toContain('regressed on 1 previously covered branch')
expect(result.stdout).toContain(
'| `src/stores/missing.ts` | 1 | 0:0 | 1 | - |'
)
})
it('ignores a covered branch on a changed source line', () => {
const directory = createTempDirectory()
const file = 'src/stores/changed.ts'
const shared = createBranch('src/stores/shared.ts', true)
const paths = writeReports(
directory,
createReport(BASE_SHA, [shared, createBranch(file, true)]),
createReport(HEAD_SHA, [shared])
)
const result = runComparison(
paths,
{},
{
[file]: {
base: 'export const changed = value ? 1 : 0\n',
head: 'export const changed = value ?? 0\n'
}
}
)
expect(result.status).toBe(0)
expect(result.stdout).toContain('| Base-only branches | 1 |')
})
it('fails when a covered branch moves and becomes uncovered', () => {
const directory = createTempDirectory()
const shared = createBranch('src/stores/shared.ts', true)
const moved = createBranch('src/stores/moved.ts', true)
const paths = writeReports(
directory,
createReport(BASE_SHA, [shared, moved]),
createReport(HEAD_SHA, [
shared,
{
...moved,
key: 'src/stores/moved.ts:2:0:0',
line: 2,
taken: 0,
covered: false
}
])
)
const result = runComparison(
paths,
{},
{
'src/stores/moved.ts': {
base: 'export const moved = value ? 1 : 0\n',
head: '\nexport const moved = value ? 1 : 0\n'
}
}
)
expect(result.status).toBe(1)
expect(result.stderr).toContain('regressed on 1 previously covered branch')
})
it('fails when an inserted branch renumbers later LCOV blocks', () => {
const directory = createTempDirectory()
const file = 'src/stores/renumbered.ts'
const target = createBranch(file, true)
const inserted = createBranch(file, true)
const paths = writeReports(
directory,
createReport(BASE_SHA, [target]),
createReport(HEAD_SHA, [
inserted,
{
...target,
key: `${file}:2:1:0`,
line: 2,
block: '1',
taken: 0,
covered: false
}
])
)
const result = runComparison(
paths,
{},
{
[file]: {
base: 'export const target = value ? 1 : 0\n',
head: 'export const inserted = other ? 1 : 0\nexport const target = value ? 1 : 0\n'
}
}
)
expect(result.status).toBe(1)
expect(result.stderr).toContain('regressed on 1 previously covered branch')
})
it('fails when same-line branch ordinals cross a digit boundary', () => {
const directory = createTempDirectory()
const file = 'src/stores/doubleDigit.ts'
const baseCovered = createBranch(file, true, '9')
const baseUncovered = createBranch(file, false, '10')
const headUncovered = createBranch(file, false, '10')
const headCovered = createBranch(file, true, '11')
const paths = writeReports(
directory,
createReport(BASE_SHA, [baseCovered, baseUncovered]),
createReport(HEAD_SHA, [headUncovered, headCovered])
)
const result = runComparison(paths)
expect(result.status).toBe(1)
expect(result.stderr).toContain('regressed on 1 previously covered branch')
})
it('fails when a renamed critical file loses branch coverage', () => {
const directory = createTempDirectory()
const baseFile = 'src/stores/renamedBase.ts'
const headFile = 'src/stores/renamedHead.ts'
const shared = createBranch('src/stores/shared.ts', true)
const baseBranch = createBranch(baseFile, true)
const headBranch = createBranch(headFile, false)
const paths = writeReports(
directory,
createReport(BASE_SHA, [shared, baseBranch]),
createReport(HEAD_SHA, [shared, headBranch])
)
const source = 'export const renamed = value ? 1 : 0\n'
const result = runComparison(
paths,
{},
{
[baseFile]: { base: source, head: source, headPath: headFile }
}
)
expect(result.status).toBe(1)
expect(result.stderr).toContain('regressed on 1 previously covered branch')
})
it('fails when the reports have no comparable branches', () => {
const directory = createTempDirectory()
const summaryPath = join(directory, 'summary.md')
const paths = writeReports(
directory,
createReport(BASE_SHA, [createBranch('src/stores/base.ts', true)]),
createReport(HEAD_SHA, [createBranch('src/stores/head.ts', true)])
)
const result = runComparison(paths, { GITHUB_STEP_SUMMARY: summaryPath })
expect(result.status).toBe(1)
expect(result.stderr).toContain(
'No comparable critical unit branches found (base: 1, head: 1)'
)
expect(readFileSync(summaryPath, 'utf-8')).toContain(
'Critical Unit Coverage Gate: FAIL'
)
})
it('fails when the head removes a critical directory', () => {
const directory = createTempDirectory()
const shared = createBranch('src/stores/shared.ts', true)
const paths = writeReports(
directory,
createReport(BASE_SHA, [shared], ['src/stores', 'src/utils']),
createReport(HEAD_SHA, [shared], ['src/stores'])
)
const result = runComparison(paths)
expect(result.status).toBe(1)
expect(result.stderr).toContain(
'Critical coverage scope removed: src/utils'
)
})
it('allows the head to add a critical directory', () => {
const directory = createTempDirectory()
const shared = createBranch('src/stores/shared.ts', true)
const paths = writeReports(
directory,
createReport(BASE_SHA, [shared], ['src/stores']),
createReport(HEAD_SHA, [shared], ['src/stores', 'src/utils'])
)
expect(runComparison(paths).status).toBe(0)
})
it('reports input errors without a stack trace', () => {
const directory = createTempDirectory()
const summaryPath = join(directory, 'summary.md')
const missingPath = join(directory, 'missing.json')
const result = spawnSync(
process.execPath,
[TSX_CLI, SCRIPT_PATH, '--base', missingPath, '--head', missingPath],
{
cwd: directory,
encoding: 'utf-8',
env: { ...process.env, GITHUB_STEP_SUMMARY: summaryPath }
}
)
expect(result.status).toBe(1)
expect(result.stderr).toContain(`ENOENT: no such file or directory`)
expect(result.stderr).not.toContain(' at ')
expect(readFileSync(summaryPath, 'utf-8')).toContain(
'Critical Unit Coverage Gate: ERROR'
)
})
it('rejects unknown options', () => {
const result = spawnSync(
process.execPath,
[TSX_CLI, SCRIPT_PATH, '--unknown'],
{ encoding: 'utf-8' }
)
expect(result.status).toBe(1)
expect(result.stderr).toContain('--unknown')
expect(result.stderr).not.toContain(' at ')
})
})
function runComparison(
paths: { basePath: string; headPath: string },
env: NodeJS.ProcessEnv = {},
sourceChanges?: Record<string, SourceChange>
): ReturnType<typeof spawnSync> {
const cwd = dirname(paths.basePath)
const { baseSha, headSha } = createGitHistory(cwd, sourceChanges ?? {})
updateReportSha(paths.basePath, baseSha)
updateReportSha(paths.headPath, headSha)
return spawnSync(
process.execPath,
[TSX_CLI, SCRIPT_PATH, '--base', paths.basePath, '--head', paths.headPath],
{
cwd,
encoding: 'utf-8',
env: { ...process.env, ...env }
}
)
}
function createGitHistory(
directory: string,
sourceChanges: Record<string, SourceChange>
): { baseSha: string; headSha: string } {
execFileSync('git', ['init', '-q'], { cwd: directory })
execFileSync('git', ['config', 'user.email', 'test@example.com'], {
cwd: directory
})
execFileSync('git', ['config', 'user.name', 'Test'], { cwd: directory })
writeFileSync(join(directory, 'fixture.txt'), 'base\n')
for (const [path, change] of Object.entries(sourceChanges)) {
const absolutePath = join(directory, path)
mkdirSync(dirname(absolutePath), { recursive: true })
writeFileSync(absolutePath, change.base)
}
execFileSync('git', ['add', 'fixture.txt', ...Object.keys(sourceChanges)], {
cwd: directory
})
execFileSync('git', ['commit', '-q', '-m', 'base'], { cwd: directory })
const baseSha = gitSha(directory)
for (const [path, change] of Object.entries(sourceChanges)) {
const headPath = change.headPath ?? path
if (headPath !== path) {
mkdirSync(dirname(join(directory, headPath)), { recursive: true })
execFileSync('git', ['mv', path, headPath], { cwd: directory })
}
writeFileSync(join(directory, headPath), change.head)
}
if (Object.keys(sourceChanges).length > 0) {
const changedPaths = Object.entries(sourceChanges).map(
([path, change]) => change.headPath ?? path
)
execFileSync('git', ['add', '-A', '--', ...changedPaths], {
cwd: directory
})
}
execFileSync('git', ['commit', '-q', '--allow-empty', '-m', 'head'], {
cwd: directory
})
return { baseSha, headSha: gitSha(directory) }
}
function updateReportSha(path: string, sha: string): void {
const report = readCriticalCoverageReport(path)
writeCriticalCoverageReport({ ...report, sha }, path)
}
function gitSha(directory: string): string {
return execFileSync('git', ['rev-parse', 'HEAD'], {
cwd: directory,
encoding: 'utf-8'
}).trim()
}
function writeReports(
directory: string,
base: CriticalCoverageReport,
head: CriticalCoverageReport
): { basePath: string; headPath: string } {
const basePath = join(directory, 'base.json')
const headPath = join(directory, 'head.json')
writeCriticalCoverageReport(base, basePath)
writeCriticalCoverageReport(head, headPath)
return { basePath, headPath }
}
function createReport(
sha: string,
branches: CriticalBranchCoverage[],
criticalDirs: readonly string[] = CRITICAL_COVERAGE_DIRS
): CriticalCoverageReport {
return {
schemaVersion: 1,
source: 'lcov',
sha,
generatedAt: '2026-07-10T00:00:00.000Z',
inputPath: 'lcov.info',
criticalDirs,
totals: {
files: new Set(branches.map(({ file }) => file)).size,
branches: branches.length,
coveredBranches: branches.filter(({ covered }) => covered).length
},
branches
}
}
function createBranch(
file: string,
covered: boolean,
block = '0'
): CriticalBranchCoverage {
return {
key: `${file}:1:${block}:0`,
file,
line: 1,
block,
branch: '0',
taken: covered ? 1 : 0,
covered
}
}
function createTempDirectory(): string {
const directory = mkdtempSync(join(tmpdir(), 'critical-coverage-cli-'))
onTestFinished(() => rmSync(directory, { recursive: true, force: true }))
return directory
}

View File

@@ -1,162 +0,0 @@
import { appendFileSync } from 'node:fs'
import { parseArgs } from 'node:util'
import { createGitLocationMapper } from './criticalCoverageGitDiff'
import {
compareCriticalCoverageReports,
readCriticalCoverageReport
} from './criticalCoverageReport'
import type { CriticalCoverageComparison } from './criticalCoverageReport'
interface Options {
base: string
head: string
}
interface GateResult {
status: 'PASS' | 'FAIL'
message: string
}
try {
main()
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
const summary = `## Critical Unit Coverage Gate: ERROR\n\n${message}`
process.stderr.write(`${message}\n`)
if (process.env.GITHUB_STEP_SUMMARY) {
appendFileSync(process.env.GITHUB_STEP_SUMMARY, `${summary}\n`)
}
process.exitCode = 1
}
function main(): void {
const options = parseOptions(process.argv.slice(2))
const base = readCriticalCoverageReport(options.base)
const head = readCriticalCoverageReport(options.head)
const comparison = compareCriticalCoverageReports(
base,
head,
createGitLocationMapper(base.sha, head.sha)
)
const result = evaluateCoverageGate(comparison)
const summary = formatComparison(comparison, result)
process.stdout.write(`${summary}\n`)
if (process.env.GITHUB_STEP_SUMMARY) {
appendFileSync(process.env.GITHUB_STEP_SUMMARY, `${summary}\n`)
}
if (result.status === 'FAIL') {
process.stderr.write(`${result.message}\n`)
process.exitCode = 1
}
}
function parseOptions(args: string[]): Options {
const { values: options } = parseArgs({
args,
options: {
base: { type: 'string' },
head: { type: 'string' }
},
strict: true,
allowPositionals: false
})
if (!options.base || !options.head) {
throw new Error(
'Usage: compareCriticalCoverage --base <json> --head <json>'
)
}
return {
base: options.base,
head: options.head
}
}
function evaluateCoverageGate(
comparison: CriticalCoverageComparison
): GateResult {
if (comparison.commonBranches === 0) {
return {
status: 'FAIL',
message: `No comparable critical unit branches found (base: ${comparison.baseBranches}, head: ${comparison.headBranches}).`
}
}
if (comparison.regressions.length > 0) {
const regressionCount = comparison.regressions.length
return {
status: 'FAIL',
message: `Critical unit coverage regressed on ${regressionCount} previously covered ${regressionCount === 1 ? 'branch' : 'branches'}.`
}
}
return {
status: 'PASS',
message: 'Critical branch coverage did not regress.'
}
}
function formatComparison(
comparison: CriticalCoverageComparison,
result: GateResult
): string {
const lines = [
`## Critical Unit Coverage Gate: ${result.status}`,
'',
`Base tested commit: \`${comparison.baseSha}\``,
`PR tested commit: \`${comparison.headSha}\``,
'',
'| Metric | Count |',
'|---|--:|',
`| Base critical branches | ${comparison.baseBranches} |`,
`| Head critical branches | ${comparison.headBranches} |`,
`| 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} |`,
'',
`${result.status}: ${result.message}`
]
if (comparison.regressions.length === 0) {
return lines.join('\n')
}
lines.push('')
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 ? '-' : String(value)
}
function formatSignedCount(value: number): string {
return value > 0 ? `+${value}` : String(value)
}

View File

@@ -1,45 +0,0 @@
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}/`)
)
}

View File

@@ -1,187 +0,0 @@
import { execFileSync } from 'node:child_process'
import { CRITICAL_COVERAGE_DIRS } from './criticalCoverageDirs'
import { isGitCommitSha } from './criticalCoverageReport'
import type {
CriticalCoverageLocation,
CriticalCoverageLocationMapper
} from './criticalCoverageReport'
interface DiffFile {
headPath: string | null
hunks: DiffHunk[]
}
interface DiffHunk {
baseStart: number
baseCount: number
headCount: number
}
const HUNK_HEADER = /^@@ -(\d+)(?:,(\d+))? \+\d+(?:,(\d+))? @@/
const MAX_GIT_OUTPUT_BYTES = 100 * 1024 * 1024
export function createGitLocationMapper(
baseSha: string,
headSha: string,
cwd = process.cwd()
): CriticalCoverageLocationMapper {
if (!isGitCommitSha(baseSha) || !isGitCommitSha(headSha)) {
throw new Error(
'Critical coverage comparison requires full Git commit SHAs'
)
}
const files = readDiffFiles(baseSha, headSha, cwd)
const diff = runGitDiff(baseSha, headSha, cwd, [
'--unified=0',
'--no-color',
'--no-ext-diff'
])
addDiffHunks(files, diff)
return (file, line) => mapBaseLocation(files.get(file), file, line)
}
function readDiffFiles(
baseSha: string,
headSha: string,
cwd: string
): Map<string, DiffFile> {
const output = runGitDiff(baseSha, headSha, cwd, ['--name-status', '-z'])
const tokens = output.split('\0')
const files = new Map<string, DiffFile>()
for (let index = 0; index < tokens.length; ) {
const status = tokens[index++]
if (!status) {
continue
}
const basePath = tokens[index++]
if (!basePath) {
break
}
if (status.startsWith('R')) {
const headPath = tokens[index++]
if (!headPath) {
break
}
files.set(basePath, { headPath, hunks: [] })
continue
}
if (status === 'A') {
continue
}
files.set(basePath, {
headPath: status === 'D' ? null : basePath,
hunks: []
})
}
return files
}
function runGitDiff(
baseSha: string,
headSha: string,
cwd: string,
options: string[]
): string {
return execFileSync(
'git',
[
'-c',
'core.quotePath=false',
'diff',
'--find-renames',
...options,
baseSha,
headSha,
'--',
...CRITICAL_COVERAGE_DIRS
],
{ cwd, encoding: 'utf-8', maxBuffer: MAX_GIT_OUTPUT_BYTES }
)
}
function addDiffHunks(files: Map<string, DiffFile>, diff: string): void {
let basePath: string | null = null
for (const line of diff.split('\n')) {
if (line.startsWith('diff --git ')) {
basePath = null
continue
}
if (line.startsWith('--- ')) {
basePath = parseDiffPath(line.slice(4), 'a/')
continue
}
const match = HUNK_HEADER.exec(line)
const file = basePath ? files.get(basePath) : undefined
if (!match || !file) {
continue
}
file.hunks.push({
baseStart: Number(match[1]),
baseCount: Number(match[2] ?? 1),
headCount: Number(match[3] ?? 1)
})
}
}
function parseDiffPath(path: string, prefix: string): string | null {
return path.startsWith(prefix) ? path.slice(prefix.length) : null
}
function mapBaseLocation(
file: DiffFile | undefined,
basePath: string,
line: number
): CriticalCoverageLocation | null {
if (!file) {
return { file: basePath, line }
}
if (file.headPath === null) {
return null
}
const mappedLine = mapBaseLine(file.hunks, line)
return mappedLine === null ? null : { file: file.headPath, line: mappedLine }
}
function mapBaseLine(hunks: DiffHunk[], line: number): number | null {
let offset = 0
for (const hunk of hunks) {
if (hunk.baseCount === 0) {
if (line <= hunk.baseStart) {
return line + offset
}
offset += hunk.headCount
continue
}
if (line < hunk.baseStart) {
return line + offset
}
if (line < hunk.baseStart + hunk.baseCount) {
return null
}
offset += hunk.headCount - hunk.baseCount
}
return line + offset
}

View File

@@ -1,404 +0,0 @@
import { spawnSync } from 'node:child_process'
import { createRequire } from 'node:module'
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
import { describe, expect, it, onTestFinished } from 'vitest'
import { CRITICAL_COVERAGE_DIRS } from './criticalCoverageDirs'
import {
compareCriticalCoverageReports,
createCriticalCoverageReport,
readCriticalCoverageReport,
writeCriticalCoverageReport
} from './criticalCoverageReport'
import type {
CriticalBranchCoverage,
CriticalCoverageReport
} from './criticalCoverageReport'
const GENERATED_AT = '2026-07-10T00:00:00.000Z'
const BASE_SHA = 'a'.repeat(40)
const HEAD_SHA = 'b'.repeat(40)
const require = createRequire(import.meta.url)
const TSX_CLI = require.resolve('tsx/cli')
const EXTRACT_SCRIPT_PATH = join(
dirname(fileURLToPath(import.meta.url)),
'extractCriticalCoverage.ts'
)
interface InvalidReportCase {
name: string
corrupt(report: CriticalCoverageReport): unknown
}
const INVALID_REPORT_CASES: InvalidReportCase[] = [
{
name: 'invalid commit SHA',
corrupt(report) {
return { ...report, sha: '--output=/tmp/coverage' }
}
},
{
name: 'a branch outside the recorded critical directories',
corrupt(report) {
return { ...report, criticalDirs: ['src/components'] }
}
},
{
name: 'fractional totals',
corrupt(report) {
return {
...report,
totals: { ...report.totals, files: 0.5 }
}
}
},
{
name: 'inconsistent totals',
corrupt(report) {
return {
...report,
totals: { ...report.totals, branches: report.totals.branches + 1 }
}
}
},
{
name: 'arbitrary branch keys',
corrupt(report) {
return {
...report,
branches: report.branches.map((branch) => ({
...branch,
key: 'arbitrary'
}))
}
}
},
{
name: 'duplicate branch keys',
corrupt(report) {
return {
...report,
totals: {
...report.totals,
branches: report.totals.branches * 2,
coveredBranches: report.totals.coveredBranches * 2
},
branches: [...report.branches, ...report.branches]
}
}
},
{
name: 'inconsistent branch coverage',
corrupt(report) {
return {
...report,
totals: { ...report.totals, coveredBranches: 0 },
branches: report.branches.map((branch) => ({
...branch,
covered: false
}))
}
}
}
]
describe('createCriticalCoverageReport', () => {
it('extracts critical branches and calculates coverage totals', () => {
const fixture = createLcovFixture(`
SF:src/stores/queueStore.ts
BRDA:10,0,0,3
BRDA:10,0,1,0
BRDA:11,0,0,-
BRDA:0,0,2,1
BRDA:12,invalid,0,1
BRDA:13,0,0,1.5
end_of_record
SF:src/components/QueuePanel.vue
BRDA:20,0,0,4
end_of_record
`)
const report = createCriticalCoverageReport({
inputPath: fixture.inputPath,
sha: HEAD_SHA,
generatedAt: GENERATED_AT,
cwd: fixture.directory
})
expect(report.totals).toEqual({
files: 1,
branches: 3,
coveredBranches: 1
})
expect(
report.branches.map(({ key, taken, covered }) => ({
key,
taken,
covered
}))
).toEqual([
{
key: 'src/stores/queueStore.ts:10:0:0',
taken: 3,
covered: true
},
{
key: 'src/stores/queueStore.ts:10:0:1',
taken: 0,
covered: false
},
{
key: 'src/stores/queueStore.ts:11:0:0',
taken: null,
covered: false
}
])
})
it('merges duplicate branch records into a consistent coverage state', () => {
const fixture = createLcovFixture(`
SF:src/stores/queueStore.ts
BRDA:10,0,0,-
BRDA:10,0,0,2
end_of_record
`)
const report = createCriticalCoverageReport({
inputPath: fixture.inputPath,
sha: HEAD_SHA,
generatedAt: GENERATED_AT,
cwd: fixture.directory
})
expect(report.branches).toEqual([
expect.objectContaining({ taken: 2, covered: true })
])
})
it('rejects an empty LCOV taken value', () => {
const fixture = createLcovFixture(`
SF:src/stores/queueStore.ts
BRDA:10,0,0,
end_of_record
`)
const report = createCriticalCoverageReport({
inputPath: fixture.inputPath,
sha: HEAD_SHA,
generatedAt: GENERATED_AT,
cwd: fixture.directory
})
expect(report.branches).toEqual([])
})
it('reports extraction errors without a stack trace', () => {
const directory = createTempDirectory()
const summaryPath = join(directory, 'summary.md')
const missingPath = join(directory, 'missing.info')
const result = spawnSync(
process.execPath,
[TSX_CLI, EXTRACT_SCRIPT_PATH, '--input', missingPath, '--sha', HEAD_SHA],
{
encoding: 'utf-8',
env: { ...process.env, GITHUB_STEP_SUMMARY: summaryPath }
}
)
expect(result.status).toBe(1)
expect(result.stderr).toContain(`ENOENT: no such file or directory`)
expect(result.stderr).not.toContain(' at ')
expect(readFileSync(summaryPath, 'utf-8')).toContain(
'Critical Unit Coverage Extraction: ERROR'
)
})
it.for([
{
name: 'an unknown option',
args: ['--unknown'],
expected: '--unknown'
},
{
name: 'an incomplete option',
args: ['--input', `--sha=${HEAD_SHA}`],
expected: '--input'
}
])('rejects $name', ({ args, expected }) => {
const result = spawnSync(
process.execPath,
[TSX_CLI, EXTRACT_SCRIPT_PATH, ...args],
{ encoding: 'utf-8' }
)
expect(result.status).toBe(1)
expect(result.stderr).toContain(expected)
expect(result.stderr).not.toContain(' at ')
})
})
describe('readCriticalCoverageReport', () => {
it('accepts a report with its own valid critical-directory scope', () => {
const directory = createTempDirectory()
const inputPath = join(directory, 'coverage.json')
const report = {
...createReport(HEAD_SHA, [
createBranch('src/stores/queueStore.ts', true)
]),
criticalDirs: ['src/stores']
}
writeFileSync(inputPath, JSON.stringify(report))
expect(readCriticalCoverageReport(inputPath)).toEqual(report)
})
it('round-trips negative LCOV branch counts', () => {
const fixture = createLcovFixture(`
SF:src/utils/linkFixer.ts
BRDA:449,72,1,-2
end_of_record
`)
const outputPath = join(fixture.directory, 'coverage.json')
const report = createCriticalCoverageReport({
inputPath: fixture.inputPath,
sha: HEAD_SHA,
generatedAt: GENERATED_AT,
cwd: fixture.directory
})
writeCriticalCoverageReport(report, outputPath)
expect(readCriticalCoverageReport(outputPath)).toEqual(report)
})
it('round-trips branch ordinals reused on different lines', () => {
const fixture = createLcovFixture(`
SF:src/stores/queueStore.ts
BRDA:10,0,0,1
BRDA:11,0,0,1
end_of_record
`)
const outputPath = join(fixture.directory, 'coverage.json')
const report = createCriticalCoverageReport({
inputPath: fixture.inputPath,
sha: HEAD_SHA,
generatedAt: GENERATED_AT,
cwd: fixture.directory
})
writeCriticalCoverageReport(report, outputPath)
expect(readCriticalCoverageReport(outputPath)).toEqual(report)
})
it('rejects malformed artifacts', () => {
const directory = createTempDirectory()
const inputPath = join(directory, 'coverage.json')
writeFileSync(
inputPath,
JSON.stringify({
...createReport(HEAD_SHA, []),
branches: [{ key: 'invalid' }]
})
)
expect(() => readCriticalCoverageReport(inputPath)).toThrow(
`Invalid critical coverage report: ${inputPath}`
)
})
it.for(INVALID_REPORT_CASES)('rejects $name', ({ corrupt }) => {
const directory = createTempDirectory()
const inputPath = join(directory, 'coverage.json')
const report = createReport(HEAD_SHA, [
createBranch('src/stores/queueStore.ts', true)
])
writeFileSync(inputPath, JSON.stringify(corrupt(report)))
expect(() => readCriticalCoverageReport(inputPath)).toThrow(
`Invalid critical coverage report: ${inputPath}`
)
})
})
describe('compareCriticalCoverageReports', () => {
it('reports covered base-only branches as regressions', () => {
const shared = createBranch('src/stores/shared.ts', true)
const base = createReport(BASE_SHA, [
shared,
createBranch('src/stores/base-only.ts', true)
])
const head = createReport(HEAD_SHA, [
shared,
createBranch('src/stores/head-only.ts', false)
])
expect(compareCriticalCoverageReports(base, head)).toMatchObject({
commonBranches: 1,
baseOnlyBranches: 1,
headOnlyBranches: 1,
commonCoveredBranchesInBase: 1,
commonCoveredBranchesInHead: 1,
coveredBranchDelta: 0,
regressions: [
expect.objectContaining({
file: 'src/stores/base-only.ts',
baseTaken: 1,
headTaken: null
})
]
})
})
})
function createLcovFixture(lcov: string): {
directory: string
inputPath: string
} {
const directory = createTempDirectory()
const inputPath = join(directory, 'lcov.info')
writeFileSync(inputPath, lcov.trimStart())
return { directory, inputPath }
}
function createTempDirectory(): string {
const directory = mkdtempSync(join(tmpdir(), 'critical-coverage-'))
onTestFinished(() => rmSync(directory, { recursive: true, force: true }))
return directory
}
function createReport(
sha: string,
branches: CriticalBranchCoverage[]
): CriticalCoverageReport {
return {
schemaVersion: 1,
source: 'lcov',
sha,
generatedAt: GENERATED_AT,
inputPath: 'lcov.info',
criticalDirs: CRITICAL_COVERAGE_DIRS,
totals: {
files: new Set(branches.map(({ file }) => file)).size,
branches: branches.length,
coveredBranches: branches.filter(({ covered }) => covered).length
},
branches
}
}
function createBranch(file: string, covered: boolean): CriticalBranchCoverage {
return {
key: `${file}:1:0:0`,
file,
line: 1,
block: '0',
branch: '0',
taken: covered ? 1 : 0,
covered
}
}

View File

@@ -1,485 +0,0 @@
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
baseBranches: number
headBranches: number
commonBranches: number
baseOnlyBranches: number
headOnlyBranches: number
commonCoveredBranchesInBase: number
commonCoveredBranchesInHead: number
coveredBranchDelta: number
regressions: CriticalCoverageRegression[]
}
export interface CriticalCoverageLocation {
file: string
line: number
}
export type CriticalCoverageLocationMapper = (
file: string,
line: number
) => CriticalCoverageLocation | null
interface CreateReportOptions {
inputPath: string
sha: string
generatedAt?: string
cwd?: string
}
interface IndexedBranch {
branch: CriticalBranchCoverage
mappedLocation: CriticalCoverageLocation | null
}
export function createCriticalCoverageReport({
inputPath,
sha,
generatedAt = new Date().toISOString(),
cwd = process.cwd()
}: CreateReportOptions): CriticalCoverageReport {
if (!isGitCommitSha(sha)) {
throw new Error(`Invalid Git commit SHA: ${sha}`)
}
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,
mapBaseLocation: CriticalCoverageLocationMapper = identityLocationMapper
): CriticalCoverageComparison {
assertCriticalCoverageScopePreserved(base.criticalDirs, head.criticalDirs)
const baseBranches = indexBranches(base.branches, mapBaseLocation, 'base')
const headBranches = indexBranches(
head.branches,
identityLocationMapper,
'head'
)
const regressions: CriticalCoverageRegression[] = []
let commonBranches = 0
let commonCoveredBranchesInBase = 0
let commonCoveredBranchesInHead = 0
let baseOnlyBranches = 0
for (const [key, indexedBaseBranch] of baseBranches) {
const indexedHeadBranch = headBranches.get(key)
const baseBranch = indexedBaseBranch.branch
if (!indexedHeadBranch) {
baseOnlyBranches++
if (baseBranch.covered && indexedBaseBranch.mappedLocation) {
regressions.push({
...baseBranch,
file: indexedBaseBranch.mappedLocation.file,
line: indexedBaseBranch.mappedLocation.line,
taken: null,
covered: false,
baseTaken: baseBranch.taken,
headTaken: null
})
}
continue
}
const headBranch = indexedHeadBranch.branch
commonBranches++
if (baseBranch.covered) {
commonCoveredBranchesInBase++
}
if (headBranch.covered) {
commonCoveredBranchesInHead++
}
if (baseBranch.covered && !headBranch.covered) {
regressions.push({
...headBranch,
baseTaken: baseBranch.taken,
headTaken: headBranch.taken
})
}
}
return {
baseSha: base.sha,
headSha: head.sha,
baseBranches: base.branches.length,
headBranches: head.branches.length,
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)
const taken = takenValue === '-' ? null : Number(takenValue)
if (
!Number.isInteger(line) ||
line <= 0 ||
!isCoverageOrdinal(block) ||
!isCoverageOrdinal(branch) ||
takenValue === undefined ||
takenValue.trim().length === 0 ||
(taken !== null && !Number.isInteger(taken))
) {
return null
}
const covered = taken !== null && taken > 0
const key = `${file}:${line}:${block}:${branch}`
return {
key,
file,
line,
block,
branch,
taken,
covered
}
}
function mergeBranchCoverage(
left: CriticalBranchCoverage,
right: CriticalBranchCoverage
): CriticalBranchCoverage {
const taken =
left.taken === null && right.taken === null
? null
: (left.taken ?? 0) + (right.taken ?? 0)
return {
...left,
taken,
covered: taken !== null && taken > 0
}
}
function compareBranches(
left: CriticalBranchCoverage,
right: CriticalBranchCoverage
): number {
return (
left.file.localeCompare(right.file) ||
left.line - right.line ||
Number(left.block) - Number(right.block) ||
Number(left.branch) - Number(right.branch)
)
}
function indexBranches(
branches: CriticalBranchCoverage[],
mapLocation: CriticalCoverageLocationMapper,
unmappedPrefix: string
): Map<string, IndexedBranch> {
const indexed = new Map<string, IndexedBranch>()
const occurrences = new Map<string, number>()
for (const branch of [...branches].sort(compareBranches)) {
const sourceLine = `${branch.file}:${branch.line}`
const occurrence = occurrences.get(sourceLine) ?? 0
const mappedLocation = mapLocation(branch.file, branch.line)
const identity =
mappedLocation === null
? `${unmappedPrefix}:${branch.key}`
: `${mappedLocation.file}:${mappedLocation.line}:${occurrence}`
occurrences.set(sourceLine, occurrence + 1)
indexed.set(identity, { branch, mappedLocation })
}
return indexed
}
function assertCriticalCoverageScopePreserved(
baseDirs: readonly string[],
headDirs: readonly string[]
): void {
const headScope = new Set(headDirs)
const removedDirs = baseDirs.filter((dir) => !headScope.has(dir))
if (removedDirs.length > 0) {
throw new Error(
`Critical coverage scope removed: ${removedDirs.join(', ')}`
)
}
}
function identityLocationMapper(
file: string,
line: number
): CriticalCoverageLocation {
return { file, line }
}
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
const branches = value.branches
const criticalDirs = value.criticalDirs
if (
value.schemaVersion !== 1 ||
value.source !== 'lcov' ||
!isGitCommitSha(value.sha) ||
typeof value.generatedAt !== 'string' ||
typeof value.inputPath !== 'string' ||
!isCriticalCoverageDirs(criticalDirs) ||
!isRecord(totals) ||
!isNonNegativeInteger(totals.files) ||
!isNonNegativeInteger(totals.branches) ||
!isNonNegativeInteger(totals.coveredBranches) ||
!Array.isArray(branches) ||
!branches.every((branch) => isCriticalBranchCoverage(branch, criticalDirs))
) {
return false
}
const keys = new Set(branches.map(({ key }) => key))
const files = new Set(branches.map(({ file }) => file))
const coveredBranches = branches.filter(({ covered }) => covered).length
return (
keys.size === branches.length &&
totals.files === files.size &&
totals.branches === branches.length &&
totals.coveredBranches === coveredBranches
)
}
function isCriticalBranchCoverage(
value: unknown,
criticalDirs: readonly string[]
): value is CriticalBranchCoverage {
if (!isRecord(value)) {
return false
}
const taken = value.taken
if (
typeof value.key !== 'string' ||
typeof value.file !== 'string' ||
!isCoveragePathInDirs(value.file, criticalDirs) ||
!isNonNegativeInteger(value.line) ||
value.line === 0 ||
!isCoverageOrdinal(value.block) ||
!isCoverageOrdinal(value.branch) ||
(taken !== null &&
(typeof taken !== 'number' || !Number.isInteger(taken))) ||
typeof value.covered !== 'boolean'
) {
return false
}
return (
value.key ===
`${value.file}:${value.line}:${value.block}:${value.branch}` &&
value.covered === (typeof taken === 'number' && taken > 0)
)
}
function isCriticalCoverageDirs(value: unknown): value is string[] {
return (
Array.isArray(value) &&
value.every(
(dir) =>
typeof dir === 'string' &&
dir.length > 0 &&
dir === dir.trim() &&
!dir.startsWith('/') &&
!dir.endsWith('/') &&
!dir.includes('\\') &&
!dir.split('/').includes('..')
) &&
new Set(value).size === value.length
)
}
function isCoveragePathInDirs(
filePath: string,
criticalDirs: readonly string[]
): boolean {
return criticalDirs.some(
(dir) => filePath === dir || filePath.startsWith(`${dir}/`)
)
}
function isNonNegativeInteger(value: unknown): value is number {
return typeof value === 'number' && Number.isInteger(value) && value >= 0
}
export function isGitCommitSha(value: unknown): value is string {
return (
typeof value === 'string' && /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/i.test(value)
)
}
function isCoverageOrdinal(value: unknown): value is string {
return (
typeof value === 'string' &&
/^\d+$/.test(value) &&
Number.isSafeInteger(Number(value))
)
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null
}

View File

@@ -1,65 +0,0 @@
import { appendFileSync } from 'node:fs'
import { parseArgs } from 'node:util'
import {
createCriticalCoverageReport,
writeCriticalCoverageReport
} from './criticalCoverageReport'
interface Options {
input: string
output: string
sha: string
}
try {
main()
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
const summary = `## Critical Unit Coverage Extraction: ERROR\n\n${message}`
process.stderr.write(`${message}\n`)
if (process.env.GITHUB_STEP_SUMMARY) {
appendFileSync(process.env.GITHUB_STEP_SUMMARY, `${summary}\n`)
}
process.exitCode = 1
}
function main(): void {
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 { values } = parseArgs({
args,
options: {
input: { type: 'string' },
output: { type: 'string' },
sha: { type: 'string' }
},
strict: true,
allowPositionals: false
})
return {
input: values.input ?? 'coverage/lcov.info',
output: values.output ?? 'coverage/critical-unit-coverage.json',
sha: values.sha ?? process.env.GITHUB_SHA ?? ''
}
}

View File

@@ -24,10 +24,6 @@ interface MockGraph {
onNodeAdded: ((node: MockNode) => void) | null
onNodeRemoved: ((node: MockNode) => void) | null
onConnectionChange: ((node: MockNode) => void) | null
events: {
addEventListener: Mock
removeEventListener: Mock
}
}
interface MockCanvas {
@@ -132,11 +128,7 @@ const setupMocks = () => {
setDirtyCanvas: vi.fn(),
onNodeAdded: null,
onNodeRemoved: null,
onConnectionChange: null,
events: {
addEventListener: vi.fn(),
removeEventListener: vi.fn()
}
onConnectionChange: null
}
moduleMockCanvas = {
@@ -300,11 +292,7 @@ describe('useMinimap', () => {
setDirtyCanvas: vi.fn(),
onNodeAdded: null,
onNodeRemoved: null,
onConnectionChange: null,
events: {
addEventListener: vi.fn(),
removeEventListener: vi.fn()
}
onConnectionChange: null
}
moduleMockCanvas = {

View File

@@ -3,8 +3,6 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
import { ref } from 'vue'
import type { Ref } from 'vue'
import { CustomEventTarget } from '@/lib/litegraph/src/infrastructure/CustomEventTarget'
import type { LGraphEventMap } from '@/lib/litegraph/src/infrastructure/LGraphEventMap'
import type { LGraph, LGraphNode } from '@/lib/litegraph/src/litegraph'
import { toLinkId } from '@/types/linkId'
import { useMinimapGraph } from '@/renderer/extensions/minimap/composables/useMinimapGraph'
@@ -47,7 +45,6 @@ describe('useMinimapGraph', () => {
createMockLGraphNode({ id: '2', pos: [300, 200], size: [120, 60] })
],
links: createMockLinks([createMockLLink({ id: toLinkId(1) })]),
events: new CustomEventTarget<LGraphEventMap>(),
onNodeAdded: vi.fn(),
onNodeRemoved: vi.fn(),
onConnectionChange: vi.fn()
@@ -111,6 +108,13 @@ describe('useMinimapGraph', () => {
const graphRef = ref(mockGraph) as Ref<LGraph | null>
const graphManager = useMinimapGraph(graphRef, onGraphChangedMock)
// Store original callbacks for comparison
// const originalCallbacks = {
// onNodeAdded: mockGraph.onNodeAdded,
// onNodeRemoved: mockGraph.onNodeRemoved,
// onConnectionChange: mockGraph.onConnectionChange
// }
graphManager.setupEventListeners()
const wrappedCallbacks = {
onNodeAdded: mockGraph.onNodeAdded,
@@ -156,96 +160,6 @@ describe('useMinimapGraph', () => {
expect(() => graphManager.cleanupEventListeners()).not.toThrow()
})
it('cleanup leaves a later wrapper alone when one is layered on top', () => {
const graphRef = ref(mockGraph) as Ref<LGraph | null>
const graphManager = useMinimapGraph(graphRef, onGraphChangedMock)
graphManager.setupEventListeners()
const minimapWrapper = mockGraph.onNodeAdded
// Simulate another system adding its own wrapper on top
const downstream = vi.fn()
const layeredWrapper = vi.fn(function (this: unknown, node: LGraphNode) {
minimapWrapper?.call(this, node)
downstream(node)
})
mockGraph.onNodeAdded = layeredWrapper
graphManager.cleanupEventListeners()
// The newer wrapper must survive cleanup
expect(mockGraph.onNodeAdded).toBe(layeredWrapper)
})
it('a buried wrapper becomes inert after cleanup', () => {
const originalOnNodeAdded = vi.fn()
mockGraph.onNodeAdded = originalOnNodeAdded
const graphRef = ref(mockGraph) as Ref<LGraph | null>
const graphManager = useMinimapGraph(graphRef, onGraphChangedMock)
graphManager.setupEventListeners()
const buriedWrapper = mockGraph.onNodeAdded
// Layer something on top so cleanup can't restore.
mockGraph.onNodeAdded = vi.fn()
graphManager.cleanupEventListeners()
vi.mocked(onGraphChangedMock).mockClear()
// Call the method directly and ensure it is a no-op
const testNode = { id: '9' } as LGraphNode
buriedWrapper!(testNode)
expect(originalOnNodeAdded).toHaveBeenCalledWith(testNode)
expect(onGraphChangedMock).not.toHaveBeenCalled()
})
it('invalidates cache and fires update on visual property changes', () => {
const graphRef = ref(mockGraph) as Ref<LGraph | null>
const graphManager = useMinimapGraph(graphRef, onGraphChangedMock)
graphManager.setupEventListeners()
mockGraph.events.dispatch('node:property:changed', {
nodeId: '1',
property: 'color',
oldValue: '',
newValue: '#fff'
})
expect(onGraphChangedMock).toHaveBeenCalled()
})
it('ignores unrelated property changes', () => {
const graphRef = ref(mockGraph) as Ref<LGraph | null>
const graphManager = useMinimapGraph(graphRef, onGraphChangedMock)
graphManager.setupEventListeners()
mockGraph.events.dispatch('node:property:changed', {
nodeId: '1',
property: 'title',
oldValue: 'a',
newValue: 'b'
})
expect(onGraphChangedMock).not.toHaveBeenCalled()
})
it('detaches the property listener on cleanup', () => {
const graphRef = ref(mockGraph) as Ref<LGraph | null>
const graphManager = useMinimapGraph(graphRef, onGraphChangedMock)
graphManager.setupEventListeners()
graphManager.cleanupEventListeners()
mockGraph.events.dispatch('node:property:changed', {
nodeId: '1',
property: 'mode',
oldValue: 0,
newValue: 1
})
expect(onGraphChangedMock).not.toHaveBeenCalled()
})
it('should detect node position changes', () => {
const graphRef = ref(mockGraph) as Ref<LGraph | null>
const graphManager = useMinimapGraph(graphRef, onGraphChangedMock)

View File

@@ -2,9 +2,11 @@ import { useThrottleFn } from '@vueuse/core'
import { ref, watch } from 'vue'
import type { Ref } from 'vue'
import { useChainCallback } from '@/composables/functional/useChainCallback'
import type { LGraphEventMap } from '@/lib/litegraph/src/infrastructure/LGraphEventMap'
import type { LGraph, LGraphNode } from '@/lib/litegraph/src/litegraph'
import type {
LGraph,
LGraphNode,
LGraphTriggerEvent
} from '@/lib/litegraph/src/litegraph'
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
import { api } from '@/scripts/api'
import { toNodeId } from '@/types/nodeId'
@@ -17,6 +19,7 @@ interface GraphCallbacks {
onNodeAdded?: (node: LGraphNode) => void
onNodeRemoved?: (node: LGraphNode) => void
onConnectionChange?: (node: LGraphNode) => void
onTrigger?: (event: LGraphTriggerEvent) => void
}
export function useMinimapGraph(
@@ -36,17 +39,8 @@ export function useMinimapGraph(
// Track LayoutStore version for change detection
const layoutStoreVersion = layoutStore.getVersion()
// Cleanup restores originals only when our wrapper is still on top, and
// marks any buried wrapper inert via `entry.live` so it can't fire dead work.
interface InstalledHooks {
originals: GraphCallbacks
wrappers: GraphCallbacks
live: boolean
onPropertyChanged: (
e: CustomEvent<LGraphEventMap['node:property:changed']>
) => void
}
const hooksMap = new Map<string, InstalledHooks>()
// Map to store original callbacks per graph ID
const originalCallbacksMap = new Map<string, GraphCallbacks>()
const handleGraphChangedThrottled = useThrottleFn(() => {
onGraphChanged()
@@ -54,85 +48,71 @@ export function useMinimapGraph(
const setupEventListeners = () => {
const g = graph.value
if (!g || hooksMap.has(g.id)) return
if (!g) return
const originals: GraphCallbacks = {
// Check if we've already wrapped this graph's callbacks
if (originalCallbacksMap.has(g.id)) {
return
}
// Store the original callbacks for this graph
const originalCallbacks: GraphCallbacks = {
onNodeAdded: g.onNodeAdded,
onNodeRemoved: g.onNodeRemoved,
onConnectionChange: g.onConnectionChange
onConnectionChange: g.onConnectionChange,
onTrigger: g.onTrigger
}
const wrappers: GraphCallbacks = {}
originalCallbacksMap.set(g.id, originalCallbacks)
const onPropertyChanged = (
e: CustomEvent<LGraphEventMap['node:property:changed']>
) => {
const { property, nodeId } = e.detail
if (
property === 'mode' ||
property === 'bgcolor' ||
property === 'color'
) {
nodeStatesCache.delete(toNodeId(nodeId))
void handleGraphChangedThrottled()
}
}
const entry: InstalledHooks = {
originals,
wrappers,
live: true,
onPropertyChanged
}
hooksMap.set(g.id, entry)
wrappers.onNodeAdded = useChainCallback(originals.onNodeAdded, function () {
if (!entry.live) return
g.onNodeAdded = function (node: LGraphNode) {
originalCallbacks.onNodeAdded?.call(this, node)
void handleGraphChangedThrottled()
})
g.onNodeAdded = wrappers.onNodeAdded
}
wrappers.onNodeRemoved = useChainCallback(
originals.onNodeRemoved,
function (node: LGraphNode) {
if (!entry.live) return
nodeStatesCache.delete(node.id)
g.onNodeRemoved = function (node: LGraphNode) {
originalCallbacks.onNodeRemoved?.call(this, node)
nodeStatesCache.delete(node.id)
void handleGraphChangedThrottled()
}
g.onConnectionChange = function (node: LGraphNode) {
originalCallbacks.onConnectionChange?.call(this, node)
void handleGraphChangedThrottled()
}
g.onTrigger = function (event: LGraphTriggerEvent) {
originalCallbacks.onTrigger?.call(this, event)
// Listen for visual property changes that affect minimap rendering
if (
event.type === 'node:property:changed' &&
(event.property === 'mode' ||
event.property === 'bgcolor' ||
event.property === 'color')
) {
// Invalidate cache for this node to force redraw
nodeStatesCache.delete(toNodeId(event.nodeId))
void handleGraphChangedThrottled()
}
)
g.onNodeRemoved = wrappers.onNodeRemoved
wrappers.onConnectionChange = useChainCallback(
originals.onConnectionChange,
function () {
if (!entry.live) return
void handleGraphChangedThrottled()
}
)
g.onConnectionChange = wrappers.onConnectionChange
g.events.addEventListener('node:property:changed', onPropertyChanged)
}
}
const cleanupEventListeners = (oldGraph?: LGraph) => {
const g = oldGraph || graph.value
if (!g) return
const entry = hooksMap.get(g.id)
if (!entry) return
const { originals, wrappers } = entry
if (g.onNodeAdded === wrappers.onNodeAdded)
g.onNodeAdded = originals.onNodeAdded
if (g.onNodeRemoved === wrappers.onNodeRemoved)
g.onNodeRemoved = originals.onNodeRemoved
if (g.onConnectionChange === wrappers.onConnectionChange)
g.onConnectionChange = originals.onConnectionChange
g.events.removeEventListener(
'node:property:changed',
entry.onPropertyChanged
)
const originalCallbacks = originalCallbacksMap.get(g.id)
if (!originalCallbacks) {
// Graph was never set up (e.g., minimap destroyed before init) - nothing to clean up
return
}
entry.live = false
hooksMap.delete(g.id)
g.onNodeAdded = originalCallbacks.onNodeAdded
g.onNodeRemoved = originalCallbacks.onNodeRemoved
g.onConnectionChange = originalCallbacks.onConnectionChange
g.onTrigger = originalCallbacks.onTrigger
originalCallbacksMap.delete(g.id)
}
const checkForChangesInternal = () => {

View File

@@ -18,7 +18,6 @@ import { createHtmlPlugin } from 'vite-plugin-html'
import vueDevTools from 'vite-plugin-vue-devtools'
import { comfyAPIPlugin } from './build/plugins'
import { CRITICAL_COVERAGE_DIRS } from './scripts/critical-coverage/criticalCoverageDirs'
dotenvConfig()
@@ -31,6 +30,46 @@ const DISABLE_TEMPLATES_PROXY = process.env.DISABLE_TEMPLATES_PROXY === 'true'
const GENERATE_SOURCEMAP = process.env.GENERATE_SOURCEMAP !== 'false'
const IS_STORYBOOK = process.env.npm_lifecycle_event === 'storybook'
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'
]
// A single glob key so vitest aggregates all critical dirs into one
// thresholds bucket instead of one bucket per glob
const CRITICAL_COVERAGE_GLOB = `{${CRITICAL_COVERAGE_DIRS.join(',')}}/**/*.{ts,vue}`