Merge branch 'main' into feat/auto-form-detection

This commit is contained in:
Frooodle
2026-07-30 12:54:55 +01:00
19 changed files with 550 additions and 237 deletions
+3
View File
@@ -55,3 +55,6 @@ jobs:
path: frontend/.a11y-scan/
retention-days: 7
if-no-files-found: ignore
# The reports live in a dot-directory, which upload-artifact treats as
# hidden and silently skips by default.
include-hidden-files: true
+3
View File
@@ -92,6 +92,9 @@ jobs:
path: frontend/.a11y-scan/
retention-days: 14
if-no-files-found: ignore
# The reports live in a dot-directory, which upload-artifact treats as
# hidden and silently skips by default.
include-hidden-files: true
# Builds all desktop platforms on a schedule so the Rust dependency cache is
# written on main, where PR and merge-queue tauri builds can restore it.
+21 -21
View File
@@ -184,13 +184,13 @@ tasks:
storybook:
desc: "Start Storybook dev server"
deps: [install]
deps: [prepare]
cmds:
- npx storybook dev -p 6006 {{.CLI_ARGS}}
storybook:build:
desc: "Build static Storybook"
deps: [install]
deps: [prepare]
cmds:
- npx storybook build {{.CLI_ARGS}}
@@ -204,7 +204,7 @@ tasks:
storybook:test:
desc: "Scan every story in real Chromium: it must render and pass axe"
deps: [install, storybook:browser]
deps: [prepare, storybook:browser]
cmds:
# Runs each story as a browser test. Pass a filter through, e.g.
# task frontend:storybook:test -- Button
@@ -212,44 +212,44 @@ tasks:
storybook:a11y:
desc: "a11y regression gate over every story: fail only on NEW axe violations"
deps: [install, storybook:browser]
deps: [prepare, storybook:browser]
cmds:
- bash .storybook/a11y-scan.sh
- node .storybook/a11y-scan.mjs
- node .storybook/a11y-check.mjs --in .a11y-scan --manifest .a11y-scan/manifest.txt
storybook:a11y:changed:
desc: "a11y gate over stories changed vs a base ref (default origin/main)"
desc: "a11y gate over the stories this branch affects (default base origin/main)"
summary: |
Scans only the stories this branch touches, which is what pull requests
run — a full scan takes ~30 minutes, far too long to sit in front of every
merge. The nightly job covers the rest of the suite.
Scans the stories a branch affects, which is what pull requests run — a
full scan takes ~30 minutes, far too long to sit in front of every merge.
A story is affected if its file changed, or if a same-named sibling
source file changed (editing Button.tsx or Button.css re-scans
Button.stories.tsx — the story renders the live component, so a component
edit changes what the story shows without touching the story file).
Changes that ripple further than a component's own stories are covered by
the nightly full sweep.
Pass a base ref through CLI_ARGS, e.g.
task frontend:storybook:a11y:changed -- origin/release
deps: [install, storybook:browser]
deps: [prepare, storybook:browser]
vars:
BASE: '{{.CLI_ARGS | default "origin/main"}}'
# Stories touched by this branch, plus any not yet committed.
CHANGED:
sh: |
{ git diff --name-only --diff-filter=d {{.CLI_ARGS | default "origin/main"}}...HEAD -- '*.stories.ts' '*.stories.tsx';
git diff --name-only --diff-filter=d -- '*.stories.ts' '*.stories.tsx';
git ls-files --others --exclude-standard -- '*.stories.ts' '*.stories.tsx'; } \
| sed 's|^frontend/||' | sort -u | tr '\n' ' '
sh: node .storybook/a11y-changed.mjs {{.CLI_ARGS | default "origin/main"}}
cmds:
- cmd: |
if [ -z "{{.CHANGED}}" ]; then
echo "a11y: no story files changed vs {{.BASE}} — nothing to check"
if [ -z '{{.CHANGED}}' ]; then
echo "a11y: no story files affected vs {{.BASE}} — nothing to check"
exit 0
fi
bash .storybook/a11y-scan.sh {{.CHANGED}}
node .storybook/a11y-scan.mjs {{.CHANGED}}
node .storybook/a11y-check.mjs --in .a11y-scan --manifest .a11y-scan/manifest.txt
storybook:a11y:record:
desc: "Re-record the a11y baseline (run after intentionally fixing/adding violations)"
deps: [install, storybook:browser]
deps: [prepare, storybook:browser]
cmds:
- bash .storybook/a11y-scan.sh
- node .storybook/a11y-scan.mjs
- node .storybook/a11y-check.mjs --in .a11y-scan --manifest .a11y-scan/manifest.txt --record
# ============================================================
+2 -2
View File
@@ -73,7 +73,7 @@ tasks:
- task: gitleaks
install:
desc: "Install the pinned pre-commit Python tools (ruff, codespell, toml-sort)"
desc: "Install the pinned pre-commit Python tools"
run: once
cmds:
- uv sync --project scripts/pre-commit --locked
@@ -112,7 +112,7 @@ tasks:
toml-sort:
deps: [install]
cmds:
- uv run --project scripts/pre-commit --no-sync toml-sort --all --ignore-case {{if .FIX}}--in-place{{else}}--check{{end}} {{.LOCALE_TOML}}
- uv run --project scripts/pre-commit --no-sync python scripts/pre-commit/sort_locale_toml.py {{if .FIX}}--fix {{end}}{{.LOCALE_TOML}}
whitespace:
cmds:
@@ -46,9 +46,10 @@ public class DefaultClassificationPolicySeeder {
.ifPresent(team -> seedIfMissing(team.getId(), team.getName()));
}
// Any team created at runtime (admin-created, SaaS sign-ups); after the team's commit so a
// rolled-back team never leaves a policy behind.
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
// Any team created at runtime (admin-created, SaaS sign-ups). Seeds inside the team's own
// transaction: rollback still leaves no policy behind, and the store's pessimistic lock needs a
// live transaction, which AFTER_COMMIT cannot offer.
@TransactionalEventListener(phase = TransactionPhase.BEFORE_COMMIT)
public void onTeamCreated(TeamCreatedEvent event) {
seedIfMissing(event.teamId(), event.teamName());
}
@@ -7,6 +7,7 @@ import org.hibernate.annotations.CreationTimestamp;
import org.hibernate.annotations.OnDelete;
import org.hibernate.annotations.OnDeleteAction;
import org.hibernate.annotations.UpdateTimestamp;
import org.springframework.data.domain.Persistable;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
@@ -16,6 +17,7 @@ import jakarta.persistence.JoinColumn;
import jakarta.persistence.MapsId;
import jakarta.persistence.OneToOne;
import jakarta.persistence.Table;
import jakarta.persistence.Transient;
import lombok.Getter;
import lombok.NoArgsConstructor;
@@ -37,7 +39,7 @@ import stirling.software.proprietary.security.model.User;
@NoArgsConstructor
@Getter
@Setter
public class SaasUserExtensions implements Serializable {
public class SaasUserExtensions implements Serializable, Persistable<Long> {
private static final long serialVersionUID = 1L;
@@ -80,4 +82,17 @@ public class SaasUserExtensions implements Serializable {
public boolean isMeteredBillingEnabled() {
return Boolean.TRUE.equals(hasMeteredBillingEnabled);
}
@Override
public Long getId() {
return userId;
}
// Decided on the timestamp, not the id: the constructor pre-sets the @MapsId id, so an
// id-based check would route a new row to merge() and fail with "null identifier".
@Override
@Transient
public boolean isNew() {
return createdAt == null;
}
}
@@ -239,7 +239,7 @@ public class SupabaseAuthenticationFilter extends OncePerRequestFilter {
&& !supabaseUser.isAnonymous()) {
user = upgradeAnonymousUser(user, supabaseUser, jwt);
}
return recoverMissingTeam(user);
return user;
}
return createUser(jwt, supabaseId, email, appMetadata);
@@ -271,10 +271,8 @@ public class SupabaseAuthenticationFilter extends OncePerRequestFilter {
user.setUsername(supabaseUser.getEmail());
}
try {
User saved = userService.saveUser(user);
// Give the account its own team rather than the shared Default team.
saved.setTeam(saasTeamService.ensurePersonalTeam(saved));
return saved;
return saasTeamService.saveUserWithPersonalTeam(user);
} catch (DataIntegrityViolationException e) {
log.warn(
"Email collision upgrading anonymous user {} to {}: {}",
@@ -372,60 +370,22 @@ public class SupabaseAuthenticationFilter extends OncePerRequestFilter {
throw new AuthenticationFailureException("Failed to create SupabaseUser", e);
}
User savedUser;
boolean weCreatedThisUser = true;
// Guests get NO team: the editor is free and needs none. Everyone else is provisioned
// atomically, so a user visible to a parallel request always already has one.
try {
savedUser = userService.saveUser(newUser);
return isAnonymous(jwt)
? userService.saveUser(newUser)
: saasTeamService.saveUserWithPersonalTeam(newUser);
} catch (DataIntegrityViolationException dup) {
// Parallel filter won the race; fetch the winning row.
weCreatedThisUser = false;
savedUser =
userService
.findBySupabaseId(supabaseId)
.orElseThrow(
() ->
new AuthenticationFailureException(
"User creation conflict, but unable to find existing user",
dup));
return userService
.findBySupabaseId(supabaseId)
.orElseThrow(
() ->
new AuthenticationFailureException(
"User creation conflict, but unable to find existing user",
dup));
}
// Only the DB-race winner runs first-time init; the losers skip it. Guests (anonymous
// sessions) get NO team: the editor is free and needs none, and automation requires a
// real account.
if (weCreatedThisUser && !isAnonymous(jwt)) {
try {
savedUser.setTeam(saasTeamService.ensurePersonalTeam(savedUser));
} catch (Exception e) {
log.warn(
"Failed to create personal team for new user {} ({}): {}",
LogRedactionUtils.redactSupabaseId(supabaseId),
LogRedactionUtils.redactEmail(savedUser.getUsername()),
e.getMessage());
}
}
return savedUser;
}
/**
* Recover an account stranded without a team: signup is the only other place one is assigned,
* so a null team_id is otherwise permanent — and portal access derives from leading a team.
* Guests get none by design.
*/
private User recoverMissingTeam(User user) {
if (user.getTeam() != null
|| ANONYMOUS.toString().equalsIgnoreCase(user.getAuthenticationType())) {
return user;
}
try {
user.setTeam(saasTeamService.ensurePersonalTeam(user));
log.info("Assigned a personal team to user {} which had none", user.getId());
} catch (Exception e) {
log.warn(
"Could not assign a personal team to user {}: {}",
user.getId(),
e.getMessage());
}
return user;
}
private boolean apiKeyAuthenticated(HttpServletRequest request) throws AuthenticationException {
@@ -53,6 +53,18 @@ public class SaasTeamService {
public static final String DEFAULT_TEAM_NAME = "Default";
public static final String INTERNAL_TEAM_NAME = "Internal";
/**
* Persist a user and their personal team atomically: an account with no team has no portal
* access and no way to acquire one, so a teamless user must never be committed. Constraint
* violations (the concurrent-signup race) propagate for the caller to resolve.
*/
@Transactional
public User saveUserWithPersonalTeam(User user) {
User saved = userService.saveUser(user);
saved.setTeam(ensurePersonalTeam(saved));
return saved;
}
/** Returns the user's personal team, creating one if they have none. Idempotent. */
@Transactional
public Team ensurePersonalTeam(User user) {
@@ -60,9 +72,37 @@ public class SaasTeamService {
if (existing != null && saasTeamExtensionService.isPersonal(existing)) {
return existing;
}
// An empty users.team_id does not prove there is no personal team; adopt one the user
// already owns rather than minting a second.
Team owned = existingPersonalTeam(user);
if (owned != null) {
user.setTeam(owned);
userService.saveUser(user);
return owned;
}
return createPersonalTeam(user);
}
/**
* The personal team the user already owns — their recorded home, else a solo team they lead.
*/
private Team existingPersonalTeam(User user) {
Long homeId = saasUserExtensionService.getHomeTeamId(user);
if (homeId != null) {
Team home = teamRepository.findById(homeId).orElse(null);
if (home != null) {
return home;
}
}
for (TeamMembership membership : membershipRepository.findByUserId(user.getId())) {
Team team = membership.getTeam();
if (membership.isLeader() && membershipRepository.countByTeamId(team.getId()) == 1) {
return team;
}
}
return null;
}
/**
* Create personal team for new user during signup or migrate existing user from Default team
*
@@ -315,14 +315,13 @@ class SupabaseAuthenticationFilterMoreTest {
local.setSupabaseId(supabaseId);
local.setAuthenticationType(AuthenticationType.ANONYMOUS);
when(userService.findBySupabaseId(supabaseId)).thenReturn(Optional.of(local));
when(userService.saveUser(any(User.class))).thenAnswer(inv -> inv.getArgument(0));
when(saasTeamService.ensurePersonalTeam(any(User.class))).thenReturn(new Team());
when(saasTeamService.saveUserWithPersonalTeam(any(User.class)))
.thenAnswer(inv -> inv.getArgument(0));
bearer("tok");
filter.doFilter(request, response, chain);
verify(userService).saveUser(any(User.class));
verify(saasTeamService).ensurePersonalTeam(any(User.class));
verify(saasTeamService).saveUserWithPersonalTeam(any(User.class));
assertThat(local.getEmail()).isEqualTo("real@example.com");
assertThat(local.getUsername()).isEqualTo("real@example.com");
assertThat(local.getAuthenticationType())
@@ -342,8 +341,8 @@ class SupabaseAuthenticationFilterMoreTest {
local.setSupabaseId(supabaseId);
local.setAuthenticationType(AuthenticationType.ANONYMOUS);
when(userService.findBySupabaseId(supabaseId)).thenReturn(Optional.of(local));
when(userService.saveUser(any(User.class))).thenAnswer(inv -> inv.getArgument(0));
when(saasTeamService.ensurePersonalTeam(any(User.class))).thenReturn(new Team());
when(saasTeamService.saveUserWithPersonalTeam(any(User.class)))
.thenAnswer(inv -> inv.getArgument(0));
bearer("tok");
filter.doFilter(request, response, chain);
@@ -365,7 +364,7 @@ class SupabaseAuthenticationFilterMoreTest {
local.setSupabaseId(supabaseId);
local.setAuthenticationType(AuthenticationType.ANONYMOUS);
when(userService.findBySupabaseId(supabaseId)).thenReturn(Optional.of(local));
when(userService.saveUser(any(User.class)))
when(saasTeamService.saveUserWithPersonalTeam(any(User.class)))
.thenThrow(new DataIntegrityViolationException("email exists"));
bearer("tok");
@@ -489,12 +488,13 @@ class SupabaseAuthenticationFilterMoreTest {
org.mockito.Mockito.doThrow(new DataIntegrityViolationException("dup"))
.when(supabaseUserService)
.createSupabaseUser(eq(supabaseId), any(), eq(false));
when(userService.saveUser(any(User.class))).thenAnswer(inv -> inv.getArgument(0));
when(saasTeamService.saveUserWithPersonalTeam(any(User.class)))
.thenAnswer(inv -> inv.getArgument(0));
bearer("tok");
filter.doFilter(request, response, chain);
verify(userService, times(1)).saveUser(any(User.class));
verify(saasTeamService, times(1)).saveUserWithPersonalTeam(any(User.class));
assertThat(SecurityContextHolder.getContext().getAuthentication())
.isInstanceOf(EnhancedJwtAuthenticationToken.class);
}
@@ -516,7 +516,7 @@ class SupabaseAuthenticationFilterMoreTest {
filter.doFilter(request, response, chain);
assertThat(response.getStatus()).isEqualTo(401);
verify(userService, never()).saveUser(any());
verify(saasTeamService, never()).saveUserWithPersonalTeam(any());
}
@Test
@@ -533,13 +533,13 @@ class SupabaseAuthenticationFilterMoreTest {
when(userService.findBySupabaseId(supabaseId))
.thenReturn(Optional.empty())
.thenReturn(Optional.of(winner));
when(userService.saveUser(any(User.class)))
when(saasTeamService.saveUserWithPersonalTeam(any(User.class)))
.thenThrow(new DataIntegrityViolationException("dup user"));
bearer("tok");
filter.doFilter(request, response, chain);
// Race loser does not run first-time init (ensurePersonalTeam).
// The winner committed user and team together, so the loser just adopts its row.
verify(saasTeamService, never()).ensurePersonalTeam(any());
assertThat(SecurityContextHolder.getContext().getAuthentication())
.isInstanceOf(EnhancedJwtAuthenticationToken.class);
@@ -554,7 +554,7 @@ class SupabaseAuthenticationFilterMoreTest {
when(supabaseUserService.getUser(supabaseId))
.thenReturn(supabaseUser(supabaseId, "lost@example.com", false));
when(userService.findBySupabaseId(supabaseId)).thenReturn(Optional.empty());
when(userService.saveUser(any(User.class)))
when(saasTeamService.saveUserWithPersonalTeam(any(User.class)))
.thenThrow(new DataIntegrityViolationException("dup user"));
bearer("tok");
@@ -564,31 +564,30 @@ class SupabaseAuthenticationFilterMoreTest {
}
@Test
@DisplayName("personal team creation failure for a new user is swallowed")
void personalTeamFailureSwallowed() throws Exception {
@DisplayName("personal team creation failure fails the request, it is not swallowed")
void personalTeamFailureFailsAuth() throws Exception {
UUID supabaseId = UUID.randomUUID();
Jwt jwt = fullJwt(supabaseId, "team@example.com", false, "email");
when(jwtDecoder.decode("tok")).thenReturn(jwt);
when(supabaseUserService.getUser(supabaseId))
.thenReturn(supabaseUser(supabaseId, "team@example.com", false));
when(userService.findBySupabaseId(supabaseId)).thenReturn(Optional.empty());
when(userService.saveUser(any(User.class))).thenAnswer(inv -> inv.getArgument(0));
when(saasTeamService.ensurePersonalTeam(any(User.class)))
when(saasTeamService.saveUserWithPersonalTeam(any(User.class)))
.thenThrow(new IllegalStateException("team boom"));
bearer("tok");
filter.doFilter(request, response, chain);
// Auth still succeeds even though team creation failed.
assertThat(SecurityContextHolder.getContext().getAuthentication())
.isInstanceOf(EnhancedJwtAuthenticationToken.class);
verify(userService, times(1)).saveUser(any(User.class));
// A teamless account has no portal access, so a failed provision must surface
// rather than admit a half-built user.
assertThat(response.getStatus()).isEqualTo(401);
assertThat(SecurityContextHolder.getContext().getAuthentication()).isNull();
}
}
@Nested
@DisplayName("Team recovery for existing accounts")
class TeamRecovery {
@DisplayName("Existing accounts are never re-provisioned on the request path")
class ExistingAccountProvisioning {
private User existingWebUser(UUID supabaseId) {
User local = newUser("real@example.com");
@@ -598,8 +597,8 @@ class SupabaseAuthenticationFilterMoreTest {
}
@Test
@DisplayName("an existing account with no team is given a personal team")
void assignsTeamWhenMissing() throws Exception {
@DisplayName("a teamless account is left alone, not healed on every request")
void teamlessAccountIsNotHealed() throws Exception {
UUID supabaseId = UUID.randomUUID();
when(jwtDecoder.decode("tok"))
.thenReturn(fullJwt(supabaseId, "real@example.com", false, "email"));
@@ -607,15 +606,16 @@ class SupabaseAuthenticationFilterMoreTest {
.thenReturn(supabaseUser(supabaseId, "real@example.com", false));
User local = existingWebUser(supabaseId);
Team recovered = new Team();
when(userService.findBySupabaseId(supabaseId)).thenReturn(Optional.of(local));
when(saasTeamService.ensurePersonalTeam(local)).thenReturn(recovered);
bearer("tok");
filter.doFilter(request, response, chain);
verify(saasTeamService).ensurePersonalTeam(local);
assertThat(local.getTeam()).isSameAs(recovered);
// Healing here would run per request with no mutual exclusion, so parallel
// requests would mint duplicate teams. Provisioning belongs to signup alone.
verify(saasTeamService, never()).ensurePersonalTeam(any(User.class));
verify(saasTeamService, never()).saveUserWithPersonalTeam(any(User.class));
assertThat(local.getTeam()).isNull();
}
@Test
@@ -168,7 +168,7 @@ class SupabaseAuthenticationFilterTest {
when(supabaseUserService.getUser(supabaseId))
.thenReturn(supabaseUserMatching(supabaseId, "bob@example.com", false));
when(userService.findBySupabaseId(supabaseId)).thenReturn(Optional.empty());
when(userService.saveUser(any())).thenAnswer(inv -> inv.getArgument(0));
when(saasTeamService.saveUserWithPersonalTeam(any())).thenAnswer(inv -> inv.getArgument(0));
request.setRequestURI("/api/v1/something");
request.setMethod("POST");
@@ -176,10 +176,9 @@ class SupabaseAuthenticationFilterTest {
filter.doFilter(request, response, chain);
verify(userService, times(1)).saveUser(any(User.class));
verify(supabaseUserService).createSupabaseUser(supabaseId, "bob@example.com", false);
// New users get their own personal team, never the shared Default team.
verify(saasTeamService).ensurePersonalTeam(any(User.class));
// Own personal team, never the shared Default team, written with the user.
verify(saasTeamService, times(1)).saveUserWithPersonalTeam(any(User.class));
verify(teamService, never()).getOrCreateDefaultTeam();
assertThat(SecurityContextHolder.getContext().getAuthentication())
.isInstanceOf(EnhancedJwtAuthenticationToken.class);
@@ -194,7 +193,7 @@ class SupabaseAuthenticationFilterTest {
when(supabaseUserService.getUser(supabaseId))
.thenReturn(supabaseUserMatching(supabaseId, "carol@example.com", false));
when(userService.findBySupabaseId(supabaseId)).thenReturn(Optional.empty());
when(userService.saveUser(any(User.class)))
when(saasTeamService.saveUserWithPersonalTeam(any(User.class)))
.thenAnswer(
inv -> {
User u = inv.getArgument(0);
@@ -210,7 +209,7 @@ class SupabaseAuthenticationFilterTest {
filter.doFilter(request, response, chain);
verify(userService, times(1)).saveUser(any(User.class));
verify(saasTeamService, times(1)).saveUserWithPersonalTeam(any(User.class));
}
@Test
@@ -222,7 +221,7 @@ class SupabaseAuthenticationFilterTest {
when(supabaseUserService.getUser(supabaseId))
.thenReturn(supabaseUserMatching(supabaseId, "dave@example.com", false));
when(userService.findBySupabaseId(supabaseId)).thenReturn(Optional.empty());
when(userService.saveUser(any(User.class)))
when(saasTeamService.saveUserWithPersonalTeam(any(User.class)))
.thenAnswer(
inv -> {
User u = inv.getArgument(0);
@@ -238,7 +237,7 @@ class SupabaseAuthenticationFilterTest {
filter.doFilter(request, response, chain);
verify(userService, times(1)).saveUser(any(User.class));
verify(saasTeamService, times(1)).saveUserWithPersonalTeam(any(User.class));
}
@Test
@@ -250,7 +249,7 @@ class SupabaseAuthenticationFilterTest {
when(supabaseUserService.getUser(supabaseId))
.thenReturn(supabaseUserMatching(supabaseId, "eve@example.com", false));
when(userService.findBySupabaseId(supabaseId)).thenReturn(Optional.empty());
when(userService.saveUser(any(User.class)))
when(saasTeamService.saveUserWithPersonalTeam(any(User.class)))
.thenAnswer(
inv -> {
User u = inv.getArgument(0);
@@ -266,7 +265,7 @@ class SupabaseAuthenticationFilterTest {
filter.doFilter(request, response, chain);
verify(userService, times(1)).saveUser(any(User.class));
verify(saasTeamService, times(1)).saveUserWithPersonalTeam(any(User.class));
}
@Test
+56
View File
@@ -0,0 +1,56 @@
// Prints the story files a branch affects, one per line — the scan set for the
// pull-request a11y gate. A story is affected if its file changed against the
// base ref (or is uncommitted/untracked), or if a same-named sibling source
// file changed: stories render the live component, so editing Button.tsx or
// Button.css changes what Button.stories.tsx shows without touching it.
//
// node a11y-changed.mjs [base-ref] (default origin/main)
//
// Node rather than shell so the task works no matter what invokes it — Task's
// embedded interpreter runs on Windows, but sed/grep/sort do not exist for
// developers calling tasks from PowerShell.
import { execFileSync } from "node:child_process";
import { existsSync } from "node:fs";
const base = process.argv[2] || "origin/main";
const git = (...args) =>
execFileSync("git", args, { encoding: "utf8" })
.split("\n")
.map((l) => l.trim().replace(/^frontend\//, ""))
.filter(Boolean);
const STORY = /\.stories\.tsx?$/;
const TEST = /\.test\.tsx?$/;
const SOURCE = /\.(ts|tsx|css)$/;
// Committed changes vs the base, plus working-tree changes, plus untracked
// files — so the gate covers exactly what the branch would merge and what a
// developer is about to commit.
const changed = [
...git("diff", "--name-only", "--diff-filter=d", `${base}...HEAD`),
...git("diff", "--name-only", "--diff-filter=d"),
...git("ls-files", "--others", "--exclude-standard"),
];
const stories = new Set();
for (const f of changed) {
if (STORY.test(f)) {
stories.add(f);
continue;
}
if (TEST.test(f) || !SOURCE.test(f)) continue;
const sibling = f.replace(SOURCE, "");
for (const s of [`${sibling}.stories.tsx`, `${sibling}.stories.ts`])
if (existsSync(s)) stories.add(s);
}
// One line, each path quoted: the output is interpolated into a task command,
// where a newline would end the command after the first story and an unquoted
// space would split a path into two arguments.
process.stdout.write(
[...stories]
.sort()
.map((s) => `"${s}"`)
.join(" "),
);
+33 -3
View File
@@ -52,6 +52,7 @@ const RULE_URL = /dequeuniversity\.com\/rules\/axe\/[\d.]+\/([a-z0-9-]+)/g;
function collect(dir) {
const rules = {}; // storyKey -> Set(ruleId)
const crashed = []; // storyKey[] — failed for a non-a11y reason
const unloadable = []; // storyFile[] — the file itself never ran
const seenFiles = new Set();
let scanned = 0;
@@ -68,6 +69,14 @@ function collect(dir) {
const idx = norm.search(/editor\/src\//);
const file = idx >= 0 ? norm.slice(idx) : norm;
seenFiles.add(file);
// A story file that fails to import produces a failed suite with no
// assertions at all. Every other check here reads assertions, so such a
// file satisfies the manifest and contributes nothing — its stories go
// unscanned while the run still reports clean.
if ((tf.assertionResults || []).length === 0 && tf.status !== "passed") {
unloadable.push(file);
continue;
}
for (const a of tf.assertionResults || []) {
scanned++;
if (a.status === "passed") continue;
@@ -88,14 +97,14 @@ function collect(dir) {
}
}
}
return { rules, crashed, seenFiles, scanned };
return { rules, crashed, unloadable, seenFiles, scanned };
}
if (!existsSync(inDir)) {
console.error(`a11y-check: scan dir not found: ${inDir}`);
process.exit(2);
}
const { rules, crashed, seenFiles, scanned } = collect(inDir);
const { rules, crashed, unloadable, seenFiles, scanned } = collect(inDir);
const observed = {};
for (const [k, set] of Object.entries(rules)) observed[k] = [...set].sort();
@@ -129,6 +138,14 @@ if (record || merge) {
merge && existsSync(baselineFile)
? JSON.parse(readFileSync(baselineFile, "utf8"))
: {};
if (unloadable.length) {
console.error(
`a11y-check: refusing to record — ${unloadable.length} story file(s) failed to load:`,
);
unloadable.slice(0, 20).forEach((f) => console.error(` ${f}`));
console.error("Their stories never ran, so the baseline would lose them.");
process.exit(2);
}
if (crashed.length) {
console.error(
`a11y-check: refusing to record — ${crashed.length} story(ies) failed for a non-a11y reason:`,
@@ -176,6 +193,19 @@ console.log(
`${pairs} story-rule pairs (baselined).`,
);
if (unloadable.length) {
console.error(
`\n${unloadable.length} story file(s) failed to load, so their stories never ran:`,
);
unloadable.slice(0, 50).forEach((f) => console.error(` ${f}`));
if (unloadable.length > 50)
console.error(` … and ${unloadable.length - 50} more`);
console.error(
"\nA file that cannot be imported reports no violations at all. The resolve " +
"or transform error is in the scan log (.a11y-scan/scan.log, uploaded as a " +
"run artifact); a missing generated asset is the usual cause.",
);
}
if (crashed.length) {
console.error(`\n${crashed.length} story(ies) failed to render:`);
crashed.slice(0, 50).forEach((k) => console.error(` ${k}`));
@@ -191,7 +221,7 @@ if (regressions.length) {
"baseline key no longer matches — re-record: task frontend:storybook:a11y:record",
);
}
if (crashed.length || regressions.length) process.exit(1);
if (unloadable.length || crashed.length || regressions.length) process.exit(1);
if (fixed.length)
console.log(
+197
View File
@@ -0,0 +1,197 @@
// Runs the Storybook Vitest scan in batches and emits one JSON report per batch
// into .a11y-scan/, plus a manifest of every story file the run was supposed to
// cover and a log of the raw output. Consumed by a11y-check.mjs, which fails if
// any manifest entry produced no results. Run from frontend/.
//
// node a11y-scan.mjs scan every story
// node a11y-scan.mjs <file> [file…] scan only these story files
//
// Batching keeps each browser session small: a single run over the whole story
// set holds one Chromium context open for the entire scan, so one crash in it
// costs every story after that point.
//
// Node rather than shell so the task works no matter what invokes it — Task's
// embedded interpreter runs on Windows, but bash/sed/sort do not exist for
// developers calling tasks from PowerShell.
import { execFileSync, spawn } from "node:child_process";
import {
existsSync,
mkdirSync,
openSync,
readFileSync,
rmSync,
statSync,
writeFileSync,
} from "node:fs";
import { join } from "node:path";
const OUT = ".a11y-scan";
const CHUNK = 20;
const BATCH_TIMEOUT_MS = 300_000;
const git = (...args) =>
execFileSync("git", args, { encoding: "utf8" })
.split("\n")
.map((l) => l.trim())
.filter(Boolean);
rmSync(OUT, { recursive: true, force: true });
mkdirSync(OUT, { recursive: true });
const manifestFile = join(OUT, "manifest.txt");
const logFile = join(OUT, "scan.log");
const args = process.argv.slice(2);
let files;
if (args.length > 0) {
// Explicit list (the pull-request path passes just the stories a branch
// affects). Anything that no longer exists is dropped, so a deleted story
// doesn't fail the manifest check.
files = [...new Set(args.filter((f) => existsSync(f)))].sort();
if (files.length === 0) {
console.log(
"a11y-scan: no existing story files in the given list — nothing to scan",
);
writeFileSync(manifestFile, "");
process.exit(0);
}
} else {
// Tracked story files plus any not yet committed, so a new story can be
// checked before it is added to the index.
files = [
...new Set([
...git(
"ls-files",
"--",
"editor/src/**/*.stories.ts",
"editor/src/**/*.stories.tsx",
),
...git(
"ls-files",
"--others",
"--exclude-standard",
"--",
"editor/src/**/*.stories.ts",
"editor/src/**/*.stories.tsx",
),
]),
].sort();
if (files.length === 0) {
console.error("a11y-scan: no story files found — check the glob");
process.exit(2);
}
}
writeFileSync(manifestFile, files.join("\n") + "\n");
/** Failures carrying no axe rule — a throw, a timeout, a dropped browser page. */
function crashCount(reportFile) {
try {
const report = JSON.parse(readFileSync(reportFile, "utf8"));
let crashes = 0;
for (const tf of report.testResults ?? [])
for (const a of tf.assertionResults ?? []) {
if (a.status === "passed") continue;
const msg = (a.failureMessages ?? []).join("\n");
if (!/dequeuniversity\.com\/rules\/axe\//.test(msg)) crashes++;
}
return crashes;
} catch {
return -1; // unreadable report counts as a failed attempt
}
}
/** Runs one vitest batch, tee'd to the log, killed (whole tree) on timeout. */
function runBatch(filters, outputFile) {
return new Promise((resolve) => {
const cmd =
`npx vitest run --config .storybook/vitest.config.ts ` +
`--reporter=json --outputFile=${outputFile} ` +
filters.map((f) => `"${f}"`).join(" ");
const log = openSync(logFile, "a");
const child = spawn(cmd, {
shell: true,
detached: process.platform !== "win32",
stdio: ["ignore", log, log],
});
const timer = setTimeout(() => {
if (process.platform === "win32") {
try {
execFileSync("taskkill", ["/pid", String(child.pid), "/T", "/F"], {
stdio: "ignore",
});
} catch {
/* already gone */
}
} else {
try {
process.kill(-child.pid, "SIGKILL");
} catch {
/* already gone */
}
}
}, BATCH_TIMEOUT_MS);
child.on("exit", () => {
clearTimeout(timer);
resolve();
});
});
}
const hasReport = (f) => existsSync(f) && statSync(f).size > 0;
const batches = [];
for (let i = 0; i < files.length; i += CHUNK)
batches.push(files.slice(i, i + CHUNK));
console.log(
`a11y-scan: ${files.length} story files, ${batches.length} batches of ${CHUNK}`,
);
const failed = [];
for (let bi = 0; bi < batches.length; bi++) {
const n = bi + 1;
const out = join(OUT, `chunk-${n}.json`);
// The scan exits non-zero whenever a story has a violation — expected here,
// so the report is what matters, not the status.
//
// A batch is retried once when it produced no report, or when its report
// contains crash-class failures. A one-off infrastructure death — the
// browser page dropping, a Vite dep re-optimize reloading mid-run — passes
// on the retry; a story that genuinely cannot render fails both attempts.
const filters = batches[bi].map((f) => f.replace(/\.tsx$/, ""));
for (let attempt = 1; attempt <= 2; attempt++) {
await runBatch(filters, out);
if (!hasReport(out)) {
console.error(
`a11y-scan: batch ${n} produced no report (attempt ${attempt})`,
);
continue;
}
if (attempt === 1) {
const crashes = crashCount(out);
if (crashes !== 0) {
console.error(
`a11y-scan: batch ${n} has ${crashes} crash-class failure(s) — retrying once`,
);
rmSync(out, { force: true });
continue;
}
}
break;
}
if (hasReport(out)) console.log(` batch ${n}/${batches.length} done`);
else {
failed.push(n);
console.error(` batch ${n}/${batches.length} FAILED — no report`);
}
}
const present = batches.filter((_, i) =>
hasReport(join(OUT, `chunk-${i + 1}.json`)),
).length;
console.log(`a11y-scan: ${present}/${batches.length} batches produced reports`);
if (failed.length > 0) {
console.error(
`a11y-scan: ${failed.length} batch(es) produced no report: ${failed.join(" ")}`,
);
console.error(`a11y-scan: see ${logFile}. Not reporting on a partial scan.`);
process.exit(2);
}
-84
View File
@@ -1,84 +0,0 @@
#!/usr/bin/env bash
# Run the Storybook Vitest scan in batches and emit one JSON report per batch
# into .a11y-scan/, plus a manifest of every story file the run was supposed to
# cover and a log of the raw output. Consumed by a11y-check.mjs, which fails if
# any manifest entry produced no results. Run from frontend/.
#
# a11y-scan.sh scan every story
# a11y-scan.sh <file> [file…] scan only these story files
#
# Batching keeps each browser session small: a single run over the whole story
# set holds one Chromium context open for the entire scan, so one crash in it
# costs every story after that point.
set -uo pipefail
cd "$(dirname "$0")/.." || exit 1
OUT=".a11y-scan"
rm -rf "$OUT"
mkdir -p "$OUT"
MANIFEST="$OUT/manifest.txt"
LOG="$OUT/scan.log"
if [ "$#" -gt 0 ]; then
# Explicit list (the pull-request path passes just the stories a branch
# touched). Anything that no longer exists is dropped, so a deleted story
# doesn't fail the manifest check.
for f in "$@"; do [ -f "$f" ] && printf '%s\n' "$f"; done | sort -u >"$MANIFEST"
else
# Tracked story files plus any not yet committed, so a new story can be
# checked before it is added to the index.
{
git ls-files 'editor/src/**/*.stories.ts' 'editor/src/**/*.stories.tsx'
git ls-files --others --exclude-standard 'editor/src/**/*.stories.ts' \
'editor/src/**/*.stories.tsx'
} | sort -u >"$MANIFEST"
fi
mapfile -t FILES <"$MANIFEST"
TOTAL=${#FILES[@]}
if [ "$TOTAL" -eq 0 ]; then
if [ "$#" -gt 0 ]; then
echo "a11y-scan: no existing story files in the given list — nothing to scan"
exit 0
fi
echo "a11y-scan: no story files found — check the glob" >&2
exit 2
fi
CHUNK=20
NB=$(((TOTAL + CHUNK - 1) / CHUNK))
echo "a11y-scan: $TOTAL story files, $NB batches of $CHUNK"
failed=()
i=0
ci=0
while [ "$i" -lt "$TOTAL" ]; do
ci=$((ci + 1))
batch=("${FILES[@]:i:CHUNK}")
i=$((i + CHUNK))
out="$OUT/chunk-$ci.json"
filters=()
for f in "${batch[@]}"; do filters+=("${f%.tsx}"); done
# The scan exits non-zero whenever a story has a violation — expected here, so
# the report is what matters, not the status. Output is teed to the log so a red
# CI run still has the offending selectors and help text to work from.
for attempt in 1 2; do
timeout 300 npx vitest run --config .storybook/vitest.config.ts \
--reporter=json --outputFile="$out" "${filters[@]}" >>"$LOG" 2>&1
[ -s "$out" ] && break
echo "a11y-scan: batch $ci produced no report (attempt $attempt)" >&2
done
if [ -s "$out" ]; then
echo " batch $ci/$NB done"
else
failed+=("$ci")
echo " batch $ci/$NB FAILED — no report" >&2
fi
done
echo "a11y-scan: $(ls "$OUT"/chunk-*.json 2>/dev/null | wc -l)/$NB batches produced reports"
if [ ${#failed[@]} -gt 0 ]; then
echo "a11y-scan: ${#failed[@]} batch(es) produced no report: ${failed[*]}" >&2
echo "a11y-scan: see $LOG. Not reporting on a partial scan." >&2
exit 2
fi
+20 -6
View File
@@ -18,18 +18,32 @@ export default defineConfig({
// Pre-scan every story + the preview so Vite discovers the story set's large
// dep surface (embedpdf plugins, @mui icons, …) in one pass up front.
entries: ["editor/src/**/*.stories.@(ts|tsx)", ".storybook/preview.tsx"],
// `entries` alone does not catch deps reached only through a transformed
// JSX runtime import, so Vite optimizes them lazily mid-run and emits
// "optimized dependencies changed, reloading". That reload tears down the
// browser worker and whichever stories were mid-load fail with a bogus
// "Failed to fetch dynamically imported module" — a scan that then looks
// like a real result. Naming them here keeps a run deterministic.
// `entries` alone does not catch deps reached through a transformed JSX
// runtime import, nor the preview's own dependency graph (the test plugin
// injects the preview in a way the entry scanner doesn't crawl). Vite then
// optimizes them lazily mid-run and emits "optimized dependencies changed,
// reloading" — the reload tears down the browser worker and whichever
// stories were mid-load fail with a bogus "Failed to fetch dynamically
// imported module" that reads like a real crash. Only a cold dep cache
// hits this, which is every CI run. Naming them keeps a run deterministic.
include: [
"react",
"react/jsx-runtime",
"react/jsx-dev-runtime",
"react-dom",
"react-dom/client",
"@storybook/react-vite",
"@storybook/addon-a11y/preview",
"@storybook/addon-themes",
"msw-storybook-addon",
"react-router-dom",
"@tanstack/react-query",
"i18next",
"react-i18next",
"smol-toml",
"@mantine/core",
"@supabase/supabase-js",
"axios",
],
},
test: {
@@ -725,7 +725,6 @@ manualLinks = "Manual downloads: click the links and place the files into the te
noLanguages = "No tessdata languages found in the configured directory."
permissionNotice = "The tessdata path is not writable. Downloads will be opened in the browser; please save the .traineddata files manually into the tessdata folder."
# AI engine admin settings (AI nav group)
[admin.settings.ai.documents]
description = "Configure the embedding model and retrieval settings used to answer questions over documents. Applied to the AI engine when saved."
title = "Documents & RAG"
@@ -7368,7 +7367,6 @@ sectionsAriaLabel = "Infrastructure sections"
subtitle = "Deployments, credentials, security posture, storage, and the audit trail for your Stirling workspace."
title = "Infrastructure"
# Fixed-enum label maps rendered via t(MAP[value]) in the infrastructure tabs.
[portal.infrastructure.apiKeys]
createKey = "Create key"
heading = "API keys"
@@ -8463,13 +8461,10 @@ region = "State / region"
regionPlaceholder = "California"
running = "{{annual}} / yr · {{years}}-yr {{tcv}}"
s1Sub = "Your team, and the PDFs you expect to run each year."
# Step 1 — volume
s1Title = "How much will you process?"
s2Sub = "Longer terms discount the rate; your service level sets support."
# Step 2 — commitment & service
s2Title = "Commitment and service"
s3Sub = "For the quote and the agreement it generates."
# Step 3 — details
s3Title = "Your details"
serviceLevel = "Service level"
size_compact = "Compact"
+1 -1
View File
@@ -9,7 +9,7 @@ requires-python = ">=3.11"
dependencies = [
"ruff==0.15.14",
"codespell==2.4.2",
"toml-sort==0.24.4",
"tomli-w==1.2.0",
]
[tool.uv]
+96
View File
@@ -0,0 +1,96 @@
#!/usr/bin/env python3
"""Key-sort the locale translation.toml files.
python sort_locale_toml.py <pathspec>... # check: report, exit 1 if unsorted
python sort_locale_toml.py --fix <pathspec>... # fix: rewrite in place
"""
from __future__ import annotations
import subprocess
import sys
import tomllib
from pathlib import Path
import tomli_w
class SortError(Exception):
"""A file could not be sorted without risking its contents."""
def ordered(table: dict[str, object]) -> dict[str, object]:
"""Rebuild a table with its keys sorted, and sub-tables after its own keys."""
keys = {key: value for key, value in table.items() if not isinstance(value, dict)}
subtables = {key: value for key, value in table.items() if isinstance(value, dict)}
result: dict[str, object] = {key: keys[key] for key in sorted(keys, key=str.lower)}
for key in sorted(subtables, key=str.lower):
result[key] = ordered(subtables[key])
return result
def tracked_files(path_specs: list[str]) -> list[str]:
result = subprocess.run(
["git", "ls-files", "-z", *path_specs],
check=True,
capture_output=True,
text=True,
)
return [path for path in result.stdout.split("\0") if path]
def sort_file(path: str, fix: bool) -> bool:
"""Rewrite one file if `fix`; return whether it was not already sorted."""
text = Path(path).read_text(encoding="utf-8")
try:
original = tomllib.loads(text)
except tomllib.TOMLDecodeError as exc:
raise SortError(f"{path}: invalid TOML: {exc}") from exc
expected = tomli_w.dumps(ordered(original))
if expected == text:
return False
try:
reordered = tomllib.loads(expected)
except tomllib.TOMLDecodeError as exc:
raise SortError(
f"{path}: refusing to sort, the sorted output is not valid TOML: {exc}"
) from exc
if reordered != original:
raise SortError(
f"{path}: refusing to sort, sorting would change the file's contents"
)
if fix:
Path(path).write_text(expected, encoding="utf-8")
return True
def main() -> int:
args = sys.argv[1:]
fix = "--fix" in args
pathspecs = [a for a in args if a != "--fix"]
offenders: list[str] = []
errors: list[str] = []
for path in tracked_files(pathspecs):
try:
if sort_file(path, fix):
offenders.append(path)
except SortError as exc:
errors.append(str(exc))
for error in errors:
print(error, file=sys.stderr)
if offenders and not fix:
print(f"{len(offenders)} file(s) need TOML sorting:")
for path in offenders:
print(f" {path}")
if offenders and fix:
print(f"Sorted TOML in {len(offenders)} file(s).")
return 1 if errors or (offenders and not fix) else 0
if __name__ == "__main__":
sys.exit(main())
+6 -18
View File
@@ -43,33 +43,21 @@ source = { virtual = "." }
dependencies = [
{ name = "codespell" },
{ name = "ruff" },
{ name = "toml-sort" },
{ name = "tomli-w" },
]
[package.metadata]
requires-dist = [
{ name = "codespell", specifier = "==2.4.2" },
{ name = "ruff", specifier = "==0.15.14" },
{ name = "toml-sort", specifier = "==0.24.4" },
{ name = "tomli-w", specifier = "==1.2.0" },
]
[[package]]
name = "toml-sort"
version = "0.24.4"
name = "tomli-w"
version = "1.2.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "tomlkit" },
]
sdist = { url = "https://files.pythonhosted.org/packages/47/c5/d6f650fdcf8e1f83096815fa67fb13a9a345b99da6015c60c4b7e4a8ea2b/toml_sort-0.24.4.tar.gz", hash = "sha256:429b69f5b98b7047a11380c80ecf0838556bdea1a8902d0be564961c48841423", size = 17793, upload-time = "2026-03-24T14:05:53.637Z" }
sdist = { url = "https://files.pythonhosted.org/packages/19/75/241269d1da26b624c0d5e110e8149093c759b7a286138f4efd61a60e75fe/tomli_w-1.2.0.tar.gz", hash = "sha256:2dd14fac5a47c27be9cd4c976af5a12d87fb1f0b4512f81d69cce3b35ae25021", size = 7184, upload-time = "2025-01-15T12:07:24.262Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/0f/5a/1f0e54df4eacf0f4d8f94ba50cf72be33d2a3f04babdfb1931bead48a0ab/toml_sort-0.24.4-py3-none-any.whl", hash = "sha256:125aa5fb94f33c542c6901040456145dd38f79bbb310b56b436a93057d30a739", size = 16577, upload-time = "2026-03-24T14:05:54.757Z" },
]
[[package]]
name = "tomlkit"
version = "0.15.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/51/db/03eaf4331631ef6b27d6e3c9b68c54dc6f0d63d87201fed600cc409307fd/tomlkit-0.15.0.tar.gz", hash = "sha256:7d1a9ecba3086638211b13814ea79c90dd54dd11993564376f3aa92271f5c7a3", size = 161875, upload-time = "2026-05-10T07:38:22.245Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/6a/43/8bd850ee71a191bf072e31302c73a66be413fecdd98fdcd111ecbcce13ca/tomlkit-0.15.0-py3-none-any.whl", hash = "sha256:4dbc8f0fc024412b57ced8757ac7461305126a648ff8c2c807fcb8e133a78738", size = 41328, upload-time = "2026-05-10T07:38:23.517Z" },
{ url = "https://files.pythonhosted.org/packages/c7/18/c86eb8e0202e32dd3df50d43d7ff9854f8e0603945ff398974c1d91ac1ef/tomli_w-1.2.0-py3-none-any.whl", hash = "sha256:188306098d013b691fcadc011abd66727d3c414c571bb01b1a174ba8c983cf90", size = 6675, upload-time = "2025-01-15T12:07:22.074Z" },
]