TypeScript's strict mode checks types and nothing else, so nothing enforced the React hook rules, the SonarJS rules, or unhandled-promise detection. Adds a flat config per workspace, a lint script in each, and a lint job in tests.yml.
The rule selection is the substance of this change and is measured rather than guessed. A full-strength config reports 435 violations across 50 files, but 325 of those are the no-unsafe-* family from recommendedTypeChecked, every one downstream of pool.query() returning any rows and untyped fetch responses. Typing those boundaries is the whole of #65, so enabling the rules here would ship a linter whose output is three-quarters another issue's backlog — the reliable way to teach everyone to ignore lint output. This enables recommended plus the two type-aware rules that catch defects rather than describe type debt, which leaves 110 findings.
Both configs downgrade every preset to a warning and then list the error rules explicitly at the bottom, so the CI gate is readable in one place instead of inferred from four presets' defaults. Errors are no-floating-promises, no-misused-promises, rules-of-hooks, exhaustive-deps and jsx-a11y/alt-text; everything else warns. No --max-warnings flag is needed because ESLint already exits non-zero on errors and zero on warnings. no-misused-promises runs with checksVoidReturn.attributes false, since onClick={async () => ...} is idiomatic React and safe when the handler catches its own errors — at the default it flags every antd button in the admin screens, 25 of its 28 hits, and a rule that is 89% noise gets switched off within a week.
The 37 errors this surfaced were mostly not the mechanical fix they looked like. The plan assumed the 30 floating promises were fire-and-forget loaders that already handled their own failures, which was true of the one sampled when writing the design and false for most of the rest: Admin, Categories, Customers, Tags, Settings, Account and CustomerAuthContext all had no rejection handling at all, so `void` on them would have hidden real failures rather than annotated deliberate ones. Each of those loaders now catches and surfaces the failure before the call site voids it. The CustomerAuthContext one was a live bug — a rejected fetchMe left loading true forever, rendering as a permanent spinner instead of a signed-out page.
Admin's load became a useCallback so its effect can name it honestly rather than suppress the dependency, Categories' drop handler was split so the function antd receives returns void as its type says, and Cart's effect now names refreshCartContext, which is a useCallback with an empty dependency list and so cannot re-run it. The only disable added is in asyncRoute, where returning a promise where Express expects void is the entire point of the wrapper and the promise cannot reject.
Two of the issue's premises did not survive measurement, both recorded in the spec: exhaustive-deps flags 2 cases rather than the 10 inferred from empty dependency arrays, and the backend was already clean on the defect rules because #59 wrapped every async route.
Verified: lint, build, 78 unit, 134 integration and 83 e2e all pass in both workspaces, and the CI gate was confirmed to fail by introducing a deliberate violation rather than by assuming the job is wired correctly.
Closes#60
The QA image is now built by Portainer from this repository rather than
by Gitea Actions. Deployed as a Git repository stack, "Pull and redeploy"
pulls the repo, builds from the Dockerfile, and recreates the containers
in one action.
This removes the runner from the loop entirely. Three dispatches failed
without ever building: the runner refuses privileged containers, so the
dind service was never created. Working around that needed either the
host Docker socket mounted into the runner or privileged containers
enabled runner-wide, and both hand every workflow on every branch
root-equivalent control of the NAS, production included. Portainer
already holds the socket — that is how it manages containers — so
building there needs no new privilege at all.
pull_policy: build is what keeps it honest. Without it the stack reuses
whatever is tagged redefined-designs:qa, which is exactly how a redeploy
appears to succeed while still serving old code — a failure this project
has already hit twice.
Deleting qa-build.yml also drops the registry, the REGISTRY_TOKEN and
BREVO_API_KEY secrets, and the notification email. The email existed
because CI worked asynchronously and had to tell you when it finished;
redeploying from Portainer is synchronous, so the browser already does.
Losing the per-commit image tags is a real cost — rollback becomes
"rebuild from the ref you want" rather than retagging a specific build.
README changes for this are deliberately not in this commit: that file
also carries uncommitted work of Thom's.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The job was holding the runner for hours on every push and pull request.
On 2026-08-18 it ran 3h12m, and because the runner is sequential it kept
frontend-e2e queued behind it for that whole time.
It is not a slow suite. That run executed 89 tests in 87 seconds, then
logged "Jest did not exit one second after the test run has completed"
and sat there until something killed it — an open handle keeping the
event loop alive after the run finishes. The same run also failed with
relation "orders" does not exist, so the schema was missing in CI even
though globalSetup migrates. Neither is root-caused yet; both follow the
job into its new home.
So the new workflow carries a 15-minute timeout. The suite needs about
90 seconds, so this is not a performance budget — it is a stop that turns
a post-run hang into minutes of wasted runner time rather than hours.
Tests.yml keeps a comment saying where the job went, so its absence reads
as deliberate rather than as something lost in an edit.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The first dispatch failed at the wait step with nothing to act on. The
run logs showed the dind service container was never created — no service
startup output at all, and teardown reporting "No such container" for the
ID it had recorded — which is what act_runner does when it refuses a
privileged container.
From the failing step, that is indistinguishable from dockerd simply
being slow, so the step now says which one it is: if the service host
resolves, the container exists and dockerd is not serving plain TCP on
2375; if it does not resolve, the service never started and the runner
needs container.privileged.
Also raises the wait from 30s to 90s. The NAS took the full 30s before
failing, so the old ceiling was too close to the observed time to
distinguish slow from broken.
The docker CLI fallback is retained: the runner image has no docker
binary, and the static install worked.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Adds a workflow_dispatch job that builds a chosen ref, pushes it to the
Gitea container registry as :qa plus a commit-sha tag, and emails when it
is ready. It deliberately does not restart the QA stack — redeploying
stays a human action in Portainer.
The build runs against a Docker-in-Docker service rather than the NAS's
Docker socket. Mounting the host socket into the runner would give every
workflow on every branch root-equivalent control of the NAS, production
included; pushing to a registry means the image does not need to survive
in the build daemon.
The QA stack now pulls that image instead of requiring a local build. The
previous arrangement meant the image existed only if someone remembered
to build it, which produced two confusing failures already: a Docker Hub
"pull access denied" when the tag was missing, and a silent stale-image
deploy when the build had not been rerun.
Two runner capabilities cannot be verified from here — privileged service
containers for dind, and a docker CLI in the runner image. The workflow
checks both and fails with an explanation rather than a connection
refused, and validates all five required secrets and variables up front
rather than part-way through a build.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
npm treats NODE_ENV=production as --omit=dev, so `npm install` in the
frontend-e2e job skipped typescript and the build died on `tsc: not found`.
The same env var would have stripped vite and @playwright/test from the
frontend install, and flipped the session cookie to Secure on a run served
over plain http.
The reported summarize crash was a symptom: the job aborted before Playwright
ran, but Summarize is `if: always()` and threw ENOENT on the missing JSON,
burying the real failure. Both summarize scripts now report the missing file
and exit 0 -- the job still fails via its own step.
Also split build from start, replaced `sleep 3` with a readiness poll against
/api/config, and dump the backend log when e2e fails.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>