Restores the escape hatch for a status list matching no preset. filtersFromSearchParams accepts any non-empty subset of {available, reserved, sold}, and only three of those seven lists are presets; the other four reported as the not-sold fallback and produced no chip, so ?status=reserved was an empty grid reading "No items yet - check back soon" with no Clear filters button and no way out but editing the URL. hasActiveFilters covered all seven before this branch, so that was a regression against main. availabilityDimension now emits a chip for any status that is not the not-sold preset, labelled from SALE_STATE_LABELS when the list matches one and from statusLabel when it does not. The Segmented still reads "Not sold" beside such a chip, which is a cosmetic wart and the cheaper half of the trade.
Runs the frontend unit suite in CI. The 22 tests added over the dimensions were invoked by nothing: the workflow's only frontend steps were the build and the end-to-end run, and its test:unit:cov step is the backend's. The new step is guarded and named in the gate like every other suite, per the invariant workflowGate.test.ts asserts.
Restores the comment explaining why availabilityDimension's render and chips pass different fallbacks to saleStateFromStatuses. The control must read "All" while sold favorites are on screen; chips must stay silent because the customer never chose it. Unifying them would give every signed-in favorites view a phantom All chip and a tally of 2, and nothing said so after the old markup was deleted.
Derives the storefront's "is anything filtered" and FilterBar's tally through one exported chipsFor rather than two expressions over two identically-built contexts. They agreed only by convention, which is the exact defect this branch exists to remove.
Documents that statusDimension and availabilityDimension are alternatives over one field, since composing both type-checks and would render two controls that double-count it, and asserts the price chip's label text, which was the only chip label nothing checked.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
260 lines
11 KiB
YAML
Executable File
260 lines
11 KiB
YAML
Executable File
name: SonarQube Analysis
|
|
|
|
# The only workflow that runs the test suites. tests.yml used to run the unit
|
|
# and end-to-end suites as well, on identical triggers, so every pull request
|
|
# installed, migrated, built, started the backend and ran the whole end-to-end
|
|
# suite twice. With one runner the second copy did not run in parallel, it
|
|
# queued. It was deleted and its two summarize steps folded in here. See #123.
|
|
#
|
|
# Note that the integration suite DOES run here, on every push and pull request,
|
|
# despite backend-integration.yml describing it as manual. That quarantine only
|
|
# ever applied to tests.yml. It is survivable here because test:integration:cov
|
|
# passes --forceExit, which papers over the post-run hang, and because of the
|
|
# timeout below.
|
|
|
|
on:
|
|
push:
|
|
branches: [main]
|
|
pull_request:
|
|
types: [opened, synchronize, reopened]
|
|
workflow_dispatch:
|
|
|
|
jobs:
|
|
sonarqube:
|
|
runs-on: ubuntu-latest
|
|
# The scanner needs every coverage report in one workspace, so the suites run
|
|
# here rather than being passed between jobs as artifacts. That makes this the
|
|
# long job. The timeout is a stop, not a budget: the integration suite has
|
|
# hung after completing before (see backend-integration.yml) and cost three
|
|
# hours of runner time.
|
|
timeout-minutes: 30
|
|
services:
|
|
postgres:
|
|
image: postgres:16
|
|
env:
|
|
POSTGRES_USER: redefined_test
|
|
POSTGRES_PASSWORD: redefined_test
|
|
POSTGRES_DB: redefined_test
|
|
options: >-
|
|
--health-cmd "pg_isready -U redefined_test"
|
|
--health-interval 5s
|
|
--health-timeout 5s
|
|
--health-retries 10
|
|
env:
|
|
# Consumed by the integration suite's setup files.
|
|
TEST_PGHOST: postgres
|
|
TEST_PGPORT: 5432
|
|
TEST_PGUSER: redefined_test
|
|
TEST_PGPASSWORD: redefined_test
|
|
TEST_PGDATABASE: redefined_test
|
|
# Consumed by the backend process the end-to-end run drives.
|
|
PGHOST: postgres
|
|
PGPORT: 5432
|
|
PGUSER: redefined_test
|
|
PGPASSWORD: redefined_test
|
|
PGDATABASE: redefined_test
|
|
PORT: 3000
|
|
DEMO_MODE: 'true'
|
|
UPLOADS_DIR: /tmp/redefined-uploads
|
|
# NODE_ENV is deliberately unset: `npm install` omits devDependencies when
|
|
# NODE_ENV=production, which strips tsc/vite/@playwright/test and breaks the
|
|
# build. It would also flip the session cookie to Secure, which the e2e run
|
|
# serves over plain http.
|
|
steps:
|
|
- name: Checkout
|
|
uses: actions/checkout@v4
|
|
with:
|
|
fetch-depth: 0
|
|
|
|
- name: Setup Node
|
|
uses: actions/setup-node@v4
|
|
with:
|
|
node-version: '20'
|
|
|
|
- name: Install backend deps
|
|
run: npm install
|
|
working-directory: backend
|
|
|
|
- name: Install frontend deps
|
|
run: npm install
|
|
working-directory: frontend
|
|
|
|
- name: TypeScript build check (backend)
|
|
run: npm run build
|
|
working-directory: backend
|
|
|
|
- name: TypeScript build check (frontend)
|
|
run: npm run build
|
|
working-directory: frontend
|
|
|
|
# The frontend's build was the only thing this workspace ran, so the unit
|
|
# suite #188 added over the filter dimensions was run by nothing but the
|
|
# author's terminal. A suite CI never runs decays into a record of what
|
|
# the code used to do, and its value is highest exactly here: chips() is
|
|
# pure, and the end-to-end run reaches it only through a browser.
|
|
#
|
|
# Guarded and named in the gate like every suite: a failing test should
|
|
# fail the job at the end, not abort it and take the scan with it.
|
|
- name: Frontend unit tests
|
|
id: frontend_unit
|
|
continue-on-error: true
|
|
run: npm run test:unit
|
|
working-directory: frontend
|
|
|
|
- name: Check the Sonar tsconfig has not drifted
|
|
run: node scripts/check-sonar-tsconfig.js
|
|
|
|
- name: Run migrations
|
|
run: node migrate.js up
|
|
working-directory: backend
|
|
|
|
# --json/--outputFile appended rather than baked into the script: the
|
|
# coverage run and the results file are wanted together here, and nowhere
|
|
# else. summarize-jest.js below reads that file.
|
|
- name: Backend unit tests with coverage
|
|
id: unit
|
|
continue-on-error: true
|
|
run: npm run test:unit:cov -- --json --outputFile=unit-results.json
|
|
working-directory: backend
|
|
|
|
# Covers everything in src/routes, which the unit suite does not touch —
|
|
# without this the backend reports around 11% rather than the ~73% it
|
|
# actually has.
|
|
#
|
|
# Guarded like the other two suites. It was the only one that was not, so
|
|
# it was the only one whose failure aborted the job — taking the scan, the
|
|
# end-to-end run and the coverage merge with it. That is what #154 has
|
|
# cost on every push since it started: not a degraded analysis, none at
|
|
# all. See #174.
|
|
- name: Backend integration tests with coverage
|
|
id: integration
|
|
continue-on-error: true
|
|
run: npm run test:integration:cov -- --json --outputFile=integration-results.json
|
|
working-directory: backend
|
|
|
|
# Guarded because the scan does not depend on it. A backend that will not
|
|
# start fails the end-to-end run below on its own, and the gate records
|
|
# both — there is no reason for it to also cost the analysis.
|
|
- name: Start backend for the end-to-end run
|
|
id: backend
|
|
continue-on-error: true
|
|
run: |
|
|
mkdir -p /tmp/redefined-uploads
|
|
node dist/server.js > /tmp/backend.log 2>&1 &
|
|
for i in $(seq 1 30); do
|
|
if node -e "require('http').get('http://localhost:3000/api/config', r => process.exit(r.statusCode === 200 ? 0 : 1)).on('error', () => process.exit(1))"; then
|
|
echo "Backend ready after ${i}s"
|
|
exit 0
|
|
fi
|
|
sleep 1
|
|
done
|
|
echo "Backend did not become ready within 30s:"
|
|
cat /tmp/backend.log
|
|
exit 1
|
|
working-directory: backend
|
|
|
|
# A network fetch, and so the least interesting way to lose an analysis.
|
|
- name: Install Playwright browsers
|
|
id: browsers
|
|
continue-on-error: true
|
|
run: npx playwright install --with-deps chromium
|
|
working-directory: frontend
|
|
|
|
# Runs against an istanbul-instrumented dev server, which is what produces
|
|
# window.__coverage__ for the fixture to collect.
|
|
- name: Frontend end-to-end tests with coverage
|
|
id: e2e
|
|
continue-on-error: true
|
|
env:
|
|
PLAYWRIGHT_JSON_OUTPUT_NAME: playwright-results.json
|
|
# list as well as json, so the log still shows which test failed rather
|
|
# than only a file nobody reads until the summary step.
|
|
run: npm run test:e2e:cov -- --reporter=list,json
|
|
working-directory: frontend
|
|
|
|
# Keyed off the step outcome rather than failure(). continue-on-error above
|
|
# means the job is not in a failed state at this point, so failure() would
|
|
# never fire and the log that explains an end-to-end failure would go
|
|
# unprinted precisely when it is wanted.
|
|
- name: Backend log
|
|
if: steps.e2e.outcome == 'failure' || steps.backend.outcome == 'failure'
|
|
run: cat /tmp/backend.log
|
|
|
|
# Fails when nothing was collected rather than writing an empty report. An
|
|
# uninstrumented dev server lets every test pass while gathering nothing,
|
|
# and the resulting 0% reads as "the tests stopped covering things".
|
|
- name: Merge frontend coverage
|
|
id: coverage
|
|
continue-on-error: true
|
|
run: npm run coverage:report
|
|
working-directory: frontend
|
|
|
|
# Guarded so that a scanner error does not take the summaries below with
|
|
# it. The gate still records it, so a failed scan fails the job.
|
|
#
|
|
# If a coverage report is missing — the end-to-end run collecting nothing,
|
|
# say — this scans anyway and SonarQube reports those files as uncovered,
|
|
# which reads as a regression rather than as a missing input. Accepted:
|
|
# jest still writes coverage for a suite whose tests fail, so the failure
|
|
# actually occurring produces all three reports; there is no
|
|
# sonar.qualitygate.wait, so a degraded run marks the dashboard and is
|
|
# overwritten by the next good one rather than blocking anything; and the
|
|
# job fails regardless, so no run in this state reads as clean.
|
|
- name: SonarQube Scan
|
|
id: scan
|
|
continue-on-error: true
|
|
uses: sonarsource/sonarqube-scan-action@v4
|
|
env:
|
|
SONAR_HOST_URL: ${{ secrets.SONAR_HOST_URL }}
|
|
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
|
|
|
|
# Readable pass and fail counts, which the raw jest and Playwright output
|
|
# does not give at a glance. Both run with if: always() so a failing suite
|
|
# is still summarised — which is the case they exist for.
|
|
- name: Summarize unit tests
|
|
if: always()
|
|
run: node scripts/summarize-jest.js backend/unit-results.json "Backend Unit Test"
|
|
|
|
# The suite this workflow has been failing on for weeks, and the only one
|
|
# that had no summary — so it presented as 36 assertion errors about
|
|
# categories and price filters rather than as a count. #154 records how
|
|
# expensive that misdirection was to read.
|
|
- name: Summarize integration tests
|
|
if: always()
|
|
run: node scripts/summarize-jest.js backend/integration-results.json "Backend Integration Test"
|
|
|
|
- name: Summarize end-to-end tests
|
|
if: always()
|
|
run: node scripts/summarize-playwright.js frontend/playwright-results.json
|
|
|
|
# Last, so a failing step still produces coverage, a scan and every
|
|
# summary first. Without this step continue-on-error above would turn a
|
|
# failing suite into a passing job, which is the one way this change could
|
|
# do real damage.
|
|
#
|
|
# Every guarded step is listed. That is the invariant — a step carrying
|
|
# continue-on-error and missing from here cannot fail the job at all — and
|
|
# tests/unit/workflowGate.test.ts asserts it, because the integration
|
|
# suite going unlisted is exactly how #174 happened.
|
|
#
|
|
# always() is load-bearing. A step whose `if:` omits it still implicitly
|
|
# requires every previous step to have succeeded, so this was skipped in
|
|
# exactly the case it exists for: the summarise step above used to exit 1
|
|
# on a failing suite, which failed the job first and left this gate as dead
|
|
# code. The job then reported its failure under a name that describes
|
|
# summarising rather than testing. The summarisers exit 0 now; this is what
|
|
# fails the run. See #142.
|
|
- name: Fail if any guarded step failed
|
|
if: >-
|
|
always() && (
|
|
steps.frontend_unit.outcome == 'failure' ||
|
|
steps.unit.outcome == 'failure' ||
|
|
steps.integration.outcome == 'failure' ||
|
|
steps.backend.outcome == 'failure' ||
|
|
steps.browsers.outcome == 'failure' ||
|
|
steps.e2e.outcome == 'failure' ||
|
|
steps.coverage.outcome == 'failure' ||
|
|
steps.scan.outcome == 'failure'
|
|
)
|
|
run: exit 1
|