mirror of
https://github.com/civitai/civitai.git
synced 2026-09-20 22:08:18 +08:00
cdf6fc8cc1
* feat(apps): surface Build apps as an /apps/* subnav tab, collapse the dropdown to one entry
The user-menu dropdown carried two adjacent App-Blocks rows — "Build apps" ->
/apps/get-started and "Apps" -> /apps. A moderator holds both flags, so they saw
two near-identical rows for one product.
Now there is ONE row. Its href is /apps with store access and /apps/get-started
without: a get-started-only viewer cannot load /apps at all (its
getServerSideProps runs resolveAppsPageAccess, which returns notFound), so
pointing them there would be a menu entry into a 404. "Build apps" is instead a
tab in the shared /apps/* subnav, ahead of Marketplace.
The subnav's whole-bar gate widens from hasAppsStoreAccess(features) to
hasAppsStoreAccess(features) || features.appBlocksGetStarted. This is required,
not optional: on the old gate the container returns null for exactly the cohort
the new tab exists for, so the tab would be invisible to them on the one page
they can load. Safe on the first paint for the same reason already written out
for appBlocksAuthor — appBlocksGetStarted is SSR-seeded into pageProps.flags,
frozen by useState in FeatureFlagsProvider, and not toggleable, so the server and
first client renders compute the same boolean.
Visibility side effect, intended: "Build apps" is unconditional, so the always-on
tab set goes 1 -> 2 and the "< 2 tabs" collapse no longer hides the bar for a
non-author with no installs. Those viewers now get the subnav on all 13 /apps/*
routes. The collapse branch is kept but is no longer reachable through the
container, and the tests that used to cover it say so rather than pretending.
The app-block chrome menu deliberately does NOT mirror the new row: it opens over
a RUNNING app, and it has no feature-flag plumbing, so mirroring a kill-switched
page would keep offering it after the switch. That exclusion is now asserted as a
set in chromeNavAlignsWithSubNav.test.ts rather than left as silence.
Known and unchanged by this commit: the Marketplace tab is unconditional, so a
viewer admitted by the get-started term alone sees a tab that /apps answers with
notFound. Not reachable today (the flag is staged mod-only and a moderator holds
the store flags), but the trigger is a Flipt toggle, not a deploy. Gating
Marketplace is not the fix — it drops that viewer to one tab and the collapse
hides the whole bar again. Recorded on both the subnav entry and get-started.tsx.
* fix(apps): gate the Build apps tab on appBlocksGetStarted, not on nothing
The tab was `visible: () => true` while the whole-bar gate is an OR
(`hasAppsStoreAccess(features) || features.appBlocksGetStarted`). A viewer with
store access but WITHOUT the get-started flag therefore passed the gate and was
offered a "Build apps" tab whose page answers 404 -- `resolveGetStartedAccess`
returns `{ notFound: true }` and the client body renders `<NotFound/>`.
Two consequences, both real:
1. A non-mod `app-dev-testers` member holds `appBlocks` (so `hasAppsStoreAccess`)
but not `appBlocksGetStarted`, so they were offered a 404. Before this branch
they had no route to that page at all -- the dropdown row was correctly gated
on `appsNav.getStarted`.
2. Flipping `app-blocks-get-started` OFF in Flipt -- the flag's stated purpose as
a kill switch -- no longer removed the nav entry, because every mod holds
`appBlocks` and the tab was unconditional. Section 4 of `IframeHost.tsx` cites
exactly that hazard as the reason to EXCLUDE the route from the app-block
chrome nav; the subnav was doing the thing that argument forbids.
The fix follows the pattern already established for `isAuthor`:
`AppsNavContext` gains `canGetStarted`, the container derives it from
`features.appBlocksGetStarted`, and the tab reads `visible: (_s, c) =>
c.canGetStarted`. The flag is verified NOT `toggleable` (absent from
`computeUserFeatureFlagsOverlay`; defined at feature-flags.service.ts:561), so it
is SSR-frozen and safe outside the `useIsClient` deferral -- the same argument
the branch already makes for the bar gate.
`canGetStarted` is deliberately NOT session-scoped, unlike `isAuthor`:
`resolveGetStartedAccess` reads the flag and consults no user, so folding it into
the `currentUser` branch would hide the tab from a logged-out viewer the page
would serve, and (Marketplace being their only other tab) the `< 2` collapse
would then hide the whole bar from them.
Resulting behaviour, each pinned by a test:
- store access only -> Marketplace alone, `< 2` collapse, no bar
= exactly main's behaviour, no new regression
- appBlocksGetStarted only -> Build apps + Marketplace, bar renders
- both -> both tabs
- logged out, no flag -> no bar; logged out WITH the flag -> Build apps
Because the collapse is reachable again, the test blocks the previous round had
to relabel as "pinning the view, not a cohort" are relabelled back to what they
are: coverage of a live cohort. Mutation-checked -- deleting `links.length < 2`
turns 6 of them red.
Also in this commit:
* chromeNavAlignsWithSubNav.test.ts: the exclusion ledger's parse controls sat on
the live counts (`toBe(4)` / `>= 8`), so they STOLE the ledger's failure.
Adding a chrome entry failed on `expect(inChrome.size, 'parsed no items out of
the chrome platform nav').toBe(4)` -- a message that states the opposite of
what happened -- and the `toEqual` ledger never ran, leaving its SHRINK
direction unproven. All three count controls are now `>= 1` and the ledgers own
their sets. Re-mutated both ways: GROW and SHRINK now each die on the ledger's
own assertion and message.
* The two `/apps/*/edit.tsx` notes and the `get-started.tsx` referent they point
at contradicted each other after this branch. Repaired against the code: the
empty-band case is closed for `/apps/get-started` and still open for the other
pages, and the tab enumeration now includes the get-started capability.
* AppsPageLayout.geometry.browser.test.tsx: dropped the computed-but-unasserted
`tabWidth`, put every element lookup behind a `required()` helper that fails
with the actual diagnosis instead of a bare TypeError, and rewrote the stale
header note from the post-fix code.
* preview-apps-marketplace.spec.ts documented "first tab is Marketplace
(AppsSubNav.tsx:55)" -- both halves wrong. Now names the row it keys on and
says why order is irrelevant to the assertion.
* hooks.tsx: recorded the label/destination decision for the get-started-only
cohort ("Apps" + plug glyph, navigating to onboarding) rather than leaving it
undocumented, with the trigger to re-decide it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SzWdpMmKwGdvb2K3eSua8h
* docs(apps): the hydration note now covers all three SSR-frozen inputs
It said "Both inputs" and enumerated only `appBlocksAuthor` and
`currentUser.isModerator`; the context object now derives a third,
`canGetStarted`, from `features.appBlocksGetStarted`. Same argument, same
evidence (not `toggleable`, so the client overlay cannot move it) — the comment
just did not say so.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SzWdpMmKwGdvb2K3eSua8h
* test(apps): own parseAllChromeLinks' route set with a ledger; correct two decision records
Round 3. Round 2 relaxed three count controls in
chromeNavAlignsWithSubNav.test.ts from exact counts to
toBeGreaterThanOrEqual(1). Two of those were right — their sets are still
owned by a toEqual elsewhere (the exact 4-item platform nav; the exact
excluded set). The third was not: nothing owned parseAllChromeLinks' set,
and its comment claimed "same reasoning as the two above", which was false.
The gap was live, not theoretical. Rules (a) and (b) in that test are
per-link, so a new chrome item pointing at a route SUB_NAV_LINKS already
carries, wearing that row's own glyph, satisfies both. Measured on the
pre-fix tree: adding a "Build apps" ChromeSurfaceItem for /apps/get-started
to the ⋮ overflow — outside the platform-nav slice the other ledgers
enumerate — passed all 8 tests. That is exactly the hazard the DELIBERATE
SUBSET note in IframeHost.tsx argues disqualifies the route: this surface
has no feature-flag plumbing, so it would keep advertising a page that
answers notFound once appBlocksGetStarted goes down.
Fix: add (d), a toEqual ledger over the whole literal-href set, sorted (so a
reorder cannot report a route change that did not happen) and keeping
duplicates (/apps/installed legitimately appears twice). Placed after
(a)/(b)/(c) so the more specific rules keep their own messages. toBe(5) is
deliberately NOT restored — it misdirected, telling a maintainer who added a
legitimate destination that the scanner had broken.
Mutation matrix (unit tier, isolated to one hunk each):
overflow-grow (add /apps/get-started to the ⋮ overflow)
pre-fix : 8 passed — SURVIVED
post-fix: 1 failed | 7 passed — dies on (d)'s own message,
"the set of routes the app-block chrome links to has changed…:
expected [ '/apps', '/apps/get-started', …(4) ] to deeply
equal [ '/apps', '/apps/installed', …(3) ]"
(a) and (b) pass, so (d) is the only thing that catches it.
platform-shrink-review (delete the Review item)
(d) fires with its own message (…(2) vs …(3)). Three other tests also
fire, which is correct — a platform-nav item is owned by them too.
overflow-shrink (delete "Manage apps")
dies on (c), which precedes (d) in the same test. Recorded honestly:
every EXISTING link is already owned by some assertion for SHRINK, so
(d)'s unique contribution is GROW outside the platform-nav slice.
Re-confirmed, no regression, both pre-existing ledgers still die on their
own toEqual:
exclusion ledger GROW (new SUB_NAV_LINKS row) → 1 failed | 7 passed
exclusion ledger SHRINK (add get-started to the platform nav) → fires
expected-glyphs GROW (same mutant) → fires
expected-glyphs SHRINK (delete Review) → fires
Also corrected two decision records that claimed more than the repo can
establish:
- The comment above the relaxed floor no longer says "same reasoning as the
two above". It now says this floor has NO sibling toEqual to inherit its
set from, and points at (d).
- hooks.tsx: the re-decide trigger was narrower than the branch's
reachability. The branch is `!marketplace && getStarted`, so the store
flags NARROWING (app-blocks-enabled / app-listings turned off in Flipt
while get-started stays on) reaches it just as widening get-started does —
every moderator would then see a row labelled "Apps" with
IconPlugConnected navigating to developer onboarding. Both directions are
now named. The stated ground was also a live-Flipt claim presented as
derived from `availability`; since getFeatureFlags returns Flipt's answer
before evaluating roles, `availability` is only the Flipt-down fallback, so
the cohort's emptiness is observable only in live Flipt. Said so. All four
App-Blocks flags verified Flipt-backed (feature-flags.service.ts:510, 520,
561, 571). Comment-only; no behaviour change.
Verification (branch worktree, instruments validated before each zero):
- vitest --project unit <this file> → 8 passed; whole AppLayout+guard → 32 passed
- vitest --project component (unfiltered) → 217 files, 2403 tests, all passed
- node scripts/typecheck.mjs → OK — 0 type errors in 81s. Positive control:
the identical command minutes earlier reported 2848 errors off a Prisma
client generated 2025-12-08; `prisma generate` under the flake's engine
paths takes it to 0.
- eslint on both files → 0. Positive control: src/utils/zod-helpers.ts
appended reports 6 problems. Array-quoted, file count read back as 2.
- prettier --check → clean. Negative control: a misformatted scratch file
reports "Code style issues found".
- Merged tree vs current origin/main (d6e5c4eec0, which moved #4666 into
IframeHost.tsx — the very file this ledger scans): the guard, including
(d), passes against the merged sources. gh reports MERGEABLE / CLEAN.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SzWdpMmKwGdvb2K3eSua8h
* test(apps): tighten (d)`s note — (a)/(b) do catch a WRONG added link
The topic sentence said (a) and (b) "structurally cannot see an ADDITION",
then immediately qualified it to the case that matters. The unqualified half
was broader than the truth: (a) does catch an invented route, and (b) a store
route drawn with the wrong glyph. What they cannot see is an addition that is
itself well-formed — a route SUB_NAV_LINKS already carries, under that row`s
own icon — which is the gap (d) exists for. Says that now.
Comment-only. vitest --project unit <this file> -> 8 passed; eslint 0;
prettier clean.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SzWdpMmKwGdvb2K3eSua8h
* test(apps): widen the chrome-link parser so (d) owns the set its message claims
F1. `parseAllChromeLinks` matched `<ChromeSurfaceItem …>text</…>` and dropped
anything lacking BOTH a literal href AND a `leftSection={<IconX`. Two ordinary
shapes walked through, both measured SURVIVING on 136fffd647 (8 passed):
P1 `<ActionIcon component={Link} href="/apps/get-started" …>` in the overflow
— the shape the chrome ALREADY uses for its `/apps` back-link;
P2 `<ChromeSurfaceItem href="/apps/get-started">Build apps</ChromeSurfaceItem>`
with no `leftSection` — legal, `ChromeSurface.tsx` types it optional, and
the very element (d) claims to enumerate.
Either is an ungated door into a flag-gated route with every test green. The
parser now scans TAGS rather than one tag name and treats the glyph as optional
metadata rather than a condition of inclusion, so (d) owns the whole literal-href
set: 7 sites, adding the compact back chevron and the breadcrumb crumb. Rule (b)
is scoped to links that HAVE a `leftSection` glyph and says so — an element with
no glyph cannot be drawing the route with the wrong one. Both shapes are pinned
as parser fixtures, plus a negative control that an href nested in another
element's attributes is not attributed to the outer tag.
Post-fix both die on (d)'s own message; (a)/(b)/(c) pass, so (d) is the only
thing that catches them.
F2. (d)'s message and comment claimed the whole chrome "has no feature-flag
plumbing at all — the only condition anywhere on it is `isModerator`". That was
true of the platform-nav SLICE and is false of the surface (d) governs:
`chromeBody()` spans through `ChromeDesktopLeadingGroup`, which renders
`<ChromeReviewMenuItem>` gating on `hasAppsStoreAccess(useOptionalFeatureFlags())`
and again through `useCanReviewListing` -> `resolveClientStoreScope`. Rewritten
from the code: every literal-href item is unconditional except the
moderator-gated `/apps/review`, and the surface CAN read flags — so the ledger
now hands a maintainer three options (exclude, or add it GATED the way that item
is) instead of foreclosing the one the repo already demonstrates.
F3. `hooks.tsx` named two of the three flags behind `marketplace`.
`hasAppsStoreAccess` is `appListings || appBlocks || appListingsPublicExternal`,
so it takes ALL THREE going off to reach the branch — a mod holding
`app-listings-public-external` alone keeps `marketplace === true`. Also scoped
the `availability` sentence: it is the Flipt-DOWN fallback for the ROLE terms
only; env/region/server-colour terms run BEFORE Flipt and Flipt cannot override
them. Accurate for a `['mod']` flag, which is the case at hand.
Comment-only in `hooks.tsx`; no behaviour change anywhere.
* docs(apps): say "gated by no flag", not "rendered unconditionally", in the chrome route ledger
Round-4 audit finding F-1. The ledger's comment and its assertion message both
claimed every literal-href item in the chrome is "rendered UNCONDITIONALLY"
except the moderator-gated /apps/review. Two of the seven are in fact
conditional: the compact back chevron renders only under `compact`
(IframeHost.tsx:762, `compact = isPage && geometry.compact`), and the breadcrumb
crumb only under `isPage` (:1048). Those are the two sites the previous round
added to the set.
The inference the sentence supports is unaffected — neither condition is a
feature flag, and the only useFeatureFlags() call in the file sits outside
chromeBody() — so no detection and no maintainer decision changes. But the
sentence as written was false, and this is the fourth draft of it to overstate
in the same direction: each round rewrote it while fixing the previous round,
and each rewrite widened a scope the code had not widened.
So the correction is the narrow one the code supports ("gated by a FEATURE
FLAG"), and the comment now records the two layout-conditional sites by name
plus the instruction not to reach for "unconditionally" again. Recording the
dead drafts is deliberate: it is what stops a fifth being derived.
Verified: guard 8/8; eslint 0 on the changed file (6-problem positive control on
src/utils/zod-helpers.ts in the same run); prettier clean, with a deliberately
misformatted control watched to fail first.
Ends the audit ladder. Rounds 2-4 changed 0 executable payload lines; the
remaining findings are prose about prose, so further rounds would audit the
ladder rather than the PR.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SzWdpMmKwGdvb2K3eSua8h
---------
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Video: https://discord.com/channels/955572167662260295/1062092338698145812/1338591521401733192
# Playwright Testing
### Goal
E2E testing for the main app.
Catch potential issues when changing code, or evaluate edge cases.
### Anti-Goal
For this to be a frustrating pain-in-the-ass time vampire.
It's better to have 1 decent test than try to do 10 perfect ones and give up because it's too time consuming.
### What to Do
- Write tests for common user flows (fill a form, click a button, get a result)
- Handle what-if scenarios (errors, browsers, etc)
- A good rule of thumb is: if it's been reported as a bug, we should have a test in place for it (and things like it)
### What Not to Do
- Write "1==1" tests (dopamine hit, but pointless)
- Mandate coverage percentages (leads to annoyance and features not being done)
---
### How
Testing is intended to work on local development (docker) for consistency with users/data and easy tear down.
1) Run local services (`make init` or devcontainers)
2) Create a file in the `tests/` directory, or use an existing one. Doesn't really matter. Open to directory structure, so something like `tests/generator/gen-queue.spec.ts` would be reasonable.
3) Start writing tests.
(a) can be done by hand if you know what you're looking to do
(b) easier approach: `npm run test:gen -- --load-storage tests/auth/{user}.json --viewport-size 1920,1080 http://localhost:3000/{url}`
- This allows you to create tests by interacting with the page and picking locators
(c) we'll need better locators, especially for icons. add `data-testid=` to the places you need them (they'll be stripped from production)
(d) use the various authed users to test different scenarios (mod, full access, muted, etc)
(e) feel free to mock responses from any of the APIs, but in general it's best to only do this for external services
4) Run with either `npm run test` or `npm run test:ui` to do it interactively with screenshots
5) If you need to reset the db after each test, you can either:
(a) clean up the mutations as part of the test (delete an object you just made)
(b) `make boostrap-db` to reset the whole database back to normal
6) We'll eventually set up the github action to run this before a deploy
### Test Failures
There are 4 types of test failures:
1) A bad test (always fail)
- these might have bad selectors or inaccurate logic
- **solution**: fix them
2) A flaky test (sometimes fail)
- frustrating tests which seem to pass most of the time, but not always
- this is usually a result of race conditions, mismatched timing, or not properly awaiting events like animations
- **solution**: narrow down which part of the test fails, and catch the flaky issue
3) Intended code change
- we might have changed the verbiage on a button, which makes certain locators no longer work
- this is fine, although locators should try to be as agnostic as possible
- alternatively, we might have simply changed the business logic
- **solution**: in either case, simply update the test itself
4) Unintended code change
- you've changed something in the app, and a test breaks due to the introduction of a bug
- this is the major reason we have tests
- **solution**: leave the tests alone, they're doing their job. fix the code.