Compare commits

..
Author SHA1 Message Date
james.maesandClaude Fable 5.2 d9f2baa079 feat(auth): automatic logoff after user inactivity (UI API 1.12.0)
build / maven (push) Successful in 4m26s
maven / build (push) Successful in 4m26s
When the server publishes userInactivityTimeoutSeconds, the app states the age
of the last user action on every request (X-QQQ-User-Activity-Age), since its
own polling is not the user. It warns a minute before the timeout, signs out
when it runs out, shares activity between the tabs of one browser, and renews
the session for an active user whose page sent nothing for four minutes. The
login page says when the sign-out was automatic. Without the server value
nothing changes and no header is sent.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
2026-11-10 12:34:44 -06:00
james.maes fefd5be0d4 Merge pull request 'fix(auth): every sign-in forces re-authentication (prompt=login, max_age=0)' (#209) from fix/signin-always-asks-credentials into main
build / maven (push) Successful in 4m22s
maven / build (push) Successful in 4m22s
2026-11-09 20:59:19 +00:00
james.maesandClaude Sonnet 5.5 c0e5b285db fix(auth): every sign-in forces re-authentication (prompt=login, max_age=0)
build / maven (push) Successful in 7m37s
maven / build (push) Successful in 7m37s
Sign out already ends the provider session (RP-initiated logout); the authorize
request now also sends prompt=login and max_age=0 so a lingering provider
session can never sign the next user in silently. prompt=login alone was seen
reusing retained SSO on real Authentik.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-09 14:43:02 -06:00
james.maes 42b509f35c Merge pull request 'ci: use shared ci-workflows Maven workflow' (#208) from ci/ci-workflows into main
QRun-IO/ci-policy: native / dotnet-0 (push) Skipped
QRun-IO/ci-policy: native / native-result (push) Successful in 7s
QRun-IO/ci-policy: ci / java-core-2-head (push) Skipped
QRun-IO/ci-policy: ci / java-docker-3 (push) Skipped
QRun-IO/ci-policy: ci / java-docker-4 (push) Skipped
QRun-IO/ci-policy: ci / java-frontend-browser-5 (push) Skipped
QRun-IO/ci-policy: ci / java-maven-7-head (push) Skipped
QRun-IO/ci-policy: ci / repository-policy-12 (push) Skipped
QRun-IO/ci-policy: ci / source-config-13 (push) Skipped
QRun-IO/ci-policy: ci / source-config-14 (push) Skipped
QRun-IO/ci-policy: ci / original-voyage (push) Skipped
QRun-IO/ci-policy: ci / original-cli (push) Skipped
QRun-IO/ci-policy: Shared Node CI with Playwright / e2e (push) Successful in 29m53s
QRun-IO/ci-policy: Shared Node CI with Playwright / gate (push) Successful in 16s
QRun-IO/ci-policy: ci / node-playwright-11 (push) Successful in 30m15s
QRun-IO/ci-policy: ci / ci-result (push) Successful in 6s
build / maven (push) Successful in 13m3s
QRun-IO/ci-policy: ci / fork-guard (push) Skipped
QRun-IO/ci-policy: native / fork-deny (push) Skipped
QRun-IO/ci-policy: ci / Scoped workflow [workflow 539bec410f7926fec3ef7fabe51934a91e0248be] (push) Successful in 9s
QRun-IO/ci-policy: ci / head-profile (push) Skipped
QRun-IO/ci-policy: ci / repository-policy (push) Skipped
QRun-IO/ci-policy: ci / application-template-0 (push) Skipped
QRun-IO/ci-policy: ci / gradle-1 (push) Skipped
QRun-IO/ci-policy: ci / java-maven-6 (push) Skipped
QRun-IO/ci-policy: ci / java-maven-6-head (push) Skipped
QRun-IO/ci-policy: ci / java-maven-7 (push) Skipped
QRun-IO/ci-policy: ci / node-8 (push) Skipped
QRun-IO/ci-policy: ci / node-9 (push) Skipped
QRun-IO/ci-policy: ci / node-10 (push) Skipped
QRun-IO/ci-policy: ci / original-business (push) Skipped
QRun-IO/ci-policy: ci / shared-pipeline (push) Skipped
QRun-IO/ci-policy: Shared Node CI with Playwright / secrets (push) Successful in 27s
QRun-IO/ci-policy: Shared Node CI with Playwright / node (push) Successful in 12m7s
QRun-IO/ci-policy: ci / java-docker-3-head (push) Skipped
QRun-IO/ci-policy: ci / android-0 (push) Skipped
QRun-IO/ci-policy: ci / original-mcp (push) Skipped
QRun-IO/ci-policy: ci / not-enrolled (push) Skipped
maven / build (push) Successful in 13m3s
2026-11-06 21:50:54 +00:00
james.maesandClaude Opus 5.5 37c4234324 ci: pin v1, build the Next export before Maven, drop the old release caller
QRun-IO/ci-policy: ci / fork-guard (pull_request) Skipped
QRun-IO/ci-policy: native / fork-deny (pull_request) Skipped
QRun-IO/ci-policy: ci / Scoped workflow [workflow 539bec410f7926fec3ef7fabe51934a91e0248be] (pull_request) Successful in 6s
QRun-IO/ci-policy: ci / head-profile (pull_request) Skipped
QRun-IO/ci-policy: ci / repository-policy (pull_request) Skipped
QRun-IO/ci-policy: ci / application-template-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-6 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-6-head (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-7 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-7-head (pull_request) Skipped
QRun-IO/ci-policy: ci / node-8 (pull_request) Skipped
QRun-IO/ci-policy: ci / original-business (pull_request) Skipped
QRun-IO/ci-policy: ci / original-voyage (pull_request) Skipped
QRun-IO/ci-policy: ci / original-cli (pull_request) Skipped
build / maven (push) Successful in 10m42s
QRun-IO/ci-policy: ci / ci-result (pull_request) Successful in 10s
QRun-IO/ci-policy: native / dotnet-0 (pull_request) Skipped
QRun-IO/ci-policy: native / native-result (pull_request) Successful in 4s
QRun-IO/ci-policy: ci / gradle-1 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-core-2-head (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-3 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-3-head (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-4 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-frontend-browser-5 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-10 (pull_request) Skipped
QRun-IO/ci-policy: ci / source-config-13 (pull_request) Skipped
QRun-IO/ci-policy: ci / android-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / original-mcp (pull_request) Skipped
QRun-IO/ci-policy: ci / not-enrolled (pull_request) Skipped
QRun-IO/ci-policy: ci / shared-pipeline (pull_request) Skipped
QRun-IO/ci-policy: Shared Node CI with Playwright / secrets (pull_request) Successful in 23s
QRun-IO/ci-policy: Shared Node CI with Playwright / e2e (pull_request) Successful in 26m38s
QRun-IO/ci-policy: ci / node-9 (pull_request) Skipped
QRun-IO/ci-policy: ci / repository-policy-12 (pull_request) Skipped
QRun-IO/ci-policy: ci / source-config-14 (pull_request) Skipped
maven / build (push) Successful in 10m42s
QRun-IO/ci-policy: Shared Node CI with Playwright / node (pull_request) Successful in 12m17s
QRun-IO/ci-policy: Shared Node CI with Playwright / gate (pull_request) Successful in 18s
QRun-IO/ci-policy: ci / node-playwright-11 (pull_request) Successful in 27m2s
Every push verifies (pnpm typecheck/lint/test/build:export, then Maven);
vX.Y.Z and vX.Y.Z-RC.N tags publish to the Gitea registry.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 21:38:06 +00:00
james.maesandClaude Opus 5.5 412d884402 ci: use shared ci-workflows Maven workflow
QRun-IO/ci-policy: ci / fork-guard (pull_request) Skipped
QRun-IO/ci-policy: native / fork-deny (pull_request) Skipped
QRun-IO/ci-policy: ci / Scoped workflow [workflow d20a578c7d818bc7ca32a2ac2c675da4e4f27385] (pull_request) Successful in 9s
QRun-IO/ci-policy: ci / head-profile (pull_request) Skipped
QRun-IO/ci-policy: ci / repository-policy (pull_request) Skipped
QRun-IO/ci-policy: ci / application-template-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-6 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-6-head (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-7 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-7-head (pull_request) Skipped
QRun-IO/ci-policy: ci / node-8 (pull_request) Skipped
QRun-IO/ci-policy: ci / original-business (pull_request) Skipped
QRun-IO/ci-policy: ci / original-voyage (pull_request) Skipped
QRun-IO/ci-policy: ci / original-cli (pull_request) Skipped
build / maven (push) Failing after 43s
QRun-IO/ci-policy: ci / ci-result (pull_request) Failing after 4s
QRun-IO/ci-policy: native / dotnet-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / gradle-1 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-core-2-head (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-3 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-3-head (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-4 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-frontend-browser-5 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-10 (pull_request) Skipped
QRun-IO/ci-policy: ci / source-config-13 (pull_request) Skipped
QRun-IO/ci-policy: ci / android-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / original-mcp (pull_request) Skipped
QRun-IO/ci-policy: ci / not-enrolled (pull_request) Skipped
QRun-IO/ci-policy: ci / shared-pipeline (pull_request) Skipped
QRun-IO/ci-policy: native / native-result (pull_request) Successful in 7s
QRun-IO/ci-policy: Shared Node CI with Playwright / secrets (pull_request) Successful in 27s
QRun-IO/ci-policy: Shared Node CI with Playwright / e2e (pull_request) Failing after 23m41s
QRun-IO/ci-policy: ci / node-9 (pull_request) Skipped
QRun-IO/ci-policy: ci / repository-policy-12 (pull_request) Skipped
QRun-IO/ci-policy: ci / source-config-14 (pull_request) Skipped
maven / build (push) Failing after 43s
QRun-IO/ci-policy: Shared Node CI with Playwright / node (pull_request) Successful in 11m58s
QRun-IO/ci-policy: Shared Node CI with Playwright / gate (pull_request) Failing after 12s
QRun-IO/ci-policy: ci / node-playwright-11 (pull_request) Failing after 28m35s
Replace the platform-ci release callers with one caller of
KofTwentyTwo/ci-workflows maven.yaml (qqq-orb port).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 23:15:45 +00:00
james.maes 27e8754f32 Merge pull request 'Preserve workflow, presence and dependency updates in the canonical Next release' (#207) from kof22/next-workflow-assets-on-pr206 into main
QRun-IO/ci-policy: native / dotnet-0 (push) Skipped
QRun-IO/ci-policy: ci / head-profile (push) Skipped
QRun-IO/ci-policy: native / native-result (push) Successful in 5s
QRun-IO/ci-policy: ci / java-core-2-head (push) Skipped
QRun-IO/ci-policy: ci / java-docker-3 (push) Skipped
QRun-IO/ci-policy: ci / java-docker-4 (push) Skipped
QRun-IO/ci-policy: ci / java-frontend-browser-5 (push) Skipped
QRun-IO/ci-policy: ci / java-maven-7-head (push) Skipped
QRun-IO/ci-policy: ci / repository-policy-12 (push) Skipped
QRun-IO/ci-policy: ci / source-config-13 (push) Skipped
QRun-IO/ci-policy: ci / source-config-14 (push) Skipped
QRun-IO/ci-policy: ci / original-voyage (push) Skipped
QRun-IO/ci-policy: ci / original-cli (push) Skipped
QRun-IO/ci-policy: Shared Node CI with Playwright / e2e (push) Successful in 21m37s
QRun-IO/ci-policy: Shared Node CI with Playwright / gate (push) Successful in 8s
QRun-IO/ci-policy: ci / node-playwright-11 (push) Successful in 21m49s
QRun-IO/ci-policy: ci / ci-result (push) Successful in 4s
QRun-IO/ci-policy: ci / fork-guard (push) Skipped
QRun-IO/ci-policy: native / fork-deny (push) Skipped
QRun-IO/ci-policy: ci / Scoped workflow [workflow 5e6bfd71ee8a5674882f7680c695a0ada599622f] (push) Successful in 5s
QRun-IO/ci-policy: ci / repository-policy (push) Skipped
QRun-IO/ci-policy: ci / application-template-0 (push) Skipped
QRun-IO/ci-policy: ci / gradle-1 (push) Skipped
QRun-IO/ci-policy: ci / java-maven-6 (push) Skipped
QRun-IO/ci-policy: ci / java-maven-6-head (push) Skipped
QRun-IO/ci-policy: ci / java-maven-7 (push) Skipped
QRun-IO/ci-policy: ci / node-8 (push) Skipped
QRun-IO/ci-policy: ci / node-9 (push) Skipped
QRun-IO/ci-policy: ci / node-10 (push) Skipped
QRun-IO/ci-policy: ci / original-business (push) Skipped
QRun-IO/ci-policy: ci / shared-pipeline (push) Skipped
QRun-IO/ci-policy: Shared Node CI with Playwright / secrets (push) Successful in 36s
QRun-IO/ci-policy: Shared Node CI with Playwright / node (push) Successful in 11m12s
QRun-IO/ci-policy: ci / java-docker-3-head (push) Skipped
QRun-IO/ci-policy: ci / android-0 (push) Skipped
QRun-IO/ci-policy: ci / original-mcp (push) Skipped
QRun-IO/ci-policy: ci / not-enrolled (push) Skipped
2026-11-05 17:22:27 +00:00
james.maesandClaude Sonnet 5.5 00c00b0e71 Merge owner PR206 46e9f121 into the PR207 branch: scoped record filter control lookups
QRun-IO/ci-policy: ci / fork-guard (pull_request) Skipped
QRun-IO/ci-policy: native / fork-deny (pull_request) Skipped
QRun-IO/ci-policy: ci / Scoped workflow [workflow 5e6bfd71ee8a5674882f7680c695a0ada599622f] (pull_request) Successful in 5s
QRun-IO/ci-policy: ci / head-profile (pull_request) Skipped
QRun-IO/ci-policy: ci / repository-policy (pull_request) Skipped
QRun-IO/ci-policy: ci / application-template-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-6 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-6-head (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-7 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-7-head (pull_request) Skipped
QRun-IO/ci-policy: ci / node-8 (pull_request) Skipped
QRun-IO/ci-policy: ci / original-business (pull_request) Skipped
QRun-IO/ci-policy: ci / original-voyage (pull_request) Skipped
QRun-IO/ci-policy: ci / original-cli (pull_request) Skipped
QRun-IO/ci-policy: ci / ci-result (pull_request) Successful in 4s
QRun-IO/ci-policy: ci / gradle-1 (pull_request) Skipped
QRun-IO/ci-policy: native / dotnet-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-core-2-head (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-3 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-3-head (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-4 (pull_request) Skipped
QRun-IO/ci-policy: native / native-result (pull_request) Successful in 7s
QRun-IO/ci-policy: ci / java-frontend-browser-5 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-10 (pull_request) Skipped
QRun-IO/ci-policy: ci / source-config-13 (pull_request) Skipped
QRun-IO/ci-policy: ci / android-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / original-mcp (pull_request) Skipped
QRun-IO/ci-policy: ci / not-enrolled (pull_request) Skipped
QRun-IO/ci-policy: ci / shared-pipeline (pull_request) Skipped
QRun-IO/ci-policy: Shared Node CI with Playwright / secrets (pull_request) Successful in 49s
QRun-IO/ci-policy: Shared Node CI with Playwright / e2e (pull_request) Successful in 23m10s
QRun-IO/ci-policy: ci / node-9 (pull_request) Skipped
QRun-IO/ci-policy: ci / repository-policy-12 (pull_request) Skipped
QRun-IO/ci-policy: ci / source-config-14 (pull_request) Skipped
QRun-IO/ci-policy: Shared Node CI with Playwright / node (pull_request) Successful in 11m59s
QRun-IO/ci-policy: Shared Node CI with Playwright / gate (pull_request) Successful in 9s
QRun-IO/ci-policy: ci / node-playwright-11 (pull_request) Successful in 23m21s
Takes the owner's test-only fix (src/components/query/RecordQuery.test.tsx scopes role queries with within(); docs/QBIT-RELEASE-STATUS.md)
for the 'confirms clearing filters and preserves the selected sort' case that timed out at the default 5000 ms on the loaded CI runner
(5150/5239 ms in CI 4993 node, twice; 1.3-1.5 s unloaded locally with the fix vs 1.5-1.7 s without). No assertion, user-semantic or
sort behavior changes; no f49, Tools, owner 205/206, zod 4 or lockfile content touched.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-05 10:46:00 -06:00
james.maesandClaude Sonnet 5.5 3a72c8b88a fix(authentik): give the access editor's inherited-permissions schema its zod 4 key type
QRun-IO/ci-policy: native / fork-deny (pull_request) Skipped
QRun-IO/ci-policy: ci / fork-guard (pull_request) Skipped
QRun-IO/ci-policy: ci / head-profile (pull_request) Skipped
QRun-IO/ci-policy: ci / repository-policy (pull_request) Skipped
QRun-IO/ci-policy: ci / application-template-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-6 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-6-head (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-7 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-7-head (pull_request) Skipped
QRun-IO/ci-policy: ci / node-8 (pull_request) Skipped
QRun-IO/ci-policy: ci / original-business (pull_request) Skipped
QRun-IO/ci-policy: ci / original-voyage (pull_request) Skipped
QRun-IO/ci-policy: ci / original-cli (pull_request) Skipped
QRun-IO/ci-policy: ci / ci-result (pull_request) Failing after 8s
QRun-IO/ci-policy: native / dotnet-0 (pull_request) Skipped
QRun-IO/ci-policy: native / native-result (pull_request) Successful in 6s
QRun-IO/ci-policy: ci / gradle-1 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-core-2-head (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-3 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-3-head (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-4 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-frontend-browser-5 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-10 (pull_request) Skipped
QRun-IO/ci-policy: ci / source-config-13 (pull_request) Skipped
QRun-IO/ci-policy: ci / android-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / original-mcp (pull_request) Skipped
QRun-IO/ci-policy: ci / not-enrolled (pull_request) Skipped
QRun-IO/ci-policy: ci / Scoped workflow [workflow 3d75c629c2706be133505268b6696fe9b1ae420c] (pull_request) Successful in 7s
QRun-IO/ci-policy: ci / shared-pipeline (pull_request) Skipped
QRun-IO/ci-policy: Shared Node CI with Playwright / e2e (pull_request) Successful in 21m26s
QRun-IO/ci-policy: Shared Node CI with Playwright / secrets (pull_request) Successful in 36s
QRun-IO/ci-policy: ci / node-9 (pull_request) Skipped
QRun-IO/ci-policy: ci / repository-policy-12 (pull_request) Skipped
QRun-IO/ci-policy: ci / source-config-14 (pull_request) Skipped
QRun-IO/ci-policy: Shared Node CI with Playwright / node (pull_request) Failing after 12m36s
QRun-IO/ci-policy: Shared Node CI with Playwright / gate (pull_request) Failing after 16s
QRun-IO/ci-policy: ci / node-playwright-11 (pull_request) Failing after 12m57s
The owner's access editor (PR205/206) was written against zod 3's single-argument z.record(value). With zod ^4.6.7 (the reviewed f49
dependency set) z.record needs the key schema too: TS2554 at (32,16), then 'inherited' infers Record<..., unknown> giving TS18046 at (84,147)
and TS2322 at (103,128) (CI 4726, node 75004 and e2e 75005, the only three type errors). Use z.record(z.string(), z.array(z.string())), the
same validation and the exact Record<string, string[]> type the component already declares; no cast, no behavior or permission change.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-05 08:29:27 -06:00
James MaesandCodex 46e9f1212c test: scope record filter control lookups
QRun-IO/ci-policy: ci / original-business (pull_request) Skipped
QRun-IO/ci-policy: ci / original-voyage (pull_request) Skipped
QRun-IO/ci-policy: ci / original-cli (pull_request) Skipped
QRun-IO/ci-policy: native / fork-deny (pull_request) Skipped
QRun-IO/ci-policy: ci / head-profile (pull_request) Skipped
QRun-IO/ci-policy: ci / repository-policy (pull_request) Skipped
QRun-IO/ci-policy: ci / application-template-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-6 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-6-head (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-7 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-7-head (pull_request) Skipped
QRun-IO/ci-policy: ci / fork-guard (pull_request) Skipped
QRun-IO/ci-policy: ci / node-8 (pull_request) Skipped
QRun-IO/ci-policy: ci / ci-result (pull_request) Successful in 7s
QRun-IO/ci-policy: ci / source-config-13 (pull_request) Skipped
QRun-IO/ci-policy: ci / android-0 (pull_request) Skipped
QRun-IO/ci-policy: Shared Node CI with Playwright / secrets (pull_request) Successful in 1m32s
QRun-IO/ci-policy: ci / original-mcp (pull_request) Skipped
QRun-IO/ci-policy: ci / not-enrolled (pull_request) Skipped
QRun-IO/ci-policy: ci / shared-pipeline (pull_request) Skipped
QRun-IO/ci-policy: native / native-result (pull_request) Successful in 13s
QRun-IO/ci-policy: native / dotnet-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / Scoped workflow [workflow cf802461ca4ebfcf5c500440715cb333d92ea606] (pull_request) Successful in 12s
QRun-IO/ci-policy: ci / gradle-1 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-core-2-head (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-3 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-frontend-browser-5 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-3-head (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-4 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-10 (pull_request) Skipped
QRun-IO/ci-policy: Shared Node CI with Playwright / e2e (pull_request) Successful in 24m36s
QRun-IO/ci-policy: ci / repository-policy-12 (pull_request) Skipped
QRun-IO/ci-policy: ci / source-config-14 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-9 (pull_request) Skipped
QRun-IO/ci-policy: Shared Node CI with Playwright / node (pull_request) Successful in 13m0s
QRun-IO/ci-policy: Shared Node CI with Playwright / gate (pull_request) Successful in 22s
QRun-IO/ci-policy: ci / node-playwright-11 (pull_request) Successful in 1h18m32s
Preserve all filter, sorting, confirmation and accessibility assertions while avoiding repeated whole-page role scans. Focused RecordQuery tests and lint pass; release CI remains required.

Co-Authored-By: Codex (GPT-6) <noreply@openai.com>
2026-11-05 07:22:21 -06:00
James MaesandClaude Sonnet 5.5 bfcc008ca2 test: carry the reviewed f49 test-only fixes that keep upstream coverage equivalent
QRun-IO/ci-policy: ci / fork-guard (pull_request) Skipped
QRun-IO/ci-policy: native / fork-deny (pull_request) Skipped
QRun-IO/ci-policy: ci / head-profile (pull_request) Skipped
QRun-IO/ci-policy: ci / application-template-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / repository-policy (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-7 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-8 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-6 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-6-head (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-7-head (pull_request) Skipped
QRun-IO/ci-policy: ci / original-business (pull_request) Skipped
QRun-IO/ci-policy: ci / original-voyage (pull_request) Skipped
QRun-IO/ci-policy: ci / original-cli (pull_request) Skipped
QRun-IO/ci-policy: ci / ci-result (pull_request) Failing after 7s
QRun-IO/ci-policy: Shared Node CI with Playwright / secrets (pull_request) Successful in 1m11s
QRun-IO/ci-policy: native / dotnet-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / Scoped workflow [workflow 60e5a80ed7a5a67877bfb3e129b1929c83464b6e] (pull_request) Successful in 13s
QRun-IO/ci-policy: native / native-result (pull_request) Successful in 10s
QRun-IO/ci-policy: ci / gradle-1 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-core-2-head (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-3 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-4 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-3-head (pull_request) Skipped
QRun-IO/ci-policy: ci / java-frontend-browser-5 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-10 (pull_request) Skipped
QRun-IO/ci-policy: ci / source-config-13 (pull_request) Skipped
QRun-IO/ci-policy: ci / android-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / original-mcp (pull_request) Skipped
QRun-IO/ci-policy: ci / not-enrolled (pull_request) Skipped
QRun-IO/ci-policy: ci / shared-pipeline (pull_request) Skipped
QRun-IO/ci-policy: Shared Node CI with Playwright / e2e (pull_request) Failing after 2m20s
QRun-IO/ci-policy: ci / node-9 (pull_request) Skipped
QRun-IO/ci-policy: ci / repository-policy-12 (pull_request) Skipped
QRun-IO/ci-policy: ci / source-config-14 (pull_request) Skipped
QRun-IO/ci-policy: Shared Node CI with Playwright / node (pull_request) Failing after 2m13s
QRun-IO/ci-policy: Shared Node CI with Playwright / gate (pull_request) Failing after 17s
QRun-IO/ci-policy: ci / node-playwright-11 (pull_request) Failing after 1h10m50s
WidgetRenderer lazy-chunk wait, the mark-all-mentions-read My Work test (dce92cfb) and the help-results wait before
search navigation (8d372142), taken from f49. Owner-side test changes in PR205/206 (keyboard shortcuts, security headers
with blob: connect-src) are kept as they are newer.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-05 07:02:39 -06:00
James MaesandClaude Sonnet 5.5 fdf12d3c40 fix(deps,test): zod 4 migration and portable standalone tests from the reviewed f49 commit 0c156b22
The schema, form-adjuster, possible-values, assistant-normalize and zod-from-metadata changes zod 4 needs, plus the
existing Windows-portable standalone acceptance-stage and the two dev/write page test adjustments. Taken from f49;
on these files PR206 differed from f49 only by the zod 3 forms.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-05 07:01:30 -06:00
James MaesandClaude Sonnet 5.5 04ab9ad6c9 fix(deps): apply the reviewed f49 dependency advisory updates and zod 4 to the 206 line
next 16.4.1 -> 16.4.3, dompurify ^3.4.17, storybook 10.6.2, vitest/coverage/ui ^4.1.12, vite 7.3.7, zod ^4.6.7 and the
pnpm overrides (follow-redirects, undici@>=7 <8, fast-uri@>=3 <4) exactly as in the reviewed f49de335 package.json
dependency sections and pnpm-lock.yaml. PR206 carried no dependency change of its own beyond f49's (codemirror,
pdfjs-dist, typescript-editor are identical), so the f49 lockfile is used as is; `pnpm install --lockfile-only
--frozen-lockfile` (pnpm 9.15.9, Node 22.20.0) accepts it unchanged. Scripts, version RC.38 and the caller are untouched.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-05 07:01:29 -06:00
James MaesandClaude Sonnet 5.5 70618bd143 build: pin the workflow bundle lock to the component main merge ede4eba
Component PR 5 merged normally as ede4eba875ca17c5921ad4caf61a08bdff6f794d; its tree 9efc0921... equals the qualified
source tree, so the qualified bundle and map (sha256 unchanged) are the canonical main output; no rebuild.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-05 06:57:56 -06:00
james.maesandCodex aaa77d7e6e fix: retain Auth0 display profile with canonical presence identity
Keep the backend user ID without dropping provider display fields on sign-in or reload. Do not reuse a different user's cached profile. Reproduced callback/reload failures; full 3664-test suite, TypeScript and lint pass. Real mobile flow assertions pass locally, with Windows export diagnostics tracked separately; Linux acceptance remains required.

Co-Authored-By: Codex <noreply@openai.com>
2026-11-05 06:57:31 -06:00
James MaesandClaude Sonnet 5.5 73b1ea16d7 feat(collaboration): use server-issued presence leases in the hook (port of published RC.37 de5c1bd0)
The current line has the lease API client (renew/open/beacon with sequence) but the hook still generated a client
connection id and never opened or ordered a lease. Port the hook, auth-storage, realtime invalidation, header and
mock changes of de5c1bd0 from the published v1.0.0-RC.37, so RC.38 does not regress against qbit-collaboration's
issued-lease protocol. CI, version and acceptance-stage conflicts resolved to the current line (no version or
dependency change); the people-tasks beacon test keeps the current line's equivalent assertion.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-05 06:57:30 -06:00
James MaesandClaude Sonnet 5.5 94726bb52c build: package the qualified precompiled QSequentialWorkflow bundle as tracked Next library assets
Track the component bundle and map compiled from the reviewed component source e6036d2 (Node 22.20.0, npm ci
--ignore-scripts, existing 3 suites passed, webpack) in dynamic-components/q-sequential-workflow/ with a sha256 lock
(scripts/dynamic-components.lock.json: source commit/tree, toolchain image digest, lockfile hash). The stage script
copies them into the export (build, dev and build:export) and refuses a missing or altered file; the pom enforcer
requires the staged bundle and map, so a release without the workflow editor fails packaging. No network, token or
component fetch is needed in the producer. The component main commit is recorded after its merge (pending in the lock).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-05 06:34:45 -06:00
James MaesandClaude Sonnet 5.5 56ab7fe21c fix(widgets): run dynamic components on the React 19 host with Material's defaultValues Map and qController.processRun
Port of the KOF22 bridge fixes onto the current line: widget defaultValues are handed to dynamic components as a
Map that still allows property access, and the bridge exposes Material's qController.processRun (real process
init/run through the existing UI API). Needed by the QSequentialWorkflow editor bundle.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-05 06:33:21 -06:00
James MaesandCodex 0f2eb173c1 chore(release): prepare QBit UI candidate RC.38
QRun-IO/ci-policy: ci / ci-result (pull_request) Canceled after 0s
QRun-IO/ci-policy: ci / fork-guard (pull_request) Skipped
QRun-IO/ci-policy: native / fork-deny (pull_request) Skipped
QRun-IO/ci-policy: ci / Scoped workflow [workflow 13053bb397bf07f3eaacd6e51ef0ffad7f9f1eb3] (pull_request) Successful in 8s
QRun-IO/ci-policy: ci / head-profile (pull_request) Skipped
QRun-IO/ci-policy: ci / repository-policy (pull_request) Skipped
QRun-IO/ci-policy: ci / application-template-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-6 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-6-head (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-7 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-7-head (pull_request) Skipped
QRun-IO/ci-policy: ci / node-8 (pull_request) Skipped
QRun-IO/ci-policy: ci / original-business (pull_request) Skipped
QRun-IO/ci-policy: ci / original-voyage (pull_request) Skipped
QRun-IO/ci-policy: ci / original-cli (pull_request) Skipped
QRun-IO/ci-policy: Shared Node CI with Playwright / e2e (pull_request) Canceled after 20m6s
QRun-IO/ci-policy: native / dotnet-0 (pull_request) Skipped
QRun-IO/ci-policy: native / native-result (pull_request) Successful in 6s
QRun-IO/ci-policy: ci / gradle-1 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-core-2-head (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-3 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-3-head (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-4 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-frontend-browser-5 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-10 (pull_request) Skipped
QRun-IO/ci-policy: ci / source-config-13 (pull_request) Skipped
QRun-IO/ci-policy: ci / android-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / original-mcp (pull_request) Skipped
QRun-IO/ci-policy: ci / not-enrolled (pull_request) Skipped
QRun-IO/ci-policy: ci / shared-pipeline (pull_request) Skipped
QRun-IO/ci-policy: Shared Node CI with Playwright / secrets (pull_request) Successful in 47s
QRun-IO/ci-policy: Shared Node CI with Playwright / node (pull_request) Failing after 15m13s
QRun-IO/ci-policy: ci / node-playwright-11 (pull_request) Canceled after 1h27m5s
QRun-IO/ci-policy: Shared Node CI with Playwright / gate (pull_request) Canceled after 0s
QRun-IO/ci-policy: ci / node-9 (pull_request) Skipped
QRun-IO/ci-policy: ci / repository-policy-12 (pull_request) Skipped
QRun-IO/ci-policy: ci / source-config-14 (pull_request) Skipped
Use the next unused Maven version and record the deployed publication route and current integration evidence. Release remains sequenced after Core and Collaboration.

Co-Authored-By: Codex <noreply@openai.com>
2026-11-05 05:55:49 -06:00
James MaesandCodex 22f5749e36 chore: preserve current permission editor in QBit UI release
Merge current main without changing the permission editor. Both branches set maxWorkers to 4; retain a single setting. All 110 affected widget/process tests and typecheck passed.

Co-Authored-By: Codex <noreply@openai.com>
2026-11-05 05:51:34 -06:00
james.maes 090f5febea Merge pull request 'feat(authentik): application permission editor RC checkpoint' (#205) from james.maes/authentik-access-rc-20261105 into main
QRun-IO/ci-policy: Shared Node CI with Playwright / e2e (push) Successful in 26m10s
QRun-IO/ci-policy: Shared Node CI with Playwright / gate (push) Successful in 26s
QRun-IO/ci-policy: ci / node-playwright-11 (push) Successful in 1h29m54s
QRun-IO/ci-policy: ci / ci-result (push) Successful in 10s
QRun-IO/ci-policy: native / dotnet-0 (push) Skipped
QRun-IO/ci-policy: ci / Scoped workflow [workflow 13053bb397bf07f3eaacd6e51ef0ffad7f9f1eb3] (push) Successful in 9s
QRun-IO/ci-policy: ci / head-profile (push) Skipped
QRun-IO/ci-policy: ci / java-core-2-head (push) Skipped
QRun-IO/ci-policy: ci / java-docker-3 (push) Skipped
QRun-IO/ci-policy: ci / java-docker-4 (push) Skipped
QRun-IO/ci-policy: native / native-result (push) Successful in 9s
QRun-IO/ci-policy: ci / java-frontend-browser-5 (push) Skipped
QRun-IO/ci-policy: ci / java-maven-7-head (push) Skipped
QRun-IO/ci-policy: ci / repository-policy-12 (push) Skipped
QRun-IO/ci-policy: ci / source-config-13 (push) Skipped
QRun-IO/ci-policy: ci / source-config-14 (push) Skipped
QRun-IO/ci-policy: ci / original-voyage (push) Skipped
QRun-IO/ci-policy: ci / original-cli (push) Skipped
QRun-IO/ci-policy: Shared Node CI with Playwright / node (push) Successful in 13m54s
QRun-IO/ci-policy: ci / fork-guard (push) Skipped
QRun-IO/ci-policy: native / fork-deny (push) Skipped
QRun-IO/ci-policy: ci / repository-policy (push) Skipped
QRun-IO/ci-policy: ci / application-template-0 (push) Skipped
QRun-IO/ci-policy: ci / gradle-1 (push) Skipped
QRun-IO/ci-policy: ci / java-maven-6 (push) Skipped
QRun-IO/ci-policy: ci / java-maven-6-head (push) Skipped
QRun-IO/ci-policy: ci / java-maven-7 (push) Skipped
QRun-IO/ci-policy: ci / node-8 (push) Skipped
QRun-IO/ci-policy: ci / node-9 (push) Skipped
QRun-IO/ci-policy: ci / node-10 (push) Skipped
QRun-IO/ci-policy: ci / original-business (push) Skipped
QRun-IO/ci-policy: ci / shared-pipeline (push) Skipped
QRun-IO/ci-policy: Shared Node CI with Playwright / secrets (push) Successful in 52s
QRun-IO/ci-policy: ci / java-docker-3-head (push) Skipped
QRun-IO/ci-policy: ci / android-0 (push) Skipped
QRun-IO/ci-policy: ci / original-mcp (push) Skipped
QRun-IO/ci-policy: ci / not-enrolled (push) Skipped
2026-11-05 11:40:13 +00:00
james.maes afb9d75c75 Merge remote-tracking branch 'origin/main' into feature/authentik-access-ui
QRun-IO/ci-policy: ci / ci-result (pull_request) Failing after 11s
QRun-IO/ci-policy: ci / fork-guard (pull_request) Skipped
QRun-IO/ci-policy: native / fork-deny (pull_request) Skipped
QRun-IO/ci-policy: ci / head-profile (pull_request) Skipped
QRun-IO/ci-policy: ci / repository-policy (pull_request) Skipped
QRun-IO/ci-policy: ci / application-template-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-6 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-6-head (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-7 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-7-head (pull_request) Skipped
QRun-IO/ci-policy: ci / node-8 (pull_request) Skipped
QRun-IO/ci-policy: ci / original-business (pull_request) Skipped
QRun-IO/ci-policy: ci / original-cli (pull_request) Skipped
QRun-IO/ci-policy: ci / original-voyage (pull_request) Skipped
QRun-IO/ci-policy: Shared Node CI with Playwright / e2e (pull_request) Successful in 34m33s
QRun-IO/ci-policy: native / dotnet-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / Scoped workflow [workflow f1d46718955078331817c6206b822a94bc7d769f] (pull_request) Successful in 7s
QRun-IO/ci-policy: native / native-result (pull_request) Successful in 7s
QRun-IO/ci-policy: ci / gradle-1 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-3-head (pull_request) Skipped
QRun-IO/ci-policy: ci / java-core-2-head (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-3 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-4 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-frontend-browser-5 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-10 (pull_request) Skipped
QRun-IO/ci-policy: ci / source-config-13 (pull_request) Skipped
QRun-IO/ci-policy: ci / android-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / original-mcp (pull_request) Skipped
QRun-IO/ci-policy: ci / not-enrolled (pull_request) Skipped
QRun-IO/ci-policy: ci / shared-pipeline (pull_request) Skipped
QRun-IO/ci-policy: Shared Node CI with Playwright / secrets (pull_request) Successful in 37s
QRun-IO/ci-policy: Shared Node CI with Playwright / gate (pull_request) Failing after 29s
QRun-IO/ci-policy: ci / node-playwright-11 (pull_request) Failing after 1h39m12s
QRun-IO/ci-policy: ci / node-9 (pull_request) Skipped
QRun-IO/ci-policy: ci / repository-policy-12 (pull_request) Skipped
QRun-IO/ci-policy: ci / source-config-14 (pull_request) Skipped
QRun-IO/ci-policy: Shared Node CI with Playwright / node (pull_request) Failing after 14m6s
2026-11-05 05:23:23 -06:00
james.maesandGPT-6 119f1211d0 feat(authentik): add application access editor and paged permission matrix
Provide user, group and QQQ application-role permission editing with searchable,
paged controls and inherited-source visibility. Reuse the matrix in policy
editing and preserve form context for process widgets.

Co-Authored-By: GPT-6 <noreply@openai.com>
2026-11-05 05:23:11 -06:00
James MaesandCodex aba43263b2 ci: wire the supported Next dashboard Maven release
Pin the reviewed frontend-next producer and document the qualified publisher and remaining deployment gates. The immutable baseline version is unchanged until final release preparation.

Co-Authored-By: Codex <noreply@openai.com>
2026-11-05 05:22:01 -06:00
James MaesandGPT-6 ddc7de731b chore: reconcile current main with QBit UI integration
Preserve the upstream companion Tools workspace alongside the prepared
standard QQQ file, script, and process controls. Record the focused merge
checks and the verified missing Next Maven Central publication integration.

Validation: typecheck, changed-file lint, license headers, staged secret
scan, and 61 focused cases across the completed runs. The unchanged lazy
chart file passed with one worker after a cold parallel timeout; no test
assertion or timeout changed. Full release CI remains pending dependencies.

Co-Authored-By: GPT-6 (Codex) <noreply@openai.com>
2026-11-05 04:43:42 -06:00
james.maes 3d162dce74 feat: add companion Tools setup workspace (#204)
QRun-IO/ci-policy: ci / head-profile (push) Skipped
QRun-IO/ci-policy: ci / java-docker-3 (push) Skipped
QRun-IO/ci-policy: ci / java-docker-4 (push) Skipped
QRun-IO/ci-policy: ci / java-frontend-browser-5 (push) Skipped
QRun-IO/ci-policy: ci / repository-policy-12 (push) Skipped
QRun-IO/ci-policy: ci / source-config-13 (push) Skipped
QRun-IO/ci-policy: ci / source-config-14 (push) Skipped
QRun-IO/ci-policy: ci / original-voyage (push) Skipped
QRun-IO/ci-policy: ci / original-cli (push) Skipped
QRun-IO/ci-policy: native / dotnet-0 (push) Skipped
QRun-IO/ci-policy: native / native-result (push) Successful in 5s
QRun-IO/ci-policy: ci / java-core-2-head (push) Skipped
QRun-IO/ci-policy: ci / java-maven-7-head (push) Skipped
QRun-IO/ci-policy: Shared Node CI with Playwright / e2e (push) Successful in 21m28s
QRun-IO/ci-policy: Shared Node CI with Playwright / gate (push) Successful in 10s
QRun-IO/ci-policy: ci / node-playwright-11 (push) Successful in 21m41s
QRun-IO/ci-policy: ci / ci-result (push) Successful in 5s
QRun-IO/ci-policy: ci / fork-guard (push) Skipped
QRun-IO/ci-policy: ci / application-template-0 (push) Skipped
QRun-IO/ci-policy: ci / gradle-1 (push) Skipped
QRun-IO/ci-policy: ci / java-maven-6 (push) Skipped
QRun-IO/ci-policy: ci / java-maven-7 (push) Skipped
QRun-IO/ci-policy: ci / node-8 (push) Skipped
QRun-IO/ci-policy: ci / node-9 (push) Skipped
QRun-IO/ci-policy: ci / node-10 (push) Skipped
QRun-IO/ci-policy: ci / original-business (push) Skipped
QRun-IO/ci-policy: native / fork-deny (push) Skipped
QRun-IO/ci-policy: Shared Node CI with Playwright / secrets (push) Successful in 25s
QRun-IO/ci-policy: ci / repository-policy (push) Skipped
QRun-IO/ci-policy: ci / java-maven-6-head (push) Skipped
QRun-IO/ci-policy: Shared Node CI with Playwright / node (push) Successful in 10m44s
QRun-IO/ci-policy: ci / android-0 (push) Skipped
QRun-IO/ci-policy: ci / original-mcp (push) Skipped
QRun-IO/ci-policy: ci / not-enrolled (push) Skipped
QRun-IO/ci-policy: ci / java-docker-3-head (push) Skipped
2026-11-05 09:41:12 +00:00
James MaesandCodex d186ad7086 docs: clarify Next UI publication prerequisites
Co-Authored-By: Codex <noreply@openai.com>
2026-11-04 16:49:23 -06:00
James MaesandGPT-6 Codex 2186561950 fix: complete file previews and portable UI verification
Allow local PDF blob reads in standalone CSP and preview signed attachment PDFs without forwarding credentials. Make static export portable on Windows, bound test workers, cover transfer cancellation and size limits, and update integration evidence. Full coverage gates, 12 browser scenarios, live file/PDF flows, lint, typecheck and snapshot packaging pass.

Co-Authored-By: GPT-6 Codex <noreply@openai.com>
2026-11-04 12:02:20 -06:00
40ee257847 chore: preserve paused QBit work and document remaining issues
Save the current implementation and README, status, and known-issue documentation at the user-requested stop. No completed release or deployment is claimed.

Co-Authored-By: Claude <noreply@anthropic.com>

Co-Authored-By: GPT-6 Codex <noreply@openai.com>
2026-11-04 10:45:31 -06:00
James MaesandClaude Sonnet 5.5 b67fb5fd20 feat(ui): file preview/delivery, PDF preview, isolated TypeScript editing and result fields
Reconcile the reviewed private UI source onto canonical main d04cfa4.
Source: private kof22-clients frontend-next commit
f49de335e8bfc88b2e96aa8a34343ac6b5e93003 (feature/kof22-rc37), files
limited to the file-preview/open/download/actions, PDF/document preview,
compiled TypeScript editor and generic API widget support
(commits b4542dbe, 0ab1f692, d0705481, 21caa18d of that branch).

Not taken: the sequential-workflow bundle staging and qfmd bridge changes,
the zod 4 migration, the RC.37 tag merge (presence, auth, My Work),
version/pom bumps and the old lockfile. Canonical next 16.4.1, zod 3 and
dompurify are unchanged; package.json and pnpm-lock.yaml add only the
editor/PDF dependencies, worker build scripts and CodeMirror pins
(lock change is additions only).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-04 10:28:55 -06:00
forge-recovery d04cfa4b58 Merge pull request 'ci: baseline reviewed historical fixture findings' (#203) from ci/historical-finding-baseline-20261101 into main
QRun-IO/ci-policy: ci / ci-result (push) Failing after 3s
QRun-IO/ci-policy: ci / gradle-0 (push) Skipped
QRun-IO/ci-policy: ci / java-docker-1 (push) Skipped
QRun-IO/ci-policy: ci / node-3 (push) Skipped
QRun-IO/ci-policy: ci / fork-guard (push) Skipped
QRun-IO/ci-policy: ci / java-maven-2 (push) Skipped
QRun-IO/ci-policy: ci / node-4 (push) Skipped
QRun-IO/ci-policy: ci / not-enrolled (push) Failing after 4s
QRun-IO/ci-policy: ci / source-config-5 (push) Skipped
2026-11-02 01:46:35 +00:00
forge-recoveryandOpenAI GPT-6 94424b3f46 ci: baseline reviewed historical fixture findings
QRun-IO/ci-policy: ci / fork-guard (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-1 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-2 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-3 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-4 (pull_request) Skipped
QRun-IO/ci-policy: ci / ci-result (pull_request) Failing after 5s
QRun-IO/ci-policy: ci / gradle-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / source-config-5 (pull_request) Skipped
QRun-IO/ci-policy: ci / not-enrolled (pull_request) Failing after 5s
Co-Authored-By: OpenAI GPT-6 <noreply@openai.com>
2026-11-02 01:13:24 +00:00
e31716aa52 fix: update Next and harden the standalone runtime image (#201)
* fix: update Next and harden the standalone runtime image

Upgrade Next and eslint-config-next to 16.4.1; build and run on the same pinned Alpine Node image, exclude pnpm cache from context, and omit build-only package managers from production.

Co-Authored-By: Codex <noreply@openai.com>

* test: make standalone staging checks portable to Windows

Use platform paths for symlink comparison and the export-presence mock. Keep Unix permission-bit checks in an explicit Unix-only case; preserve file contents and relative-link behavior on Windows.

Co-Authored-By: Codex <noreply@openai.com>

* test: load the real developer view before DOM timing assertions

The first developer-view test also fails on unchanged main under cold Windows transforms. Compile the real lazy component in setup while retaining its rendering, API assertions and original timeouts. All nine targeted tests pass.

Co-Authored-By: Codex <noreply@openai.com>

---------

Co-authored-by: James Maes <jmaes@dmdbrands.com>
Co-authored-by: Codex <noreply@openai.com>
2026-10-29 17:19:37 -05:00
182 changed files with 30685 additions and 3636 deletions

No files matched your search

+2
View File
@@ -6,3 +6,5 @@ playwright-report
coverage
.env*
*.log
.pnpm-store
+1
View File
@@ -0,0 +1 @@
dynamic-components/** -text
+13
View File
@@ -0,0 +1,13 @@
# Copyright 2026 QRun.IO, Inc.
# Licensed under the Apache License, Version 2.0.
# https://www.apache.org/licenses/LICENSE-2.0
name: build
on:
push:
jobs:
maven:
uses: self:KofTwentyTwo/ci-workflows/.gitea/workflows/maven.yaml@v1
secrets: inherit
with:
node-version: '22.20.0'
prebuild: corepack enable && pnpm install --frozen-lockfile && pnpm typecheck && pnpm lint && pnpm test && pnpm build:export
+3 -3
View File
@@ -24,11 +24,11 @@ jobs:
ci:
if: (github.event_name == 'pull_request' && !contains(fromJSON('["labeled","unlabeled"]'),
github.event.action)) || (github.event_name == 'push' && github.ref == 'refs/heads/main')
uses: QRun-IO/qqq-orb/.github/workflows/next-ci.yml@1c389210e6dfda1630d053c07ae4dae523dd5576
uses: QRun-IO/qqq-orb/.github/workflows/next-ci.yml@0f33ddadf88b9a1b940b8883782fe2000ee1d2bd
acceptance:
if: github.event_name != 'pull_request' || !contains(fromJSON('["labeled","unlabeled"]'),
github.event.action) || github.event.label.name == 'full-acceptance'
uses: QRun-IO/qqq-orb/.github/workflows/next-acceptance.yml@1c389210e6dfda1630d053c07ae4dae523dd5576
uses: QRun-IO/qqq-orb/.github/workflows/next-acceptance.yml@0f33ddadf88b9a1b940b8883782fe2000ee1d2bd
with:
qqq_ref: ${{ inputs.qqq_ref || '' }}
dashboard-image:
@@ -38,4 +38,4 @@ jobs:
permissions:
contents: read
packages: write
uses: QRun-IO/qqq-orb/.github/workflows/next-dashboard-image.yml@1c389210e6dfda1630d053c07ae4dae523dd5576
uses: QRun-IO/qqq-orb/.github/workflows/next-dashboard-image.yml@0f33ddadf88b9a1b940b8883782fe2000ee1d2bd
+5
View File
@@ -10,6 +10,8 @@
# next.js
/.next/
/.next-collaboration/
/.next-presence-live/
/out/
# production
@@ -41,3 +43,6 @@ target/
/log/
/.playwright-mcp/
/storybook-static/
/public/scripting-generated/
/public/pdf-generated/
/public/dynamic-qfmd-components/
+5
View File
@@ -0,0 +1,5 @@
# Reviewed findings committed before the Gitea migration; exact historical fingerprints only.
0969231c95f876bb0a41529e8c6c4621a0257aee:src/lib/qa-capture/sanitizers.test.ts:jwt:33
23a855297de0f84b564b20d782e01180a68b5257:tests/acceptance/specs/security/restrictions.spec.ts:generic-api-key:248
+9
View File
@@ -0,0 +1,9 @@
<!-- BEGIN:nextjs-agent-rules -->
## This is NOT the Next.js you know
This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` (resolved from this file's directory; in monorepos the `next` package may not be visible from the repo root) before writing any code. Heed deprecation notices.
This block is written and re-added by `next dev` — verify at `node_modules/next/dist/server/lib/generate-agent-files.js`. Removing it from a diff only re-creates the uncommitted change; committing it with your work keeps the tree clean.
<!-- END:nextjs-agent-rules -->
+4 -1
View File
@@ -1,8 +1,11 @@
# QQQ Frontend Next
> **QBit integration branch (2026-11-04).** File delivery/preview, TypeScript editing and generic process controls use the QQQ UIUX APIs. See [current verification and dependency setup](docs/QBIT-RELEASE-STATUS.md) and [remaining release requirements](docs/QBIT-KNOWN-ISSUES.md). These changes are source/local snapshots, not part of the published RC.36 baseline described below.
A metadata-driven admin UI for the QQQ low-code application framework, rewritten from React + Material UI to Next.js 16 + Tailwind CSS + shadcn/ui. The UI renders entirely from backend metadata — no table names, field lists, or navigation items are hardcoded in the frontend.
**This checkout targets Next UI 1.0.0-RC.36**, which adds accessibility fixes: readable link text, 24px clear targets, h2 widget titles and skip-link focus to RC35. Confirm availability in the [RC36 GitHub release](https://github.com/QRun-IO/qqq-frontend-next/releases/tag/v1.0.0-RC.36) before installing; see the [RC36 notes](docs/releases/1.0.0-RC.36.md) for the preparation checkpoint and verification scope. [RC35](https://github.com/QRun-IO/qqq-frontend-next/releases/tag/v1.0.0-RC.35) was the previously verified public candidate at that checkpoint and remains immutable. Final 1.0 remains held for application soak and explicit owner approval.
**Published baseline: Next UI 1.0.0-RC.36**, which adds accessibility fixes: readable link text, 24px clear targets, h2 widget titles and skip-link focus to RC35. Confirm availability in the [RC36 GitHub release](https://github.com/QRun-IO/qqq-frontend-next/releases/tag/v1.0.0-RC.36) before installing; see the [RC36 notes](docs/releases/1.0.0-RC.36.md) for the preparation checkpoint and verification scope. [RC35](https://github.com/QRun-IO/qqq-frontend-next/releases/tag/v1.0.0-RC.35) was the previously verified public candidate at that checkpoint and remains immutable. Final 1.0 remains held for application soak and explicit owner approval.
**Current roadmap and known issues:** [Next UI 1.0 tracker](https://github.com/QRun-IO/qqq/issues/713). GitHub issues are the source of truth for status and remaining work; see [issue tracking](docs/ISSUE-TRACKING.md).
+6 -2
View File
@@ -1,4 +1,4 @@
FROM registry-1.docker.io/library/node:22-bookworm-slim AS frontend
FROM registry-1.docker.io/library/node:22-alpine@sha256:fe4f6d74e20370acb04ad889077d14d0f64a9c8ea6a42bc60cddf1e844699914 AS frontend
WORKDIR /app
ENV NEXT_TELEMETRY_DISABLED=1
RUN corepack enable && corepack prepare pnpm@9.15.9 --activate
@@ -9,8 +9,12 @@ ENV QQQ_BACKEND_URL=http://host.docker.internal:8000
ENV NEXT_PUBLIC_MOCK_API=false
RUN pnpm build
FROM registry-1.docker.io/library/node:22-bookworm-slim AS runtime
FROM registry-1.docker.io/library/node:22-alpine@sha256:fe4f6d74e20370acb04ad889077d14d0f64a9c8ea6a42bc60cddf1e844699914 AS runtime
LABEL org.opencontainers.image.source="https://github.com/QRun-IO/qqq-frontend-next"
# Standalone production starts node directly; package managers are build-only.
RUN rm -rf /usr/local/lib/node_modules/npm /usr/local/lib/node_modules/corepack /opt/yarn-* \
&& rm -f /usr/local/bin/npm /usr/local/bin/npx /usr/local/bin/corepack \
/usr/local/bin/pnpm /usr/local/bin/pnpx /usr/local/bin/yarn /usr/local/bin/yarnpkg
WORKDIR /app
ENV NODE_ENV=production NEXT_TELEMETRY_DISABLED=1 HOSTNAME=0.0.0.0 PORT=3000
COPY --from=frontend --chown=node:node /app/.next/standalone ./
+29
View File
@@ -0,0 +1,29 @@
# Isolated scripting editor
ScriptViewer routes explicit server-controlled ISOLATED projects before legacy hooks mount. Unknown nonempty modes fail closed. The native scripting extension must be advertised; otherwise isolated source stays unavailable. Developer page provides semantic project creation and opens the same IDE; project names never imply execution mode or actor identity.
The isolated CodeMirror editor supports multi-file TypeScript, immutable source history, explicit save/build/test/publish, server compiler diagnostics, scoped API declarations, JSON context tests and exact-commit GitHub imports with retained provenance. Edits invalidate build/test evidence. Saves and imports carry the observed latest revision. No browser credential fields or background repository synchronization.
## Semantic language service
Pinned editor-only typescript-editor alias6.0.3 powers a same-origin classic worker. It receives a bounded virtual filesystem and host-generated SDK declarations, with57 trusted ES2022 standard-library files bundled at build time. Diagnostics, completion, hover, go-to-definition(F12), formatting(Ctrl+Shift+F), file navigation and compiler diagnostic selection operate on the same workspace. Text remains ordinary undoable editor state. Tab moves focus.
There is no source execution, compiler emit, runtime fetch, arbitrary package loading, filesystem, DOM/Node declarations or user npm hooks. Browser analysis is advisory; authoritative strict builds still use the qualified isolated server. Worker cold deadline10s/warm3s terminates it on timeout; bounded request/result sizes, four pending calls, idle30s termination and recycling after200 requests limit work. The next explicit request may start a new worker; no hidden retry of timed-out source analysis. A browser worker supplies termination responsiveness, not an OS-enforced memory sandbox. Debugging and automatic imports are not implemented.
scripts/build-scripting-worker.mjs assembles only trusted package/compiler and our own session source into public/scripting-generated/typescript-6.0.3.worker.js, with license/notices. Generated9.6MB artifact is ignored and built before dev/build/build:export. Runtime worker requests no other resource. Exact compiler and library dependencies remain independent from server runtime qualification.
The alias package also exports tsc and would shadow the existing compiler under pnpm exec tsc. Use **pnpm typecheck** or **pnpm tsc**: both explicitly call node_modules/typescript/bin/tsc (the existing5.9 build compiler). Existing CI typecheck scripts follow that path. Do not replace frontend build TypeScript with the editor compiler. Compatible CodeMirror versions are pinned through pnpm overrides to avoid duplicate nominal EditorView/state classes.
## Authorization and uncertainty
Server permissions and worker availability control lifecycle actions, with backend checks authoritative. Use the matching Core `4.1.0-SNAPSHOT` dependency set described in [current integration status](QBIT-RELEASE-STATUS.md); the old mixed private-artifact/RC20 setup is superseded. Project creation and publication use typed core authority; isolated revision/file content and ownership are immutable. Real native HTTP lifecycle succeeds with protocol-only fixture worker; no production provider is implied.
Stop waiting or network timeout makes mutation outcome uncertain and prevents another mutation. History refresh does not prove completion; inspect history/reopen. Creation uncertainty directs the user to Scripts before retrying. Abort does not undo committed work. GitHub import needs a server-installed managed access provider and full immutable commit.
## Verification
2026-10-30: **114 focused tests across12 files**, existing build-compiler typecheck and changed-file ESLint passed. Real Playwright Chromium worker check exercises strict diagnostics, multi-file completion/hover/definition/format, rejects remote types and proves source does not execute or fetch. Final optimized production build with typecheck, static generation and standalone packaging passed at 21:36, including the creation panel (parent run20209, editor-build-verification.log). Native server tests separately cover create/save/build/context-test/publish and permission/association/legacy denials. No live browser-to-deployed-worker end-to-end or production sandbox qualification claim.
Run pnpm scripting:worker:check after generating the worker; this environment uses PLAYWRIGHT_BROWSERS_PATH=C:/Users/james/AppData/Local/ms-playwright. The harness serves only a local trusted page/worker with connect-src none and closes browser/server on completion.
Current integration verification (2026-11-04): the real Chromium offline-worker check passes strict diagnostics, multi-file completion/hover/definition and formatting, with no source execution or external requests. Typecheck, lint and production worker packaging pass on the canonical integration branch. See [current status](QBIT-RELEASE-STATUS.md) for the final full-suite evidence. This frontend result does not qualify the server's untrusted-code runtime.
+47
View File
@@ -0,0 +1,47 @@
# PDF preview boundary
The generic file lightbox uses pinned Mozilla `pdfjs-dist` **6.4.299**, loaded only for
PDFs, and a locally packaged worker. It no longer embeds uploaded PDF responses in an
application-origin iframe or offers a PDF "Open original" navigation. Downloads retain
the existing owner authorization callback.
Retained attachment previews reach it only after the tracked-download helper verifies
server receipt, byte length and SHA-256, and assigns `application/pdf` from the fixed
PDF signature. The generic renderer accepts local same-origin blob URLs or native
file endpoints at the exact configured API origin and base path. Both dashboard hosts must permit `blob:` in CSP `connect-src` so the verified local bytes can be read; the current Javalin and standalone defaults include it. It never forwards API
credentials to arbitrary external URLs. Uploaded MIME labels alone are insufficient.
The renderer supplies bytes (not a document URL) to an explicitly created parser
Worker, preventing the library's fake-worker fallback. It creates a canvas and an
optional plain-text view only: no PDF annotation/action, form, XFA, scripting or HTML
viewer layer. Auxiliary resource requests are rejected, worker fetching and WASM are
disabled, and no document-selected external URL is followed. Missing auxiliary fonts
or unsupported resources can limit fidelity; the retained original is downloadable.
Limits: 20 MiB input, 1000 pages, one visible page at a time, 8 million canvas pixels,
8192 pixels per edge, 30 seconds to load/parse, 15 seconds per page render/text read.
Text is streamed and stopped at 10,000 items or 100,000 characters, slicing each
string before aggregation. The stream is cancelled on page change, close or timeout;
page controls remain busy through text completion.
Closing the dialog aborts input loading, cancels rendering, destroys the loading task
and terminates the worker. These are application limits, not a browser-process memory
quota: compressed PDF complexity can allocate parser memory before page checks.
Canvas rendering and a Web Worker do **not** constitute a full hostile-parser security
sandbox. Browser/library patching, CSP and deployment qualification remain necessary.
Primary references checked on 2026-10-30:
- [Mozilla release 6.4.299](https://github.com/mozilla/pdf.js/releases/tag/v6.4.299)
- [Mozilla PDF.js API](https://mozilla.github.io/pdf.js/api/draft/module-pdfjsLib.html)
- [Mozilla prior arbitrary-JavaScript advisory](https://github.com/mozilla/pdf.js/security/advisories/GHSA-wgrm-67xf-hhpq)
The prior advisory affected versions through 4.1.392 and was fixed in 4.2.67. The current
6.4.299 API no longer exposes the old `isEvalSupported` option; this integration does
not pretend an obsolete option supplies isolation. It uses the pinned current release,
no PDF scripting manager, and the application's restrictive script CSP.
## Current verification (2026-11-04)
The production export and local Maven JAR include the pinned worker. Twelve Chromium browser scenarios pass against that packaged UI, including actual PDF worker/canvas pixels, page navigation, no executed PDF OpenAction, separate retained preview/download receipts, refusal of incomplete receipts, and ordered-file submission. Real Core/Collaboration API sessions separately pass ordinary signed-PDF canvas preview, page controls and PDF download.
Signed attachment PDFs are read with credentials omitted and redirects refused, bounded to 20 MiB and 30 seconds, and checked by byte signature before a local preview URL is exposed. Their storage origin needs host CSP/CORS configuration. Tracked deliveries retain the receipt/hash path. Cancellation and streaming-limit tests cover both paths. See [current status](QBIT-RELEASE-STATUS.md) for the full suite and [release requirements](QBIT-KNOWN-ISSUES.md) for remaining deployment qualifications.
+13
View File
@@ -0,0 +1,13 @@
# Metadata-driven process result fields
`ViewFormComponent` delegates adorned result fields to the shared `FieldValue` component. Hidden fields remain in the result value map so native metadata can supply display companions; they are not rendered as separate controls. Ordinary process values and validation errors keep their existing behavior.
`LINK` supports either `toRecordFromTable` (a static table name) or `toRecordFromTableField` (the name of another result field containing the table name). The field value is the record identity. Navigation uses the native encoded table/record route. A missing companion table produces no link. Possible-value sources that identify a known instance table also retain native record navigation.
`FILE_DOWNLOAD` can specify `fileNameField` and `deliveryModeField`. If the latter is present, its companion value must be `SIGNED_URL` or `AUTHENTICATED_FETCH`; missing or unknown modes render no access action. Older fields without that metadata retain their existing behavior.
For `AUTHENTICATED_FETCH`, the issued URL is never placed in an `href`, image source or iframe. Download uses the shared authenticated transfer helper, which checks the configured origin, response bounds, exact digest/length and completed durable receipt. An additional `FILE_PREVIEW` adornment enables an explicit preview action. Only verified PDF/raster blobs reach the shared preview component; component-owned blob URLs are revoked when closed or unmounted. Unsupported content and transfer/audit errors remain visible and never fall back to direct access.
`CODE_EDITOR` result fields use the shared read-only source display, including renderer-supplied plain catalog help and HTML/JSON examples. No template or script evaluation occurs in a process result.
Verification for this increment: 178 focused tests across 18 files, owned ESLint and the explicit build-compiler typecheck passed. Each actual transfer creates a fresh correlation UUID only after validating the configured endpoint, preserving the immutable file coordinate and using that same fresh attempt for its receipt. Source changes and unmount abort active body/receipt work. Final integrated production-build/browser acceptance remains pending. These generic components do not embed a QBit name or depend on its backend implementation.
+11
View File
@@ -0,0 +1,11 @@
# QBit integration known issues and release requirements
Updated: 2026-11-05.
- The Core, Collaboration and Next UI changes are verified source branches and local snapshots, not a published dependency set. Public Core RC.21 / Collaboration RC.6 lack the required retention contracts. Merge and publication through the existing release process remain separate from local verification.
- The frontend checks include real Core/Collaboration sessions and file storage, plus browser protocol fixtures for retained receipts. They do not establish end-to-end document generation/printing or deployed untrusted-script execution. Those QBits and workers retain their own qualification requirements.
- External signed PDF storage needs CORS for the dashboard origin and its origin in CSP `connect-src`. Application credentials are never sent to those URLs. Hosts replacing dashboard CSP must retain `blob:` in `connect-src` for verified local previews.
- Lint reports six existing warnings in unchanged files. The unit suite has one existing skipped test. Canonical Next 16.4.1 and Zod 3 are preserved; dependency patch upgrades remain a separate release review.
- The local artifact is `qqq-frontend-next:1.0.0-SNAPSHOT`, built with an explicit Maven revision override. It includes generated PDF and TypeScript workers. The prepared release-managed version is `1.0.0-RC.38`; RC.36 and RC.37 already exist in Central and must never be overwritten.
- Next publisher support and the shared producer are merged (Praetor PR238, platform-ci PR67); the local caller pins327e9b3a9d45a08665fa9468f0f9741e972d8e3e. Runtime qualification passed736 Linux tests and its vulnerability scan. GitOps PR269 deployed the qualified runtime and exact workflow/GAV enrollment; ArgoCD adoption and the scheduled publisher startup are verified. The signed application tag and resulting Central artifact still remain. The existing tag, package.json and POM version agreement, typecheck/lint/tests/static export and signed Central delivery are preserved. Neither the local JAR nor source merge proves publication.
- The full application acceptance matrix across all browsers and deployment-specific storage/printer/scanner behavior is not claimed by the scoped QBit integration checks.
+58
View File
@@ -0,0 +1,58 @@
# Next UI QBit integration status
Updated: 2026-11-05. Source integration is prepared; no published release or deployment is claimed.
Current main `090f5febeaacf3f9c39a22952e5c93c859cdbeb7` (permission editor and companion Tools workspace) is integrated. The latest merge passed all 110 affected Authentik/widget/process tests, typecheck and the copyright-header gate. Both branches supplied the same worker limit; the merged configuration retains one setting. Prior focused Tools/UI-API checks covered 61 cases. These are integration checks, not completed release CI.
Core PR1343 merged as7825261ab after full CI4482 passed; promotion PR1345 merged as1bef44cf and immutable publication4629 is running. Collaboration must consume the resulting published version. Next publisher support merged in Praetor PR238 and platform-ci PR67. GitOps PR269 merged as883e1ce3; ArgoCD adopted the exact configuration and a scheduled publisher is running image sha256:ea068b2a02147280cacedd095fbc55114daa9e3db72f1b0ca8511bc43bfd12c7 with the exact Next enrollment. Its 736 Linux tests and vulnerability scan passed. The dashboard candidate version is 1.0.0-RC.38; this source preparation is not a published release.
Candidate CI4602 passed 3,800 tests but hit the unchanged five-second timeout in two `RecordQuery` filter tests. These also timed out on an earlier baseline; the newer main baseline's Node job passed. Profiling the focused cases identified repeated whole-page accessibility lookups. Scoping those lookups to the existing filter controls and confirmation dialog reduced their sampled CPU cost from 387 to 261 samples; the same assertions, interactions and timeouts are preserved. All 17 tests in the affected file and focused lint pass after the change. Release CI for this correction remains required; these focused results do not replace it.
## Changes
The canonical Next UI integration renders file preview/download adornments, retained-transfer receipts, isolated TypeScript editing and generic process results through QQQ metadata and UIUX APIs. It adds no table-specific application routes. The completion pass fixes Windows static-export execution, local PDF reads under dashboard CSP, and ordinary signed-storage PDF previews. Signed storage reads omit credentials, refuse redirects and enforce byte/time limits before canvas rendering. Retained transfers preserve their receipt/hash checks. Tests now use four workers by default to avoid high-core host contention; no timeout or coverage threshold was relaxed.
## Verified dependencies
- Core `feat/standalone-scripting-api`, code commit `d384d1c753cb0ac2ee4d0f6ccd564a3e47488a1c`, locally installed as `4.1.0-SNAPSHOT`.
- Collaboration `feat/record-files-standalone`, `53bd8f00d8a180b0fa4eb6cd029dc31044b6d4b3`, locally installed as `0.1.0-SNAPSHOT`.
- Next UI retains canonical Next 16.4.1, Zod 3 and existing dependencies. Local packaging explicitly overrides Maven revision to `1.0.0-SNAPSHOT`; it does not replace public RC.36.
## Completed verification
| Check | Result |
| --- | --- |
| Full `pnpm test:coverage` | 359 files; **3,784 passed**, 1 existing skip; all coverage gates passed |
| Coverage | 93.14% statements, 87.00% branches, 94.57% functions, 94.95% lines; API/utility sub-gates also passed |
| Lint and license headers | Zero errors, six existing warnings in unchanged files; Apache headers passed |
| Typecheck | Passed |
| Offline TypeScript worker in Chromium | Strict diagnostics, multi-file completion/hover/definition and formatting passed; no source execution or external fetch |
| Windows production static export | Passed using `pnpm build:export` |
| Local Maven snapshot JAR | Built successfully with PDF/TypeScript workers included |
| Browser file/process regressions | **12 passed** against the packaged production UI; API fixtures exercise PDF canvas, separate audit receipts, incomplete-receipt refusal and keyboard ordering |
| Live Core + Collaboration file workflow | Upload, thumbnail, preview, open, exact-byte download, rename/describe, reload and delete passed with synthetic data |
| Live PDF and authorization | Signed PDF canvas/page controls and download passed; another session was denied access to the parent record |
| Live UI API specification check | Bundled 1.8.0 compatible with running Core 1.10.0 |
The live tests used the existing disposable `CollaborationDevServer`, synthetic Alice/Bob sessions and the current UI JAR on loopback. No API mocks were used for those file/permission checks. The separate 12-case browser suite uses its existing API fixtures, not a deployed Record Files or scripting worker. Test-owned services and browsers were stopped after verification.
## Reproduce
```shell
pnpm install --frozen-lockfile
pnpm test:coverage
pnpm lint
pnpm typecheck
pnpm scripting:worker:check
pnpm build:export
mvn -B -Drevision=1.0.0-SNAPSHOT package
pnpm ui-api:check --server-url http://localhost:18098
```
Start the matching Collaboration demo using its README and place the resulting UI JAR first on its classpath. The live check used port 18098. The standard `pnpm test:e2e` owns its frontend fixture for the browser scenarios. Maven was invoked through the sibling Collaboration wrapper during this Windows run.
Local JAR SHA-256: `0626B33DF49569476E17FA080E2777A7AD9C7AF717CB75DFABF9EB40BE0A46A8`.
## Release boundary
See [known issues and release requirements](QBIT-KNOWN-ISSUES.md). Compatible public artifact publication, the complete application acceptance matrix and deployed document/script worker qualification are not implied by these source and local integration results.
+21
View File
@@ -0,0 +1,21 @@
# Record Files client integration
Updated: 2026-11-04. Current canonical integration branch: `feat/standalone-qbits-ui`. See [verification and dependencies](QBIT-RELEASE-STATUS.md).
## Standard controls and APIs
The Files tab uses standard themed buttons for Preview, Open, Download and Rename / describe. Navigation uses Next links with keyboard focus. Attachment metadata supplies independent `canEdit` and `canDelete` permissions; an explicitly denied edit capability never falls back to deletion permission. The server remains authoritative.
Every download requests a fresh owner-issued descriptor. `AUTHENTICATED_FETCH` deliveries use the configured native Record Files route, a new correlation identifier, and a separately verified completion receipt, byte count and SHA-256. Preview and save are separate attempts. A failed receipt never becomes a direct link or a successful save. Generic `FILE_DOWNLOAD` and `FILE_PREVIEW` adornments use the same controls; they do not require table-specific pages.
Tracked raster/PDF previews expose a local object URL only after verification. Ordinary signed PDF previews read bounded bytes with credentials omitted, redirects refused and a 30-second deadline. Both use the same canvas PDF renderer; uploaded PDFs are never framed. Closing the preview aborts work and revokes local URLs. Hosts serving signed files from another origin must allow that storage origin in CSP `connect-src` and configure storage CORS for the dashboard origin. This never forwards QQQ credentials to storage.
Open previews supported media; unsupported types use the authorized download action. The UI does not claim that a browser saved bytes or that a printer physically printed them. Retention, upload sessions, collections and durable audits belong to their server QBits.
## Dependency order
Install matching Core, then Collaboration, then Record Files and other feature QBits before the matching frontend. Public Core RC.21 and Collaboration RC.6 do not contain the new retention contracts. Local verification uses Core `4.1.0-SNAPSHOT` and Collaboration `0.1.0-SNAPSHOT`; see their branch status files for exact source commits. Core's dashboard CSP must include `blob:` in `connect-src` for local PDF-byte reads.
## Verification scope
The full component/API suite and coverage gates, production export, browser PDF/receipt/ordering scenarios and live Collaboration file workflow are recorded in [current status](QBIT-RELEASE-STATUS.md). Live tests use disposable synthetic data and real Core/Collaboration APIs. Browser mocks of the retained-transfer protocol do not establish production storage or worker qualification.
+11
View File
@@ -0,0 +1,11 @@
# 1.0.0-RC.38 — prepared candidate
This candidate is not yet released. Merge and publication follow the compatible Core and Collaboration releases.
- Record file controls support previews, opening and downloading through the documented delivery APIs, including PDF previews.
- Script authoring includes the isolated TypeScript editor, server lifecycle actions and record hook controls.
- Process results support file delivery, PDF preview and ordered record lists through standard metadata.
- Record queries respect authorization-filtered pagination; record layouts preserve section metadata.
- The current permission editor and companion Tools workspace remain integrated.
The release uses the standard UI API and extension documents. Worker execution, storage configuration and printer/scanner qualification remain responsibilities of the respective QBits and host application. See [verification and release status](../QBIT-RELEASE-STATUS.md) and [known requirements](../QBIT-KNOWN-ISSUES.md).
+2
View File
@@ -4,6 +4,8 @@
## Features
- **Tools:** the `QQQ_TOOLS` widget provides a shared setup workspace for CLI/TUI, mobile scanners and Print Client, with platform-specific install commands, copyable scripts and instance connection details. An account-menu shortcut follows visible Tools metadata. Requires the Tools Qbit; existing device enrollment and permissions remain unchanged.
- **UI API 1.6.0 (QQQ 4.1.0-RC.15).** Next reads the typed `clientCapabilities` of table metadata for saved views, sharing, bulk processes and audits, applies `field.validation` (required, length, inclusive and exclusive bounds, patterns) before a request, reads client-neutral `uiSettings` setting by setting with the deprecated `materialDashboard` values as fallback, and shows the process error `traceId`. The full `materialDashboardTheme` still wins over the `uiSettings.theme` subset. Error messages keep their order (`userFacingError`, `error`, then the new `message`). Audits fall back to the permission-filtered instance metadata when a table's capability is unavailable, since RC.15 marks unaudited tables unavailable although explicit audits can exist. Off-spec calls stay at 0.
- **What's new:** a paged release history with new badges, safe links and screenshots, notification deep links, and an unread count in the user menu. Uses UI API extension `release-notes` 1.0.0; requires qbit-release-notes 0.1.0-RC.1+.
- **Profile photo and avatars everywhere** (QQQ 4.1.0-RC.17, UI API 1.8.0 `avatars`):
File diff suppressed because it is too large. Load diff
@@ -0,0 +1 @@
{"version":3,"file":"q-sequential-workflow.bundle.js","mappings":"AAAA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA,CAAC;AACD,O;;;;;;;;;;;;;;ACVA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA,6BAA6B;AAC7B;AACA;AACA;AACA;AACA;AACA;AACA,gBAAgB,SAAI,IAAI,SAAI;AAC5B;AACA,iDAAiD,OAAO;AACxD;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA,kDAAkD;AAClD;AACA;AACA;AACA,CAAC;AACyB;AAC1B,0C;;;;;;;;;;;;;;AClDA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA,6BAA6B;AAC7B;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA,gCAAgC,0CAA0C;AAC1E;AACA;AACA;AACA;AACA;AACA;AACA,4BAA4B,0BAA0B;AACtD;AACA;AACA;AACA;AACA;AACA,4BAA4B,4BAA4B;AACxD;AACA;AACA;AACA;AACA;AACA,CAAC;AACkB;AACnB,mC;;;;;;;;;;;;;;AC/DA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA,6BAA6B;AAC7B;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA,aAAa;AACb;AACA,yCAAyC,iBAAiB;AAC1D;AACA;AACA;AACA;AACA,CAAC;AACyB;AAC1B,0C;;;;;;;;;;;;;;;;;;ACzDA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA,6BAA6B;AAC7B;AACA;AACA;AACA;AACA;AACA;AACA,gBAAgB,SAAI,IAAI,SAAI;AAC5B;AACA,iDAAiD,OAAO;AACxD;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA,qBAAqB,SAAI,IAAI,SAAI;AACjC,6EAA6E,OAAO;AACpF;AACA;AACA;AACA;AACA;AACA;AACA;AACmD;AACE;AACH;AACJ;AAC9C;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA,4BAA4B,8BAA8B;AAC1D,yCAAyC,2DAAc;AACvD;AACA;AACA;AACA;AACA,4BAA4B,yGAAyG;AACrI,wDAAwD,2DAAc;AACtE;AACA;AACA,4BAA4B,uDAAY;AACxC;AACA;AACA;AACA;AACA;AACA;AACA;AACA,6CAA6C,6DAAY;AACzD;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA,4BAA4B,4BAA4B;AACxD;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA,kEAAkE,gBAAgB;AAClF;AACA;AACA;AACA;AACA;AACA;AACA,mDAAmD,gBAAgB;AACnE;AACA;AACA;AACA;AACA;AACA;AACA,qDAAqD,gBAAgB;AACrE;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA,SAAS;AACT;AACA,2DAA2D,WAAW,iJAAiJ;AACvN;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA,CAAC;AACyB;AAC1B,0C;;;;;;;;;;;;;;AC5JA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA,6BAA6B;AAC7B;AACA;AACA;AACA;AACA;AACA;AACA,gBAAgB,SAAI,IAAI,SAAI;AAC5B;AACA,iDAAiD,OAAO;AACxD;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA,4BAA4B,yBAAyB;AACrD;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA,4BAA4B,yBAAyB;AACrD;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA,oCAAoC,wBAAwB;AAC5D;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA,SAAS;AACT,gDAAgD;AAChD;AACA;AACA;AACA;AACA;AACA;AACA;AACA,CAAC;AACuB;AACxB,wC;;;;;;;;;;;;;;AC3GA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA,6BAA6B;AAC7B;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACO;AACP;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA,CAAC,8CAA8C;AAC/C,6C;;;;;;;;;;;;;;AC9CA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA,6BAA6B;AAC7B;AACA;AACA;AACA;AACA;AACA;AACA,qBAAqB,SAAI,IAAI,SAAI;AACjC,6EAA6E,OAAO;AACpF;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA,CAAC;AAC0B;AAC3B,2C;;;;;;;;;;;;;;AClDA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA,6BAA6B;AAC7B;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA,CAAC;AACyB;AAC1B,0C;;;;;;;;;;;;;;;;ACvCA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA,6BAA6B;AAC7B;AACA;AACA;AACA;AACA;AACA;AACoD;AACF;AAClD;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA,0CAA0C;AAC1C;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA,wBAAwB,6EAA6E;AACrG;AACA,8BAA8B,6DAAe;AAC7C;AACA,wBAAwB,6EAA6E;AACrG;AACA,8BAA8B,2DAAc;AAC5C;AACA,wBAAwB,+EAA+E;AACvG;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA,iDAAiD,gBAAgB;AACjE;AACA;AACA;AACA;AACA;AACA;AACA,iDAAiD,gBAAgB;AACjE;AACA;AACA;AACA;AACA;AACA;AACA,mDAAmD,gBAAgB;AACnE;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA,CAAC;AACuB;AACxB,wC;;;;;;;;;;;;;;;;;;ACjHA;AACgG;AACjB;AAC/E,8BAA8B,sEAA2B,CAAC,+EAAqC;AAC/F;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;;AAEA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;;AAEA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;;AAEA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;;AAEA;AACA;AACA;AACA;AACA;AACA;;AAEA;AACA;AACA;;AAEA;AACA;AACA;AACA;;AAEA;AACA;AACA;;AAEA;AACALine truncated
+2
View File
@@ -20,6 +20,8 @@ const BACKEND_PREFIXES: string[] = backendPrefixes
const nextConfig: NextConfig = {
reactStrictMode: true,
// Allow acceptance servers to run without sharing the developer server's lock/cache.
distDir: process.env.QQQ_NEXT_DIST_DIR || '.next',
// Reported to the backend as frontendVersion and shown on the developer page.
env: { NEXT_PUBLIC_APP_VERSION: process.env.NEXT_PUBLIC_APP_VERSION || packageJson.version },
...(exportBuild
+37 -19
View File
@@ -1,12 +1,15 @@
{
"name": "qqq-frontend-next",
"version": "1.0.0-RC.36",
"version": "1.0.0-RC.38",
"private": true,
"packageManager": "pnpm@9.15.9",
"scripts": {
"dev": "next dev --turbopack",
"build": "next build",
"build:export": "QQQ_NEXT_OUTPUT=export NEXT_PUBLIC_MOCK_API=false next build",
"scripting:worker": "node scripts/build-scripting-worker.mjs",
"scripting:worker:check": "node scripts/build-scripting-worker.mjs && node scripts/check-scripting-worker.mjs",
"dev": "node scripts/build-scripting-worker.mjs && node scripts/build-pdf-worker.mjs && node scripts/stage-dynamic-components.mjs && next dev --turbopack",
"build": "node scripts/build-scripting-worker.mjs && node scripts/build-pdf-worker.mjs && node scripts/stage-dynamic-components.mjs && next build",
"build:export": "node scripts/build-scripting-worker.mjs && node scripts/build-pdf-worker.mjs && node scripts/stage-dynamic-components.mjs && node scripts/build-export.mjs",
"workflow:bundle": "node scripts/stage-dynamic-components.mjs",
"build:jar": "pnpm build:export && mvn -B -q clean package",
"start": "next start",
"lint": "eslint src && node scripts/check-license-headers.mjs",
@@ -22,17 +25,20 @@
"test:acceptance": "node scripts/acceptance.mjs",
"perf:budget": "node scripts/perf-budget.mjs",
"ui-api:check": "node scripts/check-ui-api-spec.mjs",
"tsc": "tsc",
"typecheck": "tsc --noEmit",
"tsc": "node node_modules/typescript/bin/tsc",
"typecheck": "node node_modules/typescript/bin/tsc --noEmit",
"prepare": "git config core.hooksPath .githooks || true",
"storybook": "storybook dev -p 6006",
"build-storybook": "storybook build"
"build-storybook": "storybook build",
"pdf:worker": "node scripts/build-pdf-worker.mjs"
},
"dependencies": {
"@codemirror/autocomplete": "6.18.6",
"@codemirror/commands": "6.8.1",
"@codemirror/lang-javascript": "6.2.5",
"@codemirror/lang-sql": "6.9.0",
"@codemirror/language": "6.11.2",
"@codemirror/lint": "6.8.5",
"@codemirror/state": "6.5.2",
"@codemirror/view": "6.38.1",
"@hookform/resolvers": "^5.2.2",
@@ -54,10 +60,11 @@
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"date-fns": "^4.1.0",
"dompurify": "^3.4.16",
"dompurify": "^3.4.17",
"lodash": "^4.18.1",
"lucide-react": "^0.485.0",
"next": "16.3.8",
"next": "16.4.3",
"pdfjs-dist": "6.4.299",
"rapidoc": "10.1.0",
"react": "^19.2.4",
"react-dom": "^19.2.4",
@@ -70,15 +77,16 @@
"sonner": "^2.0.7",
"sql-formatter": "15.6.6",
"tailwind-merge": "^3.5.0",
"zod": "^3.25.76"
"typescript-editor": "npm:typescript@6.0.3",
"zod": "^4.6.7"
},
"devDependencies": {
"@axe-core/playwright": "^4.11.1",
"@eslint/eslintrc": "^3.3.4",
"@playwright/test": "1.64.0-alpha-2026-10-01",
"@storybook/addon-docs": "10.6.0",
"@storybook/nextjs-vite": "10.6.0",
"@storybook/react": "10.6.0",
"@storybook/addon-docs": "10.6.2",
"@storybook/nextjs-vite": "10.6.2",
"@storybook/react": "10.6.2",
"@tailwindcss/postcss": "^4.2.1",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
@@ -89,11 +97,11 @@
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.1.4",
"@vitest/coverage-v8": "^4.1.11",
"@vitest/ui": "^4.1.11",
"@vitest/coverage-v8": "^4.1.12",
"@vitest/ui": "^4.1.12",
"autoprefixer": "^10.4.27",
"eslint": "^9.39.3",
"eslint-config-next": "16.3.6",
"eslint-config-next": "16.4.1",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-jsdoc": "^62.7.1",
"jest-axe": "^10.0.0",
@@ -101,10 +109,11 @@
"msw": "^2.12.10",
"postcss": "^8.5.28",
"prettier": "^3.8.1",
"storybook": "10.6.0",
"storybook": "10.6.2",
"tailwindcss": "^4.2.1",
"typescript": "^5.9.3",
"vitest": "^4.1.11"
"vite": "7.3.7",
"vitest": "^4.1.12"
},
"msw": {
"workerDirectory": [
@@ -113,10 +122,19 @@
},
"pnpm": {
"overrides": {
"@codemirror/autocomplete": "6.18.6",
"@codemirror/commands": "6.8.1",
"@codemirror/language": "6.11.2",
"@codemirror/lint": "6.8.5",
"@codemirror/state": "6.5.2",
"@codemirror/view": "6.38.1",
"serialize-javascript": ">=7.0.3",
"@humanfs/node": ">=0.16.8",
"@babel/core": ">=7.29.6 <8",
"source-map-js": ">=1.2.2"
"source-map-js": ">=1.2.2",
"follow-redirects": ">=1.16.2",
"undici@>=7 <8": ">=7.30.1 <8",
"fast-uri@>=3 <4": ">=3.1.9 <4"
},
"ignoredOptionalDependencies": [
"electron"
+1 -1
View File
@@ -34,7 +34,7 @@ export default defineConfig({
webServer: {
command: `pnpm dev --port ${port}`,
url: baseURL,
env: { NEXT_PUBLIC_MOCK_API: 'true' },
env: { NEXT_PUBLIC_MOCK_API: 'true', QQQ_NEXT_DIST_DIR: '.next-collaboration' },
reuseExistingServer: false,
timeout: 120_000,
},
+407 -262
View File
File diff suppressed because it is too large. Load diff
+4 -2
View File
@@ -47,7 +47,7 @@
</developers>
<properties>
<revision>1.0.0-RC.36</revision>
<revision>1.0.0-RC.38</revision>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<project.build.outputTimestamp>2026-09-24T00:00:00Z</project.build.outputTimestamp>
</properties>
@@ -89,8 +89,10 @@
<files>
<file>${project.basedir}/out/index.html</file>
<file>${project.basedir}/out/app/_/_/index.html</file>
<file>${project.basedir}/out/dynamic-qfmd-components/q-sequential-workflow.bundle.js</file>
<file>${project.basedir}/out/dynamic-qfmd-components/q-sequential-workflow.bundle.js.map</file>
</files>
<message>Run "QQQ_NEXT_OUTPUT=export pnpm build" (or pnpm build:jar) before packaging.</message>
<message>Run pnpm build:export (or pnpm build:jar) before packaging; the export must contain the QSequentialWorkflow editor bundle staged from dynamic-components/.</message>
</requireFilesExist>
</rules>
</configuration>
+18
View File
@@ -0,0 +1,18 @@
/*
* Copyright 2026 QRun.IO, Inc.
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at https://www.apache.org/licenses/LICENSE-2.0
*/
// Pass build settings through the child environment on Windows and POSIX alike.
import { spawnSync } from 'node:child_process'
import { createRequire } from 'node:module'
const require = createRequire(import.meta.url)
const result = spawnSync(process.execPath, [require.resolve('next/dist/bin/next'), 'build'], {
stdio: 'inherit',
env: { ...process.env, QQQ_NEXT_OUTPUT: 'export', NEXT_PUBLIC_MOCK_API: 'false' },
})
if (result.error) console.error(result.error.message)
process.exit(result.status ?? 1)
+30
View File
@@ -0,0 +1,30 @@
/*
* Copyright 2026 QRun.IO, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
/** @file Copy the pinned trusted PDF worker; never copy uploaded files. */
import { createRequire } from 'node:module'
import { readFile, mkdir, copyFile } from 'node:fs/promises'
import path from 'node:path'
const require = createRequire(import.meta.url)
const entry = require.resolve('pdfjs-dist/package.json')
const root = path.dirname(entry)
const pkg = JSON.parse(await readFile(entry, 'utf8'))
if (pkg.version !== '6.4.299') throw new Error('Pinned PDF.js 6.4.299 is required')
const output = new URL('../public/pdf-generated/', import.meta.url)
await mkdir(output, { recursive: true })
await copyFile(path.join(root, 'build/pdf.worker.min.mjs'), new URL('pdf-6.4.299.worker.js', output))
await copyFile(path.join(root, 'LICENSE'), new URL('LICENSE', output))
console.log('Prepared pinned PDF.js 6.4.299 worker and Apache-2.0 license')
+63
View File
@@ -0,0 +1,63 @@
/*
* Copyright 2026 QRun.IO, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
/** @file Build the offline editor worker from pinned trusted dependencies; never reads a script project. */
import { createRequire } from 'node:module'
import { readFile, writeFile, mkdir } from 'node:fs/promises'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
const require = createRequire(import.meta.url)
const ts = require('typescript-editor')
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')
if (ts.version !== '6.0.3') throw new Error('Pinned editor TypeScript 6.0.3 is required')
const libraryDirectory = path.dirname(require.resolve('typescript-editor'))
const libraries = Object.create(null)
/** Include only the standard ES2022 library closure, never DOM, Node or project packages. */
async function includeLibrary(name) {
if (!/^lib\.[a-z0-9.]+\.d\.ts$/.test(name)) throw new Error('Invalid shipped library name')
if (libraries['/lib/' + name] !== undefined) return
const source = await readFile(path.join(libraryDirectory, name), 'utf8')
libraries['/lib/' + name] = source
for (const reference of source.matchAll(/<reference lib="([a-z0-9.]+)"\s*\/>/g)) await includeLibrary('lib.' + reference[1] + '.d.ts')
}
await includeLibrary('lib.es2022.d.ts')
const compiler = (await readFile(require.resolve('typescript-editor'), 'utf8')).replace(/\/\/# sourceMappingURL=.*$/gm, '')
const source = await readFile(path.join(root, 'src/lib/scripting/typescript-session.ts'), 'utf8')
const engine = ts.transpileModule(source, { compilerOptions: { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.CommonJS } }).outputText
if (/require\s*\(/.test(engine)) throw new Error('Editor engine must have no runtime module imports')
const worker = compiler + '\n;(() => {\nconst exports = {};\n' + engine + '\n' +
'const session = new exports.TypeScriptSession(ts, ' + JSON.stringify(libraries) + ');\n' +
`self.onmessage = (event) => {
const id = event.data?.id;
try {
const result = session.run(event.data);
if (new TextEncoder().encode(JSON.stringify(result)).length > 262144) throw new Error("Result limit");
self.postMessage({ protocol: "qqq-typescript-ide/1", id, version: "6.0.3", result });
} catch {
self.postMessage({ protocol: "qqq-typescript-ide/1", id, version: "6.0.3", error: "Editor analysis could not be completed" });
}
};\n})();\n`
const destination = path.join(root, 'public/scripting-generated')
await mkdir(destination, { recursive: true })
await writeFile(path.join(destination, 'typescript-6.0.3.worker.js'), worker)
await writeFile(path.join(destination, 'LICENSE.txt'), await readFile(path.join(libraryDirectory, '../LICENSE.txt'), 'utf8'))
await writeFile(path.join(destination, 'ThirdPartyNoticeText.txt'), await readFile(path.join(libraryDirectory, '../ThirdPartyNoticeText.txt'), 'utf8'))
console.log('Generated offline TypeScript 6.0.3 worker (' + Buffer.byteLength(worker) + ' bytes, ' + Object.keys(libraries).length + ' standard libraries)')
+76
View File
@@ -0,0 +1,76 @@
/*
* Copyright 2026 QRun.IO, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
/** @file Real Chromium smoke test for the generated offline worker, served only on loopback. */
import assert from 'node:assert/strict'
import { readFile } from 'node:fs/promises'
import http from 'node:http'
import { chromium } from '@playwright/test'
const workerBytes = await readFile(new URL('../public/scripting-generated/typescript-6.0.3.worker.js', import.meta.url))
const server = http.createServer((request, response) => {
if (request.url === '/worker.js') {
response.writeHead(200, { 'Content-Type': 'text/javascript', 'Cache-Control': 'no-store' }); response.end(workerBytes)
} else if (request.url === '/') {
response.writeHead(200, { 'Content-Type': 'text/html', 'Content-Security-Policy': "default-src 'none'; script-src 'self'; worker-src 'self'; connect-src 'none'" })
response.end('<!doctype html><title>Offline TypeScript fixture</title>')
} else { response.writeHead(404); response.end() }
})
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve))
let browser
try {
browser = await chromium.launch({ headless: true })
const page = await browser.newPage()
const requested = []
page.on('request', (request) => requested.push(new URL(request.url()).pathname))
await page.goto('http://127.0.0.1:' + server.address().port)
const result = await page.evaluate(async () => {
const worker = new Worker('/worker.js')
let id = 0
const request = (operation, files, position = 0) => new Promise((resolve, reject) => {
const requestId = ++id
const timer = setTimeout(() => { worker.terminate(); reject(new Error('Worker deadline exceeded')) }, 10_000)
worker.onmessage = (event) => { clearTimeout(timer); if (event.data.id !== requestId || event.data.error) reject(new Error('Worker protocol failed')); else resolve(event.data.result) }
worker.onerror = () => { clearTimeout(timer); reject(new Error('Worker failed')) }
worker.postMessage({ id: requestId, operation, files, file: 'src/main.ts', position, declarations: '' })
})
try {
const diagnostics = await request('diagnostics', [{ path: 'src/main.ts', content: 'export const count: number = "wrong";' }])
const source = 'import { order } from "./order.js";\norder.status;\norder.'
const files = [{ path: 'src/main.ts', content: source }, { path: 'src/order.ts', content: 'export const order = { status: "READY" };' }]
const completion = await request('completion', files, source.length)
const hover = await request('hover', files, source.indexOf('status') + 1)
const definition = await request('definition', files, source.indexOf('order.status') + 1)
const formatting = await request('format', [{ path: 'src/main.ts', content: 'export const value={a:1,b:2};' }])
await request('diagnostics', [{ path: 'src/main.ts', content: 'fetch("https://example.invalid/leak"); globalThis.__qqqNeverExecute = true;' }])
return { diagnostics, completion, hover, definition, formatting, executed: globalThis.__qqqNeverExecute === true }
} finally { worker.terminate() }
})
assert(result.diagnostics.diagnostics.some((item) => item.code === 2322))
assert(result.completion.completions.some((item) => item.label === 'status'))
assert.match(result.hover.hover.text, /string/)
assert.equal(result.definition.definitions[0].file, 'src/order.ts')
assert(result.formatting.edits.length > 0)
assert.equal(result.executed, false)
assert.deepEqual(requested.sort(), ['/', '/worker.js'])
console.log('PASS real Chromium: strict diagnostics, multi-file completion/hover/definition, formatting, no source execution or external requests')
} finally {
await browser?.close()
await new Promise((resolve) => server.close(resolve))
}
+27
View File
@@ -0,0 +1,27 @@
{
"component": {
"repository": "QRun-IO/qqq-frontend-component-sequential-workflow",
"sourceCommit": "e6036d2a474a90d80c72bc4c5f242e666b3415f6",
"sourceTree": "9efc09210a81dc4121aa53e156443b7f4fcb6dd7",
"mainCommit": "ede4eba875ca17c5921ad4caf61a08bdff6f794d",
"build": {
"node": "v22.20.0",
"npm": "10.9.3",
"image": "node@sha256:b21fe589dfbe5cc39365d0544b9be3f1f33f55f3c86c87a76ff65a02f8f5848e",
"commands": [
"npm ci --ignore-scripts",
"npm test (existing 3 suites, 62 tests)",
"npx webpack"
],
"packageLockSha256": "737cc1a3e416d942c050076dfae36345e9ba3f62d679dec71f04c2a2382b1a21",
"qualifiedUtc": "2026-11-05T12:31Z"
},
"mainTree": "9efc09210a81dc4121aa53e156443b7f4fcb6dd7",
"mainMerge": "normal merge of PR 5 (parents a32a7b4d437f09c09b03eb8828fccfd7de4eae7a, e6036d2a474a90d80c72bc4c5f242e666b3415f6); its tree equals the qualified source tree, so the qualified assets are the canonical main build output"
},
"assetDirectory": "dynamic-components/q-sequential-workflow",
"files": {
"q-sequential-workflow.bundle.js": "9f8358b105d0c157cb32a476313a7750281549f8e9423dd7d125ceaaafe8f32d",
"q-sequential-workflow.bundle.js.map": "776c716690a357213e00d109652ab7eb9c9025073b362c995564e11ca5c5ee92"
}
}
+45
View File
@@ -0,0 +1,45 @@
/*
* Copyright 2026 QRun.IO, Inc.
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at https://www.apache.org/licenses/LICENSE-2.0
*/
/**
* @file Stage the versioned, precompiled QSequentialWorkflow editor bundle (the qbit-workflows `QSequentialWorkflow` editor)
* under `public/dynamic-qfmd-components/`, which is where qbit-workflows' widget metadata points
* (`/dynamic-qfmd-components/q-sequential-workflow.bundle.js`) and where the single-origin host serves the UI from.
* The bundle and its map are tracked library assets in `dynamic-components/q-sequential-workflow/`, compiled from the
* canonical component repository (source commit and toolchain recorded in `dynamic-components.lock.json`). Nothing is
* downloaded or built here, and a file is refused unless its sha256 matches the lock. A missing or altered bundle fails the
* build, so a release can never silently omit the workflow editor.
*/
import { createHash } from 'node:crypto'
import { copyFile, mkdir, readFile } from 'node:fs/promises'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
const here = path.dirname(fileURLToPath(import.meta.url))
const sha256 = (bytes) => createHash('sha256').update(bytes).digest('hex')
/** Copies every locked file from `assetDir` to `outDir`; throws on a missing file or a hash mismatch. */
export async function stageDynamicComponents({ assetDir, lock, outDir }) {
await mkdir(outDir, { recursive: true })
const staged = []
for (const [name, expected] of Object.entries(lock.files)) {
if (path.basename(name) !== name) throw new Error(`Locked file name must be a plain file name: ${name}`)
if (!/^[0-9a-f]{64}$/.test(expected ?? '')) throw new Error(`${name} has no sha256 in the dynamic component lock`)
const source = path.join(assetDir, name)
const actual = sha256(await readFile(source).catch(() => { throw new Error(`Missing ${name} in ${assetDir}`) }))
if (actual !== expected) throw new Error(`${name} sha256 ${actual} does not match the lock ${expected}`)
await copyFile(source, path.join(outDir, name))
staged.push(name)
}
return staged
}
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
const lock = JSON.parse(await readFile(path.join(here, 'dynamic-components.lock.json'), 'utf8'))
const staged = await stageDynamicComponents({ assetDir: path.join(here, '..', lock.assetDirectory), lock, outDir: path.join(here, '../public/dynamic-qfmd-components') })
console.log(`Staged dynamic components compiled from ${lock.component.repository}@${lock.component.sourceCommit.slice(0, 8)}: ${staged.join(', ')}`)
}
+12
View File
@@ -118,6 +118,18 @@ describe('LoginPage', () => {
expect(screen.getByLabelText('Username')).toBeInTheDocument()
})
it('says so when the sign-out was the automatic logoff', async () => {
window.sessionStorage.setItem('qqq.signedOutForInactivity', '1')
try {
withAuth({ name: 'mock', type: 'MOCK' }, { isSignedOut: true })
render(<LoginPage />)
expect(await screen.findByRole('heading', { name: 'You were signed out' })).toBeInTheDocument()
expect(screen.getByText('You were signed out after a period of inactivity. Sign in again to continue.')).toBeInTheDocument()
} finally {
window.sessionStorage.removeItem('qqq.signedOutForInactivity')
}
})
it('has no accessibility violations', async () => {
withAuth(tableBased)
const { container } = render(<LoginPage />)
+17 -4
View File
@@ -37,6 +37,7 @@ import { useRouter, useSearchParams } from 'next/navigation'
import type { QLoginBranding } from '@/types'
import { useAuth } from '@/lib/auth/use-auth'
import { INACTIVITY_SIGN_OUT_FLAG } from '@/lib/auth/user-activity'
import { recordReauthAttempt, resetReauthAttempts } from '@/lib/auth/auth-storage'
import { safeReturnTo } from '@/lib/auth/return-to'
import { callbackErrorMessage, consumeProviderError } from '@/lib/auth/callback-errors'
@@ -194,6 +195,7 @@ function LoginContent() {
const autoSignInStarted = useRef(false)
const [loopStopped, setLoopStopped] = React.useState(false)
const [providerError, setProviderError] = useState<string | null>(null)
const [forInactivity, setForInactivity] = useState(false)
const returnTo = safeReturnTo(searchParams.get('returnTo'))
const callbackCode = searchParams.get('error')
@@ -207,6 +209,17 @@ function LoginContent() {
setProviderError(consumeProviderError(callbackCode))
}, [callbackCode])
// Automatic logoff says so: the user did not choose to sign out.
useEffect(() => {
try {
setForInactivity(window.sessionStorage.getItem(INACTIVITY_SIGN_OUT_FLAG) === '1')
} catch {
setForInactivity(false)
}
}, [isSignedOut])
const signedOutHeading = forInactivity ? 'You were signed out' : 'You have signed out'
const signedOutMessage = forInactivity ? 'You were signed out after a period of inactivity. Sign in again to continue.' : 'Sign in again to continue.'
// Pre-sign-in branding: accent colors and favicon, and the app name in the title.
useEffect(() => {
applyBrandingTheme(branding)
@@ -241,8 +254,8 @@ function LoginContent() {
return (
<LoginCard branding={branding}>
<PasswordSignIn
heading={isSignedOut ? 'You have signed out' : 'Sign in'}
message={isSignedOut ? 'Sign in again to continue.' : undefined}
heading={isSignedOut ? signedOutHeading : 'Sign in'}
message={isSignedOut ? signedOutMessage : undefined}
error={errorMessage}
onSubmit={signInWithPassword}
/>
@@ -253,8 +266,8 @@ function LoginContent() {
if (!isLoading && !isAuthenticated && isSignedOut) {
return (
<LoginCard branding={branding}>
<h1 className="text-xl font-semibold text-foreground">You have signed out</h1>
<p className="text-sm text-muted-foreground">Sign in again to continue.</p>
<h1 className="text-xl font-semibold text-foreground">{signedOutHeading}</h1>
<p className="text-sm text-muted-foreground">{signedOutMessage}</p>
<button type="button" onClick={signInAgain} className={BUTTON_CLASS} data-qqq-id="button-sign-in">
Sign in
</button>
@@ -19,7 +19,7 @@
import { render, screen, waitFor, within } from '@testing-library/react'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { http, HttpResponse } from 'msw'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
import type { QInstance } from '@/types'
import { QContextProvider, useQContext } from '@/lib/context/q-context'
@@ -113,6 +113,9 @@ function renderPage() {
}
describe('RecordDeveloperViewPage', () => {
// The page lazy-loads this view; transforming its module graph cold must not count against the first test's waits
beforeAll(async () => { await import('@/components/developer/RecordDeveloperView') }, 120_000)
beforeEach(() => {
vi.mocked(getRecord).mockResolvedValue(record)
vi.mocked(getRecordDeveloperData).mockResolvedValue({
@@ -17,7 +17,7 @@
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { http, HttpResponse } from 'msw'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
import apiClient from '@/lib/api/client'
import { QContextProvider } from '@/lib/context/q-context'
@@ -54,6 +54,8 @@ function renderPage(page: React.ReactNode) {
}
describe('Create and base edit use full metadata and actual write contracts', () => {
// The pages lazy-load the form; transforming its module graph cold must not count against the first test's waits
beforeAll(async () => { await import('@/components/forms/EntityForm') }, 120_000)
beforeEach(() => { vi.restoreAllMocks(); vi.clearAllMocks(); localStorage.clear(); fixture() })
it('edits the base without requesting denied children or replacing associations', async () => {
@@ -43,6 +43,7 @@ import { loadMetaData } from '@/lib/api/metadata'
import apiClient from '@/lib/api/client'
import { queryKeys } from '@/lib/query-client'
import { cn } from '@/lib/utils/cn'
import { CreateIsolatedScript } from '@/components/scripts/CreateIsolatedScript'
/** Duration in milliseconds before the "copied!" feedback resets to the copy icon. */
const COPY_RESET_MS = 2000
@@ -252,6 +253,8 @@ export default function DeveloperPage() {
</p>
</div>
<CreateIsolatedScript />
{/* Session Info */}
<SectionCard
title="Session Info"
+2 -4
View File
@@ -180,10 +180,8 @@ function DashboardLayoutContent({ children }: { children: React.ReactNode }) {
// Sync user ID to QContext
useEffect(() => {
if (user?.email) {
setUserId(user.email)
}
}, [user?.email, setUserId])
setUserId(user?.id ?? user?.email)
}, [user?.id, user?.email, setUserId])
// Redirect to login if not authenticated
useEffect(() => {
+3
View File
@@ -28,6 +28,7 @@ import { QueryClientProvider } from '@tanstack/react-query'
import { queryClient } from '@/lib/query-client'
import { AuthProvider } from '@/lib/auth/auth-provider'
import { InactivityLogoff } from '@/components/layout/InactivityLogoff'
import { ThemeProvider } from '@/lib/theme/theme-provider'
import { injectThemeTokens } from '@/lib/theme/tokens'
import { AppToaster } from '@/components/feedback/Toast'
@@ -83,6 +84,8 @@ export default function Providers({ children }: { children: ReactNode }) {
<AuthProvider>
{/* QA capture (qa-capture extension): nothing is loaded unless the server enables it for this user */}
<QaCaptureLoader />
{/* Automatic logoff: nothing is rendered unless the server has a user inactivity timeout */}
<InactivityLogoff />
{children}
</AuthProvider>
</ThemeProvider>
@@ -0,0 +1,124 @@
/*
* Copyright 2026 QRun.IO, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
/** @file Access editor behavior: stable selections, grant sources and API failures. */
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { describe, it, expect, vi } from 'vitest'
import type { QWidgetMetaData } from '@/types'
import { fetchWidgetData } from '@/lib/api/widgets'
import { fetchPossibleValues } from '@/lib/api/possible-values'
import AuthentikAccessEditorWidget from './AuthentikAccessEditorWidget'
vi.mock('@/lib/api/widgets', () => ({ fetchWidgetData: vi.fn() }))
vi.mock('@/lib/api/possible-values', () => ({ fetchPossibleValues: vi.fn() }))
const widget = { name: 'access', type: 'AUTHENTIK_ACCESS_EDITOR' } as QWidgetMetaData
/**
* Render with an isolated query cache.
* @param fields - Editor fields.
* @returns Process value callback.
*/
function setup(fields: Record<string, unknown>[]) {
const changed = vi.fn()
render(<QueryClientProvider client={new QueryClient({ defaultOptions: { queries: { retry: false } } })}>
<AuthentikAccessEditorWidget widgetMetaData={widget} data={{ fields }} onWidgetData={changed} />
</QueryClientProvider>)
return changed
}
describe('Access editor', () => {
it('preserves unknown grants and displays inherited access without making it editable', async () => {
const changed = setup([{ name: 'permissions', label: 'Direct permissions', selected: [{ id: 'legacy.read', label: 'legacy.read' }],
choices: [{ id: 'posts.edit', label: 'Edit posts' }], inherited: { 'posts.read': ['Readers'] } }])
expect(screen.getByLabelText('legacy.read')).toBeChecked()
await userEvent.click(screen.getByLabelText('Edit posts'))
expect(changed).toHaveBeenLastCalledWith({ permissions: 'legacy.read\nposts.edit' })
expect(screen.queryByRole('checkbox', { name: /posts.read/ })).not.toBeInTheDocument()
expect(screen.getByText('posts.read — Readers')).toBeInTheDocument()
})
it('searches the server and preserves selected IDs when results change', async () => {
vi.mocked(fetchPossibleValues).mockImplementation(async (_source, request) => request?.searchTerm === 'alex'
? [{ id: 42, label: 'Alex' }] : [{ id: 3, label: 'Sam' }])
const changed = setup([{ name: 'userIds', label: 'Users', source: 'identityUsers', selected: [] }])
await userEvent.click(await screen.findByLabelText('Sam'))
await userEvent.type(screen.getByLabelText('Search users'), 'alex')
await userEvent.click(await screen.findByLabelText('Alex'))
expect(screen.getByLabelText('Sam')).toBeChecked()
expect(changed).toHaveBeenLastCalledWith({ userIds: '3\n42' })
expect(fetchPossibleValues).toHaveBeenLastCalledWith('identityUsers', { searchTerm: 'alex', useCase: 'form' })
})
it('keeps existing assignments when the directory search fails', async () => {
vi.mocked(fetchPossibleValues).mockRejectedValue(new Error('Offline'))
const changed = setup([{ name: 'parentIds', label: 'Roles', source: 'roles', selected: [{ id: 'role-1', label: 'Reader' }] }])
await waitFor(() => expect(screen.getByRole('alert')).toHaveTextContent('Could not load choices'))
expect(screen.getByLabelText('Reader')).toBeChecked()
expect(changed).toHaveBeenCalledWith({ parentIds: 'role-1' })
})
it('rejects malformed payloads without submitting an empty permission list', () => {
const changed = setup([{ name: 'permissions', label: 'Permissions', selected: 'broken' }])
expect(screen.getByRole('alert')).toHaveTextContent('unavailable')
expect(changed).toHaveBeenCalledWith({ __accessEditorReady: false })
expect(changed).not.toHaveBeenCalledWith({ permissions: '' })
})
})
describe('Paged permission matrix', () => {
it('accepts the live QQQ payload that omits empty application names and IDs', async () => {
vi.mocked(fetchWidgetData).mockResolvedValue({ type: 'AUTHENTIK_ACCESS_EDITOR', fields: [{
total: 1, page: 0, pageSize: 25, apps: [{ label: 'Other' }],
rows: [{ name: 'documents', label: 'Documents', kind: 'TABLE', permissions: [{ id: 'documents.read', label: 'read' }] }],
}] })
const changed = setup([{ name: 'permissions', label: 'Direct permissions', catalogWidget: 'grantEditor', selected: [] }])
await userEvent.click(await screen.findByRole('checkbox', { name: 'documents.read' }))
expect(changed).toHaveBeenLastCalledWith({ permissions: 'documents.read' })
await userEvent.selectOptions(screen.getByLabelText('Application'), '')
await waitFor(() => expect(fetchWidgetData).toHaveBeenLastCalledWith('grantEditor', expect.objectContaining({ app: '' })))
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
})
it('keeps unknown and off-page grants across bulk page edits and failed searches', async () => {
vi.mocked(fetchWidgetData).mockImplementation(async (_widget, params) => {
if (params?.search === 'offline') throw new Error('Offline')
const page = Number(params?.page ?? 0)
return { type: 'AUTHENTIK_ACCESS_EDITOR', fields: [{ total: 1000, page, pageSize: 25, apps: [{ id: 'sales', label: 'Sales' }],
rows: Array.from({ length: 25 }, (_, i) => ({ name: 'table' + (page * 25 + i), label: 'Table ' + (page * 25 + i), kind: 'TABLE', app: 'sales', permissions: [{ id: 'table' + (page * 25 + i) + '.read', label: 'read' }] })) }] }
})
const changed = setup([{ name: 'permissions', label: 'Direct permissions', catalogWidget: 'grantEditor', selected: [{ id: 'legacy.read', label: 'legacy.read' }], inherited: { 'table999.read': ['Readers'] } }])
await screen.findByLabelText('table0.read')
expect(screen.getAllByRole('checkbox')).toHaveLength(25)
await userEvent.click(screen.getByRole('button', { name: 'Select 25 permissions on this page' }))
await userEvent.click(screen.getByRole('button', { name: 'Next' }))
await screen.findByLabelText('table25.read')
await userEvent.click(screen.getByLabelText('table25.read'))
await userEvent.click(screen.getByRole('button', { name: 'Previous' }))
await screen.findByLabelText('table0.read')
await userEvent.click(screen.getByRole('button', { name: 'Clear 25 permissions on this page' }))
expect(changed).toHaveBeenLastCalledWith({ permissions: 'legacy.read\ntable25.read' })
await userEvent.type(screen.getByLabelText('Search permissions or resources'), 'offline')
await screen.findByRole('alert')
expect(changed).toHaveBeenLastCalledWith({ permissions: 'legacy.read\ntable25.read' })
await userEvent.clear(screen.getByLabelText('Search permissions or resources'))
await userEvent.selectOptions(screen.getByLabelText('Show'), 'inherited')
expect(screen.getByText('table999.read — Readers')).toBeInTheDocument()
expect(screen.queryByRole('checkbox')).not.toBeInTheDocument()
})
})
@@ -0,0 +1,104 @@
/*
* Copyright 2026 QRun.IO, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
/** @file Structured named grants and searchable membership assignments. */
'use client'
import { useEffect, useId, useState } from 'react'
import { useQuery } from '@tanstack/react-query'
import { z } from 'zod'
import type { WidgetComponentProps } from '@/components/widgets/widget-types'
import { fetchPossibleValues } from '@/lib/api/possible-values'
import { PermissionMatrix } from './PermissionMatrix'
import { INPUT_CLASS, ALERT_CLASS } from '@/components/ui/controls'
const optionSchema = z.object({ id: z.string(), label: z.string() })
const fieldSchema = z.object({
catalogWidget: z.string().optional(), name: z.string().min(1), label: z.string(), source: z.string().optional(),
selected: z.array(optionSchema).default([]), choices: z.array(optionSchema).optional(),
inherited: z.record(z.string(), z.array(z.string())).optional(), policyPermissions: z.array(z.string()).optional(),
})
const payloadSchema = z.object({ fields: z.array(fieldSchema) })
type Field = z.infer<typeof fieldSchema>
type Option = z.infer<typeof optionSchema>
/**
* Edit one structured field while keeping selection independent of search results.
* @param props - Server metadata and the process value callback.
* @returns A checkbox list and, for assignments, a server-backed search box.
*/
function AccessField({ field, onChange }: { field: Field; onChange: (values: Record<string, unknown>) => void }) {
const [selected, setSelected] = useState<Option[]>(field.selected)
const [search, setSearch] = useState('')
const [page, setPage] = useState(0)
const [debounced, setDebounced] = useState('')
const id = useId()
useEffect(() => { const timer = setTimeout(() => setDebounced(search), 250); return () => clearTimeout(timer) }, [search])
const options = useQuery({
queryKey: ['access-editor-options', field.source, debounced],
queryFn: () => fetchPossibleValues(field.source!, { searchTerm: debounced, useCase: 'form' }),
enabled: Boolean(field.source),
})
useEffect(() => { onChange({ [field.name]: selected.map(option => option.id).join('\n') }) }, [selected, field.name, onChange])
const choices: Option[] = field.source
? (options.data ?? []).map(option => ({ id: String(option.id), label: option.label }))
: (field.choices ?? []).filter(option => option.label.toLowerCase().includes(search.toLowerCase()))
const selectedIds = new Set(selected.map(option => option.id))
const displayed = [...selected, ...choices.filter(option => !selectedIds.has(option.id))].filter(option => Boolean(field.source) || option.label.toLowerCase().includes(search.toLowerCase()))
const currentPage = Math.min(page, Math.max(0, Math.ceil(displayed.length / 25) - 1))
return <fieldset className="space-y-3 rounded-lg border border-border p-4">
<legend className="px-2 font-semibold">{field.label}</legend>
<label htmlFor={id} className="block text-sm">Search {field.label.toLowerCase()}</label>
<input id={id} data-qqq-id={'access-search-' + field.name} className={INPUT_CLASS} value={search} onChange={event => { setSearch(event.target.value); setPage(0) }} />
{options.isFetching && field.source && <p role="status" className="text-sm">Searching…</p>}
{options.isError && <p role="alert" className={ALERT_CLASS}>Could not load choices. Your selections are preserved. Change the search to try again.</p>}
<p className="text-sm text-muted-foreground">{selected.length} selected. Review validates every change before saving.</p>
<div className="max-h-80 space-y-2 overflow-auto">
{displayed.slice(currentPage * 25, currentPage * 25 + 25).map(option => <label key={option.id} className="flex items-start gap-2 text-sm">
<input type="checkbox" data-qqq-id={'access-choice-' + field.name + '-' + option.id} checked={selectedIds.has(option.id)}
onChange={event => setSelected(current => event.target.checked ? [...current, option] : current.filter(item => item.id !== option.id))} />
<span>{option.label}</span>
</label>)}
{!options.isFetching && displayed.length === 0 && <p className="text-sm text-muted-foreground">No choices found.</p>}
</div>
{displayed.length > 25 && <div className="flex gap-4">
<button type="button" disabled={currentPage === 0} onClick={() => setPage(currentPage - 1)}>Previous choices</button>
<span>Page {currentPage + 1} of {Math.ceil(displayed.length / 25)}</span>
<button type="button" disabled={(currentPage + 1) * 25 >= displayed.length} onClick={() => setPage(currentPage + 1)}>Next choices</button>
</div>}
{field.inherited && Object.keys(field.inherited).length > 0 && <details>
<summary className="cursor-pointer text-sm font-medium">Inherited access — edit the source to remove it</summary>
<ul className="mt-2 space-y-1 text-sm">{Object.entries(field.inherited).map(([permission, sources]) => <li key={permission}>{permission} — {sources.join(', ')}</li>)}</ul>
</details>}
{Boolean(field.policyPermissions?.length) && <details>
<summary className="cursor-pointer text-sm font-medium">Access from this item’s policy — use Edit Policy to change it</summary>
<ul className="mt-2 text-sm">{field.policyPermissions?.map(permission => <li key={permission}>{permission}</li>)}</ul>
</details>}
</fieldset>
}
/**
* Validate server-provided metadata before rendering editable process values.
* @param props - Standard widget payload and process callback.
* @returns The access editor or a contained error.
*/
export default function AuthentikAccessEditorWidget({ data, onWidgetData }: WidgetComponentProps<Record<string, unknown>>) {
const payload = payloadSchema.safeParse(data)
const ready = payload.success
useEffect(() => { onWidgetData?.({ __accessEditorReady: ready }) }, [ready, onWidgetData])
if (!payload.success || !onWidgetData) return <p role="alert" className={ALERT_CLASS}>The access editor is unavailable. Reopen the action before continuing.</p>
return <div className="space-y-4">{payload.data.fields.map(field => field.catalogWidget ? <PermissionMatrix key={field.name} field={field} onChange={onWidgetData} /> : <AccessField key={field.name} field={field} onChange={onWidgetData} />)}</div>
}
@@ -17,6 +17,8 @@
// Tests for the AUTHENTIK_POLICY_EDITOR widget: matrix, select-all, search, unknowns, JSON output
import React from 'react'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { fetchWidgetData } from '@/lib/api/widgets'
import { render, screen, waitFor, within } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { axe } from 'jest-axe'
@@ -27,6 +29,8 @@ import { WidgetRenderer } from '@/components/widgets/WidgetRenderer'
import { AuthentikPolicyEditorWidget, isPolicyPermission } from './AuthentikPolicyEditorWidget'
import type { PolicyEditorPayload } from './AuthentikPolicyEditorWidget'
vi.mock('@/lib/api/widgets', () => ({ fetchWidgetData: vi.fn() }))
const widget = { name: 'authentikPolicyEditor', type: 'AUTHENTIK_POLICY_EDITOR', label: 'Policy' } as unknown as QWidgetMetaData
const payload = (overrides: Partial<PolicyEditorPayload> = {}): PolicyEditorPayload => ({
@@ -74,7 +78,7 @@ const documentFor = (actions: string[], resources = ['person']) => JSON.stringif
*/
function setup(data: PolicyEditorPayload) {
const onWidgetData = vi.fn()
const view = render(<AuthentikPolicyEditorWidget widgetMetaData={widget} data={data as unknown as Record<string, unknown>} onWidgetData={onWidgetData} />)
const view = render(<QueryClientProvider client={new QueryClient({ defaultOptions: { queries: { retry: false } } })}><AuthentikPolicyEditorWidget widgetMetaData={widget} data={data as unknown as Record<string, unknown>} onWidgetData={onWidgetData} /></QueryClientProvider>)
const last = () => String(onWidgetData.mock.calls.at(-1)?.[0]?.policyDocument ?? '')
return { onWidgetData, last, ...view }
}
@@ -375,3 +379,41 @@ describe('AuthentikPolicyEditorWidget', () => {
expect(isPolicyPermission('a.b.read')).toBe(false)
})
})
describe('Large policy catalogs', () => {
it('bounds rendering and preserves off-page grants when selecting one page', async () => {
const { last } = setup(payload({ catalog: [{ app: 'large', label: 'Large', resources: Array.from({ length: 1000 }, (_, i) => ({ name: 'resource' + i, label: 'Resource ' + i, kind: 'TABLE', actions: ['read'] })) }] }))
expect(screen.getByLabelText('Read: Resource 0')).toBeInTheDocument()
expect(screen.queryByLabelText('Read: Resource 25')).not.toBeInTheDocument()
await userEvent.click(screen.getByLabelText('Everything in Large (this page)'))
await userEvent.click(screen.getByRole('button', { name: 'Next resources' }))
expect(screen.queryByLabelText('Read: Resource 0')).not.toBeInTheDocument()
await userEvent.click(screen.getByLabelText('Read: Resource 25'))
const doc = JSON.parse(last())
const resources = doc.Statement.flatMap((statement: { Resource: string[] }) => statement.Resource)
expect(resources).toHaveLength(26)
expect(resources).toContain('resource0')
expect(resources).toContain('resource25')
await userEvent.type(screen.getByLabelText('Find a resource'), 'Resource 999')
expect(screen.getByLabelText('Read: Resource 999')).toBeInTheDocument()
expect(JSON.parse(last())).toEqual(doc)
})
})
describe('Server-paged policies', () => {
it('keeps controlled selection synchronized with unknown removal and clear all', async () => {
vi.mocked(fetchWidgetData).mockResolvedValue({ type: 'AUTHENTIK_ACCESS_EDITOR', fields: [{ total: 1, page: 0, pageSize: 25, apps: [], rows: [{ name: 'cms_page', label: 'Pages', kind: 'TABLE', app: '', permissions: [{ id: 'cms_page.read', label: 'read' }, { id: 'cms_page.edit', label: 'edit' }] }] }] })
const { last } = setup(payload({ catalog: [], catalogWidget: 'pagedPolicy', unknownPermissions: ['legacy.read'], document: documentFor(['read'], ['legacy']) }))
await userEvent.click(await screen.findByLabelText('cms_page.read'))
expect(last()).toContain('legacy')
await userEvent.click(screen.getByRole('button', { name: 'Remove legacy.read' }))
await userEvent.click(screen.getByLabelText('cms_page.edit'))
expect(last()).not.toContain('legacy')
await userEvent.click(screen.getByRole('button', { name: 'Clear all' }))
expect(screen.getByLabelText('cms_page.read')).not.toBeChecked()
expect(screen.getByLabelText('cms_page.edit')).not.toBeChecked()
expect(JSON.parse(last()).Statement).toEqual([])
})
})
@@ -29,6 +29,7 @@ import React, { lazy, Suspense, useEffect, useId, useMemo, useRef, useState } fr
import type { WidgetComponentProps } from '@/components/widgets/widget-types'
import { asList, isPlainObject } from '@/components/widgets/widget-types'
import { ALERT_CLASS, INPUT_CLASS, WARNING_CLASS, buttonClass } from '@/components/ui/controls'
import { PermissionMatrix } from './PermissionMatrix'
import { cn } from '@/lib/utils/cn'
import type { PolicyAction, PolicyFormat } from './policy-document'
import { ACTION_LABELS, FULL_ACCESS_PERMISSION, POLICY_ACTIONS, generatePolicy, parsePolicy, permissionName, roundTrips, splitPermission } from './policy-document'
@@ -65,6 +66,10 @@ export interface PolicyEditorPayload {
/** The group the policy belongs to (display only). */
groupLabel?: string
catalog: PolicyCatalogGroup[]
/** Server-paged catalog source; older hosts send the full catalog. */
catalogWidget?: string
/** Unknown grants identified against the complete server catalog. */
unknownPermissions?: string[]
/** The group's current document (JSON text), or empty for none. */
document?: string
/** Whether the current document grants full access; older servers omit this. */
@@ -172,11 +177,12 @@ export function AuthentikPolicyEditorWidget({ widgetMetaData, data, onWidgetData
const [selected, setSelected] = useState<Set<string>>(() => new Set(initial.permissions))
const fullAccess = selected.has(FULL_ACCESS_PERMISSION)
const [search, setSearch] = useState('')
const [page, setPage] = useState(0)
const [memberUsername, setMemberUsername] = useState('')
const member = payload.members?.find((candidate) => candidate.username === memberUsername)
const generated = useMemo(() => generatePolicy(selected, format), [format, selected])
const unknown = useMemo(() => fullAccess ? [] : [...selected].filter((permission) => !known.has(permission)).sort(), [fullAccess, known, selected])
const unknown = useMemo(() => fullAccess ? [] : [...selected].filter((permission) => payload.catalogWidget ? payload.unknownPermissions?.includes(permission) : !known.has(permission)).sort(), [fullAccess, known, selected, payload.catalogWidget, payload.unknownPermissions])
const valid = useMemo(() => roundTrips(selected, format), [format, selected])
useEffect(() => {
@@ -194,6 +200,16 @@ export function AuthentikPolicyEditorWidget({ widgetMetaData, data, onWidgetData
}))
.filter((group) => group.resources.length > 0), [catalog, term])
const matchingCount = visibleGroups.reduce((sum, group) => sum + group.resources.length, 0)
const currentPage = Math.min(page, Math.max(0, Math.ceil(matchingCount / 25) - 1))
const pageResources = visibleGroups.flatMap((group) => group.resources.map((resource) => ({ group, resource })))
.slice(currentPage * 25, (currentPage + 1) * 25)
const pageGroups = visibleGroups.map((group) => ({
...group,
resources: pageResources.filter((entry) => entry.group === group).map((entry) => entry.resource),
label: matchingCount > 25 ? group.label + ' (this page)' : group.label,
})).filter((group) => group.resources.length > 0)
const set = (permissions: string[], on: boolean) => {
setSelected((current) => {
const next = new Set(current)
@@ -235,7 +251,7 @@ export function AuthentikPolicyEditorWidget({ widgetMetaData, data, onWidgetData
<fieldset disabled={fullAccess} aria-disabled={fullAccess} hidden={fullAccess} className="min-w-0">
<div className="flex flex-col gap-2 @xl:flex-row @xl:items-end @xl:justify-between">
<div className="flex min-w-0 flex-1 flex-col gap-1">
<div hidden={Boolean(payload.catalogWidget)} className="flex min-w-0 flex-1 flex-col gap-1">
<label htmlFor={searchId} className="text-sm font-medium text-foreground">Find a resource</label>
<input
id={searchId}
@@ -244,7 +260,7 @@ export function AuthentikPolicyEditorWidget({ widgetMetaData, data, onWidgetData
placeholder="App, table, process or report"
value={search}
aria-disabled={fullAccess}
onChange={(event) => setSearch(event.target.value)}
onChange={(event) => { setSearch(event.target.value); setPage(0) }}
data-qqq-id="policy-editor-search"
/>
</div>
@@ -262,11 +278,18 @@ export function AuthentikPolicyEditorWidget({ widgetMetaData, data, onWidgetData
{fullAccess && <p className="rounded-md border border-border bg-muted p-3 text-sm text-muted-foreground">Individual resource selection is disabled while full access is on.</p>}
<div hidden={fullAccess}>
<div className="flex min-w-0 flex-col gap-3">
{payload.catalogWidget ? <PermissionMatrix field={{ name: 'policyPermissions', label: 'Policy permissions', catalogWidget: payload.catalogWidget, selected: [] }} selection={selected} onSelectionChange={setSelected} /> : <>
{resourceCount === 0 && <p className="text-sm text-muted-foreground">This app has no resources that a policy can grant.</p>}
{resourceCount > 0 && visibleGroups.length === 0 && <p className="text-sm text-muted-foreground" role="status">No resource matches the search.</p>}
{visibleGroups.map((group) => (
{matchingCount > 25 && <div className="flex items-center gap-3 text-sm">
<button type="button" className={buttonClass('secondary', 'sm')} disabled={currentPage === 0} onClick={() => setPage(currentPage - 1)}>Previous resources</button>
<span role="status">{matchingCount} resources · Page {currentPage + 1} of {Math.ceil(matchingCount / 25)}. Bulk controls affect only this page.</span>
<button type="button" className={buttonClass('secondary', 'sm')} disabled={(currentPage + 1) * 25 >= matchingCount} onClick={() => setPage(currentPage + 1)}>Next resources</button>
</div>}
{pageGroups.map((group) => (
<PolicyGroupSection key={group.app ?? '(other)'} group={group} selected={selected} onSet={set} />
))}
</>}
</div>
</div>
</fieldset>
@@ -0,0 +1,121 @@
/*
* Copyright 2026 QRun.IO, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
/** @file Bounded permission catalog and grant source views. */
'use client'
import { useEffect, useId, useMemo, useState } from 'react'
import { useQuery } from '@tanstack/react-query'
import { z } from 'zod'
import { fetchWidgetData } from '@/lib/api/widgets'
import { INPUT_CLASS, ALERT_CLASS } from '@/components/ui/controls'
const optionSchema = z.object({ id: z.string(), label: z.string() })
// QQQ omits empty strings on the wire. Only application membership uses the
// empty string as a valid identity (the Other/unassigned application bucket).
const appOptionSchema = optionSchema.extend({ id: z.string().default('') })
const pageSchema = z.object({ fields: z.array(z.object({
rows: z.array(z.object({ name: z.string(), label: z.string(), kind: z.string(), app: z.string().default(''), permissions: z.array(optionSchema) })),
total: z.number().int().nonnegative(), page: z.number().int().nonnegative(), pageSize: z.number().int().positive(), apps: z.array(appOptionSchema),
})).min(1) })
type Option = z.infer<typeof optionSchema>
interface Field {
name: string; label: string; catalogWidget?: string; selected: Option[]
inherited?: Record<string, string[]>; policyPermissions?: string[]
}
/**
* Paged resource matrix. Selection is independent of catalog pages and requests.
* @param props - Initial grants, catalog source, and process output callback.
* @returns A searchable matrix with page-scoped bulk actions.
*/
export function PermissionMatrix({ field, onChange, selection, onSelectionChange }: {
field: Field; onChange?: (values: Record<string, unknown>) => void
selection?: Set<string>; onSelectionChange?: (selection: Set<string>) => void
}) {
const [localSelected, setSelected] = useState(() => new Map(field.selected.map(option => [option.id, option])))
const selected = useMemo(() => selection ? new Map([...selection].map(id => [id, { id, label: id }])) : localSelected, [selection, localSelected])
const [search, setSearch] = useState('')
const [debounced, setDebounced] = useState('')
const [app, setApp] = useState('*')
const [kind, setKind] = useState('*')
const [page, setPage] = useState(0)
const [view, setView] = useState('catalog')
const id = useId()
useEffect(() => { const timer = setTimeout(() => { setDebounced(search); setPage(0) }, 250); return () => clearTimeout(timer) }, [search])
useEffect(() => { if (!selection) onChange?.({ [field.name]: [...selected.keys()].join('\n') }) }, [selected, field.name, onChange, selection])
const catalog = useQuery({
queryKey: ['access-catalog', field.catalogWidget, debounced, app, kind, page],
queryFn: async () => pageSchema.parse(await fetchWidgetData(field.catalogWidget!, { accessCatalog: true, search: debounced, app, kind, page })).fields[0],
enabled: view === 'catalog',
})
const data = catalog.data
const busy = catalog.isFetching || search !== debounced
const visible = data?.rows.flatMap(row => row.permissions) ?? []
const actions = [...new Set(visible.map(option => option.label))]
const change = (options: Option[], checked: boolean) => {
const next = new Map(selected)
options.forEach(option => { if (checked) next.set(option.id, option); else next.delete(option.id) })
if (selection) onSelectionChange?.(new Set(next.keys()))
else setSelected(next)
}
const localEntries = (view === 'selected' ? [...selected.values()].map(option => [option.id, 'Direct grant'] as const)
: [...Object.entries(field.inherited ?? {}).map(([permission, sources]) => [permission, sources.join(', ')] as const),
...(field.policyPermissions ?? []).map(permission => [permission, 'This item’s policy'] as const)])
.filter(([permission, source]) => (permission + ' ' + source).toLowerCase().includes(search.toLowerCase()))
const localPage = Math.min(page, Math.max(0, Math.ceil(localEntries.length / 25) - 1))
const currentPage = view === 'catalog' ? (data?.page ?? 0) : localPage
const total = view === 'catalog' ? (data?.total ?? 0) : localEntries.length
return <fieldset className="space-y-3 rounded-lg border border-border p-4" data-qqq-id="access-permission-matrix">
<legend className="px-2 font-semibold">{field.label}</legend>
<p className="text-sm">{selected.size} direct permissions selected. Changes affect named permissions on existing resources. Future resources are not included.</p>
<label htmlFor={id + '-view'}>Show</label>
<select id={id + '-view'} className={INPUT_CLASS} value={view} onChange={event => { setView(event.target.value); setPage(0) }}>
<option value="catalog">Permission catalog</option><option value="selected">Selected direct grants</option><option value="inherited">Why access is granted</option>
</select>
<label htmlFor={id + '-search'}>Search permissions or resources</label>
<input id={id + '-search'} className={INPUT_CLASS} value={search} onChange={event => setSearch(event.target.value)} />
{view === 'catalog' && <>
<label htmlFor={id + '-app'}>Application</label>
<select id={id + '-app'} className={INPUT_CLASS} value={app} onChange={event => { setApp(event.target.value); setPage(0) }}>
<option value="*">All applications</option>{data?.apps.map(option => <option key={option.id} value={option.id}>{option.label}</option>)}
</select>
<label htmlFor={id + '-kind'}>Resource type</label>
<select id={id + '-kind'} className={INPUT_CLASS} value={kind} onChange={event => { setKind(event.target.value); setPage(0) }}>
<option value="*">All resource types</option>{['APP', 'TABLE', 'PROCESS', 'REPORT', 'WIDGET', 'OTHER'].map(value => <option key={value}>{value}</option>)}
</select>
{busy && <p role="status">Loading permission page…</p>}
{catalog.isError && <p role="alert" className={ALERT_CLASS}>Could not load permissions. Your selections are preserved. <button type="button" onClick={() => void catalog.refetch()}>Retry</button></p>}
<div className="flex gap-4">
<button type="button" disabled={busy || !visible.length || catalog.isError} onClick={() => change(visible, true)}>Select {visible.length} permissions on this page</button>
<button type="button" disabled={busy || !visible.length || catalog.isError} onClick={() => change(visible, false)}>Clear {visible.length} permissions on this page</button>
</div>
<div className="overflow-auto"><table className="w-full text-sm"><caption className="sr-only">Direct permissions by resource and action</caption>
<thead><tr><th scope="col" className="text-left">Resource</th>{actions.map(action => <th scope="col" key={action}>{action === 'insert' ? 'Create' : action === 'hasAccess' ? 'Access' : action}</th>)}</tr></thead>
<tbody>{data?.rows.map(row => <tr key={row.name}><th scope="row" className="text-left"><span>{row.label}</span><small className="block font-normal">{row.name} · {row.kind}</small></th>
{actions.map(action => { const option = row.permissions.find(item => item.label === action); return <td key={action} className="p-2 text-center">{option ? <input type="checkbox" aria-label={option.id} data-qqq-id={'access-choice-permissions-' + option.id} checked={selected.has(option.id)} disabled={busy} onChange={event => change([option], event.target.checked)} /> : '—'}</td> })}
</tr>)}</tbody>
</table></div>
</>}
{view !== 'catalog' && <ul className="space-y-2">{localEntries.slice(localPage * 25, localPage * 25 + 25).map(([permission, source]) => <li key={permission + source}>
{view === 'selected' ? <label><input type="checkbox" checked aria-label={permission} onChange={() => change([{ id: permission, label: permission }], false)} /> {permission}</label> : <span>{permission} — {source}</span>}
</li>)}</ul>}
<p role="status">{total} {view === 'catalog' ? 'resources' : 'permissions'} · Page {currentPage + 1} of {Math.max(1, Math.ceil(total / 25))}</p>
<div className="flex gap-4"><button type="button" disabled={currentPage === 0 || (view === 'catalog' && busy)} onClick={() => setPage(currentPage - 1)}>Previous</button>
<button type="button" disabled={(currentPage + 1) * 25 >= total || (view === 'catalog' && busy)} onClick={() => setPage(currentPage + 1)}>Next</button></div>
{view === 'inherited' && <p className="text-sm">Inherited grants must be changed at their source. Policy grants use Edit Policy.</p>}
</fieldset>
}
@@ -167,6 +167,7 @@ export function CollaborationEdit({
return (
<div className="space-y-3">
<PresenceAvatars viewers={presence.viewers} />
{presence.unavailable && <span role="status">Presence temporarily unavailable</span>}
{conflict && (
<div
role="alert"
@@ -49,11 +49,9 @@ vi.mock('@/lib/hooks/use-metadata', () => ({
}))
vi.mock('@/lib/api/tables', () => ({
getRecord: vi.fn(),
queryRecords: vi
.fn()
.mockResolvedValue({
records: [{ tableName: 'person', values: { id: 1 }, recordLabel: 'Alice' }],
}),
queryRecords: vi.fn().mockResolvedValue({
records: [{ tableName: 'person', values: { id: 1 }, recordLabel: 'Alice' }],
}),
}))
vi.mock('@/lib/api/collaboration', () => ({
searchPeople: vi.fn(),
@@ -66,6 +64,7 @@ vi.mock('@/lib/api/collaboration', () => ({
createTask: vi.fn(),
patchTask: vi.fn(),
getMyWork: vi.fn(),
markMentionsRead: vi.fn().mockResolvedValue({ success: true }),
listMyTasks: vi.fn(),
}))
const record = { table: 'person', pk: '1' }
@@ -302,6 +301,16 @@ it('renders My work with overdue task, assigned table and unread mentions links'
)
expect(screen.getByText(/Overdue:/)).toBeVisible()
expect(screen.getByRole('link', { name: '3 unread mentions' })).toBeVisible()
vi.mocked(api.getMyWork).mockResolvedValue({
openTaskCount: 1,
tasks: [],
assignedRecordsByTable: { person: 1 },
unreadMentionCount: 0,
})
fireEvent.click(screen.getByRole('button', { name: 'Mark all mentions read' }))
await waitFor(() => expect(api.markMentionsRead).toHaveBeenCalledTimes(1))
expect(await screen.findByRole('link', { name: '0 unread mentions' })).toBeVisible()
expect(screen.queryByRole('button', { name: 'Mark all mentions read' })).not.toBeInTheDocument()
expect(screen.getByRole('link', { name: /People \(1\)|Person \(1\)/ })).toHaveAttribute(
'href',
expect.stringContaining('CurrentUser')
+111 -28
View File
@@ -18,16 +18,24 @@
'use client'
import { useState } from 'react'
import dynamic from 'next/dynamic'
import Link from 'next/link'
import type { Attachment, CollaborationRecord, RecordFeaturesV1 } from '@/types/collaboration'
import type { FilePreviewKind } from '@/lib/utils/file-preview'
import { formatBytes } from '@/lib/utils/file-preview'
import { attachmentUrl } from '@/lib/api/collaboration'
import { saveAttachmentDownload } from '@/lib/api/attachment-download'
import { useAttachmentPreview } from '@/lib/hooks/use-attachment-preview'
import { attachmentRecoveryView } from '@/lib/utils/attachment-recovery-view'
import {
useAttachments,
useAttachmentActions,
useAttachmentUrl,
} from '@/lib/hooks/use-collaboration-record'
import { FileKindIcon } from '@/components/preview/FileKindIcon'
import { INPUT_CLASS, LABEL_CLASS, buttonClass } from '@/components/ui/controls'
/** Standard in-text link: themed color, underline on hover, visible keyboard focus. */
const LINK_CLASS =
'inline-flex min-h-11 items-center rounded-sm font-medium text-primary underline-offset-2 hover:underline focus:outline-none focus-visible:ring-2 focus-visible:ring-ring'
const ROW_ACTION = buttonClass('secondary', 'sm')
const Lightbox = dynamic(
() => import('@/components/preview/FilePreviewLightbox').then((m) => m.FilePreviewLightbox),
{ ssr: false }
@@ -54,20 +62,35 @@ function AttachmentRow({ file, record }: { file: Attachment; record: Collaborati
const [description, setDescription] = useState(file.description ?? '')
const [preview, setPreview] = useState(false)
const [error, setError] = useState('')
const [downloading, setDownloading] = useState(false)
const kind = kindFor(file.contentType)
const url = useAttachmentUrl(file.id, 'preview', kind === 'image' || preview)
const canEdit = Object.prototype.hasOwnProperty.call(file, 'canEdit') ? file.canEdit === true : file.canDelete === true
const url = useAttachmentPreview(file.id, kind === 'image' || preview, kind === 'pdf')
const download = async () => {
setError('')
setDownloading(true)
try {
const result = await attachmentUrl(file.id, 'download')
await saveAttachmentDownload(result, file.fileName ?? 'Attachment')
} catch {
// Backend/storage diagnostics are not display messages. The lightbox and
// direct action share this safe error and the same verified download path.
throw new Error('Download unavailable. Please try again.')
} finally {
setDownloading(false)
}
}
const open = async () => {
setError('')
try {
if (kind === 'other') {
const result = await attachmentUrl(file.id, 'download')
window.location.assign(result.url)
await download()
} else {
setPreview(true)
void url.refetch()
}
} catch (error) {
setError(error instanceof Error ? error.message : 'Could not open file.')
} catch {
setError('Download unavailable. Please try again.')
}
}
return (
@@ -76,7 +99,8 @@ function AttachmentRow({ file, record }: { file: Attachment; record: Collaborati
data-qqq-id={`attachment-${file.id}`}
>
<button
className="flex w-full min-w-0 items-start gap-3 text-left"
type="button"
className="flex w-full min-w-0 items-start gap-3 rounded-md text-left focus:outline-none focus-visible:ring-2 focus-visible:ring-ring"
data-qqq-id={`attachment-open-${file.id}`}
onClick={() => void open()}
>
@@ -100,10 +124,40 @@ function AttachmentRow({ file, record }: { file: Attachment; record: Collaborati
{file.description && !editing && (
<p className="break-words text-xs text-muted-foreground">{file.description}</p>
)}
{file.canDelete && (
<div className="flex gap-3 text-xs">
<button
data-qqq-id={`attachment-edit-${file.id}`}
<div className="flex flex-wrap gap-2">
{kind !== 'other' && <button
type="button"
className={ROW_ACTION}
data-qqq-id={`attachment-preview-${file.id}`}
onClick={() => void open()}
>
Preview
</button>}
<button
type="button"
className={ROW_ACTION}
data-qqq-id={`attachment-open-action-${file.id}`}
disabled={downloading}
onClick={() => void open()}
>
Open
</button>
<button
type="button"
className={ROW_ACTION}
data-qqq-id={`attachment-download-${file.id}`}
disabled={downloading}
onClick={() => void download().catch(() => setError('Download unavailable. Please try again.'))}
>
{downloading ? 'Downloading…' : 'Download'}
</button>
</div>
{(canEdit || file.canDelete) && (
<div className="flex flex-wrap gap-2">
{canEdit && <button
type="button"
className={ROW_ACTION}
data-qqq-id={`attachment-edit-${file.id}`}
onClick={() => {
setName(file.fileName ?? '')
setDescription(file.description ?? '')
@@ -111,8 +165,10 @@ function AttachmentRow({ file, record }: { file: Attachment; record: Collaborati
}}
>
Rename / describe
</button>
<button
</button>}
{file.canDelete && <button
type="button"
className={buttonClass('destructive', 'sm')}
data-qqq-id={`attachment-delete-${file.id}`}
disabled={actions.remove.isPending}
onClick={() => {
@@ -121,10 +177,10 @@ function AttachmentRow({ file, record }: { file: Attachment; record: Collaborati
}}
>
Delete
</button>
</button>}
</div>
)}
{editing && (
{editing && canEdit && (
<form
className="space-y-2"
onSubmit={(event) => {
@@ -135,38 +191,37 @@ function AttachmentRow({ file, record }: { file: Attachment; record: Collaborati
)
}}
>
<label className="block text-xs">
<label className={`block ${LABEL_CLASS}`}>
File name
<input
value={name}
onChange={(event) => setName(event.target.value)}
className="mt-1 w-full rounded border border-border bg-background p-2"
className={`mt-1 ${INPUT_CLASS}`}
data-qqq-id="attachment-name"
/>
</label>
<label className="block text-xs">
<label className={`block ${LABEL_CLASS}`}>
Description
<textarea
value={description}
onChange={(event) => setDescription(event.target.value)}
className="mt-1 w-full rounded border border-border bg-background p-2"
className={`mt-1 ${INPUT_CLASS}`}
data-qqq-id="attachment-description"
/>
</label>
<button
disabled={actions.patch.isPending || !name.trim()}
className="rounded border border-border px-3 py-2 text-xs"
className={buttonClass('primary', 'md')}
data-qqq-id="attachment-save"
>
Save file details
</button>
</form>
)}
{(error || actions.patch.isError || actions.remove.isError || (preview && url.isError)) && (
<p role="alert" className="text-sm text-destructive">
{error || 'Could not update or open this file. Please try again.'}
</p>
)}
{url.isError && <p role="alert" className="text-sm text-destructive">Preview unavailable. Try again or use Download.</p>}
{error && <p role="alert" className="text-sm text-destructive">{error}</p>}
{actions.patch.isError && <p role="alert" className="text-sm text-destructive">Could not save file details. Please try again.</p>}
{actions.remove.isError && <p role="alert" className="text-sm text-destructive">Could not delete this file. Please try again.</p>}
{preview && url.data && (
<Lightbox
items={[
@@ -184,6 +239,7 @@ function AttachmentRow({ file, record }: { file: Attachment; record: Collaborati
index={0}
onIndexChange={() => undefined}
onClose={() => setPreview(false)}
onDownload={download}
/>
)}
</li>
@@ -210,6 +266,11 @@ export function FilesTab({
const setting = features.settings?.ATTACHMENTS?.maxBytes
const maxBytes = typeof setting === 'number' && setting > 0 ? setting : 100 * 1024 * 1024
const files = query.data?.pages.flatMap((page) => page.items) ?? []
const recovery = attachmentRecoveryView(features.settings?.ATTACHMENTS?.recoveryView, record)
const relatedSettings = features.settings?.ATTACHMENTS?.relatedViews
const relatedViews = Array.isArray(relatedSettings) && relatedSettings.length <= 10
? relatedSettings.map((value) => attachmentRecoveryView(value, record)).filter((value) => value !== null)
: []
const upload = async (selection: File[]) => {
if (!canAttach) return
for (const file of selection) {
@@ -250,6 +311,27 @@ export function FilesTab({
}
return (
<div className="space-y-4" data-qqq-id="collaboration-files">
{relatedViews.length > 0 && (
<div className="flex flex-wrap gap-4 text-sm" data-qqq-id="files-related-views">
{relatedViews.map((view, index) => (
<Link key={index} href={view.href} className={LINK_CLASS} data-qqq-id={`files-related-view-${index}`}>
{view.label}
</Link>
))}
</div>
)}
{recovery && (
<div className="space-y-1 text-sm">
<Link href={recovery.href} className={LINK_CLASS} data-qqq-id="files-upload-recovery">
{recovery.label}
</Link>
{uploads.some((item) => item.error) && (
<p className="text-xs text-muted-foreground">
An interrupted upload may have saved the file. Review it before uploading again.
</p>
)}
</div>
)}
{canAttach ? (
<div
className={`rounded-lg border-2 border-dashed p-4 text-center ${dragging ? 'border-primary bg-accent' : 'border-border'}`}
@@ -267,7 +349,7 @@ export function FilesTab({
>
<p className="text-sm font-medium">Drop files here</p>
{/* the native picker's "Choose Files / No file chosen" text ignores the theme; a styled label opens it */}
<label className="mt-3 inline-flex min-h-11 cursor-pointer items-center rounded-md border border-border bg-background px-4 text-sm font-medium hover:bg-accent focus-within:ring-2 focus-within:ring-ring">
<label className={`mt-3 min-h-11 cursor-pointer focus-within:ring-2 focus-within:ring-ring ${buttonClass('secondary', 'md')}`}>
Choose files
<input
type="file"
@@ -316,7 +398,7 @@ export function FilesTab({
{query.isError && (
<p role="alert">
Could not load files.{' '}
<button data-qqq-id="files-retry" onClick={() => void query.refetch()}>
<button type="button" className={buttonClass('secondary', 'sm')} data-qqq-id="files-retry" onClick={() => void query.refetch()}>
Retry
</button>
</p>
@@ -331,9 +413,10 @@ export function FilesTab({
</ul>
{query.hasNextPage && (
<button
type="button"
data-qqq-id="files-more"
disabled={query.isFetchingNextPage}
className="w-full rounded border border-border py-2 text-sm"
className={`w-full ${buttonClass('secondary', 'md')}`}
onClick={() => void query.fetchNextPage()}
>
Load more
+18 -2
View File
@@ -17,11 +17,11 @@
/** @file Personal tasks, assigned record links and unread mentions. */
'use client'
import Link from 'next/link'
import { useQuery, useInfiniteQuery } from '@tanstack/react-query'
import { useQuery, useInfiniteQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { useMetaData, useTableMetaData } from '@/lib/hooks/use-metadata'
import { useCollaborationAvailable } from '@/lib/hooks/use-collaboration-core'
import { useRealtimeChannel } from '@/lib/hooks/use-realtime-channel'
import { getMyWork, listMyTasks } from '@/lib/api/collaboration'
import { getMyWork, listMyTasks, markMentionsRead } from '@/lib/api/collaboration'
import { queryRecords } from '@/lib/api/tables'
import { queryKeys } from '@/lib/query-client'
import { collaborationButton } from './CollaborationDialog'
@@ -78,6 +78,11 @@ function AssignedTable({ name, label, count }: { name: string; label: string; co
* @returns Work page.
*/
export function MyWork() {
const cache = useQueryClient()
const read = useMutation({
mutationFn: markMentionsRead,
onSuccess: () => cache.invalidateQueries({ queryKey: queryKeys.collaborationMyWork() }),
})
const available = useCollaborationAvailable()
const metadata = useMetaData()
const work = useQuery({
@@ -158,6 +163,17 @@ export function MyWork() {
</section>
<section className="rounded-xl border border-border bg-card p-4">
<h2 className="text-lg font-semibold">Unread mentions</h2>
{!!work.data.unreadMentionCount && (
<button
type="button"
className={collaborationButton}
disabled={read.isPending}
onClick={() => read.mutate()}
>
Mark all mentions read
</button>
)}
{read.isError && <p role="alert">Could not mark mentions read.</p>}
<Link
href="/app/notifications?status=unread"
className="text-sm underline"
@@ -72,6 +72,35 @@ beforeEach(() => {
})
vi.mocked(api.putRecordTags).mockResolvedValue({ items: [], nextCursor: null })
})
it('shows metadata recovery navigation pinned to the source after an interrupted upload', async () => {
vi.mocked(api.uploadAttachment).mockRejectedValue(new Error('Registration interrupted'))
const recoveryView = {
tableName: 'owner_pending_uploads', label: 'Review interrupted uploads',
filter: { booleanOperator: 'AND', criteria: [
{ fieldName: 'createdByUserId', operator: 'EQUALS', values: [{ type: 'CurrentUser' }] },
{ fieldName: 'state', operator: 'IN', values: ['PREPARED', 'REGISTERED'] },
] },
}
const source = { table: 'purchase_order', pk: 'a/b & 雪' }
const { container } = mount(<FilesTab record={source} features={{ ...features, settings: { ATTACHMENTS: { maxBytes: 10, recoveryView } } }} />)
const link = screen.getByRole('link', { name: recoveryView.label })
const target = new URL(link.getAttribute('href')!, 'https://app.example')
expect(target.pathname).toBe('/app/owner_pending_uploads')
expect(JSON.parse(target.searchParams.get('filter')!).criteria).toEqual([
...recoveryView.filter.criteria,
{ fieldName: 'tableName', operator: 'EQUALS', values: [source.table] },
{ fieldName: 'recordId', operator: 'EQUALS', values: [source.pk] },
])
fireEvent.change(container.querySelector('input[type=file]')!, { target: { files: [new File(['a'], 'bol.txt')] } })
expect(await screen.findByRole('alert')).toHaveTextContent('Registration interrupted')
expect(screen.getByText('An interrupted upload may have saved the file. Review it before uploading again.')).toBeVisible()
expect(screen.queryByText('bol.txt Uploaded')).not.toBeInTheDocument()
})
it('preserves ordinary Files tabs when no recovery metadata is supplied', async () => {
mount(<FilesTab record={record} features={features} />)
expect(await screen.findByText('No files yet.')).toBeVisible()
expect(document.querySelector('[data-qqq-id="files-upload-recovery"]')).toBeNull()
})
it('inserts a mention with the keyboard, warns and submits the literal token', async () => {
const submit = vi.fn().mockResolvedValue(undefined)
mount(<CommentComposer record={record} onSubmit={submit} />)
@@ -173,6 +202,76 @@ it('does not offer file uploads without attach permission', async () => {
expect(document.querySelector('[data-qqq-id="files-picker"]')).toBeNull()
expect(screen.getByText(/permission to attach/)).toBeVisible()
})
it.each([
{ canEdit: true, canDelete: false, edit: true, remove: false },
{ canEdit: false, canDelete: true, edit: false, remove: true },
{ canEdit: null, canDelete: true, edit: false, remove: true },
{ canEdit: undefined, canDelete: true, edit: false, remove: true },
{ canDelete: true, edit: true, remove: true },
])('keeps attachment editing separate from deletion: %j', async (permissions) => {
const { edit, remove, ...capabilities } = permissions
vi.mocked(api.listAttachments).mockResolvedValue({ items: [{ id: 9, fileName: 'retained.txt', ...capabilities }], nextCursor: null })
mount(<FilesTab record={record} features={features} />)
await screen.findByText('retained.txt')
expect(screen.queryByRole('button', { name: 'Rename / describe' }) !== null).toBe(edit)
expect(screen.queryByRole('button', { name: 'Delete' }) !== null).toBe(remove)
})
it('resolves a fresh download action from an attachment preview', async () => {
vi.mocked(api.listAttachments).mockResolvedValue({ items: [{ id: 9, fileName: 'retained.png', contentType: 'image/png' }], nextCursor: null })
vi.mocked(api.attachmentUrl).mockImplementation(async (_id, mode) => ({ url: mode === 'preview' ? '/preview.png' : '/authorized-download.png' }))
const click = vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => undefined)
try {
mount(<FilesTab record={record} features={features} />)
fireEvent.click(await screen.findByRole('button', { name: /retained.png/ }))
const dialog = await screen.findByRole('dialog')
fireEvent.click(within(dialog).getByRole('button', { name: 'Download retained.png' }))
await waitFor(() => expect(api.attachmentUrl).toHaveBeenCalledWith(9, 'download'))
await waitFor(() => expect(click).toHaveBeenCalledTimes(1))
expect(click.mock.instances[0]).toHaveAttribute('href', '/authorized-download.png')
expect(dialog.querySelector('[data-qqq-id="file-preview-download"]')).not.toHaveAttribute('href')
} finally {
click.mockRestore()
}
})
it('reports a failed thumbnail as preview unavailable and still offers a separate native download', async () => {
vi.mocked(api.listAttachments).mockResolvedValue({ items: [{ id: 9, fileName: 'scan.jpg', contentType: 'image/jpeg', canEdit: false, canDelete: false }], nextCursor: null })
vi.mocked(api.attachmentUrl).mockImplementation(async (_id, mode) => {
if (mode === 'preview') throw new Error('HTTP 500: private storage diagnostics')
return { url: '/authorized-original.jpg' }
})
const click = vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => undefined)
try {
mount(<FilesTab record={record} features={features} />)
expect(await screen.findByRole('alert')).toHaveTextContent('Preview unavailable. Try again or use Download.')
expect(screen.queryByText(/private storage|update or open/i)).toBeNull()
expect(screen.getByRole('button', { name: 'Preview' })).toBeVisible()
expect(screen.getByRole('button', { name: 'Open' })).toBeVisible()
fireEvent.click(screen.getByRole('button', { name: 'Download' }))
await waitFor(() => expect(api.attachmentUrl).toHaveBeenCalledWith(9, 'download'))
await waitFor(() => expect(click).toHaveBeenCalledTimes(1))
expect(click.mock.instances[0]).toHaveAttribute('href', '/authorized-original.jpg')
expect(screen.queryByRole('dialog')).toBeNull()
} finally {
click.mockRestore()
}
})
it('keeps a denied direct download separate from preview and editing errors without exposing server details', async () => {
vi.mocked(api.listAttachments).mockResolvedValue({ items: [{ id: 9, fileName: 'original.bin', contentType: 'application/octet-stream' }], nextCursor: null })
vi.mocked(api.attachmentUrl).mockRejectedValue(new Error('private backend path'))
const click = vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => undefined)
try {
mount(<FilesTab record={record} features={features} />)
fireEvent.click(await screen.findByRole('button', { name: 'Download' }))
expect(await screen.findByRole('alert')).toHaveTextContent('Download unavailable. Please try again.')
expect(screen.queryByText(/private backend|update or open/i)).toBeNull()
expect(screen.queryByRole('button', { name: 'Preview' })).toBeNull()
expect(click).not.toHaveBeenCalled()
expect(api.attachmentUrl).toHaveBeenCalledExactlyOnceWith(9, 'download')
} finally {
click.mockRestore()
}
})
it('pages activity with the returned cursor and shows audit details', async () => {
vi.mocked(api.listTimeline)
.mockResolvedValueOnce({
@@ -285,3 +384,18 @@ it('follows and exposes notification preferences and follower count', async () =
expect(await screen.findByText('1 follower')).toBeVisible()
expect(within(screen.getByRole('dialog')).getByLabelText('Notify me about changes')).toBeChecked()
})
it('renders only valid server-defined related views pinned to this source record', async () => {
mount(<FilesTab record={record} features={{ ...features, settings: { ATTACHMENTS: { relatedViews: [
{ tableName: 'evidence_history', label: 'Retained evidence', filter: { booleanOperator: 'AND', criteria: [{ fieldName: 'tableName', operator: 'EQUALS', values: [record.table] }] } },
{ tableName: '../outside', label: 'Unsafe', filter: { booleanOperator: 'AND', criteria: [] } },
] } } }} />)
const link = screen.getByRole('link', { name: 'Retained evidence' })
const url = new URL(link.getAttribute('href')!, 'https://app.example')
expect(url.pathname).toBe('/app/evidence_history')
expect(JSON.parse(url.searchParams.get('filter')!).criteria).toEqual(expect.arrayContaining([
{ fieldName: 'tableName', operator: 'EQUALS', values: [record.table] },
{ fieldName: 'recordId', operator: 'EQUALS', values: [record.pk] },
]))
expect(screen.queryByRole('link', { name: 'Unsafe' })).toBeNull()
})
@@ -24,6 +24,8 @@ import { useCollaborationPresence } from '@/lib/hooks/use-collaboration-presence
import { useRealtimeChannel } from '@/lib/hooks/use-realtime-channel'
import { getRecordFeatures, putAssignment } from '@/lib/api/collaboration'
import { queryKeys } from '@/lib/query-client'
import { updateRecord } from '@/lib/api/tables'
import { recordVersion } from '@/lib/utils/record-version'
import { pluralize } from '@/lib/utils/string-utils'
import { PresenceAvatars } from './PresenceAvatars'
import { CollaborationDialog, collaborationButton } from './CollaborationDialog'
@@ -66,7 +68,23 @@ export function RecordCollaborationHeader({
enabled: available && !!features,
})
const assignment = useMutation({
mutationFn: () => putAssignment(target, { [role]: selected[0] ?? null }),
mutationFn: async () => {
const nativeField =
role === 'ownerUserId'
? features?.ownership?.ownerFieldName
: features?.ownership?.assigneeFieldName
if (nativeField) {
await updateRecord(
table.name,
pk,
{ [nativeField]: selected[0] ?? null },
undefined,
recordVersion(table, record)
)
} else {
await putAssignment(target, { [role]: selected[0] ?? null })
}
},
onSuccess: () => {
setAssign(false)
void cache.invalidateQueries({ queryKey: queryKeys.collaborationRecord(table.name, pk) })
@@ -89,6 +107,7 @@ export function RecordCollaborationHeader({
data-qqq-id="record-collaboration-header"
>
{presence.viewers.length > 0 && <PresenceAvatars viewers={presence.viewers} />}
{presence.unavailable && <span role="status">Presence temporarily unavailable</span>}
{(owner != null || assignee != null) && (
<dl className="flex flex-wrap items-center gap-x-3 gap-y-1">
{owner != null && (
@@ -151,11 +170,11 @@ export function RecordCollaborationHeader({
<PeoplePicker record={target} selected={selected} onChange={setSelected} requireRead />
<button
className={collaborationButton}
disabled={assignment.isPending || !selected.length}
disabled={assignment.isPending}
onClick={() => assignment.mutate()}
data-qqq-id="assignment-save"
>
Assign
{selected.length ? 'Assign' : 'Clear assignment'}
</button>
{assignment.isError && <p role="alert">{assignment.error.message}</p>}
</CollaborationDialog>
@@ -176,7 +195,11 @@ export function TablePresence({ table, available }: { table: QTableMetaData; ava
return (
<div className="flex flex-wrap items-center gap-2 text-sm" data-qqq-id="table-presence">
<PresenceAvatars viewers={presence.viewers} />
<span>{pluralize(presence.viewers.length, 'person', 'people')} viewing</span>
<span role={presence.unavailable ? 'status' : undefined}>
{presence.unavailable
? 'Presence temporarily unavailable'
: `${pluralize(presence.viewers.length, 'person', 'people')} viewing`}
</span>
</div>
)
}
@@ -57,6 +57,8 @@ export function RecordCollaborationPanel({
{ id: 'TASKS', label: 'Tasks' },
{ id: 'TIMELINE', label: 'Activity' },
].filter((tab) => enabled.includes(tab.id as NonNullable<RecordFeaturesV1['features']>[number]))
// A panel whose only feature is attachments is a file list, not a collaboration surface.
const title = tabs.length === 1 && tabs[0].id === 'ATTACHMENTS' ? 'Files' : 'Collaboration'
const [selected, setSelected] = useState(tabs[0]?.id)
const [collapsed, setCollapsed] = useState(false)
useEffect(() => {
@@ -87,15 +89,15 @@ export function RecordCollaborationPanel({
const active = tabs.some((tab) => tab.id === selected) ? selected : tabs[0]?.id
return (
<aside
aria-label="Collaboration"
aria-label={title}
className={`min-w-0 self-start rounded-xl border border-border bg-card ${collapsed ? 'xl:w-12' : 'xl:w-[360px]'}`}
data-qqq-id="record-collaboration-panel"
>
<div className="flex items-center justify-between gap-2 border-b border-border p-3">
<h2 className={`text-sm font-semibold ${collapsed ? 'xl:hidden' : ''}`}>Collaboration</h2>
<h2 className={`text-sm font-semibold ${collapsed ? 'xl:hidden' : ''}`}>{title}</h2>
<button
type="button"
aria-label={collapsed ? 'Expand collaboration' : 'Collapse collaboration'}
aria-label={collapsed ? `Expand ${title.toLowerCase()}` : `Collapse ${title.toLowerCase()}`}
aria-expanded={!collapsed}
className="hidden rounded p-1 hover:bg-accent xl:inline-flex"
data-qqq-id="collaboration-collapse"
+3 -1
View File
@@ -35,7 +35,7 @@ import { cn } from '@/lib/utils/cn'
import { CodeEditor } from '@/components/scripts/CodeEditor'
/** Languages the editor badge names. */
export type ScriptEditorLanguage = 'javascript' | 'groovy' | 'java' | 'python' | 'sql' | 'json' | 'velocity' | 'html' | 'text'
export type ScriptEditorLanguage = 'javascript' | 'typescript' | 'groovy' | 'java' | 'python' | 'sql' | 'json' | 'velocity' | 'html' | 'text'
/**
* Props for the {@link ScriptEditor} component.
@@ -71,6 +71,7 @@ export interface ScriptEditorProps {
/** Human-readable display names for each supported language. */
const LANGUAGE_LABELS: Record<ScriptEditorLanguage, string> = {
javascript: 'JavaScript',
typescript: 'TypeScript',
groovy: 'Groovy',
java: 'Java',
python: 'Python',
@@ -90,6 +91,7 @@ const LANGUAGE_LABELS: Record<ScriptEditorLanguage, string> = {
export function scriptEditorLanguage(mode: string | null | undefined): ScriptEditorLanguage {
const normalized = (mode ?? '').trim().toLowerCase()
if (normalized === 'js') return 'javascript'
if (normalized === 'ts') return 'typescript'
if (normalized === 'xml') return 'html'
return normalized in LANGUAGE_LABELS ? normalized as ScriptEditorLanguage : 'text'
}
+9 -1
View File
@@ -25,7 +25,10 @@
import React from 'react'
import Link from 'next/link'
import { useQuery } from '@tanstack/react-query'
import { Bug, ChevronDown, LogOut, Moon, Settings } from 'lucide-react'
import { Bug, ChevronDown, LogOut, Moon, Settings, Wrench } from 'lucide-react'
import { useMetaData } from '@/lib/hooks/use-metadata'
import { toolsTarget } from '@/components/tools/tools-catalog'
import { getPreferences } from '@/lib/api/preferences'
import { useMediaQuery } from '@/lib/hooks/use-media-query'
@@ -107,6 +110,8 @@ function AccountMenuItems({ displayName, userEmail, logout, close, compactItems
compactItems?: (close: (then?: () => void) => void) => React.ReactNode
}) {
const { theme, isDarkMode, setDarkMode } = useTheme()
const metadata = useMetaData()
const tools = toolsTarget(metadata.data)
const capability = useQaCaptureCapability()
const preferences = useQuery({ queryKey: queryKeys.preferencesIndex(), queryFn: getPreferences, retry: false })
const qaSection = capability?.enabled && capability.reason === 'QA_MODE' ? capability.qaMode.preferenceSection : undefined
@@ -127,6 +132,9 @@ function AccountMenuItems({ displayName, userEmail, logout, close, compactItems
<HeaderMenuSeparator />
</div>
)}
{tools && <Link href={tools.path} role="menuitem" onClick={() => close()} className={HEADER_MENU_ITEM_CLASS} data-qqq-id="menu-item-tools">
<Wrench className="h-4 w-4 text-muted-foreground" aria-hidden="true" />{tools.label}
</Link>}
<Link href="/app/preferences" role="menuitem" onClick={() => close()} className={HEADER_MENU_ITEM_CLASS} data-qqq-id="menu-item-preferences">
<Settings className="h-4 w-4 text-muted-foreground" aria-hidden="true" />
Preferences
@@ -0,0 +1,164 @@
/*
* Copyright 2026 QRun.IO, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React from 'react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { act, fireEvent, render, screen } from '@testing-library/react'
import type { QAuthenticationMetaData } from '@/types'
import type { AuthContextType } from '@/lib/auth/auth-provider'
const authState = vi.hoisted(() => ({ value: null as unknown as AuthContextType }))
const api = vi.hoisted(() => ({ lastRequestAt: 0, resumeSession: vi.fn(async () => ({})) }))
vi.mock('@/lib/auth/use-auth', () => ({ useAuth: () => authState.value }))
vi.mock('@/lib/api/auth', () => ({ resumeSession: api.resumeSession }))
vi.mock('@/lib/api/client', () => ({ default: { lastRequestAt: () => api.lastRequestAt } }))
import { InactivityLogoff } from './InactivityLogoff'
import { INACTIVITY_SIGN_OUT_FLAG, resetUserActivityForTests, userActivityHeader, userInactivityTimeout } from '@/lib/auth/user-activity'
const T0 = 1_800_000_000_000
/**
* Sets the auth context the component sees.
*
* @param timeout - The server's user inactivity timeout in seconds, or undefined for none.
* @param isAuthenticated - Whether a user is signed in.
* @returns The logout mock.
*/
function withAuth(timeout: number | undefined, isAuthenticated = true) {
const logout = vi.fn(async () => {})
const metadata: QAuthenticationMetaData = { name: 'oauth2', type: 'OAUTH2', values: { clientId: 'c', userInactivityTimeoutSeconds: timeout } }
authState.value = { isAuthenticated, isLoading: false, user: null, authMetadata: metadata, authError: null, isSignedOut: false, logout } as unknown as AuthContextType
return logout
}
/**
* Lets the given time pass, one tick of the component's clock at a time.
*
* @param seconds - Seconds to advance.
*/
function pass(seconds: number) {
act(() => {
vi.advanceTimersByTime(seconds * 1000)
})
}
describe('InactivityLogoff', () => {
beforeEach(() => {
vi.useFakeTimers()
vi.setSystemTime(T0)
window.localStorage.clear()
window.sessionStorage.clear()
resetUserActivityForTests()
api.lastRequestAt = T0
api.resumeSession.mockClear()
})
afterEach(() => {
vi.useRealTimers()
resetUserActivityForTests()
})
it('does nothing when the server has no user inactivity timeout or nobody is signed in', () => {
const logout = withAuth(undefined)
const { unmount } = render(<InactivityLogoff />)
pass(3600)
expect(logout).not.toHaveBeenCalled()
expect(userInactivityTimeout()).toBeNull()
expect(userActivityHeader()).toEqual({})
unmount()
const signedOutLogout = withAuth(900, false)
render(<InactivityLogoff />)
pass(3600)
expect(signedOutLogout).not.toHaveBeenCalled()
expect(screen.queryByText(/signed out in/)).toBeNull()
})
it('warns a minute before and signs the user out when the timeout runs out', () => {
const logout = withAuth(900)
render(<InactivityLogoff />)
expect(userInactivityTimeout()).toBe(900)
pass(839)
expect(screen.queryByText(/signed out in/)).toBeNull()
pass(2)
expect(screen.getByText(/You will be signed out in 59 seconds because of inactivity/)).toBeInTheDocument()
expect(logout).not.toHaveBeenCalled()
pass(59)
expect(logout).toHaveBeenCalledTimes(1)
expect(window.sessionStorage.getItem(INACTIVITY_SIGN_OUT_FLAG)).toBe('1')
expect(screen.queryByText(/signed out in/)).toBeNull()
// once is enough, however long the sign-out takes
pass(120)
expect(logout).toHaveBeenCalledTimes(1)
})
it('polling does not count: only a user action starts the time again', () => {
const logout = withAuth(900)
render(<InactivityLogoff />)
pass(850)
expect(screen.getByText(/signed out in/)).toBeInTheDocument()
// the page keeps talking to the server; that is not the user
api.lastRequestAt = Date.now()
window.dispatchEvent(new Event('focus'))
pass(10)
expect(screen.getByText(/signed out in/)).toBeInTheDocument()
fireEvent.keyDown(document.body, { key: 'a' })
pass(1)
expect(screen.queryByText(/signed out in/)).toBeNull()
pass(830)
expect(logout).not.toHaveBeenCalled()
pass(70)
expect(logout).toHaveBeenCalledTimes(1)
})
it('"Stay signed in" is a user action', () => {
const logout = withAuth(900)
render(<InactivityLogoff />)
pass(870)
fireEvent.click(screen.getByRole('button', { name: 'Stay signed in' }))
expect(screen.queryByText(/signed out in/)).toBeNull()
pass(800)
expect(logout).not.toHaveBeenCalled()
})
it('tells the server about an active user whose page sends no requests', () => {
withAuth(900)
render(<InactivityLogoff />)
// quiet page, idle user: nothing is sent on the user's behalf
pass(300)
expect(api.resumeSession).not.toHaveBeenCalled()
// the user is here, and the page has sent nothing for four minutes
fireEvent.pointerDown(document.body)
pass(1)
expect(api.resumeSession).toHaveBeenCalledTimes(1)
// the request went out: no more until the page is quiet for that long again
api.lastRequestAt = Date.now()
api.resumeSession.mockClear()
fireEvent.pointerDown(document.body)
pass(60)
expect(api.resumeSession).not.toHaveBeenCalled()
})
})
+132
View File
@@ -0,0 +1,132 @@
/*
* Copyright 2026 QRun.IO, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
/**
* @file Automatic logoff after user inactivity: the timer, the warning and the sign-out.
*/
'use client'
import { useEffect, useRef, useState } from 'react'
import * as AlertDialog from '@radix-ui/react-alert-dialog'
import apiClient from '@/lib/api/client'
import { resumeSession } from '@/lib/api/auth'
import { useAuth } from '@/lib/auth/use-auth'
import {
INACTIVITY_SIGN_OUT_FLAG,
INACTIVITY_WARNING_SECONDS,
lastUserActivityAt,
recordUserActivity,
setUserInactivityTimeout,
startUserActivityTracking,
userActivityAgeSeconds,
} from '@/lib/auth/user-activity'
/** An active user whose page sent nothing for this long has the session renewed, in milliseconds. */
const KEEPALIVE_AFTER_MS = 4 * 60 * 1000
/**
* Signs the user out when the server's user inactivity timeout runs out, and warns shortly
* before. Renders nothing unless the server has such a timeout and a user is signed in.
*
* The server enforces the same timeout by itself (it ends the session at the next request);
* this component is what the user sees. While the user is active on a page that sends no
* request, it renews the session so the user's activity reaches the server.
*
* @returns The warning dialog, or null.
*/
export function InactivityLogoff() {
const { isAuthenticated, authMetadata, logout } = useAuth()
const timeout = authMetadata?.values?.userInactivityTimeoutSeconds
const active = isAuthenticated && typeof timeout === 'number' && timeout > 0
const [remaining, setRemaining] = useState<number | null>(null)
const signingOut = useRef(false)
const logoutRef = useRef(logout)
useEffect(() => {
logoutRef.current = logout
}, [logout])
useEffect(() => {
setUserInactivityTimeout(active ? timeout : null)
if (!active) return
signingOut.current = false
try {
window.sessionStorage.removeItem(INACTIVITY_SIGN_OUT_FLAG)
} catch {
// nothing to forget
}
const stopTracking = startUserActivityTracking()
/** One look at the clock: sign out, warn, or keep the server informed. */
const tick = () => {
if (signingOut.current) return
const now = Date.now()
const left = timeout - userActivityAgeSeconds(now)
if (left <= 0) {
signingOut.current = true
setRemaining(null)
try {
window.sessionStorage.setItem(INACTIVITY_SIGN_OUT_FLAG, '1')
} catch {
// the login page then shows its ordinary signed-out state
}
void logoutRef.current()
return
}
setRemaining(left <= INACTIVITY_WARNING_SECONDS ? left : null)
const lastRequestAt = apiClient.lastRequestAt()
if (lastUserActivityAt() > lastRequestAt && now - lastRequestAt >= KEEPALIVE_AFTER_MS) {
// any request states the user's activity; errors are the 401 handler's business
resumeSession().catch(() => undefined)
}
}
const timer = window.setInterval(tick, 1000)
return () => {
window.clearInterval(timer)
stopTracking()
}
}, [active, timeout])
if (!active || remaining === null) return null
return (
<AlertDialog.Root open>
<AlertDialog.Portal>
<AlertDialog.Overlay className="fixed inset-0 z-[300] bg-black/40" />
<AlertDialog.Content
data-qqq-id="inactivity-logoff-warning"
className="fixed left-1/2 top-1/2 z-[301] w-[min(28rem,calc(100vw-2rem))] -translate-x-1/2 -translate-y-1/2 rounded-lg border bg-background p-6 shadow-lg"
>
<AlertDialog.Title className="text-lg font-semibold">Are you still there?</AlertDialog.Title>
<AlertDialog.Description className="mt-2 text-sm text-muted-foreground" aria-live="polite">
You will be signed out in {remaining} {remaining === 1 ? 'second' : 'seconds'} because of inactivity.
</AlertDialog.Description>
<div className="mt-4 flex justify-end">
<AlertDialog.Action
data-qqq-id="inactivity-logoff-stay"
className="rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground"
onClick={() => {
recordUserActivity()
setRemaining(null)
}}
>
Stay signed in
</AlertDialog.Action>
</div>
</AlertDialog.Content>
</AlertDialog.Portal>
</AlertDialog.Root>
)
}
@@ -0,0 +1,82 @@
/*
* Copyright 2026 QRun.IO, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React from 'react'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { DeliveredFileActions } from './DeliveredFileActions'
import * as delivery from '@/lib/api/attachment-download'
vi.mock('@/lib/api/attachment-download', () => ({ fetchTrackedAttachment: vi.fn(), safeAttachmentPreviewBlob: vi.fn(), saveAttachmentDownload: vi.fn() }))
vi.mock('./FilePreviewLightbox', () => ({ FilePreviewLightbox: ({ items, onClose }: { items: { url: string }[]; onClose: () => void }) =>
<div role="dialog"><span>{items[0].url}</span><button onClick={onClose}>Close preview</button></div> }))
const URL_VALUE = 'https://api.example/credential-bound-transfer'
describe('metadata authenticated delivery', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.stubGlobal('URL', Object.assign(class extends URL {}, { createObjectURL: vi.fn(() => 'blob:verified'), revokeObjectURL: vi.fn() }))
vi.mocked(delivery.fetchTrackedAttachment).mockResolvedValue(new Blob(['verified']))
vi.mocked(delivery.safeAttachmentPreviewBlob).mockResolvedValue(new Blob(['%PDF-1.7'], { type: 'application/pdf' }))
vi.mocked(delivery.saveAttachmentDownload).mockResolvedValue()
})
afterEach(() => vi.unstubAllGlobals())
it('never renders an authenticated capability in href/src and verifies only on request', async () => {
const result = render(<DeliveredFileActions url={URL_VALUE} fileName="Packing.pdf" allowPreview dataQqqId="delivery" />)
expect(delivery.fetchTrackedAttachment).not.toHaveBeenCalled()
expect(result.container.querySelector('a, img, iframe')).toBeNull()
fireEvent.click(screen.getByRole('button', { name: 'Preview file' }))
await screen.findByRole('dialog')
expect(delivery.fetchTrackedAttachment).toHaveBeenCalledWith(URL_VALUE, expect.any(AbortSignal))
expect(delivery.safeAttachmentPreviewBlob).toHaveBeenCalledWith(expect.any(Blob))
expect(screen.getByRole('dialog')).toHaveTextContent('blob:verified')
expect(result.container.innerHTML).not.toContain(URL_VALUE)
fireEvent.click(screen.getByRole('button', { name: 'Close preview' }))
await waitFor(() => expect(URL.revokeObjectURL).toHaveBeenCalledWith('blob:verified'))
})
it('shows receipt refusal and never opens a preview', async () => {
vi.mocked(delivery.fetchTrackedAttachment).mockRejectedValue(new Error('No durable receipt'))
render(<DeliveredFileActions url={URL_VALUE} fileName="Packing.pdf" allowPreview dataQqqId="delivery" />)
fireEvent.click(screen.getByRole('button', { name: 'Preview file' }))
expect(await screen.findByRole('alert')).toHaveTextContent('No durable receipt')
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
expect(URL.createObjectURL).not.toHaveBeenCalled()
})
it('saves through the shared receipt gate and does not invent a plain link fallback', async () => {
vi.mocked(delivery.saveAttachmentDownload).mockRejectedValue(new Error('Access revoked'))
const result = render(<DeliveredFileActions url={URL_VALUE} fileName="Label.zpl" allowPreview={false} dataQqqId="delivery" />)
fireEvent.click(screen.getByRole('button', { name: 'Download file' }))
expect(await screen.findByRole('alert')).toHaveTextContent('Access revoked')
expect(delivery.saveAttachmentDownload).toHaveBeenCalledWith({ url: URL_VALUE, delivery: 'AUTHENTICATED_FETCH' }, 'Label.zpl', expect.any(AbortSignal))
expect(result.container.querySelector('a')).toBeNull()
expect(screen.queryByRole('button', { name: 'Preview file' })).not.toBeInTheDocument()
})
it('does not allocate a blob when its view unmounts during verification', async () => {
let finish!: (blob: Blob) => void
vi.mocked(delivery.fetchTrackedAttachment).mockReturnValue(new Promise((resolve) => { finish = resolve }))
const result = render(<DeliveredFileActions url={URL_VALUE} fileName="Packing.pdf" allowPreview dataQqqId="delivery" />)
fireEvent.click(screen.getByRole('button', { name: 'Preview file' }))
const signal = vi.mocked(delivery.fetchTrackedAttachment).mock.calls[0][1]
result.unmount(); finish(new Blob(['verified']))
await waitFor(() => expect(signal?.aborted).toBe(true))
expect(delivery.safeAttachmentPreviewBlob).not.toHaveBeenCalled()
expect(URL.createObjectURL).not.toHaveBeenCalled()
})
})
@@ -0,0 +1,42 @@
/* Copyright 2026 QRun.IO, Inc. Licensed under the Apache License, Version 2.0. */
import React from 'react'
import { Blob as NodeBlob } from 'node:buffer'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
import apiClient from '@/lib/api/client'
import { DeliveredFileActions } from './DeliveredFileActions'
vi.mock('@/lib/api/client', () => ({ default: { get: vi.fn(), getInstance: () => ({ defaults: { baseURL: 'https://api.example/qqq/v1' } }) } }))
vi.mock('./FilePreviewLightbox', () => ({ FilePreviewLightbox: () => <div role="dialog" /> }))
const url = 'https://api.example/qqq/v1/qbit/record-files/files/12/downloads/10000000-0000-0000-0000-000000000001'
beforeEach(() => {
vi.resetAllMocks()
vi.stubGlobal('Blob', NodeBlob)
vi.stubGlobal('URL', Object.assign(class extends URL {}, { createObjectURL: vi.fn(), revokeObjectURL: vi.fn() }))
})
afterEach(() => vi.unstubAllGlobals())
it.each(['unmount', 'source change'])('aborts an actual deferred receipt on %s without creating a preview', async (change) => {
let receiptSignal: AbortSignal | undefined
let rejectReceipt!: (error: Error) => void
vi.mocked(apiClient.get).mockResolvedValueOnce(new Blob(['%PDF-1.7\nfixture'])).mockImplementationOnce((_route, options) => {
receiptSignal = options?.signal as AbortSignal
return new Promise((_resolve, reject) => {
rejectReceipt = reject
receiptSignal?.addEventListener('abort', () => reject(new DOMException('Cancelled', 'AbortError')), { once: true })
})
})
const view = render(<DeliveredFileActions url={url} fileName="packing.pdf" allowPreview dataQqqId="delivery" />)
fireEvent.click(screen.getByRole('button', { name: 'Preview file' }))
await waitFor(() => expect(apiClient.get).toHaveBeenCalledTimes(2))
expect(vi.mocked(apiClient.get).mock.calls[1][0]).toMatch(/\/receipt$/)
expect(receiptSignal?.aborted).toBe(false)
if (change === 'unmount') view.unmount()
else view.rerender(<DeliveredFileActions url={url.replace('/12/', '/13/')} fileName="other.pdf" allowPreview dataQqqId="delivery" />)
await waitFor(() => expect(receiptSignal?.aborted).toBe(true))
expect(URL.createObjectURL).not.toHaveBeenCalled()
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
rejectReceipt(new Error('Late response cannot revive the preview'))
})
@@ -0,0 +1,102 @@
/*
* Copyright 2026 QRun.IO, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
/** @file Receipt-gated actions for metadata-declared authenticated file delivery. */
'use client'
import { useEffect, useRef, useState } from 'react'
import { fetchTrackedAttachment, safeAttachmentPreviewBlob, saveAttachmentDownload } from '@/lib/api/attachment-download'
import { getErrorMessage } from '@/lib/utils/error-utils'
import { kindOfMimeType, type FilePreviewItem } from '@/lib/utils/file-preview'
import { FilePreviewLightbox } from './FilePreviewLightbox'
/** Props supplied by generic FILE_DOWNLOAD field metadata. */
interface Props {
url: string
fileName: string
allowPreview: boolean
dataQqqId: string
}
/**
* Authenticated URLs are never href/src values. Only verified inert blobs can preview.
* @param props - Current authorized delivery and display-only name.
* @returns Explicit download/preview controls and any visible audit failure.
*/
export function DeliveredFileActions({ url, fileName, allowPreview, dataQqqId }: Props) {
const [pending, setPending] = useState(false)
const [error, setError] = useState<string>()
const [preview, setPreview] = useState<FilePreviewItem>()
const alive = useRef(true)
const activeOperation = useRef<AbortController | undefined>(undefined)
useEffect(() => {
alive.current = true
setPreview(undefined); setPending(false); setError(undefined)
return () => { alive.current = false; activeOperation.current?.abort() }
}, [url])
useEffect(() => () => { if (preview) URL.revokeObjectURL(preview.url) }, [preview])
/** Download always goes through the existing authenticated receipt verifier.
* @param signal - Current operation lifetime, including source changes/unmount.
*/
async function download(signal: AbortSignal) {
await saveAttachmentDownload({ url, delivery: 'AUTHENTICATED_FETCH' }, fileName, signal)
}
/** Load an authorized bounded body, then restrict preview to verified raster/PDF.
* @param signal - Current operation lifetime, including source changes/unmount.
*/
async function openPreview(signal: AbortSignal) {
const original = await fetchTrackedAttachment(url, signal)
signal.throwIfAborted()
const blob = await safeAttachmentPreviewBlob(original)
signal.throwIfAborted()
if (!alive.current) return
setPreview({ url: URL.createObjectURL(blob), fileName, mimeType: blob.type, kind: kindOfMimeType(blob.type),
alt: fileName, sizeBytes: blob.size, previewable: true, thumbnailSize: 160 })
}
/** Keep all failures visible; an audit failure never becomes an ordinary link.
* @param action - Receipt-gated operation selected by the user.
*/
async function perform(action: (signal: AbortSignal) => Promise<void>) {
activeOperation.current?.abort()
const controller = new AbortController()
activeOperation.current = controller
setPending(true); setError(undefined)
try { await action(controller.signal) }
catch (failure) {
if (alive.current && activeOperation.current === controller && !controller.signal.aborted) setError(getErrorMessage(failure))
throw failure
} finally {
if (activeOperation.current === controller) {
activeOperation.current = undefined
if (alive.current) setPending(false)
}
}
}
return <span className="inline-flex flex-wrap items-center gap-2" data-qqq-id={dataQqqId}>
<span>{fileName}</span>
<button type="button" className="text-link underline disabled:opacity-50 pointer-coarse:inline-flex pointer-coarse:min-h-11 pointer-coarse:items-center" disabled={pending}
data-qqq-id={dataQqqId + '-download'} onClick={() => { void perform(download).catch(() => {}) }}>Download file</button>
{allowPreview && <button type="button" className="text-link underline disabled:opacity-50 pointer-coarse:inline-flex pointer-coarse:min-h-11 pointer-coarse:items-center" disabled={pending}
data-qqq-id={dataQqqId + '-preview'} onClick={() => { void perform(openPreview).catch(() => {}) }}>Preview file</button>}
{pending && <span role="status">Verifying file delivery…</span>}
{error && <span role="alert" className="text-destructive">{error}</span>}
{preview && <FilePreviewLightbox items={[preview]} index={0} onIndexChange={() => {}} onClose={() => setPreview(undefined)} onDownload={() => perform(download)} />}
</span>
}
+23 -4
View File
@@ -15,7 +15,7 @@
*/
import React from 'react'
import { act, fireEvent, render, screen, within } from '@testing-library/react'
import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { describe, expect, it, vi } from 'vitest'
@@ -29,6 +29,8 @@ import { FileThumbnail } from './FileThumbnail'
import { FilePreviewLightbox } from './FilePreviewLightbox'
import type { FilePreviewItem } from '@/lib/utils/file-preview'
vi.mock('./PdfPreview', () => ({ PdfPreview: ({ url }: { url: string }) => <div data-qqq-id="file-preview-pdf" data-source={url} /> }))
vi.mock('next/navigation', () => ({ useRouter: () => ({ push: vi.fn() }) }))
function field(name: string, values: Record<string, unknown> = {}, extra: Partial<QFieldMetaData> = {}): QFieldMetaData {
@@ -139,7 +141,9 @@ describe('FilePreviewGallery', () => {
await user.keyboard('{ArrowRight}')
expect(within(dialog).getByRole('heading', { name: 'two.pdf' })).toBeInTheDocument()
expect(dialog).toHaveAttribute('data-preview-kind', 'pdf')
expect(document.querySelector('[data-qqq-id="file-preview-pdf"]')).toHaveAttribute('src', '/qqq/v1/table/media/2/blob/two.pdf')
await waitFor(() => expect(document.querySelector('[data-qqq-id="file-preview-pdf"]')).toHaveAttribute('data-source', '/qqq/v1/table/media/2/blob/two.pdf'))
expect(document.querySelector('iframe')).toBeNull()
expect(document.querySelector('[data-qqq-id="file-preview-open"]')).toBeNull()
await user.keyboard('{End}')
expect(within(dialog).getByText('3 of 3')).toBeInTheDocument()
@@ -182,6 +186,21 @@ describe('FilePreviewGallery', () => {
})
describe('FilePreviewLightbox', () => {
it('uses the authorized callback and never falls back to its preview URL on failure', async () => {
let reject!: (reason: Error) => void
const download = vi.fn(() => new Promise<void>((_resolve, rejectPromise) => { reject = rejectPromise }))
const file = item()
render(<FilePreviewLightbox items={[file]} index={0} onIndexChange={() => undefined} onClose={() => undefined} onDownload={download} />)
const button = screen.getByRole('button', { name: 'Download a.png' })
fireEvent.click(button)
expect(download).toHaveBeenCalledWith(file)
expect(button).toBeDisabled()
expect(button).not.toHaveAttribute('href')
await act(async () => reject(new Error('Audit unavailable')))
expect(await screen.findByRole('alert')).toHaveTextContent('Could not authorize this download')
await waitFor(() => expect(button).not.toBeDisabled())
expect(button).not.toHaveAttribute('href')
})
function show(items: FilePreviewItem[], index = 0) {
const onIndexChange = vi.fn()
const onClose = vi.fn()
@@ -248,13 +267,13 @@ describe('FilePreviewLightbox', () => {
it('does not frame a PDF from another origin', () => {
show([item({ kind: 'pdf', mimeType: 'application/pdf', fileName: 'c.pdf', url: 'https://elsewhere.example/c.pdf' })])
expect(document.querySelector('[data-qqq-id="file-preview-pdf"]')).toBeNull()
expect(screen.getByText(/opens in a new tab instead/)).toBeInTheDocument()
expect(screen.getByText(/trusted file preview source/)).toBeInTheDocument()
})
it('frames only backend file paths, not other pages of this origin', () => {
show([item({ kind: 'pdf', mimeType: 'application/pdf', fileName: 'd.pdf', url: '/app/media/1' })])
expect(document.querySelector('[data-qqq-id="file-preview-pdf"]')).toBeNull()
expect(screen.getByText(/not a file served by the application/)).toBeInTheDocument()
expect(screen.getByText(/trusted file preview source/)).toBeInTheDocument()
})
it('plays video and audio, falling back when the player cannot load them', () => {
+37 -17
View File
@@ -23,7 +23,7 @@
'use client'
import React, { useCallback, useEffect, useState } from 'react'
import React, { lazy, Suspense, useCallback, useEffect, useState } from 'react'
import * as Dialog from '@radix-ui/react-dialog'
import { ChevronLeft, ChevronRight, Download, ExternalLink, Maximize2, X, ZoomIn, ZoomOut } from 'lucide-react'
@@ -31,9 +31,12 @@ import type { FilePreviewItem } from '@/lib/utils/file-preview'
import { apiUrl } from '@/lib/api/client'
import { attachmentUrl } from '@/lib/utils/adornment-utils'
import { cn } from '@/lib/utils/cn'
import { formatBytes, isBackendFileUrl, isSameOrigin } from '@/lib/utils/file-preview'
import { formatBytes } from '@/lib/utils/file-preview'
import { useRestoreFocus } from '@/lib/hooks/use-restore-focus'
import { FileKindIcon } from './FileKindIcon'
import { pdfPreviewSource } from '@/lib/api/pdf-preview'
const PdfPreview = lazy(() => import('./PdfPreview').then((module) => ({ default: module.PdfPreview })))
/** Zoom steps of an image preview (1 = fit to the window). */
export const ZOOM_STEPS = [1, 1.5, 2, 3, 4]
@@ -48,6 +51,8 @@ export interface FilePreviewLightboxProps {
onIndexChange: (index: number) => void
/** Called when the preview closes. */
onClose: () => void
/** Authorizes a fresh download; when supplied, the preview URL is never used to download. */
onDownload?: (item: FilePreviewItem) => Promise<void>
}
/**
@@ -58,7 +63,7 @@ export interface FilePreviewLightboxProps {
* @param props - See {@link FilePreviewLightboxProps}.
* @returns The dialog.
*/
export function FilePreviewLightbox({ items, index, onIndexChange, onClose }: FilePreviewLightboxProps) {
export function FilePreviewLightbox({ items, index, onIndexChange, onClose, onDownload }: FilePreviewLightboxProps) {
const restoreFocus = useRestoreFocus(true)
const item = items[Math.min(Math.max(index, 0), items.length - 1)]
const count = items.length
@@ -156,7 +161,7 @@ export function FilePreviewLightbox({ items, index, onIndexChange, onClose }: Fi
<ToolbarButton label="Zoom in" onClick={() => zoomBy(1)} disabled={zoom >= ZOOM_STEPS[ZOOM_STEPS.length - 1]} id="zoom-in"><ZoomIn className="h-4 w-4" aria-hidden="true" /></ToolbarButton>
</>
)}
<FileActions item={item} />
<FileActions key={item.url} item={item} onDownload={onDownload} />
<Dialog.Close asChild>
<button type="button" aria-label="Close preview" data-qqq-id="file-preview-close"
className="inline-flex h-9 w-9 items-center justify-center rounded-md text-muted-foreground hover:bg-muted hover:text-foreground focus:outline-none focus-visible:ring-2 focus-visible:ring-ring">
@@ -246,15 +251,10 @@ function PreviewStage({ item, zoom, failed, onFailed, onNaturalSize, onToggleZoo
}
if (item.kind === 'pdf') {
// frames are limited to the dashboard's own origin by its Content-Security-Policy, and within
// it to backend file paths, so a stored URL cannot frame another page of the dashboard
if (!isSameOrigin(item.url)) {
return <Unavailable item={item} reason="This PDF is on another site, so it opens in a new tab instead of here." />
try { pdfPreviewSource(item.url) } catch {
return <Unavailable item={item} reason="This PDF is not available through a trusted file preview source." />
}
if (!isBackendFileUrl(item.url)) {
return <Unavailable item={item} reason="This PDF is not a file served by the application, so it opens in a new tab instead of here." />
}
return <iframe src={item.url} title={item.fileName} className="absolute inset-0 h-full w-full border-0 bg-white" data-qqq-id="file-preview-pdf" />
return <Suspense fallback={<p role="status" className="p-4">Loading PDF viewer…</p>}><PdfPreview key={item.url} url={item.url} fileName={item.fileName} /></Suspense>
}
if (item.kind === 'video') {
@@ -297,7 +297,9 @@ function Unavailable({ item, reason }: { item: FilePreviewItem; reason: string }
* @param props - The item.
* @returns The links.
*/
function FileActions({ item }: { item: FilePreviewItem }) {
function FileActions({ item, onDownload }: { item: FilePreviewItem; onDownload?: (item: FilePreviewItem) => Promise<void> }) {
const [pending, setPending] = useState(false)
const [error, setError] = useState(false)
if (!item.url) return null
// backend field downloads honour ?download=1; other URLs rely on the download attribute (same origin)
const backendFile = item.url.startsWith(apiUrl('/table/'))
@@ -305,14 +307,32 @@ function FileActions({ item }: { item: FilePreviewItem }) {
const linkClass = 'inline-flex h-9 w-9 items-center justify-center rounded-md text-muted-foreground hover:bg-muted hover:text-foreground focus:outline-none focus-visible:ring-2 focus-visible:ring-ring'
return (
<>
<a href={item.url} target="_blank" rel="noopener noreferrer" className={linkClass} title="Open original" data-qqq-id="file-preview-open">
{item.kind !== 'pdf' && <a href={item.url} target="_blank" rel="noopener noreferrer" className={linkClass} title="Open original" data-qqq-id="file-preview-open">
<ExternalLink className="h-4 w-4" aria-hidden="true" />
<span className="sr-only">Open original (opens in a new tab)</span>
</a>
<a href={downloadHref} download={item.fileName} className={linkClass} title="Download" data-qqq-id="file-preview-download">
</a>}
{onDownload ? (
<button type="button" disabled={pending} aria-busy={pending} className={cn(linkClass, 'disabled:opacity-50')}
title="Download" data-qqq-id="file-preview-download"
onClick={async () => {
setPending(true)
setError(false)
try {
await onDownload(item)
} catch {
setError(true)
} finally {
setPending(false)
}
}}>
<Download className="h-4 w-4" aria-hidden="true" />
<span className="sr-only">{pending ? 'Preparing download' : `Download ${item.fileName}`}</span>
</button>
) : <a href={downloadHref} download={item.fileName} className={linkClass} title="Download" data-qqq-id="file-preview-download">
<Download className="h-4 w-4" aria-hidden="true" />
<span className="sr-only">Download {item.fileName}</span>
</a>
</a>}
{error && <p role="alert" className="max-w-xs text-xs text-destructive">Could not authorize this download. Please try again.</p>}
</>
)
}
+100
View File
@@ -0,0 +1,100 @@
/*
* Copyright 2026 QRun.IO, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React from 'react'
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { beforeEach, afterEach, expect, it, vi } from 'vitest'
import { PdfPreview } from './PdfPreview'
const mocks = vi.hoisted(() => ({ load: vi.fn(), getDocument: vi.fn(), create: vi.fn(), terminate: vi.fn(), destroy: vi.fn(), render: vi.fn(), cancel: vi.fn(), reader: vi.fn(), cleanup: vi.fn() }))
vi.mock('@/lib/api/pdf-preview', () => ({ loadPdfPreviewBytes: mocks.load, PDF_PAGE_LIMIT: 1000, PDF_PIXEL_LIMIT: 8_000_000 }))
vi.mock('pdfjs-dist', () => ({ PDFWorker: { create: mocks.create }, getDocument: mocks.getDocument }))
beforeEach(() => {
vi.clearAllMocks()
vi.stubGlobal('Worker', class { terminate = mocks.terminate })
vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue({} as CanvasRenderingContext2D)
mocks.load.mockResolvedValue(new Uint8Array([1]))
mocks.destroy.mockResolvedValue(undefined)
mocks.render.mockReturnValue({ promise: Promise.resolve(), cancel: mocks.cancel })
mocks.reader.mockImplementation(() => {
let read = false
return { read: async () => read ? { done: true } : (read = true, { done: false, value: { items: [{ str: '<script>inert page text</script>' }] } }), cancel: async () => {}, releaseLock: () => {} }
})
mocks.getDocument.mockReturnValue({ destroy: mocks.destroy, promise: Promise.resolve({ numPages: 2, getPage: async () => ({
getViewport: ({ scale }: { scale: number }) => ({ width: 600 * scale, height: 800 * scale }),
render: mocks.render, cleanup: mocks.cleanup, streamTextContent: () => ({ getReader: mocks.reader }),
}) }) })
})
afterEach(() => { vi.restoreAllMocks(); vi.unstubAllGlobals() })
it('renders pixels and inert text with explicit worker/data-only policy, then cancels on close', async () => {
const user = userEvent.setup()
const view = render(<PdfPreview url="blob:http://localhost/fixture" fileName="Packing slip.pdf" />)
await waitFor(() => expect(screen.getByRole('button', { name: 'Next page' })).toBeEnabled())
expect(mocks.getDocument).toHaveBeenCalledWith(expect.objectContaining({ data: expect.any(Uint8Array), useWorkerFetch: false, useWasm: false, enableXfa: false, maxImageSize: 8_000_000 }))
expect(mocks.getDocument.mock.calls[0][0].url).toBeUndefined()
expect(mocks.render).toHaveBeenCalledWith(expect.objectContaining({ annotationMode: 0 }))
await waitFor(() => expect(mocks.cleanup).toHaveBeenCalledTimes(1))
expect(document.querySelector('iframe,object,embed,script')).toBeNull()
await waitFor(() => expect(screen.getByText('<script>inert page text</script>')).toBeInTheDocument())
await user.click(screen.getByRole('button', { name: 'Next page' }))
await waitFor(() => expect(screen.getByRole('img')).toHaveAccessibleName('Packing slip.pdf, page 2'))
view.unmount()
expect(mocks.terminate).toHaveBeenCalled()
expect(mocks.destroy).toHaveBeenCalled()
})
it('starts the packaged worker from a .js URL, because hosts serve .mjs as application/octet-stream and module workers refuse it', async () => {
const urls: string[] = []
vi.stubGlobal('Worker', class { terminate = mocks.terminate; constructor(url: string) { urls.push(url) } })
render(<PdfPreview url="blob:http://localhost/fixture" fileName="Packing slip.pdf" />)
await waitFor(() => expect(urls).toHaveLength(1))
expect(urls[0]).toMatch(/^\/pdf-generated\/pdf-[\d.]+\.worker\.js$/)
})
it('rejects page counts outside the bound without rendering', async () => {
mocks.getDocument.mockReturnValue({ destroy: mocks.destroy, promise: Promise.resolve({ numPages: 1001 }) })
render(<PdfPreview url="blob:http://localhost/fixture" fileName="Huge.pdf" />)
expect(await screen.findByRole('alert')).toHaveTextContent('preview limits')
expect(mocks.render).not.toHaveBeenCalled()
expect(mocks.terminate).toHaveBeenCalled()
})
it('stops before starting a parser when closed during input loading', async () => {
let resolve!: (bytes: Uint8Array) => void
mocks.load.mockImplementation(() => new Promise<Uint8Array>((done) => { resolve = done }))
const view = render(<PdfPreview url="blob:http://localhost/fixture" fileName="Slow.pdf" />)
view.unmount(); resolve(new Uint8Array([1]))
await new Promise((done) => setTimeout(done, 0))
expect(mocks.getDocument).not.toHaveBeenCalled()
})
it('bounds streamed text before materializing a full page string', async () => {
const reader = { read: vi.fn().mockResolvedValue({ done: false, value: { items: [{ str: 'x'.repeat(200_000) }] } }), cancel: vi.fn().mockResolvedValue(undefined), releaseLock: vi.fn() }
mocks.reader.mockReturnValue(reader)
render(<PdfPreview url="blob:http://localhost/fixture" fileName="Text.pdf" />)
await waitFor(() => expect(screen.getByRole('button', { name: 'Next page' })).toBeEnabled())
expect(reader.read).toHaveBeenCalledTimes(1)
expect(reader.cancel).toHaveBeenCalled()
expect(document.querySelector('details p')?.textContent?.length).toBe(100_000)
})
it('keeps navigation busy during text transfer and cancels that transfer on close', async () => {
let finish!: (value: { done: boolean }) => void
const reader = { read: vi.fn(() => new Promise<{ done: boolean }>((resolve) => { finish = resolve })), cancel: vi.fn(async () => { finish({ done: true }) }), releaseLock: vi.fn() }
mocks.reader.mockReturnValue(reader)
const view = render(<PdfPreview url="blob:http://localhost/fixture" fileName="Text.pdf" />)
await waitFor(() => expect(reader.read).toHaveBeenCalled())
expect(screen.getByRole('button', { name: 'Next page' })).toBeDisabled()
view.unmount()
await waitFor(() => expect(reader.cancel).toHaveBeenCalled())
expect(mocks.terminate).toHaveBeenCalled()
})
+148
View File
@@ -0,0 +1,148 @@
/*
* Copyright 2026 QRun.IO, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
/** @file Canvas-only PDF pages with a real parser worker, bounded resources and no document actions. */
'use client'
import { useEffect, useRef, useState } from 'react'
import type { PDFDocumentProxy, PDFDocumentLoadingTask, PDFPageProxy, RenderTask } from 'pdfjs-dist'
import { loadPdfPreviewBytes, PDF_PAGE_LIMIT, PDF_PIXEL_LIMIT } from '@/lib/api/pdf-preview'
/** No document-selected auxiliary URL is fetched by the main thread. */
class NoExternalPdfData {
/** Reject an auxiliary request instead of resolving any document-selected URL.
* @returns A rejected resource request.
*/
async fetch(): Promise<Uint8Array> { throw new Error('External PDF resources are disabled') }
}
/** Render a PDF as pixels/text only; the browser never navigates to the uploaded content.
* @param props - Authorized source URL and accessible document name.
* @returns Bounded page controls, canvas and optional plain text.
*/
export function PdfPreview({ url, fileName }: { url: string; fileName: string }) {
const canvas = useRef<HTMLCanvasElement>(null)
const [document, setDocument] = useState<PDFDocumentProxy | null>(null)
const [page, setPage] = useState(1)
const [zoom, setZoom] = useState(1)
const [error, setError] = useState(false)
const [busy, setBusy] = useState(true)
const [text, setText] = useState('')
const workerRef = useRef<Worker | null>(null)
useEffect(() => {
const abort = new AbortController()
let task: PDFDocumentLoadingTask | undefined
let worker: Worker | undefined
let disposed = false
setDocument(null); setPage(1); setError(false); setBusy(true)
const timeout = setTimeout(() => { abort.abort(); worker?.terminate(); if (!disposed) { setError(true); setBusy(false) } }, 30_000)
void (async () => {
const bytes = await loadPdfPreviewBytes(url, abort.signal)
const pdf = await import('pdfjs-dist')
abort.signal.throwIfAborted()
// Supplying a real port avoids PDF.js's main-thread fake-worker fallback.
worker = new Worker('/pdf-generated/pdf-6.4.299.worker.js', { type: 'module', name: 'qqq-pdf-preview' })
workerRef.current = worker
const parser = pdf.PDFWorker.create({ port: worker })
task = pdf.getDocument({ data: bytes, worker: parser, enableXfa: false,
useWorkerFetch: false, useWasm: false, BinaryDataFactory: NoExternalPdfData,
disableAutoFetch: true, disableRange: true, disableStream: true,
maxImageSize: PDF_PIXEL_LIMIT, canvasMaxAreaInBytes: PDF_PIXEL_LIMIT * 4,
disableFontFace: true, useSystemFonts: true, stopAtErrors: true, verbosity: 0,
})
const loaded = await task.promise
if (loaded.numPages < 1 || loaded.numPages > PDF_PAGE_LIMIT) throw new Error('PDF page limit exceeded')
if (!disposed && !abort.signal.aborted) setDocument(loaded)
})().catch(() => { if (!disposed) { setError(true); setBusy(false); worker?.terminate() } }).finally(() => clearTimeout(timeout))
return () => {
disposed = true; abort.abort(); clearTimeout(timeout)
worker?.terminate(); workerRef.current = null
void task?.destroy().catch(() => {})
}
}, [url])
useEffect(() => {
if (!document || !canvas.current || error) return
let disposed = false
let render: RenderTask | undefined
let loadedPage: PDFPageProxy | undefined
let cancelText: (() => void) | undefined
setBusy(true); setText('')
const target = canvas.current
const timeout = setTimeout(() => { render?.cancel(); cancelText?.(); workerRef.current?.terminate(); if (!disposed) { setError(true); setBusy(false) } }, 15_000)
void (async () => {
const pdfPage = await document.getPage(page)
loadedPage = pdfPage
if (disposed) return
const natural = pdfPage.getViewport({ scale: 1 })
if (!Number.isFinite(natural.width * natural.height) || natural.width <= 0 || natural.height <= 0) throw new Error('Invalid page size')
const desired = Math.min(2, window.devicePixelRatio || 1) * zoom * Math.min(1.5, 1000 / natural.width)
const scale = Math.min(desired, 8192 / natural.width, 8192 / natural.height, Math.sqrt(PDF_PIXEL_LIMIT / (natural.width * natural.height)))
const viewport = pdfPage.getViewport({ scale })
target.width = Math.max(1, Math.floor(viewport.width)); target.height = Math.max(1, Math.floor(viewport.height))
const context = target.getContext('2d', { alpha: false })
if (!context) throw new Error('Canvas unavailable')
// No annotation, form, action, link, HTML or scripting layer is created.
render = pdfPage.render({ canvas: target, canvasContext: context, viewport, annotationMode: 0 })
await render.promise
if (disposed) return
const reader = pdfPage.streamTextContent().getReader()
cancelText = () => { void reader.cancel().catch(() => {}) }
const parts: string[] = []
let characters = 0
let items = 0
try {
while (!disposed && characters < 100_000 && items < 10_000) {
const chunk = await reader.read()
if (chunk.done || disposed) break
for (const item of chunk.value.items) {
if (++items > 10_000 || characters >= 100_000) break
if ('str' in item) {
const part = item.str.slice(0, 100_000 - characters)
parts.push(part); characters += part.length + 1
}
}
}
} finally {
await reader.cancel().catch(() => {})
reader.releaseLock()
}
if (!disposed) { setText(parts.join(' ')); setBusy(false) }
})().catch(() => { if (!disposed) { setError(true); setBusy(false) } }).finally(() => {
clearTimeout(timeout)
// PDF.js defers cleanup if a cancelled render still has pending work.
loadedPage?.cleanup()
})
return () => { disposed = true; clearTimeout(timeout); render?.cancel(); cancelText?.() }
}, [document, page, zoom, error])
const button = 'min-h-11 rounded border border-border px-3 disabled:opacity-50 focus-visible:ring-2 focus-visible:ring-ring'
return <section className="absolute inset-0 flex flex-col" aria-label={'PDF preview: ' + fileName} data-qqq-id="file-preview-pdf">
<div className="flex flex-wrap items-center gap-2 border-b border-border bg-card p-2" role="toolbar" aria-label="PDF page controls">
<button type="button" className={button} disabled={!document || busy || error || page <= 1} onClick={() => setPage(page - 1)} data-qqq-id="pdf-previous">Previous page</button>
<span role="status" aria-live="polite">{document ? 'Page ' + page + ' of ' + document.numPages : 'Loading PDF'}</span>
<button type="button" className={button} disabled={!document || busy || error || page >= document.numPages} onClick={() => setPage(page + 1)} data-qqq-id="pdf-next">Next page</button>
<button type="button" className={button} disabled={!document || busy || error || zoom <= 0.5} onClick={() => setZoom(zoom - 0.25)} data-qqq-id="pdf-zoom-out">Zoom out</button>
<button type="button" className={button} disabled={!document || busy || error || zoom >= 2} onClick={() => setZoom(zoom + 0.25)} data-qqq-id="pdf-zoom-in">Zoom in</button>
</div>
{error ? <p role="alert" className="p-4">This PDF could not be safely previewed within the preview limits. Use Download to retain the original.</p>
: <div className="min-h-0 flex-1 overflow-auto p-3" aria-busy={busy}>
{busy && <p role="status">Rendering PDF page…</p>}
<canvas ref={canvas} className="mx-auto max-w-full bg-white" role="img" aria-label={fileName + ', page ' + page} />
<details className="mt-3 rounded border border-border bg-card p-3"><summary className="cursor-pointer" data-qqq-id="pdf-page-text">Page text</summary><p className="whitespace-pre-wrap">{text || 'No extractable text on this page.'}</p></details>
</div>}
</section>
}
@@ -0,0 +1,75 @@
/*
* Copyright 2026 QRun.IO, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { beforeEach, expect, it, vi } from 'vitest'
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import type { QFieldMetaData, QFrontendStepMetaData, QProcessMetaData } from '@/types'
import { processRecords } from '@/lib/api/processes'
import { ProcessStepScreen } from './ProcessStepScreen'
vi.mock('@/lib/api/processes', async (original) => ({ ...await original<typeof import('@/lib/api/processes')>(), processRecords: vi.fn() }))
const step: QFrontendStepMetaData = {
name: 'order', label: 'Order files', components: [{ type: 'RECORD_LIST', values: { orderFieldName: 'orderedIds', identityFieldName: 'id' } }],
recordListFields: [{ name: 'name', label: 'File', type: 'STRING', isRequired: false, isEditable: false, isHeavy: false, isHidden: false, adornments: [] } as QFieldMetaData],
}
const result: QFrontendStepMetaData = { name: 'done', label: 'Done', components: [] }
const process: QProcessMetaData = { name: 'order', label: 'Order', tableName: '', isHidden: false, iconName: '', hasPermission: true, stepFlow: 'LINEAR', minInputRecords: 1, frontendSteps: [step, result] }
/** Render the real process screen and collect submitted contributions.
* @returns Submit spy.
*/
function mount() {
const submit = vi.fn()
render(<QueryClientProvider client={new QueryClient({ defaultOptions: { queries: { retry: false } } })}>
<ProcessStepScreen processName="order" processMetaData={process} processUUID="run" step={step} steps={process.frontendSteps} values={{}}
backStep={null} isWorking={false} onSubmit={submit} onBack={vi.fn()} onCancel={vi.fn()} onReturn={vi.fn()} />
</QueryClientProvider>)
return submit
}
beforeEach(() => { vi.resetAllMocks() })
it('moves rows with keyboard controls and submits the complete identity order automatically', async () => {
vi.mocked(processRecords).mockResolvedValue({ totalRecords: 2, records: [
{ tableName: 'items', values: { id: 11, name: 'Front photo' } }, { tableName: 'items', values: { id: 22, name: 'BOL scan' } },
] })
const submit = mount()
const user = userEvent.setup()
const down = await screen.findByRole('button', { name: 'Move Front photo down' })
expect(screen.getByRole('button', { name: 'Move Front photo up' })).toBeDisabled()
down.focus()
await user.keyboard('{Enter}')
expect(screen.getAllByRole('listitem')[0]).toHaveTextContent('BOL scan')
expect(screen.getByRole('status')).toHaveTextContent('Moved to position 2 of 2')
expect(screen.queryByRole('textbox')).toBeNull()
await user.click(screen.getByRole('button', { name: 'Submit' }))
await waitFor(() => expect(submit).toHaveBeenCalledWith({ orderedIds: '22,11' }, undefined))
})
it('blocks submit when the complete identity set cannot be loaded', async () => {
vi.mocked(processRecords).mockResolvedValue({ totalRecords: 2, records: [{ tableName: 'items', values: { id: 1, name: 'First' } }, { tableName: 'items', values: { id: 1, name: 'Duplicate' } }] })
const submit = mount()
await screen.findByRole('alert')
await userEvent.click(screen.getByRole('button', { name: 'Submit' }))
expect(submit).not.toHaveBeenCalled()
})
it('loads all bounded pages before enabling ordering and never silently drops missing members', async () => {
vi.mocked(processRecords).mockResolvedValueOnce({ totalRecords: 2, records: [{ tableName: 'items', values: { id: 1, name: 'First' } }] })
.mockResolvedValueOnce({ totalRecords: 2, records: [] })
const submit = mount()
await screen.findByRole('alert')
expect(processRecords).toHaveBeenCalledTimes(2)
await userEvent.click(screen.getByRole('button', { name: 'Submit' }))
expect(submit).not.toHaveBeenCalled()
})
@@ -0,0 +1,102 @@
/*
* Copyright 2026 QRun.IO, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
/** @file Optional native record-list ordering, with bounded complete-set loading. */
'use client'
import { useState } from 'react'
import { useQuery } from '@tanstack/react-query'
import type { QFrontendComponent, QRecord } from '@/types'
import { processRecords } from '@/lib/api/processes'
import { isRestricted } from '@/lib/utils/secured-data'
import { recordCellText } from './RecordListComponent'
import { useProcessStep, useSubmitContributor } from './ProcessStepContext'
/** Render a metadata-enabled ordering list; ordinary record lists stay unchanged.
* @param props - Component metadata and index.
* @returns Accessible up/down controls and submit contribution.
*/
export function OrderedRecordListComponent({ component, index }: { component: QFrontendComponent; index: number }) {
const { step, processName, processUUID, tableVariant, isWorking } = useProcessStep()
const [ordered, setOrdered] = useState<{ scope: string; records: QRecord[] } | null>(null)
const [announcement, setAnnouncement] = useState('')
const orderField = component.values?.orderFieldName
const identityField = component.values?.identityFieldName
const valid = typeof orderField === 'string' && /^[A-Za-z_][A-Za-z0-9_]{0,127}$/.test(orderField) &&
typeof identityField === 'string' && /^[A-Za-z_][A-Za-z0-9_]{0,127}$/.test(identityField)
const scope = JSON.stringify([processName, processUUID, step.name, tableVariant, identityField])
const fields = (step.recordListFields ?? []).filter((field) => !field.isHidden && !isRestricted(field))
const query = useQuery({
queryKey: ['qqq', 'processOrderRecords', processName, processUUID, step.name, tableVariant, identityField],
enabled: valid && Boolean(processUUID), retry: false, staleTime: Infinity, refetchOnWindowFocus: false,
queryFn: async ({ signal }) => {
const records: QRecord[] = []
let total = 1
while (records.length < total) {
signal.throwIfAborted()
const page = await processRecords(processName, processUUID!, records.length, 100, tableVariant)
signal.throwIfAborted()
if (!Number.isInteger(page.totalRecords) || page.totalRecords < 1 || page.totalRecords > 5000 ||
(records.length > 0 && page.totalRecords !== total) || page.records.length === 0 || page.records.length > 100) {
throw new Error('The complete ordering list is unavailable or exceeds 5,000 records.')
}
total = page.totalRecords
records.push(...page.records)
}
const ids = records.map((record) => record.values?.[identityField as string])
if (records.length !== total || ids.some((id) => typeof id !== 'number' || !Number.isSafeInteger(id) || id < 1) || new Set(ids).size !== total) {
throw new Error('The ordering list contains invalid or duplicate identities.')
}
return records
},
})
const records = ordered?.scope === scope ? ordered.records : query.data ?? []
const ready = valid && query.isSuccess && records.length > 0 && !isWorking
useSubmitContributor('record-order-' + index, () => ({
maySubmit: ready,
values: ready ? { [orderField as string]: records.map((record) => record.values?.[identityField as string]).join(',') } : undefined,
}))
/** Move exactly one row, preserving its stable identity and keyboard focus.
* @param position - Current row position.
* @param direction - Adjacent movement.
* @returns Nothing.
*/
const move = (position: number, direction: -1 | 1): void => {
const next = [...records]
const target = position + direction
if (!ready || target < 0 || target >= next.length) return
;[next[position], next[target]] = [next[target], next[position]]
setOrdered({ scope, records: next })
setAnnouncement('Moved to position ' + (target + 1) + ' of ' + next.length + '.')
}
if (!valid) return <p role="alert">This ordering control is not configured correctly.</p>
if (query.isError) return <div role="alert"><p>{query.error.message}</p><button type="button" data-qqq-id="record-order-retry" onClick={() => { setOrdered(null); void query.refetch() }}>Retry loading</button></div>
if (!query.isSuccess) return <p role="status">Loading the complete list for ordering…</p>
return <section aria-label="Record order" data-qqq-id="record-order">
<p className="text-sm text-muted-foreground">Use Move up and Move down to set the order, then submit.</p>
<p role="status" className="sr-only">{announcement}</p>
<ol className="space-y-2">
{records.map((record, position) => {
const label = fields.map((field) => recordCellText(record, field)).filter((value) => value !== '—').join(' — ') || 'Record'
return <li key={String(record.values?.[identityField as string])} className="flex flex-wrap items-center gap-3 rounded border p-3">
<span className="min-w-0 flex-1 break-words">{position + 1}. {label}</span>
<button type="button" className="min-h-11 rounded border px-3" disabled={!ready || position === 0} aria-label={'Move ' + label + ' up'} data-qqq-id="record-order-up" onClick={() => move(position, -1)}>Move up</button>
<button type="button" className="min-h-11 rounded border px-3" disabled={!ready || position === records.length - 1} aria-label={'Move ' + label + ' down'} data-qqq-id="record-order-down" onClick={() => move(position, 1)}>Move down</button>
</li>
})}
</ol>
</section>
}
+4 -1
View File
@@ -36,6 +36,7 @@ import { HelpTextComponent } from './HelpTextComponent'
import { HtmlComponent } from './HtmlComponent'
import { ProcessSummaryResultsComponent } from './ProcessSummaryResultsComponent'
import { RecordListComponent } from './RecordListComponent'
import { OrderedRecordListComponent } from './OrderedRecordListComponent'
import { ValidationReviewComponent } from './ValidationReviewComponent'
import { ViewFormComponent } from './ViewFormComponent'
import { WidgetComponent } from './WidgetComponent'
@@ -57,7 +58,9 @@ export function ProcessComponent({ component, index }: ProcessComponentProps) {
case 'HELP_TEXT': return <HelpTextComponent component={component} index={index} />
case 'EDIT_FORM': return <EditFormComponent component={component} index={index} />
case 'VIEW_FORM': return <ViewFormComponent index={index} />
case 'RECORD_LIST': return <RecordListComponent index={index} />
case 'RECORD_LIST': return component.values?.orderFieldName !== undefined
? <OrderedRecordListComponent key={index} component={component} index={index} />
: <RecordListComponent index={index} />
case 'VALIDATION_REVIEW_SCREEN': return <ValidationReviewComponent index={index} />
case 'PROCESS_SUMMARY_RESULTS': return <ProcessSummaryResultsComponent index={index} />
case 'DOWNLOAD_FORM': return <DownloadFormComponent index={index} />
@@ -0,0 +1,62 @@
/*
* Copyright 2026 QRun.IO, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React from 'react'
import { render, screen } from '@testing-library/react'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { describe, expect, it, vi } from 'vitest'
import type { QFieldMetaData } from '@/types'
import { ViewFormComponent } from './ViewFormComponent'
import { useProcessStep } from './ProcessStepContext'
vi.mock('./ProcessStepContext', () => ({ useProcessStep: vi.fn() }))
vi.mock('next/navigation', () => ({ useRouter: () => ({ push: vi.fn() }) }))
function field(name: string, extra: Partial<QFieldMetaData> = {}): QFieldMetaData {
return { name, label: name, type: 'STRING', isRequired: false, isEditable: false, isHeavy: false, isHidden: false, adornments: [], ...extra }
}
function show(fields: QFieldMetaData[], values: Record<string, unknown>) {
vi.mocked(useProcessStep).mockReturnValue({ step: { viewFields: fields }, values, processName: 'nativeAction' } as unknown as ReturnType<typeof useProcessStep>)
return render(<QueryClientProvider client={new QueryClient()}><ViewFormComponent index={0} /></QueryClientProvider>)
}
describe('native process result adornments', () => {
it('opens actual record links and hides absent navigation fields', () => {
show([field('request', { adornments: [{ type: 'LINK', values: { toRecordFromTable: 'request' } }] }),
field('absent', { adornments: [{ type: 'LINK', values: { toRecordFromTable: 'other' } }] }), field('state')], { request: 42, state: 'RETAINED' })
expect(screen.getByRole('link', { name: '42' })).toHaveAttribute('href', '/app/request/42')
expect(screen.queryByText('absent:')).not.toBeInTheDocument()
expect(screen.getByText('RETAINED')).toBeVisible()
})
it('uses hidden companion metadata without showing capability URLs as links', () => {
const result = show([field('file', { adornments: [{ type: 'LINK', values: { toRecordFromTableField: 'table' } }] }),
field('table', { isHidden: true }), field('url', { adornments: [{ type: 'FILE_DOWNLOAD', values: { deliveryModeField: 'mode', fileNameField: 'name' } }] }),
field('mode', { isHidden: true }), field('name', { isHidden: true })],
{ file: '7', table: 'retained', url: 'https://api.example/private-transfer', mode: 'AUTHENTICATED_FETCH', name: 'Packing.pdf' })
expect(screen.getByRole('link', { name: '7' })).toHaveAttribute('href', '/app/retained/7')
expect(screen.getByRole('button', { name: 'Download file' })).toBeEnabled()
expect(result.container.innerHTML).not.toContain('https://api.example/private-transfer')
expect(screen.queryByText('mode:')).not.toBeInTheDocument()
})
it('does not downgrade an unknown or missing delivery mode to a link', () => {
const result = show([field('url', { adornments: [{ type: 'FILE_DOWNLOAD', values: { deliveryModeField: 'mode' } }, { type: 'FILE_PREVIEW' }] }),
field('mode', { isHidden: true })], { url: 'https://api.example/private-transfer', mode: 'UNKNOWN' })
expect(result.container.querySelector('a, img, iframe, button')).toBeNull()
})
})
+8 -1
View File
@@ -29,6 +29,7 @@ import { fetchProcessPossibleValues } from '@/lib/api/possible-values'
import { useProcessStep } from './ProcessStepContext'
import { formatProcessValue } from './process-values'
import { FieldValue } from '@/components/records/FieldValue'
/** Props for {@link ViewFormComponent}. */
export interface ViewFormComponentProps {
@@ -48,6 +49,7 @@ interface ViewFieldScope {
* @returns The rendered line.
*/
function ViewField({ field, value, scope: { processName, values } }: { field: QFieldMetaData; value: unknown; scope: ViewFieldScope }) {
const { instance, tableMetaData } = useProcessStep()
const hasValue = value !== null && value !== undefined && value !== ''
const { data: label } = useQuery({
queryKey: ['qqq', 'processViewValueLabel', processName, field.name, String(value), values],
@@ -58,6 +60,8 @@ function ViewField({ field, value, scope: { processName, values } }: { field: QF
const text = formatProcessValue(field, value, label ?? undefined)
const isError = field.adornments?.some((adornment) => adornment.type === 'ERROR')
if (!hasValue && field.adornments?.some((adornment) => ['LINK', 'FILE_DOWNLOAD', 'FILE_PREVIEW', 'CODE_EDITOR'].includes(adornment.type))) return null
if (isError) {
if (!text) return null
return (
@@ -67,7 +71,10 @@ function ViewField({ field, value, scope: { processName, values } }: { field: QF
return (
<div className="flex flex-wrap gap-x-2 py-1 text-sm" data-qqq-id={`process-view-field-${field.name}`}>
<span className="font-semibold text-foreground">{field.label}:</span>
<span className="text-foreground" data-qqq-id={`process-view-value-${field.name}`}>{text || <span className="text-muted-foreground">&mdash;</span>}</span>
<div className="min-w-0 text-foreground" data-qqq-id={`process-view-value-${field.name}`}>{field.adornments?.length || (field.possibleValueSourceName && instance?.tables?.[field.possibleValueSourceName])
? <FieldValue field={field} record={{ tableName: tableMetaData?.name ?? '', values,
displayValues: label ? { [field.name]: label } : undefined }} allTables={instance?.tables} tableMetaData={tableMetaData} />
: text || <span className="text-muted-foreground">&mdash;</span>}</div>
</div>
)
}
+1 -1
View File
@@ -87,7 +87,7 @@ function NamedWidget({ widget, seeded, index }: { widget: QWidgetMetaData; seede
produced.current = type === 'childRecordList' ? { frontendRecords: frontendRecordsJson(data) } : { ...(produced.current ?? {}), ...data }
}, [type])
useSubmitContributor(`widget-${index}-${widget.name}`, () => ({ maySubmit: true, values: produced.current ?? undefined }))
useSubmitContributor(`widget-${index}-${widget.name}`, () => ({ maySubmit: type !== 'AUTHENTIK_ACCESS_EDITOR' || produced.current?.__accessEditorReady === true, values: produced.current ?? undefined }))
return (
<div data-qqq-id={`process-widget-${widget.name}`}>
@@ -95,6 +95,20 @@ function instanceWith(...widgets: QWidgetMetaData[]): QInstance {
describe('process screens: fields and chrome (#725)', () => {
beforeEach(() => vi.clearAllMocks())
it('submits TypeScript source unchanged through a native EDIT_FORM CODE_EDITOR field', async () => {
const source = ' export async function main(): Promise<string> {\n return "日本語";\n}\n\n'
const step: QFrontendStepMetaData = {
name: 'source', label: 'Source', components: [{ type: 'EDIT_FORM' }],
formFields: [field('source', 'TypeScript source', {
type: 'TEXT', adornments: [{ type: 'CODE_EDITOR', values: { languageMode: 'typescript' } }],
})],
}
const { onSubmit } = renderStep(step, { source: '// initial\n' })
fireEvent.change(screen.getByRole('textbox', { name: 'TypeScript source' }), { target: { value: source } })
await userEvent.click(screen.getByRole('button', { name: 'Submit' }))
await waitFor(() => expect(onSubmit).toHaveBeenCalledWith({ source }, undefined))
})
it('adds inputFieldList fields to a screen with an edit form, searching their source, and submits them', async () => {
const user = userEvent.setup()
const step: QFrontendStepMetaData = { name: 'input', label: 'Input', components: [{ type: 'EDIT_FORM' }], formFields: [field('note', 'Note')] }
@@ -0,0 +1,112 @@
/*
* Copyright 2026 QRun.IO, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { http, HttpResponse } from 'msw'
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
import type { QTableMetaData } from '@/types'
import { useRecordQuery } from '@/lib/hooks/use-record-query'
import { authorizationFilteredPagination, paginationSourceTables } from '@/lib/utils/query-pagination'
import { qInstance } from '@/mocks/fixtures/q-instance'
import { server } from '@/mocks/node'
import { RecordQueryContent } from './RecordQueryContent'
let client: QueryClient
let table: QTableMetaData
let sources: Record<string, QTableMetaData>
function Screen() {
const rq = useRecordQuery({ tableName: table.name, tableMetaData: table, allTables: { [table.name]: table } })
return <RecordQueryContent
allTables={sources}
tableName={table.name} tableMetaData={table} viewMode="card" activeFilterCount={0}
{...rq.data} {...rq.pagination} {...rq.columns}
sortOrder={rq.filter.sortOrder} onSortChange={rq.filter.setSort}
onResetFilter={rq.filter.resetFilter} quickSearchTerm={rq.filter.quickSearchTerm}
rowSelection={rq.selection.rowSelection} onRowSelectionChange={rq.selection.setRowSelection}
onColumnWidthChange={rq.columns.setColumnWidth} density={rq.density}
onPageChange={rq.pagination.setPage} onPageSizeChange={rq.pagination.setPageSize}
/>
}
beforeEach(() => {
localStorage.clear()
client = new QueryClient({ defaultOptions: { queries: { retry: false } } })
table = { ...qInstance.tables.person, capabilities: ['TABLE_QUERY', 'TABLE_COUNT'], supplementalMetaData: {
queryPagination: { authorizationFiltered: true },
} }
sources = { archive: { ...qInstance.tables.person, name: 'record_files_archive_source', label: 'Retained Source Archives', readPermission: true, isHidden: false,
supplementalMetaData: { queryPagination: { sourceBrowser: true } } } }
vi.spyOn(window, 'scrollTo').mockImplementation(() => {})
})
afterEach(() => { cleanup(); client.clear(); vi.restoreAllMocks() })
it('requests one later page after an empty authorized page, permits back navigation, and never counts', async () => {
const offsets: number[] = []
let countCalls = 0
let revoked = false
server.use(
http.post('*/qqq/v1/table/person/query', async ({ request }) => {
const body = await request.json() as { filter: { skip?: number; limit: number } }
const skip = body.filter.skip ?? 0
offsets.push(skip)
expect(body.filter.limit).toBe(50)
return HttpResponse.json({ records: skip === 50 && !revoked ? [{ tableName: 'person', recordLabel: 'Visible later', values: { id: 99, firstName: 'Visible later' } }] : [] })
}),
http.post('*/qqq/v1/table/person/count', () => { countCalls++; return HttpResponse.json({ count: 999 }) }),
)
render(<QueryClientProvider client={client}><Screen /></QueryClientProvider>)
expect(await screen.findByText('No authorized records on this page')).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Next page' })).toBeEnabled()
expect(screen.getByText('0 visible records on page 1')).toBeInTheDocument()
expect(screen.getByRole('link', { name: 'Retained Source Archives' })).toHaveAttribute('href', '/app/record_files_archive_source')
fireEvent.click(screen.getByRole('button', { name: 'Next page' }))
await waitFor(() => expect(screen.getByText('1 visible records on page 2')).toBeInTheDocument())
expect(offsets).toEqual([0, 50])
expect(countCalls).toBe(0)
revoked = true
fireEvent.click(screen.getByRole('button', { name: 'Previous page' }))
await waitFor(() => expect(screen.getByText('0 visible records on page 1')).toBeInTheDocument())
await waitFor(() => expect(screen.getByRole('button', { name: 'Next page' })).toBeEnabled())
fireEvent.click(screen.getByRole('button', { name: 'Next page' }))
await waitFor(() => expect(screen.getByText('0 visible records on page 2')).toBeInTheDocument())
expect(screen.getByRole('button', { name: 'Next page' })).toBeEnabled()
expect(countCalls).toBe(0)
})
it('preserves ordinary empty tables and rejects a truthy string opt-in', async () => {
table = { ...table, supplementalMetaData: { queryPagination: { authorizationFiltered: 'true' } } }
server.use(http.post('*/qqq/v1/table/person/query', () => HttpResponse.json({ records: [] })),
http.post('*/qqq/v1/table/person/count', () => HttpResponse.json({ count: 0 })))
render(<QueryClientProvider client={client}><Screen /></QueryClientProvider>)
expect(await screen.findByText('No records found')).toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'Next page' })).not.toBeInTheDocument()
expect(screen.queryByText(/Completeness is unknown/)).not.toBeInTheDocument()
})
it('does not turn malformed navigation metadata into arbitrary links', () => {
sources.denied = { ...sources.archive, name: 'denied', readPermission: false }
sources.hidden = { ...sources.archive, name: 'hidden', isHidden: true }
sources.bad = { ...sources.archive, name: '../secret' }
sources.missing = { ...sources.archive, name: undefined } as unknown as QTableMetaData
sources.nullName = { ...sources.archive, name: null } as unknown as QTableMetaData
sources.truthyRead = { ...sources.archive, readPermission: 'true' } as unknown as QTableMetaData
sources.truthySource = { ...sources.archive, supplementalMetaData: { queryPagination: { sourceBrowser: 'true' } } }
expect(paginationSourceTables(sources)).toEqual([sources.archive])
expect(authorizationFilteredPagination(undefined)).toBe(false)
})
+6 -1
View File
@@ -31,6 +31,8 @@ import { PAGE_SIZE_OPTIONS, type PageSize } from '@/lib/hooks/use-record-query'
* Props for the Pagination component.
*/
interface PaginationProps {
/** Parent authorization filters pages after selection, so completeness is unknown. */
authorizationFiltered?: boolean
/** Current 1-based page number. */
pageNum: number
/** Number of records displayed per page (constrained to the PageSize union type). */
@@ -66,6 +68,7 @@ interface PaginationProps {
* @returns The rendered pagination controls.
*/
export function Pagination({
authorizationFiltered = false,
pageNum,
pageSize,
totalCount,
@@ -121,7 +124,9 @@ export function Pagination({
{/* Left: record count summary */}
<div className="flex flex-wrap items-center gap-3 text-sm text-muted-foreground">
<span aria-live="polite" aria-atomic="true" data-qqq-id="pagination-summary">
{isCounting && !uncounted ? (
{authorizationFiltered ? (
`${pageRowCount.toLocaleString()} visible records on page ${pageNum.toLocaleString()}`
) : isCounting && !uncounted ? (
'Counting...'
) : shown === 0 ? (
'No records'
+8 -6
View File
@@ -211,19 +211,20 @@ describe('RecordQuery joined read permissions', () => {
render(<RecordQuery {...options} />, { wrapper: createWrapper() })
expect(await screen.findByText('Alice')).toBeVisible()
await user.click(screen.getByRole('button', { name: 'Toggle advanced filter panel' }))
const filterMode = within(screen.getByRole('group', { name: 'Filter mode' }))
await user.click(screen.getByRole('button', { name: 'First Name' }))
await user.type(screen.getByRole('textbox', { name: 'Filter value for First Name' }), 'Bob')
await user.click(screen.getByRole('button', { name: 'Apply quick filter' }))
await waitFor(() => expect(requests.at(-1)?.body.filter.criteria?.length).toBe(1))
await user.selectOptions(screen.getByRole('combobox', { name: 'Sort field' }), 'lastName')
await user.click(screen.getByRole('button', { name: 'Clear all filters' }))
await user.selectOptions(filterMode.getByRole('combobox', { name: 'Sort field' }), 'lastName')
await user.click(filterMode.getByRole('button', { name: 'Clear all filters' }))
expect(screen.getByRole('alertdialog')).toBeVisible()
await user.click(screen.getByRole('button', { name: 'Cancel' }))
await user.click(within(screen.getByRole('alertdialog')).getByRole('button', { name: 'Cancel' }))
expect(requests.at(-1)?.body.filter.criteria?.length).toBe(1)
await user.click(screen.getByRole('button', { name: 'Clear all filters' }))
await user.click(screen.getByRole('button', { name: 'Clear filters' }))
await user.click(filterMode.getByRole('button', { name: 'Clear all filters' }))
await user.click(within(screen.getByRole('alertdialog')).getByRole('button', { name: 'Clear filters' }))
await waitFor(() => expect(requests.filter(({ action }) => action === 'query').at(-1)?.body.filter.criteria?.length).toBe(0))
expect(requests.filter(({ action }) => action === 'query').at(-1)?.body.filter.orderBys).toEqual([{ fieldName: 'lastName', isAscending: false }])
})
@@ -238,11 +239,12 @@ describe('RecordQuery joined read permissions', () => {
render(<RecordQuery {...options} />, { wrapper: createWrapper() })
expect(await screen.findByText('Alice')).toBeVisible()
await user.click(screen.getByRole('button', { name: 'Toggle advanced filter panel' }))
const filterMode = within(screen.getByRole('group', { name: 'Filter mode' }))
await user.click(screen.getByRole('button', { name: 'First Name' }))
await user.type(screen.getByRole('textbox', { name: 'Filter value for First Name' }), 'Bob')
await user.click(screen.getByRole('button', { name: 'Apply quick filter' }))
await user.click(screen.getByRole('button', { name: 'Advanced' }))
await user.click(filterMode.getByRole('button', { name: 'Advanced' }))
expect(screen.queryByLabelText('Advanced filter preview')).not.toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'Clear all filters' })).not.toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Clear all' })).toBeVisible()
+1
View File
@@ -876,6 +876,7 @@ export function RecordQuery({ tableName, tableMetaData: sourceTableMetaData, all
</div>
) : (
<RecordQueryContent
allTables={allTables}
tableName={tableName}
tableMetaData={tableMetaData}
viewMode={viewMode}
+22 -1
View File
@@ -22,6 +22,8 @@
import { useState } from 'react'
import { useRouter } from 'next/navigation'
import Link from 'next/link'
import { authorizationFilteredPagination, paginationSourceTables } from '@/lib/utils/query-pagination'
import { useQueryClient } from '@tanstack/react-query'
import { X } from 'lucide-react'
@@ -51,6 +53,8 @@ export interface RecordQueryContentProps {
tableName: string
/** Full table metadata from the QQQ backend. */
tableMetaData: QTableMetaData
/** Current authorized metadata, used only for source navigation hints. */
allTables?: Record<string, QTableMetaData>
/** Currently active view mode (grid or card). */
viewMode: ViewMode
/** Number of active filter criteria (used by the empty-state clear action). */
@@ -139,6 +143,7 @@ export interface RecordQueryContentProps {
export function RecordQueryContent({
tableName,
tableMetaData,
allTables = {},
viewMode,
activeFilterCount,
records,
@@ -174,11 +179,25 @@ export function RecordQueryContent({
}: RecordQueryContentProps) {
const router = useRouter()
const queryClient = useQueryClient()
const pagePolicy = authorizationFilteredPagination(tableMetaData)
const sourceTables = paginationSourceTables(allTables)
// Material's grid alert can be closed; a new error shows again
const [dismissedError, setDismissedError] = useState<Error | null>(null)
return (
<>
{pagePolicy && (
<div className="rounded border border-border p-3 text-sm text-muted-foreground" data-qqq-id="query-page-completeness">
<p>Completeness is unknown. Each page shows only records you can access. Later pages may contain records even when this page is empty. Next requests one page and remains available because the end cannot be determined.</p>
{sourceTables.length > 0 && (
<p className="mt-2">To narrow by source, choose a record and open its Files tab:{' '}
{sourceTables.map((source) => (
<Link key={source.name} href={`/app/${encodeURIComponent(source.name)}/`} className="mr-3 inline-flex min-h-[44px] items-center underline">{source.label || source.name}</Link>
))}
</p>
)}
</div>
)}
{/* ============================================================
Error state
============================================================ */}
@@ -235,7 +254,7 @@ export function RecordQueryContent({
{/* ============================================================
Data Grid / Card View
============================================================ */}
{(isLoading || records.length > 0) && (
{(isLoading || records.length > 0 || (pagePolicy && !isError)) && (
<ErrorBoundary className="rounded-xl" resetLabel="Reset saved query and reload" onReset={() => {
clearStoredQueryState(tableName)
if (window.location.pathname.includes('/savedView/')) router.replace(`/app/${encodeURIComponent(tableName)}/`)
@@ -291,6 +310,7 @@ export function RecordQueryContent({
{/* Pagination */}
<Pagination
authorizationFiltered={pagePolicy}
pageNum={pageNum}
pageSize={pageSize}
totalCount={totalCount}
@@ -321,6 +341,7 @@ export function RecordQueryContent({
* @returns EmptyState title, description and width.
*/
export function emptyStateText(tableMetaData: QTableMetaData, filtered: boolean): { title: string; description: string; wideDescription?: boolean } {
if (authorizationFilteredPagination(tableMetaData)) return { title: 'No authorized records on this page', description: 'Later pages may contain records. Use Next or narrow your search to a source record.' }
const help = filtered ? undefined : selectSlotHelp(tableMetaData.helpContents, 'help', QUERY_SCREEN_HELP_ROLES)
const text = help ? helpPlainText(help) : ''
if (!text) return { title: 'No records found', description: 'Try adjusting your filters or search term.' }
@@ -331,7 +331,7 @@ describe('AssociatedScriptViewer', () => {
await user.type(input, 'Ada')
await user.click(byQqqId(`button-test-script-${FIELD}`)!)
expect(testScript).toHaveBeenCalledWith({ scriptId: 101, files: { 'Script.js': "return 'Hello, friend';" }, inputValues: { name: 'Ada' } })
expect(testScript).toHaveBeenCalledWith({ scriptId: 101, files: { 'Script.js': "return 'Hello, friend';" }, inputValues: { name: 'Ada' } }, { signal: expect.any(AbortSignal) })
const output = byQqqId(`script-test-output-${FIELD}`)!
await waitFor(() => expect(byQqqId(`script-test-output-${FIELD}-greeting`)).toHaveTextContent('Greeting: Hello, Ada! (22 characters)'))
expect(within(output).queryByRole('alert')).toBeNull()
+38 -1
View File
@@ -15,11 +15,14 @@
*/
import React from 'react'
import { fireEvent, render, screen } from '@testing-library/react'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { describe, expect, it, vi } from 'vitest'
import type { FieldAdornment, QFieldMetaData, QRecord, QWidgetMetaData } from '@/types'
import { FieldValue } from './FieldValue'
import * as attachmentDelivery from '@/lib/api/attachment-download'
vi.mock('@/lib/api/attachment-download', () => ({ fetchTrackedAttachment: vi.fn(), safeAttachmentPreviewBlob: vi.fn(), saveAttachmentDownload: vi.fn() }))
vi.mock('next/navigation', () => ({ useRouter: () => ({ push: vi.fn() }) }))
@@ -79,6 +82,40 @@ describe('FieldValue adornments use the backend value keys', () => {
expect(document.querySelector('[data-qqq-id="field-value-doc-download"]')).toHaveAttribute('href', '/qqq/v1/table/lab/1/doc/a.txt?download=1')
})
it.each(['preview', 'download'] as const)('uses the verified delivery gate for native attachment %s metadata', async (kind) => {
const url = 'https://api.example/qqq/v1/qbit/record-files/files/7/downloads/00000000-0000-4000-8000-000000000001'
const name = `${kind}Url`
const adornments: FieldAdornment[] = [{ type: 'FILE_DOWNLOAD', values: { fileNameField: 'fileName', deliveryModeField: `${kind}Delivery`, ...(kind === 'download' ? { downloadUrlDynamic: true } : {}) } }]
if (kind === 'preview') adornments.push({ type: 'FILE_PREVIEW', values: { fileNameField: 'fileName', mimeTypeField: 'contentType', sizeBytesField: 'sizeBytes', thumbnailMaxSize: 48 } })
vi.mocked(attachmentDelivery.fetchTrackedAttachment).mockRejectedValue(new Error('Receipt unavailable'))
vi.mocked(attachmentDelivery.saveAttachmentDownload).mockRejectedValue(new Error('Current access revoked'))
const value = show(field(name, adornments), { [name]: url, [`${kind}Delivery`]: 'AUTHENTICATED_FETCH', fileName: 'Retained.pdf', contentType: 'application/pdf', sizeBytes: 13 },
kind === 'download' ? { downloadUrl: 'Retained.pdf', 'downloadUrl:downloadUrlDynamic': url } : {})
expect(value.querySelector('a,img,iframe')).toBeNull()
expect(value.innerHTML).not.toContain(url)
if (kind === 'preview') {
fireEvent.click(screen.getByRole('button', { name: 'Preview file' }))
await waitFor(() => expect(screen.getByRole('alert')).toHaveTextContent('Receipt unavailable'))
expect(attachmentDelivery.fetchTrackedAttachment).toHaveBeenCalledWith(url, expect.any(AbortSignal))
expect(value.querySelector('a,img,iframe')).toBeNull()
} else {
expect(screen.queryByRole('button', { name: 'Preview file' })).not.toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: 'Download file' }))
await waitFor(() => expect(screen.getByRole('alert')).toHaveTextContent('Current access revoked'))
expect(attachmentDelivery.saveAttachmentDownload).toHaveBeenCalledWith({ url, delivery: 'AUTHENTICATED_FETCH' }, 'Retained.pdf', expect.any(AbortSignal))
expect(value.querySelector('a')).toBeNull()
}
})
it('keeps native signed downloads compatible and refuses a missing declared delivery mode', () => {
const nativeField = field('downloadUrl', [{ type: 'FILE_DOWNLOAD', values: { fileNameField: 'fileName', deliveryModeField: 'downloadDelivery', downloadUrlDynamic: true } }])
const url = 'https://storage.example/immutable'
const value = show(nativeField, { downloadUrl: url, fileName: 'Original.pdf', downloadDelivery: 'SIGNED_URL' }, { 'downloadUrl:downloadUrlDynamic': url })
expect(value.querySelector('a')).toHaveAttribute('href', url)
const missing = show(field('missingUrl', [{ type: 'FILE_DOWNLOAD', values: { deliveryModeField: 'missingDelivery' } }]), { missingUrl: url })
expect(missing.querySelector('a,button,img,iframe')).toBeNull()
})
it('reveals REVEAL values on request', () => {
const secret = show(field('token', [{ type: 'REVEAL' }], { label: 'Token' }), { token: 'abc' })
expect(secret).toHaveTextContent('••••••••')
+8
View File
@@ -47,6 +47,7 @@ import { HighlightedCode } from '@/components/scripts/HighlightedCode'
import { SeededWidget } from '@/components/widgets/ConnectedWidget'
import { DateTimeText } from '@/components/ui/DateTimeText'
import { FileThumbnail } from '@/components/preview/FileThumbnail'
import { DeliveredFileActions } from '@/components/preview/DeliveredFileActions'
import { RecordHoverCard } from './RecordHoverCard'
/**
@@ -159,6 +160,13 @@ function FieldValueContent({ field, record, allTables, navigateFrom, widgetMetaD
)
}
const deliveryAdornment = findAdornment(field, 'FILE_DOWNLOAD')
if (deliveryAdornment?.values?.deliveryModeField !== undefined) {
const delivery = fileDownload(field, record)
if (!delivery) return <EmptyValue fieldName={field.name} className={className} />
if (delivery.delivery === 'AUTHENTICATED_FETCH') return <DeliveredFileActions key={delivery.url} url={delivery.url} fileName={delivery.fileName}
allowPreview={!!findAdornment(field, 'FILE_PREVIEW')} dataQqqId={dataQqqId} />
}
const preview = filePreview(field, record, storedRecordVariantJson(tableMetaData ?? allTables?.[record.tableName]))
if (preview) {
return <FileThumbnail field={field} record={record} item={preview} size={thumbnailSize} showName={thumbnailSize !== undefined}
+5 -2
View File
@@ -567,8 +567,11 @@ function RecordViewContent({
)
const viewId = `record-view-${tableMetaData.name}`
// the record's FILE_PREVIEW values, in section order, form the preview gallery of the page
const previewFields = [...new Set((tableMetaData.sections ?? []).flatMap((section) => section.fieldNames ?? []))]
.map((name) => tableMetaData.fields[name]).filter((field): field is QFieldMetaData => Boolean(field))
const previewFields = [...new Set(orderedSections.flatMap((section) => section.fieldNames ?? []))]
.map((name) => recordSectionField(tableMetaData, allTables, name))
.filter((field): field is QFieldMetaData => Boolean(field && !field.isHidden))
// Delivery-aware fields use their own verified transfer controls, never a raw gallery URL.
.filter((field) => !field.adornments?.some((adornment) => adornment.type === 'FILE_DOWNLOAD' && adornment.values?.deliveryModeField !== undefined))
const materialViewId = `record-view-${sanitizeQqqId(tableMetaData.name)}`
return (
@@ -38,6 +38,18 @@ const record = { tableName: 'lab', values: { id: 1, title: 'Visible title' } } a
afterEach(() => window.localStorage.clear())
describe('RecordViewSection widget headings', () => {
it('renders native virtual values while excluding hidden and absent fields', () => {
const metadata = { ...table, virtualFields: {
previewUrl: { name: 'previewUrl', label: 'Preview', type: 'STRING', isHidden: false },
hiddenUrl: { name: 'hiddenUrl', label: 'Hidden', type: 'STRING', isHidden: true },
} } as unknown as QTableMetaData
const section = { name: 'file', label: 'File', fieldNames: ['previewUrl', 'hiddenUrl', 'absent'], isHidden: false } as QTableSection
render(<RecordViewSection section={section} tableMetaData={metadata} record={{ ...record, values: { previewUrl: 'visible virtual value', hiddenUrl: 'must stay hidden' } }} />)
expect(screen.getByText('visible virtual value')).toBeVisible()
expect(screen.queryByText('must stay hidden')).not.toBeInTheDocument()
expect(screen.queryByText('undefined')).not.toBeInTheDocument()
})
it('leaves the heading to the widget card when the card has the same label', () => {
const section = { name: 'progress', label: 'Live Progress', fieldNames: [], widgetName: 'progressWidget', isHidden: false } as QTableSection
const widget = { name: 'progressWidget', label: 'Live Progress', hasPermission: true } as QWidgetMetaData
+5 -2
View File
@@ -39,7 +39,7 @@ import React, { useCallback, useLayoutEffect, useRef, useState, useSyncExternalS
import type { CodeLanguage } from '@/lib/utils/code-highlight'
import { cn } from '@/lib/utils/cn'
import { findCodeCompletions, type CodeCompletionMatch } from '@/lib/utils/code-completions'
import { findCodeCompletions, type CodeCompletion, type CodeCompletionMatch } from '@/lib/utils/code-completions'
import { HighlightedCode } from './HighlightedCode'
/** The text a Tab inserts. */
@@ -101,6 +101,8 @@ export interface CodeEditorProps {
language: CodeLanguage
/** Enable local code suggestions for script files. */
autocomplete?: boolean
/** Runtime-specific helper suggestions; omitted for the existing trusted API. */
apiCompletions?: readonly CodeCompletion[]
/** Read-only: selectable and focusable, never edited, never captures Tab. */
readOnly?: boolean
/** Visible rows. Defaults to 12. */
@@ -163,6 +165,7 @@ export function CodeEditor({
language,
readOnly = false,
autocomplete = false,
apiCompletions,
rows = 12,
ariaLabel,
describedBy,
@@ -200,7 +203,7 @@ export function CodeEditor({
setCompletion(null)
return
}
const match = findCodeCompletions(textarea.value, textarea.selectionStart, language, explicit)
const match = findCodeCompletions(textarea.value, textarea.selectionStart, language, explicit, apiCompletions)
if (!match) { setCompletion(null); return }
const before = textarea.value.slice(0, textarea.selectionStart).split('\n')
const metrics = metricsRef.current?.getBoundingClientRect()
@@ -0,0 +1,61 @@
/*
* Copyright 2026 QRun.IO, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
/** @file Creation stays separate from legacy workflows and avoids ambiguous retry. */
import React from 'react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { fireEvent, render, screen } from '@testing-library/react'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { AxiosError } from 'axios'
import { createScriptProject } from '@/lib/api/scripting'
import { CreateIsolatedScript } from './CreateIsolatedScript'
vi.mock('@/lib/api/scripting', () => ({ createScriptProject: vi.fn() }))
const discovery = vi.hoisted(() => ({ installed: true }))
vi.mock('@/lib/api/ui-api-extensions', () => ({ useUiApiExtensionInstalled: () => discovery.installed }))
vi.mock('./IsolatedScriptEditor', () => ({ IsolatedScriptEditor: ({ scriptId }: { scriptId: number }) => <div>Isolated project {scriptId}</div> }))
/** Mount a fresh query cache. */
function mount() {
return render(<QueryClientProvider client={new QueryClient()}><CreateIsolatedScript /></QueryClientProvider>)
}
describe('isolated project creation', () => {
beforeEach(() => { vi.clearAllMocks(); discovery.installed = true })
it('opens the isolated editor only after successful semantic creation', async () => {
vi.mocked(createScriptProject).mockResolvedValue(17)
mount()
fireEvent.change(screen.getByLabelText('Project name'), { target: { value: 'Receiving context' } })
fireEvent.click(screen.getByRole('button', { name: 'Create TypeScript project' }))
await screen.findByText('Isolated project 17')
expect(createScriptProject).toHaveBeenCalledWith('Receiving context')
})
it('blocks another creation after an uncertain transport result', async () => {
vi.mocked(createScriptProject).mockRejectedValue(new AxiosError('timeout', 'ECONNABORTED'))
mount()
fireEvent.change(screen.getByLabelText('Project name'), { target: { value: 'Receiving context' } })
fireEvent.click(screen.getByRole('button', { name: 'Create TypeScript project' }))
await screen.findByRole('status')
expect(screen.getByRole('button', { name: 'Create TypeScript project' })).toBeDisabled()
expect(createScriptProject).toHaveBeenCalledTimes(1)
})
it('does not offer source intake without the scripting extension', () => {
discovery.installed = false
mount()
expect(screen.queryByRole('button')).toBeNull()
})
})
@@ -0,0 +1,55 @@
/*
* Copyright 2026 QRun.IO, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
/** @file Explicit isolated project creation through the semantic scripting API. */
'use client'
import React, { useId, useState } from 'react'
import { useMutation } from '@tanstack/react-query'
import { isAxiosError } from 'axios'
import { createScriptProject } from '@/lib/api/scripting'
import { useUiApiExtensionInstalled } from '@/lib/api/ui-api-extensions'
import { HANDLES_OWN_ERRORS } from '@/lib/query-client'
import { getErrorMessage } from '@/lib/utils/error-utils'
import { IsolatedScriptEditor } from './IsolatedScriptEditor'
import { ALERT_CLASS, INPUT_CLASS, PRIMARY_BUTTON_CLASS } from './ScriptPanels'
/**
* Create a separate source project and immediately open the shared IDE.
* @returns Extension-gated creation panel or the newly created project editor.
*/
export function CreateIsolatedScript() {
const installed = useUiApiExtensionInstalled('scripting')
const inputId = useId()
const [name, setName] = useState('')
const [uncertain, setUncertain] = useState(false)
const create = useMutation({
mutationFn: () => createScriptProject(name), retry: false, meta: HANDLES_OWN_ERRORS,
onError: (error) => { if (isAxiosError(error) && !error.response) setUncertain(true) },
})
if (!installed) return null
if (create.data) return <IsolatedScriptEditor scriptId={create.data} label={name.trim()} />
return <section aria-label="Create TypeScript project" className="space-y-3 rounded-xl border border-border bg-card p-6">
<h2 className="text-base font-semibold">New TypeScript project</h2>
<p className="text-sm text-muted-foreground">Create a source project, then save, build, test and publish each revision explicitly.</p>
<form className="flex flex-wrap items-end gap-3" onSubmit={(event) => { event.preventDefault(); if (!uncertain && !create.isPending && name.trim()) create.mutate() }}>
<div><label htmlFor={inputId} className="block text-sm">Project name</label><input id={inputId} className={INPUT_CLASS} required maxLength={200} value={name} disabled={uncertain || create.isPending} onChange={(event) => setName(event.target.value)} /></div>
<button type="submit" className={PRIMARY_BUTTON_CLASS} disabled={!name.trim() || uncertain || create.isPending}>{create.isPending ? 'Creating…' : 'Create TypeScript project'}</button>
</form>
{uncertain ? <p role="status">The server may have created this project. Check the Scripts table before trying again.</p> : create.error && <p role="alert" className={ALERT_CLASS}>{getErrorMessage(create.error)}</p>}
</section>
}
@@ -0,0 +1,158 @@
/*
* Copyright 2026 QRun.IO, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
/** @file Isolated source workflow tests; no real compiler or legacy script process. */
import React from 'react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { AxiosError } from 'axios'
import * as api from '@/lib/api/scripting'
import { IsolatedScriptEditor } from './IsolatedScriptEditor'
vi.mock('@/lib/api/scripting', async (original) => {
const actual = await original<typeof import('@/lib/api/scripting')>()
return { ...actual, getScriptProject: vi.fn(), getScriptDraft: vi.fn(), saveScriptDraft: vi.fn(),
compileScriptRevision: vi.fn(), testScriptContext: vi.fn(), publishScriptBuild: vi.fn(), getScriptApiTypes: vi.fn(), importScriptDraft: vi.fn() }
})
const discovery = vi.hoisted(() => ({ installed: true }))
vi.mock('@/lib/api/ui-api-extensions', () => ({ useUiApiExtensionInstalled: () => discovery.installed }))
vi.mock('./TypeScriptSourceEditor', async () => {
const { CodeEditor } = await import('./CodeEditor')
return { TypeScriptSourceEditor: (props: import('./TypeScriptSourceEditor').TypeScriptSourceEditorProps) => <CodeEditor
id="fixture-source" ariaLabel={`TypeScript source: ${props.active}`} language="typescript" autocomplete
apiCompletions={[{ text: 'qqq.query(', description: 'Authorized reads' }]}
value={props.files.find((file) => file.path === props.active)!.content} readOnly={props.readOnly} onChange={props.onChange} /> }
})
const PROJECT: api.ScriptProject = {
scriptId: 7, executionMode: 'ISOLATED', latestRevisionId: 10, revisions: [{ id: 10, sequence: 1, message: 'Initial', author: 'alice' }],
builds: [], publications: [], actions: ['draft', 'check', 'test', 'publish'], workerAvailable: true, importAvailable: false,
}
/** Mount with an isolated query cache for every editor test. */
function mount() {
const client = new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } } })
return render(<QueryClientProvider client={client}><IsolatedScriptEditor scriptId={7} label="Context script" /></QueryClientProvider>)
}
describe('isolated source editor', () => {
beforeEach(() => {
vi.clearAllMocks()
discovery.installed = true
vi.mocked(api.getScriptProject).mockResolvedValue(PROJECT)
vi.mocked(api.getScriptDraft).mockResolvedValue({ revisionId: 10, sourceSha256: 'a'.repeat(64), files: [{ path: 'src/index.ts', content: 'export async function main() { return {}; }' }] })
vi.mocked(api.compileScriptRevision).mockResolvedValue({ buildId: 20, success: true, artifactId: 30, artifactVersion: 1, diagnostics: [] })
vi.mocked(api.testScriptContext).mockResolvedValue({ executionId: 40, status: 'OK', message: null, contextJson: '{"hello":"world"}' })
vi.mocked(api.publishScriptBuild).mockResolvedValue(50)
})
it('keeps build, context test and publication distinct, then invalidates the build on edits', async () => {
const user = userEvent.setup()
mount()
await screen.findByRole('textbox', { name: 'TypeScript source: src/index.ts' })
expect(screen.getByRole('button', { name: 'Test context' })).toBeDisabled()
expect(screen.getByRole('button', { name: 'Publish tested build' })).toBeDisabled()
await user.click(screen.getByRole('button', { name: 'Build saved revision' }))
await waitFor(() => expect(screen.getByRole('button', { name: 'Test context' })).toBeEnabled())
expect(api.compileScriptRevision).toHaveBeenCalledWith(7, 10, 'src/index.ts', [], expect.any(AbortSignal))
expect(api.publishScriptBuild).not.toHaveBeenCalled()
await user.click(screen.getByRole('button', { name: 'Test context' }))
await waitFor(() => expect(screen.getByRole('button', { name: 'Publish tested build' })).toBeEnabled())
await user.click(screen.getByRole('button', { name: 'Publish tested build' }))
await screen.findByText('Published immutable version 50.')
expect(api.publishScriptBuild).toHaveBeenCalledWith(7, 20, '', expect.any(AbortSignal))
fireEvent.change(screen.getByRole('textbox', { name: 'TypeScript source: src/index.ts' }), { target: { value: 'export const changed = true;' } })
expect(screen.getByRole('button', { name: 'Build saved revision' })).toBeDisabled()
expect(screen.getByRole('button', { name: 'Publish tested build' })).toBeDisabled()
expect(screen.getByRole('button', { name: 'Save draft' })).toBeEnabled()
})
it('saves the observed base revision and never invokes publication automatically', async () => {
const user = userEvent.setup()
vi.mocked(api.saveScriptDraft).mockRejectedValue(new Error('Source changed; reload'))
mount()
const editor = await screen.findByRole('textbox', { name: 'TypeScript source: src/index.ts' })
fireEvent.change(editor, { target: { value: 'new source' } })
await user.click(screen.getByRole('button', { name: 'Save draft' }))
await screen.findByText('Source changed; reload')
expect(api.saveScriptDraft).toHaveBeenCalledWith(7, 10, [{ path: 'src/index.ts', content: 'new source' }], '', expect.any(AbortSignal))
expect(api.compileScriptRevision).not.toHaveBeenCalled()
expect(api.publishScriptBuild).not.toHaveBeenCalled()
expect(editor).toHaveValue('new source')
})
it('fails closed on missing extension and disables unavailable workers and unauthorized actions', async () => {
discovery.installed = false
const first = mount()
expect(screen.getByText(/requires the scripting API extension/)).toBeVisible()
expect(api.getScriptProject).not.toHaveBeenCalled()
first.unmount()
discovery.installed = true
vi.mocked(api.getScriptProject).mockResolvedValue({ ...PROJECT, workerAvailable: false, actions: [] })
mount()
expect(await screen.findByRole('button', { name: 'Build saved revision' })).toBeDisabled()
expect(screen.getByRole('button', { name: 'Save draft' })).toBeDisabled()
expect(screen.getByRole('textbox', { name: 'TypeScript source: src/index.ts' })).toHaveAttribute('readonly')
})
it('suggests the isolated read broker without offering legacy write helpers', async () => {
const user = userEvent.setup()
mount()
const editor = await screen.findByRole('textbox', { name: 'TypeScript source: src/index.ts' })
fireEvent.change(editor, { target: { value: 'qqq.qu' } })
await user.click(editor)
await user.keyboard('{End}{Control>} {/Control}')
await waitFor(() => expect(screen.getByRole('option', { name: /qqq.query/ })).toBeVisible())
expect(screen.queryByRole('option', { name: /api.insert/ })).not.toBeInTheDocument()
})
it('keeps timed-out mutations uncertain after refreshing history instead of retrying them', async () => {
const user = userEvent.setup()
vi.mocked(api.compileScriptRevision).mockRejectedValue(new AxiosError('Timeout', 'ECONNABORTED'))
mount()
await screen.findByRole('textbox', { name: 'TypeScript source: src/index.ts' })
await user.click(screen.getByRole('button', { name: 'Build saved revision' }))
await screen.findByText(/Server work may still finish/)
expect(screen.getByRole('button', { name: 'Build saved revision' })).toBeDisabled()
expect(screen.getByRole('combobox', { name: 'Source revision' })).toBeDisabled()
await user.click(screen.getByRole('button', { name: 'Refresh server history' }))
await waitFor(() => expect(api.getScriptProject).toHaveBeenCalledTimes(2))
expect(screen.getByRole('button', { name: 'Build saved revision' })).toBeDisabled()
expect(api.compileScriptRevision).toHaveBeenCalledTimes(1)
expect(api.publishScriptBuild).not.toHaveBeenCalled()
})
it('imports only an explicit immutable commit into the normal draft lifecycle', async () => {
const user = userEvent.setup()
vi.mocked(api.getScriptProject).mockResolvedValue({ ...PROJECT, importAvailable: true })
vi.mocked(api.importScriptDraft).mockResolvedValue(10)
mount()
await screen.findByRole('textbox', { name: 'TypeScript source: src/index.ts' })
await user.click(screen.getByText('Import / sync from an exact GitHub commit'))
await user.type(screen.getByRole('textbox', { name: 'GitHub repository' }), 'acme/scripts')
await user.type(screen.getByRole('textbox', { name: 'Commit SHA' }), 'main')
expect(screen.getByRole('button', { name: 'Import commit as draft' })).toBeDisabled()
fireEvent.change(screen.getByRole('textbox', { name: 'Commit SHA' }), { target: { value: 'a'.repeat(40) } })
await user.type(screen.getByRole('textbox', { name: 'Source subdirectory' }), 'scripts')
await user.click(screen.getByRole('button', { name: 'Import commit as draft' }))
await waitFor(() => expect(api.importScriptDraft).toHaveBeenCalledWith(7, 10, 'acme/scripts', 'a'.repeat(40), 'scripts', expect.any(AbortSignal)))
expect(api.compileScriptRevision).not.toHaveBeenCalled()
expect(api.publishScriptBuild).not.toHaveBeenCalled()
})
})
@@ -0,0 +1,243 @@
/*
* Copyright 2026 QRun.IO, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
/** @file Source-first editor for isolated TypeScript projects; every lifecycle action is explicit. */
'use client'
import React, { lazy, Suspense, useEffect, useRef, useState } from 'react'
import { useMutation, useQuery } from '@tanstack/react-query'
import { z } from 'zod'
import { isAxiosError } from 'axios'
import {
compileScriptRevision, getScriptApiTypes, getScriptBuild, getScriptDraft, getScriptProject, importScriptDraft,
publishScriptBuild, saveScriptDraft, scriptCapabilitySchema, testScriptContext,
type ScriptBuild, type ScriptContextResult, type ScriptProject, type ScriptSourceFile, type ScriptProvenance,
} from '@/lib/api/scripting'
import { useUiApiExtensionInstalled } from '@/lib/api/ui-api-extensions'
import { HANDLES_OWN_ERRORS } from '@/lib/query-client'
import { getErrorMessage } from '@/lib/utils/error-utils'
import { CodeEditor } from './CodeEditor'
import { ScriptHookPanel } from './ScriptHookPanel'
import type { IdeDefinition } from '@/lib/scripting/typescript-session'
import { ALERT_CLASS, BUTTON_CLASS, INPUT_CLASS, PRIMARY_BUTTON_CLASS } from './ScriptPanels'
const INITIAL_SOURCE = 'import type { Context, Qqq } from "@qqq/sdk";\n\nexport async function main(context: Context, qqq: Qqq) {\n return {};\n}\n'
const LanguageEditor = lazy(() => import('./TypeScriptSourceEditor').then((module) => ({ default: module.TypeScriptSourceEditor })))
/** Properties for a shared isolated Script project editor. */
export interface IsolatedScriptEditorProps {
/** Core project identity; no caller-supplied runtime or actor. */
scriptId: string | number
/** Accessible panel heading. */
label: string
}
/**
* Discover the optional extension and load authorized project/source views.
* @param props - Project identity and label.
* @returns Source editor or a fail-closed availability message.
*/
export function IsolatedScriptEditor({ scriptId, label }: IsolatedScriptEditorProps) {
const installed = useUiApiExtensionInstalled('scripting')
const [selected, setSelected] = useState<number | null>(null)
const project = useQuery({
queryKey: ['scripting', scriptId, 'project'], queryFn: ({ signal }) => getScriptProject(scriptId, signal),
enabled: installed, retry: false, meta: HANDLES_OWN_ERRORS,
})
const revisionId = selected ?? project.data?.latestRevisionId ?? null
const draft = useQuery({
queryKey: ['scripting', scriptId, 'revision', revisionId],
queryFn: ({ signal }) => getScriptDraft(scriptId, revisionId!, signal),
enabled: installed && revisionId !== null, retry: false, meta: HANDLES_OWN_ERRORS,
})
if (!installed) return <p role="status">The isolated scripting editor requires the scripting API extension.</p>
if (project.error || draft.error) return <p role="alert" className={ALERT_CLASS}>{getErrorMessage(project.error ?? draft.error)}</p>
if (!project.data || (revisionId !== null && !draft.data)) return <p role="status">Loading script project…</p>
if (project.data.executionMode !== 'ISOLATED') return <p role="alert">This editor requires an isolated script project.</p>
return <SourceWorkspace
key={`${scriptId}:${revisionId ?? 'new'}`}
scriptId={scriptId} label={label} project={project.data} revisionId={revisionId}
initialFiles={draft.data?.files ?? [{ path: 'src/index.ts', content: INITIAL_SOURCE }]}
provenance={draft.data?.provenance ?? null}
onSelect={setSelected}
onSaved={async (id) => { await project.refetch(); setSelected(id) }}
onRefresh={async () => { await project.refetch() }}
/>
}
interface WorkspaceProps extends IsolatedScriptEditorProps {
project: ScriptProject
revisionId: number | null
initialFiles: ScriptSourceFile[]
provenance: ScriptProvenance | null
onSelect: (id: number) => void
onSaved: (id: number) => Promise<void>
onRefresh: () => Promise<void>
}
/**
* Keep unsaved files local while all durable state lives in the shared registry.
* @param props - Authorized snapshot and refresh callbacks.
* @returns Explicit draft/build/test/publish controls and diagnostics.
*/
function SourceWorkspace({ scriptId, label, project, revisionId, initialFiles, provenance, onSelect, onSaved, onRefresh }: WorkspaceProps) {
const [files, setFiles] = useState(initialFiles)
const [baseRevisionId] = useState(project.latestRevisionId)
const [active, setActive] = useState(initialFiles[0]?.path ?? '')
const [newPath, setNewPath] = useState('')
const [entryPoint, setEntryPoint] = useState(initialFiles[0]?.path ?? 'src/index.ts')
const [note, setNote] = useState('')
const [capabilityText, setCapabilityText] = useState('[]')
const [contextText, setContextText] = useState('{}')
const [build, setBuild] = useState<ScriptBuild | null>(null)
const [test, setTest] = useState<ScriptContextResult | null>(null)
const [declarations, setDeclarations] = useState('')
const [notice, setNotice] = useState('')
const [uncertain, setUncertain] = useState(false)
const [repository, setRepository] = useState(provenance?.repository ?? '')
const [commitSha, setCommitSha] = useState(provenance?.commitSha ?? '')
const [subdirectory, setSubdirectory] = useState(provenance?.subdirectory ?? '')
const [selection, setSelection] = useState<IdeDefinition | null>(null)
const controller = useRef<AbortController | null>(null)
const dirty = revisionId === null || JSON.stringify(files) !== JSON.stringify(initialFiles)
const file = files.find((candidate) => candidate.path === active) ?? files[0]
useEffect(() => {
const warn = (event: BeforeUnloadEvent) => { if (dirty) { event.preventDefault(); event.returnValue = '' } }
window.addEventListener('beforeunload', warn)
return () => window.removeEventListener('beforeunload', warn)
}, [dirty])
useEffect(() => () => controller.current?.abort(), [])
const operation = useMutation({
mutationFn: async (work: (signal: AbortSignal) => Promise<void>) => {
controller.current = new AbortController()
setNotice('')
await work(controller.current.signal)
},
onError: (error) => {
if (isAxiosError(error) && !error.response) setUncertain(true)
},
meta: HANDLES_OWN_ERRORS,
})
const busy = operation.isPending
const disabled = busy || uncertain
const can = (action: string) => project.actions.includes(action)
const capabilities = () => z.array(scriptCapabilitySchema).max(20).parse(JSON.parse(capabilityText))
const changeFiles = (next: ScriptSourceFile[]) => { setFiles(next); setBuild(null); setTest(null) }
return <section className="space-y-4 rounded-xl border border-border bg-card p-4" aria-label={label}>
<div className="flex flex-wrap items-center justify-between gap-3">
<div><h3 className="text-lg font-semibold">{label}</h3><p className="text-sm text-muted-foreground">Isolated TypeScript · {dirty ? 'Unsaved draft' : `Revision ${revisionId}`}</p></div>
<label className="text-sm">Source revision <select className={INPUT_CLASS} value={revisionId ?? ''} disabled={disabled}
onChange={(event) => {
if (!dirty || window.confirm('Discard unsaved source changes?')) onSelect(Number(event.target.value))
}}>
{revisionId === null && <option value="">New draft</option>}
{project.revisions.map((item) => <option key={item.id} value={item.id}>#{item.sequence ?? item.id} {item.message ?? ''}</option>)}
</select></label>
</div>
{!project.workerAvailable && <p role="status" className="text-sm">The isolated compiler/runtime is unavailable. Source can be reviewed and saved; building and testing require the configured worker.</p>}
<p className="text-sm text-muted-foreground">Save creates a draft. Build compiles saved files. Test runs that build with the context below. Publish creates an immutable version; it does not attach a record hook.</p>
{provenance && <p className="break-all text-sm" aria-label="Imported source provenance">Imported {provenance.repository}@{provenance.commitSha}{provenance.subdirectory ? ` / ${provenance.subdirectory}` : ''} · by {provenance.importedBy} · SHA-256 {provenance.sourceSha256}</p>}
{project.importAvailable && <details><summary className="cursor-pointer text-sm font-medium">Import / sync from an exact GitHub commit</summary>
<p className="my-2 text-sm text-muted-foreground">Select a directory containing only TypeScript source. Import creates a new draft; review it before building, testing and publishing. Repository access is managed by the server.</p>
<div className="grid gap-3 md:grid-cols-3">
<label className="text-sm">GitHub repository<input className={INPUT_CLASS} value={repository} disabled={disabled || !can('draft')} maxLength={201} placeholder="owner/repository" onChange={(event) => setRepository(event.target.value)} /></label>
<label className="text-sm">Commit SHA<input className={INPUT_CLASS} value={commitSha} disabled={disabled || !can('draft')} maxLength={40} onChange={(event) => setCommitSha(event.target.value)} /></label>
<label className="text-sm">Source subdirectory<input className={INPUT_CLASS} value={subdirectory} disabled={disabled || !can('draft')} maxLength={200} placeholder="scripts" onChange={(event) => setSubdirectory(event.target.value)} /></label>
</div>
<button type="button" className={BUTTON_CLASS} disabled={disabled || !can('draft') || !repository || !/^[a-f0-9]{40}$/.test(commitSha) || (dirty && revisionId !== null)}
onClick={() => operation.mutate(async (signal) => onSaved(await importScriptDraft(scriptId, baseRevisionId, repository, commitSha, subdirectory || null, signal)))}>Import commit as draft</button>
{dirty && revisionId !== null && <p className="text-sm">Save or discard local source edits before importing.</p>}
</details>}
<div className="flex flex-wrap items-end gap-2">
<label className="text-sm">File <select className={INPUT_CLASS} value={file?.path ?? ''} onChange={(event) => setActive(event.target.value)}>
{files.map((item) => <option key={item.path}>{item.path}</option>)}
</select></label>
<label className="text-sm">New file path <input className={INPUT_CLASS} value={newPath} disabled={disabled || !can('draft')} onChange={(event) => setNewPath(event.target.value)} placeholder="src/helper.ts" /></label>
<button type="button" className={BUTTON_CLASS} disabled={disabled || !can('draft') || !newPath || files.some((item) => item.path === newPath)}
onClick={() => { changeFiles([...files, { path: newPath, content: '' }]); setActive(newPath); setNewPath('') }}>Add file</button>
<button type="button" className={BUTTON_CLASS} disabled={disabled || !can('draft') || files.length < 2}
onClick={() => { const next = files.filter((item) => item.path !== file.path); changeFiles(next); setActive(next[0].path) }}>Remove file</button>
</div>
<Suspense fallback={<p role="status">Loading TypeScript editor…</p>}>
<LanguageEditor files={files} active={file.path} readOnly={disabled || !can('draft')} declarations={declarations} selection={selection}
onNavigate={(target) => { if (files.some((item) => item.path === target.file)) { setActive(target.file); setSelection(target) } }}
onChange={(content) => changeFiles(files.map((item) => item.path === file.path ? { ...item, content } : item))} />
</Suspense>
<div className="grid gap-3 md:grid-cols-2">
<label className="text-sm">Entry file <input className={INPUT_CLASS} value={entryPoint} disabled={disabled} onChange={(event) => { setEntryPoint(event.target.value); setBuild(null); setTest(null) }} /></label>
<label className="text-sm">Revision / publication note <input className={INPUT_CLASS} value={note} maxLength={2000} disabled={disabled} onChange={(event) => setNote(event.target.value)} /></label>
</div>
<details><summary className="cursor-pointer text-sm font-medium">Read capabilities and scoped API types</summary>
<p className="my-2 text-sm text-muted-foreground">Each declaration names operation READ, table, fields, maxRows and maxCalls. Reads also require your current permissions.</p>
<CodeEditor id={`script-capabilities-${scriptId}`} ariaLabel="Read capability declarations" language="json" rows={6} value={capabilityText}
readOnly={disabled} onChange={(value) => { setCapabilityText(value); setBuild(null); setTest(null); setDeclarations('') }} />
<button type="button" className={BUTTON_CLASS} disabled={disabled} onClick={() => operation.mutate(async (signal) => setDeclarations(await getScriptApiTypes(scriptId, capabilities(), signal)))}>Load scoped API types</button>
{declarations && <CodeEditor id={`script-types-${scriptId}`} ariaLabel="Scoped API declarations" language="typescript" rows={10} value={declarations} readOnly />}
</details>
<div className="flex flex-wrap gap-2">
<button type="button" className={PRIMARY_BUTTON_CLASS} disabled={disabled || !can('draft') || !dirty}
onClick={() => operation.mutate(async (signal) => onSaved(await saveScriptDraft(scriptId, baseRevisionId, files, note, signal)))}>Save draft</button>
<button type="button" className={BUTTON_CLASS} disabled={disabled || dirty || revisionId === null || !can('check') || !project.workerAvailable}
onClick={() => operation.mutate(async (signal) => { setBuild(await compileScriptRevision(scriptId, revisionId!, entryPoint, capabilities(), signal)); setTest(null); await onRefresh() })}>Build saved revision</button>
<button type="button" className={BUTTON_CLASS} disabled={disabled || dirty || !build?.success || !can('test') || !project.workerAvailable}
onClick={() => operation.mutate(async (signal) => {
const input = JSON.parse(contextText) as unknown
if (!input || typeof input !== 'object' || Array.isArray(input)) throw new Error('Test context must be a JSON object')
setTest(await testScriptContext(scriptId, build!.buildId, input as Record<string, unknown>, capabilities(), signal))
})}>Test context</button>
<button type="button" className={BUTTON_CLASS} disabled={disabled || dirty || !build?.success || test?.status !== 'OK' || !can('publish')}
onClick={() => operation.mutate(async (signal) => { const id = await publishScriptBuild(scriptId, build!.buildId, note, signal); setNotice(`Published immutable version ${id}.`); await onRefresh() })}>Publish tested build</button>
{busy && <button type="button" className={BUTTON_CLASS} onClick={() => { controller.current?.abort(); setUncertain(true) }}>Stop waiting</button>}
{uncertain && <button type="button" className={BUTTON_CLASS} disabled={busy} onClick={() => operation.mutate(async () => { await onRefresh() })}>Refresh server history</button>}
</div>
<ScriptHookPanel scriptId={scriptId} project={project} build={dirty ? null : build} disabled={disabled} capabilities={capabilities}
run={(work) => operation.mutate(work)} onTested={setTest} />
{uncertain && <p role="status">Observation stopped. Server work may still finish. Refresh and inspect history, then reopen the editor before another mutation.</p>}
{operation.error && !uncertain && <p role="alert" className={ALERT_CLASS}>{getErrorMessage(operation.error)}</p>}
{notice && <p role="status">{notice}</p>}
{build && <div aria-label="Build diagnostics">
<p className="font-medium">Build {build.buildId}: {build.success ? 'Compiled' : 'Failed'}</p>
<ul>{build.diagnostics.map((diagnostic, index) => <li key={index}>
<button type="button" className="text-left underline" onClick={() => {
const source = files.find((item) => item.path === diagnostic.file)
if (!source) return
const lines = source.content.split('\n')
const line = Math.max(0, Math.min(lines.length - 1, diagnostic.line - 1))
const offset = lines.slice(0, line).reduce((sum, text) => sum + text.length + 1, 0) + Math.max(0, Math.min(lines[line].length, diagnostic.column - 1))
setActive(source.path); setSelection({ file: source.path, from: offset, to: offset })
}}>{diagnostic.file}:{diagnostic.line}:{diagnostic.column}</button>
{' '}{diagnostic.code}: {diagnostic.message}
</li>)}</ul>
</div>}
<details open><summary className="cursor-pointer text-sm font-medium">Context test input and result</summary>
<CodeEditor id={`script-context-${scriptId}`} ariaLabel="Context test input" language="json" rows={6} value={contextText} readOnly={disabled}
onChange={(value) => { setContextText(value); setTest(null) }} />
{test && <div role="status"><p>Execution {test.executionId}: {test.status}</p><pre className="overflow-auto whitespace-pre-wrap text-sm">{test.contextJson ?? test.message}</pre></div>}
</details>
<details><summary className="cursor-pointer text-sm font-medium">Recent builds and publications (up to 100 each)</summary>
<ul>{project.builds.map((item) => <li key={item.id}>Build {item.id}: {item.status} · revision {item.revisionId}
{item.revisionId === revisionId && <button type="button" className={BUTTON_CLASS} disabled={disabled || dirty}
onClick={() => operation.mutate(async (signal) => { setBuild(await getScriptBuild(scriptId, item.id, signal)); setTest(null) })}>Load diagnostics</button>}
</li>)}</ul>
<ul>{project.publications.map((item) => <li key={item.id}>Publication {item.id} · version {item.version} · {item.actor}</li>)}</ul>
</details>
</section>
}
@@ -211,7 +211,7 @@ describe('ScriptEditorDialog', () => {
await user.click(within(dialog).getByRole('button', { name: 'Submit' }))
await waitFor(() => expect(testScript).toHaveBeenCalledWith(expect.objectContaining({
scriptId: 7, inputValues: { who: 'Ada' }, files: expect.objectContaining({ 'main.js': 'unsaved();' }),
})))
}), { signal: expect.any(AbortSignal) }))
expect(await within(dialog).findByText('ran')).toBeInTheDocument()
expect(storeScriptRevision).not.toHaveBeenCalled()
})
@@ -0,0 +1,174 @@
/*
* Copyright 2026 QRun.IO, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
/** @file Hook administration keeps dry runs, publication and active bindings distinct. */
import React, { useState } from 'react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import * as api from '@/lib/api/scripting'
import { ScriptHookPanel } from './ScriptHookPanel'
vi.mock('@/lib/api/scripting', async (original) => {
const actual = await original<typeof import('@/lib/api/scripting')>()
return { ...actual, getScriptHooks: vi.fn(), createScriptBinding: vi.fn(), setScriptBindingEnabled: vi.fn(), testScriptHook: vi.fn() }
})
vi.mock('./CodeEditor', () => ({ CodeEditor: (props: { ariaLabel: string; value: string; readOnly?: boolean; onChange?: (value: string) => void }) => <textarea
aria-label={props.ariaLabel} value={props.value} readOnly={props.readOnly} onChange={(event) => props.onChange?.(event.target.value)} /> }))
const PROJECT: api.ScriptProject = { scriptId: 7, executionMode: 'ISOLATED', latestRevisionId: 10, revisions: [], builds: [],
publications: [{ id: 50, revisionId: 10, artifactId: 30, version: 2, status: null, actor: 'alice', createdAt: null }],
actions: ['view', 'bind', 'test'], workerAvailable: true, importAvailable: false }
const VIEW: api.ScriptHookView = { targets: [{ tableName: 'orders', label: 'Orders', phases: [{ event: 'PRE_UPDATE', patchableFields: ['status'] }, { event: 'POST_DELETE', patchableFields: [] }] }],
bindings: [{ id: 40, name: 'Production', tableName: 'orders', event: 'PRE_UPDATE', publicationId: 2, enabled: true, grants: [], patchableFields: ['status'] }], historyLimited: true, nextBindingId: 5 }
const RESULT: api.ScriptHookResult = { executionId: 60, status: 'OK', message: null, contextJson: null, hook: { action: 'PATCH', message: null, patch: { status: 'SIMULATED' } } }
const tested = vi.fn()
/**
* Mirror the editor's shared cancellation/error runner without executing script text.
* @param props - Current authorized project.
* @returns Isolated hook panel fixture.
*/
function Harness({ project }: { project: api.ScriptProject }) {
const [busy, setBusy] = useState(false)
const [error, setError] = useState('')
return <><ScriptHookPanel scriptId={7} project={project} build={{ buildId: 20, success: true, artifactId: 30, artifactVersion: 1, diagnostics: [] }}
disabled={busy} capabilities={() => []} onTested={tested} run={(work) => {
setBusy(true)
void work(new AbortController().signal).catch((failure: Error) => setError(failure.message)).finally(() => setBusy(false))
}} />{error && <p role="alert">{error}</p>}</>
}
/**
* Start an isolated React Query cache for one project fixture.
* @param project - Project permissions and runtime availability.
* @returns Rendered hook controls.
*/
function mount(project = PROJECT) {
const client = new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } } })
return render(<QueryClientProvider client={client}><Harness project={project} /></QueryClientProvider>)
}
describe('record hook controls', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.mocked(api.getScriptHooks).mockImplementation(async (_project, _signal, cursor) => cursor === 5
? { ...VIEW, bindings: [{ ...VIEW.bindings[0], id: 1, name: 'Old publication', publicationId: 1 }], nextBindingId: null, historyLimited: false } : VIEW)
vi.mocked(api.testScriptHook).mockResolvedValue(RESULT)
vi.mocked(api.createScriptBinding).mockResolvedValue(99)
vi.mocked(api.setScriptBindingEnabled).mockResolvedValue(40)
})
it('loads on demand and simulates the selected record without activating a binding', async () => {
const user = userEvent.setup()
mount()
expect(api.getScriptHooks).not.toHaveBeenCalled()
await user.click(screen.getByText('Record hooks: test and bindings'))
await screen.findByRole('combobox', { name: 'Hook table' })
await user.type(screen.getByRole('textbox', { name: 'Selected record ID' }), '42')
fireEvent.change(screen.getByRole('textbox', { name: 'Proposed hook test changes' }), { target: { value: '{"status":"PROPOSED"}' } })
await user.click(screen.getByRole('button', { name: 'Test hook without writes' }))
await screen.findByText('Hook dry run: OK. No record changes were applied.')
expect(api.testScriptHook).toHaveBeenCalledWith(7, 20, { tableName: 'orders', event: 'PRE_UPDATE', recordId: '42', changes: { status: 'PROPOSED' } }, expect.any(AbortSignal))
expect(tested).toHaveBeenCalledWith(RESULT)
expect(api.createScriptBinding).not.toHaveBeenCalled()
expect(api.setScriptBindingEnabled).not.toHaveBeenCalled()
expect(screen.getByRole('textbox', { name: 'Simulated hook decision' })).toHaveValue(JSON.stringify(RESULT.hook, null, 2))
})
it.each(['AFTER_INSERT', 'AFTER_UPDATE', 'AFTER_DELETE'] as const)('explains %s and dry-runs only metadata-authorized observations', async (phase) => {
const user = userEvent.setup()
vi.mocked(api.getScriptHooks).mockResolvedValue({ ...VIEW, targets: [{ ...VIEW.targets[0], phases: [{ event: phase, patchableFields: [] }] }] })
mount()
await user.click(screen.getByText('Record hooks: test and bindings'))
expect(await screen.findByRole('combobox', { name: 'Hook event' })).toHaveValue(phase)
expect(screen.queryByRole('textbox', { name: 'Proposed hook test changes' })).not.toBeInTheDocument()
expect(screen.getByText(/AFTER scripts may only return ALLOW/)).toHaveTextContent(phase === 'AFTER_DELETE' ? 'never deleted record values' : 'read the currently authorized record at delivery time')
await user.type(screen.getByRole('textbox', { name: 'Selected record ID' }), '42')
await user.click(screen.getByRole('button', { name: 'Test hook without writes' }))
await waitFor(() => expect(api.testScriptHook).toHaveBeenCalledWith(7, 20, { tableName: 'orders', event: phase, recordId: '42', changes: {} }, expect.any(AbortSignal)))
expect(api.createScriptBinding).not.toHaveBeenCalled()
})
it('creates a pinned enabled binding only after its explicit action and permits disabling it', async () => {
const user = userEvent.setup()
mount()
await user.click(screen.getByText('Record hooks: test and bindings'))
await screen.findByRole('textbox', { name: 'Binding name' })
await user.type(screen.getByRole('textbox', { name: 'Binding name' }), 'Warehouse')
await user.click(screen.getByRole('checkbox', { name: 'status' }))
await user.click(screen.getByRole('button', { name: 'Create enabled binding' }))
await screen.findByText('Binding 99 is enabled.')
expect(api.createScriptBinding).toHaveBeenCalledWith(7, { name: 'Warehouse', tableName: 'orders', event: 'PRE_UPDATE', publicationId: 50, capabilities: [], patchableFields: ['status'] }, expect.any(AbortSignal))
await user.click(screen.getByRole('button', { name: 'Disable Production' }))
await waitFor(() => expect(api.setScriptBindingEnabled).toHaveBeenCalledWith(7, 40, false, expect.any(AbortSignal)))
})
it('pages retained bindings and can disable one pinned to an old publication', async () => {
const user = userEvent.setup()
mount()
await user.click(screen.getByText('Record hooks: test and bindings'))
await user.click(await screen.findByRole('button', { name: 'Older bindings' }))
await user.click(await screen.findByRole('button', { name: 'Disable Old publication' }))
await waitFor(() => expect(api.setScriptBindingEnabled).toHaveBeenCalledWith(7, 1, false, expect.any(AbortSignal)))
expect(api.getScriptHooks).toHaveBeenCalledWith(7, expect.any(AbortSignal), 5)
await user.click(screen.getByRole('button', { name: 'First binding page' }))
await screen.findByRole('button', { name: 'Disable Production' })
})
it('returns to the newest page after creating from an older page and identifies controls', async () => {
const user = userEvent.setup()
const rendered = mount()
await user.click(screen.getByText('Record hooks: test and bindings'))
await user.click(await screen.findByRole('button', { name: 'Older bindings' }))
await screen.findByRole('button', { name: 'Disable Old publication' })
await user.type(screen.getByRole('textbox', { name: 'Binding name' }), 'New binding')
await user.click(screen.getByRole('button', { name: 'Create enabled binding' }))
await screen.findByText('Binding 99 is enabled.')
await screen.findByRole('button', { name: 'Disable Production' })
expect(screen.queryByRole('button', { name: 'First binding page' })).not.toBeInTheDocument()
for (const control of rendered.container.querySelectorAll('summary, select, input, button')) {
expect(control.getAttribute('data-qqq-id')).toMatch(/^script-hook/)
}
})
it('disables unauthorized actions and allows emergency disable when the worker is unavailable', async () => {
const user = userEvent.setup()
const rendered = mount({ ...PROJECT, actions: ['view'] })
await user.click(screen.getByText('Record hooks: test and bindings'))
expect(await screen.findByRole('button', { name: 'Disable Production' })).toBeDisabled()
expect(screen.getByRole('button', { name: 'Create enabled binding' })).toBeDisabled()
expect(screen.getByRole('button', { name: 'Test hook without writes' })).toBeDisabled()
rendered.unmount()
mount({ ...PROJECT, workerAvailable: false })
await user.click(screen.getByText('Record hooks: test and bindings'))
expect(await screen.findByRole('button', { name: 'Disable Production' })).toBeEnabled()
expect(screen.getByRole('button', { name: 'Test hook without writes' })).toBeDisabled()
})
it('shows a denied action and retains the pinned binding for inspection', async () => {
const user = userEvent.setup()
vi.mocked(api.setScriptBindingEnabled).mockRejectedValue(new Error('Current target permission required'))
mount()
await user.click(screen.getByText('Record hooks: test and bindings'))
await user.click(await screen.findByRole('button', { name: 'Disable Production' }))
await screen.findByRole('alert')
expect(screen.getByRole('alert')).toHaveTextContent('Current target permission required')
expect(screen.getByRole('button', { name: 'Disable Production' })).toBeEnabled()
})
})
+145
View File
@@ -0,0 +1,145 @@
/*
* Copyright 2026 QRun.IO, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
/** @file Source-project hook administration and read-only selected-record event tests. */
'use client'
import React, { useState } from 'react'
import { useQuery, useQueryClient } from '@tanstack/react-query'
import {
createScriptBinding, getScriptHooks, setScriptBindingEnabled, testScriptHook,
type ScriptBuild, type ScriptCapability, type ScriptHookEvent, type ScriptHookResult, type ScriptProject,
} from '@/lib/api/scripting'
import { HANDLES_OWN_ERRORS } from '@/lib/query-client'
import { getErrorMessage } from '@/lib/utils/error-utils'
import { CodeEditor } from './CodeEditor'
import { ALERT_CLASS, BUTTON_CLASS, INPUT_CLASS } from './ScriptPanels'
interface Props {
scriptId: string | number
project: ScriptProject
build: ScriptBuild | null
disabled: boolean
capabilities: () => ScriptCapability[]
run: (work: (signal: AbortSignal) => Promise<void>) => void
onTested: (result: ScriptHookResult) => void
}
/**
* Keep binding activation distinct from publication and tests distinct from writes.
* @param props - Current authorized project and shared cancellable action runner.
* @returns Explicit hook test, activation and paged retained binding controls.
*/
export function ScriptHookPanel({ scriptId, project, build, disabled, capabilities, run, onTested }: Props) {
const queryClient = useQueryClient()
const [open, setOpen] = useState(false)
const [tableName, setTableName] = useState('')
const [event, setEvent] = useState<ScriptHookEvent>('PRE_INSERT')
const [recordId, setRecordId] = useState('')
const [changes, setChanges] = useState('{}')
const [name, setName] = useState('')
const [publication, setPublication] = useState<number | null>(null)
const [patchFields, setPatchFields] = useState<string[]>([])
const [tested, setTested] = useState<{ buildId: number; result: ScriptHookResult } | null>(null)
const [cursor, setCursor] = useState<number | null>(null)
const [notice, setNotice] = useState('')
const catalog = useQuery({
queryKey: ['scripting', scriptId, 'hooks', cursor],
queryFn: ({ signal }) => getScriptHooks(scriptId, signal, cursor), enabled: open,
retry: false, meta: HANDLES_OWN_ERRORS,
})
const target = catalog.data?.targets.find((item) => item.tableName === tableName) ?? catalog.data?.targets[0]
const phase = target?.phases.find((item) => item.event === event) ?? target?.phases[0]
const publicationId = publication ?? project.publications[0]?.id ?? null
const canBind = project.actions.includes('bind')
const canTest = project.actions.includes('test')
const result = tested?.buildId === build?.buildId ? tested?.result : null
const canChange = phase?.event === 'PRE_INSERT' || phase?.event === 'PRE_UPDATE'
return <details data-qqq-id="script-hooks-panel" className="rounded-lg border border-border p-3" onToggle={(toggle) => setOpen(toggle.currentTarget.open)}>
<summary data-qqq-id="script-hooks-toggle" className="cursor-pointer font-medium">Record hooks: test and bindings</summary>
<p className="my-2 text-sm text-muted-foreground">Test reads the selected record and simulates this event. It never inserts, updates or deletes a record, and never applies the returned patch. Native field validation occurs during an actual write. Post events observe backend actions and do not prove commit.</p>
{catalog.error && <p role="alert" className={ALERT_CLASS}>{getErrorMessage(catalog.error)}</p>}
{catalog.isFetching && <p role="status">Loading authorized hook controls…</p>}
{catalog.data && <div className="space-y-3">
{target && phase ? <>
<div className="grid gap-3 md:grid-cols-2">
<label className="text-sm">Hook table<select data-qqq-id="script-hook-table" className={INPUT_CLASS} value={target.tableName} disabled={disabled} onChange={(change) => { setTableName(change.target.value); setRecordId(''); setPatchFields([]); setTested(null) }}>
{catalog.data.targets.map((item) => <option key={item.tableName} value={item.tableName}>{item.label ?? item.tableName}</option>)}
</select></label>
<label className="text-sm">Hook event<select data-qqq-id="script-hook-event" className={INPUT_CLASS} value={phase.event} disabled={disabled} onChange={(change) => { setEvent(change.target.value as ScriptHookEvent); setPatchFields([]); setChanges('{}'); setTested(null) }}>
{target.phases.map((item) => <option key={item.event}>{item.event}</option>)}
</select></label>
<label className="text-sm">Selected record ID<input data-qqq-id="script-hook-record-id" className={INPUT_CLASS} value={recordId} maxLength={1024} disabled={disabled} onChange={(change) => { setRecordId(change.target.value); setTested(null) }} /></label>
</div>
{phase.event.startsWith('AFTER_') && <p data-qqq-id="script-hook-delivery-semantics" className="text-sm text-muted-foreground">
{phase.event === 'AFTER_DELETE'
? 'AFTER_DELETE runs from a committed Jobs intent and receives event metadata only, never deleted record values. This dry run reads the selected record for authorization, then sends only metadata; it does not delete or enqueue work.'
: 'AFTER_INSERT and AFTER_UPDATE run from a committed Jobs intent and read the currently authorized record at delivery time, not its historical values. This dry run reads the selected current record; it does not write, enqueue work, or prove commit.'}
{' '}AFTER scripts may only return ALLOW. Delivery failures cannot undo the committed write.
</p>}
{canChange && <CodeEditor id={`hook-changes-${scriptId}`} ariaLabel="Proposed hook test changes" language="json" rows={4}
value={changes} readOnly={disabled} onChange={(value) => { setChanges(value); setTested(null) }} />}
<button data-qqq-id="script-hook-test" type="button" className={BUTTON_CLASS} disabled={disabled || !build?.success || !recordId.trim() || !canTest || !project.workerAvailable}
onClick={() => run(async (signal) => {
const answer = await testScriptHook(scriptId, build!.buildId, { tableName: target.tableName, event: phase.event, recordId, changes: canChange ? JSON.parse(changes) as unknown : {} }, signal)
setTested({ buildId: build!.buildId, result: answer }); onTested(answer)
})}>Test hook without writes</button>
{result && <div aria-label="Hook dry-run result"><p role="status">Hook dry run: {result.status}. No record changes were applied.</p>
{result.message && <p className="text-sm">{result.message}</p>}
{result.hook && <CodeEditor id={`hook-result-${scriptId}`} ariaLabel="Simulated hook decision" language="json" rows={5} value={JSON.stringify(result.hook, null, 2)} readOnly />}
</div>}
<fieldset className="space-y-2 rounded border border-border p-3" disabled={disabled || !canBind}>
<legend className="px-1 text-sm font-medium">Create an enabled binding</legend>
<p className="text-sm text-muted-foreground">This activates the chosen immutable publication for the table and event. Read grants use the editor’s capability declarations; the server checks them against that publication.</p>
<div className="grid gap-3 md:grid-cols-2">
<label className="text-sm">Binding name<input data-qqq-id="script-hook-binding-name" className={INPUT_CLASS} value={name} maxLength={200} onChange={(change) => setName(change.target.value)} /></label>
<label className="text-sm">Published version<select data-qqq-id="script-hook-publication" className={INPUT_CLASS} value={publicationId ?? ''} onChange={(change) => setPublication(Number(change.target.value))}>
{!publicationId && <option value="">Publish a tested build first</option>}
{project.publications.map((item) => <option key={item.id} value={item.id}>Version {item.version ?? item.id} · publication {item.id}</option>)}
</select></label>
</div>
{phase.patchableFields.length > 0 ? <div className="flex flex-wrap gap-3" aria-label="Allowed binding patch fields">
{phase.patchableFields.map((field) => <label className="text-sm" key={field}><input data-qqq-id={`script-hook-patch-${encodeURIComponent(field)}`} type="checkbox" checked={patchFields.includes(field)} onChange={(change) => setPatchFields(change.target.checked ? [...patchFields, field] : patchFields.filter((selected) => selected !== field))} /> {field}</label>)}
</div> : <p className="text-sm">This phase does not support patches.</p>}
<button data-qqq-id="script-hook-create-binding" type="button" className={BUTTON_CLASS} disabled={disabled || !canBind || !publicationId || !name.trim() || !project.workerAvailable}
onClick={() => run(async (signal) => {
const id = await createScriptBinding(scriptId, { name, tableName: target.tableName, event: phase.event, publicationId: publicationId!, capabilities: capabilities(), patchableFields: patchFields.filter((field) => phase.patchableFields.includes(field)) }, signal)
setNotice(`Binding ${id} is enabled.`); setCursor(null)
await queryClient.invalidateQueries({ queryKey: ['scripting', scriptId, 'hooks'] })
})}>Create enabled binding</button>
</fieldset>
</> : <p role="status">No host-installed hook phases are available under your current permissions.</p>}
<div className="overflow-x-auto"><table className="w-full text-left text-sm" aria-label="Retained hook bindings">
<thead><tr><th>Name</th><th>Table / event</th><th>Publication</th><th>State</th><th>Action</th></tr></thead>
<tbody>{catalog.data.bindings.map((item) => <tr key={item.id}>
<td>{item.name}</td><td>{item.tableName} / {item.event}</td><td>{item.publicationId}</td><td>{item.enabled ? 'Enabled' : 'Disabled'}</td>
<td><button data-qqq-id={`script-hook-binding-state-${encodeURIComponent(String(item.id))}`} type="button" className={BUTTON_CLASS} disabled={disabled || !canBind || (!item.enabled && !project.workerAvailable)}
onClick={() => run(async (signal) => { await setScriptBindingEnabled(scriptId, item.id, !item.enabled, signal); setNotice(`Binding ${item.id} ${item.enabled ? 'disabled' : 'enabled'}.`); await catalog.refetch() })}>{item.enabled ? 'Disable' : 'Enable'} {item.name}</button></td>
</tr>)}</tbody>
</table></div>
{catalog.data.bindings.length === 0 && <p className="text-sm">No visible bindings on this page.</p>}
<div className="flex gap-2">
{cursor !== null && <button data-qqq-id="script-hook-bindings-first" type="button" className={BUTTON_CLASS} disabled={disabled} onClick={() => setCursor(null)}>First binding page</button>}
{catalog.data.nextBindingId !== null && <button data-qqq-id="script-hook-bindings-older" type="button" className={BUTTON_CLASS} disabled={disabled} onClick={() => setCursor(catalog.data!.nextBindingId)}>Older bindings</button>}
<button data-qqq-id="script-hook-bindings-refresh" type="button" className={BUTTON_CLASS} disabled={disabled} onClick={() => { void catalog.refetch() }}>Refresh bindings</button>
</div>
{catalog.data.historyLimited && <p className="text-sm">This view is bounded. Use older binding pages to manage retained bindings from earlier publications.</p>}
{notice && <p role="status">{notice}</p>}
</div>}
</details>
}
@@ -0,0 +1,60 @@
/*
* Copyright 2026 QRun.IO, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React from 'react'
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { beforeEach, expect, it, vi } from 'vitest'
import { testScript } from '@/lib/api/developer'
import { ScriptTest } from './ScriptPanels'
vi.mock('@/lib/api/developer', () => ({ testScript: vi.fn() }))
beforeEach(() => {
vi.mocked(testScript).mockReset()
vi.mocked(testScript).mockImplementation((_request, options) => new Promise((_resolve, reject) => {
options?.signal?.addEventListener('abort', () => reject(options.signal?.reason), { once: true })
}))
})
function mount() {
return render(<QueryClientProvider client={new QueryClient()}>
<ScriptTest idKey="test" scriptId={1} files={{ 'main.ts': 'return 1' }} inputFields={[]} outputFields={[]} />
</QueryClientProvider>)
}
it('aborts observation when the test panel unmounts', async () => {
const { unmount } = mount()
fireEvent.click(screen.getByRole('button', { name: 'Submit' }))
await waitFor(() => expect(testScript).toHaveBeenCalledTimes(1))
const signal = vi.mocked(testScript).mock.calls[0][1]?.signal
expect(signal?.aborted).toBe(false)
await act(async () => unmount())
expect(signal?.aborted).toBe(true)
})
it('stops waiting with an honest accessible message and permits another test', async () => {
mount()
fireEvent.click(screen.getByRole('button', { name: 'Submit' }))
const stop = await screen.findByRole('button', { name: 'Stop waiting' })
fireEvent.click(stop)
expect(await screen.findByRole('alert')).toHaveTextContent('server test may still be running')
expect(screen.getByRole('button', { name: 'Submit' })).not.toBeDisabled()
vi.mocked(testScript).mockResolvedValueOnce({ outputObject: {}, logLines: [] })
fireEvent.click(screen.getByRole('button', { name: 'Submit' }))
await waitFor(() => expect(testScript).toHaveBeenCalledTimes(2))
await waitFor(() => expect(screen.queryByRole('alert')).toBeNull())
})
+16 -3
View File
@@ -21,7 +21,7 @@
'use client'
import React, { useState } from 'react'
import React, { useEffect, useRef, useState } from 'react'
import { useMutation } from '@tanstack/react-query'
import { Loader2 } from 'lucide-react'
@@ -112,9 +112,14 @@ export function ScriptTest({ idKey, scriptId, files, inputFields, outputFields,
}) {
const [inputValues, setInputValues] = useState<Record<string, string>>(() =>
Object.fromEntries(inputFields.map((field) => [field.name, text(field.defaultValue)])))
const activeTest = useRef<AbortController | null>(null)
useEffect(() => () => activeTest.current?.abort(new DOMException('Stopped waiting because the test panel closed. The server test may still be running.', 'AbortError')), [])
const run = useMutation({
mutationFn: () => testScript({ scriptId, files, inputValues, apiName, apiVersion }),
mutationFn: (controller: AbortController) => testScript({ scriptId, files, inputValues, apiName, apiVersion }, { signal: controller.signal }),
meta: HANDLES_OWN_ERRORS,
onSettled: (_data, _error, controller) => {
if (activeTest.current === controller) activeTest.current = null
},
})
const result = run.data
const outputs = result?.outputObject ?? {}
@@ -130,7 +135,10 @@ export function ScriptTest({ idKey, scriptId, files, inputFields, outputFields,
onSubmit={(event) => {
event.preventDefault()
event.stopPropagation()
run.mutate()
if (activeTest.current) return
const controller = new AbortController()
activeTest.current = controller
run.mutate(controller)
}}
>
<h4 className="text-base font-semibold text-foreground">Test Input</h4>
@@ -154,6 +162,11 @@ export function ScriptTest({ idKey, scriptId, files, inputFields, outputFields,
)
})}
<div className="flex justify-end">
{run.isPending && <button type="button" className={BUTTON_CLASS}
data-qqq-id={`button-stop-waiting-script-${idKey}`}
onClick={() => activeTest.current?.abort(new DOMException('Stopped waiting. The server test may still be running.', 'AbortError'))}>
Stop waiting
</button>}
<button type="submit" className={PRIMARY_BUTTON_CLASS} disabled={run.isPending} data-qqq-id={`button-test-script-${idKey}`}>
{run.isPending && <Loader2 className="h-4 w-4 animate-spin" aria-hidden="true" />}
Submit
@@ -0,0 +1,45 @@
/*
* Copyright 2026 QRun.IO, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
/** @file Isolated and unknown modes never mount legacy script actions. */
import React from 'react'
import { describe, expect, it, vi } from 'vitest'
import { render, screen } from '@testing-library/react'
import { ScriptViewer } from './ScriptViewer'
vi.mock('./IsolatedScriptEditor', () => ({ IsolatedScriptEditor: ({ scriptId }: { scriptId: number }) => <div>Isolated project {scriptId}</div> }))
/**
* Render without a QueryClient; any legacy viewer mount would try to use it.
* @param mode - Stored server-controlled execution mode.
*/
function mount(mode: string) {
render(<ScriptViewer idKey="example" label="Script" scriptId={7} script={{ tableName: 'script', values: { id: 7, executionMode: mode } }}
canEdit canTest logs={{ queryKey: () => [], load: async () => [] }} onChanged={async () => undefined} />)
}
describe('script execution mode routing', () => {
it('routes isolated source directly to the isolated editor', () => {
mount('ISOLATED')
expect(screen.getByText('Isolated project 7')).toBeVisible()
expect(screen.queryByText('Edit and Activate')).not.toBeInTheDocument()
})
it('fails closed on unknown execution modes', () => {
mount('FUTURE_MODE')
expect(screen.getByRole('alert')).toHaveTextContent('not supported')
})
})
+17 -1
View File
@@ -44,6 +44,7 @@ import { getErrorMessage } from '@/lib/utils/error-utils'
import { DateTimeText } from '@/components/ui/DateTimeText'
import { CodeBlock } from './HighlightedCode'
import { ScriptEditorDialog } from './ScriptEditorDialog'
import { IsolatedScriptEditor } from './IsolatedScriptEditor'
import { ALERT_CLASS, BUTTON_CLASS, INPUT_CLASS, ScriptDocs, ScriptTest, type ScriptTestFields } from './ScriptPanels'
import {
FILE_MODE_MULTI_PRE_DEFINED,
@@ -113,7 +114,22 @@ export interface ScriptViewerProps {
* @param props - {@link ScriptViewerProps}
* @returns The viewer.
*/
export function ScriptViewer({
export function ScriptViewer(props: ScriptViewerProps) {
if (props.script.values.executionMode === 'ISOLATED') {
return <IsolatedScriptEditor scriptId={props.scriptId} label={props.label} />
}
if (props.script.values.executionMode && props.script.values.executionMode !== 'LEGACY') {
return <p role="alert">This script execution mode is not supported by this editor.</p>
}
return <LegacyScriptViewer {...props} />
}
/**
* Existing trusted-project viewer, never mounted for isolated source.
* @param props - Legacy viewer properties.
* @returns The existing trusted workflow.
*/
function LegacyScriptViewer({
idKey,
label,
scriptId,
@@ -0,0 +1,131 @@
/*
* Copyright 2026 QRun.IO, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
/**
* @file Typing while the cold language-service worker is still answering. Uses the REAL editor, the REAL
* worker client (finite queue, stale rejection, deadlines) and the REAL CodeMirror linter; only the browser
* Worker itself is controlled by the test.
*/
import React from 'react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { act, render, waitFor } from '@testing-library/react'
import { EditorView } from '@codemirror/view'
import { TypeScriptSourceEditor } from './TypeScriptSourceEditor'
import { IDE_VERSION } from '@/lib/scripting/typescript-session'
interface Posted { id: number; operation: string; files: Array<{ path: string; content: string }> }
/** A Worker whose answers are released by the test, exactly as a cold worker releases them late. */
class FakeWorker {
static instances: FakeWorker[] = []
static auto = false
onmessage: ((event: MessageEvent) => void) | null = null
onerror: ((event: ErrorEvent) => void) | null = null
posted: Posted[] = []
answered = new Set<number>()
terminated = false
constructor() { FakeWorker.instances.push(this) }
postMessage(request: Posted) {
this.posted.push(request)
if (FakeWorker.auto) queueMicrotask(() => this.answer(request))
}
terminate() { this.terminated = true }
answer(request: Posted) {
if (this.terminated || this.answered.has(request.id)) return
this.answered.add(request.id)
const content = request.files[0].content
const at = content.indexOf('"x"')
const diagnostics = at < 0 ? [] : [{ from: at, to: at + 3, code: 2322, severity: 'error', message: "Type 'string' is not assignable to type 'number'." }]
this.onmessage?.({ data: { protocol: 'qqq-typescript-ide/1', version: IDE_VERSION, id: request.id, result: { diagnostics } } } as MessageEvent)
}
releaseHeld() { for (const request of this.posted) this.answer(request) }
}
const originalRects = Object.getOwnPropertyDescriptor(Range.prototype, 'getClientRects')
const originalBounds = Object.getOwnPropertyDescriptor(Range.prototype, 'getBoundingClientRect')
beforeEach(() => {
FakeWorker.instances = []
FakeWorker.auto = false
vi.stubGlobal('Worker', FakeWorker)
Object.defineProperty(Range.prototype, 'getClientRects', { configurable: true, value: () => [] })
Object.defineProperty(Range.prototype, 'getBoundingClientRect', { configurable: true, value: () => new DOMRect() })
})
afterEach(() => {
vi.unstubAllGlobals()
if (originalRects) Object.defineProperty(Range.prototype, 'getClientRects', originalRects)
else Reflect.deleteProperty(Range.prototype, 'getClientRects')
if (originalBounds) Object.defineProperty(Range.prototype, 'getBoundingClientRect', originalBounds)
else Reflect.deleteProperty(Range.prototype, 'getBoundingClientRect')
})
const FINAL = 'export const n: number = "x"'
const keystrokes = Array.from({ length: FINAL.length }, (_, i) => FINAL.slice(0, i + 1))
/** The parent workspace feeds each onChange back as a NEW files array, exactly like the real isolated editor. */
function Workspace({ initial }: { initial: string }) {
const [files, setFiles] = React.useState([{ path: 'src/main.ts', content: initial }])
return <TypeScriptSourceEditor files={files} active="src/main.ts" readOnly={false} declarations="" onNavigate={vi.fn()}
onChange={(content) => setFiles([{ path: 'src/main.ts', content }])} />
}
/** Real edits on the real CodeMirror view, one keystroke at a time (the user's own typing, not a prop replacement). */
function mount(initial: string) {
const view = render(<Workspace initial={initial} />)
const cm = EditorView.findFromDOM(view.container.querySelector('.cm-content') as HTMLElement)!
const typeAll = (text: string) => { for (const ch of text) act(() => { cm.dispatch({ changes: { from: cm.state.doc.length, insert: ch }, userEvent: 'input.type' }) }) }
return { ...view, typeAll }
}
describe('typing while the cold worker is still answering', () => {
it('analyses the final buffer once the held requests drain, and reports its error', async () => {
const editor = mount('')
editor.typeAll(FINAL)
const worker = FakeWorker.instances[0]
// the cold worker finally answers everything it was holding (all of it describes older buffers)
FakeWorker.auto = true
worker.releaseHeld()
// the product must now analyse the CURRENT buffer by itself; no further keystroke is made
await waitFor(() => expect(worker.posted.some((request) => request.files[0].content === FINAL)).toBe(true), { timeout: 4000 })
await waitFor(() => expect(editor.container.querySelector('.cm-lintRange-error')).not.toBeNull(), { timeout: 4000 })
})
it('never exceeds the finite queue and does not leave a queue-full message once the current buffer is analysed', async () => {
const editor = mount('')
editor.typeAll(FINAL)
const worker = FakeWorker.instances[0]
expect(worker.posted.filter((request) => !worker.answered.has(request.id)).length).toBeLessThanOrEqual(4)
FakeWorker.auto = true
worker.releaseHeld()
await waitFor(() => expect(worker.posted.some((request) => request.files[0].content === FINAL)).toBe(true), { timeout: 4000 })
await waitFor(() => expect(editor.container.textContent ?? '').not.toMatch(/queue is full/i), { timeout: 4000 })
})
it('re-admits the current buffer after a queue-full refusal caused by something other than the editor typing', async () => {
const props = { active: 'src/main.ts', readOnly: false, onChange: vi.fn(), onNavigate: vi.fn(), files: [{ path: 'src/main.ts', content: FINAL }] }
const view = render(<TypeScriptSourceEditor {...props} declarations="" />)
const worker = FakeWorker.instances[0]
// six external declaration refreshes while the cold worker holds its answers: the 5th and 6th are refused by the finite queue
for (let i = 0; i < 6; i++) view.rerender(<TypeScriptSourceEditor {...props} declarations={'declare const sdk' + i + ': number'} />)
expect(worker.posted.filter((request) => !worker.answered.has(request.id)).length).toBeLessThanOrEqual(4)
FakeWorker.auto = true
const before = worker.posted.length
worker.releaseHeld()
await waitFor(() => expect(worker.posted.length).toBeGreaterThan(before), { timeout: 4000 })
await waitFor(() => expect(view.container.querySelector('.cm-lintRange-error')).not.toBeNull(), { timeout: 4000 })
})
})
@@ -0,0 +1,79 @@
/*
* Copyright 2026 QRun.IO, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
/** @file Real CodeMirror integration tests with a deterministic advisory worker transport. */
import React from 'react'
import { beforeEach, afterEach, describe, expect, it, vi } from 'vitest'
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { TypeScriptSourceEditor } from './TypeScriptSourceEditor'
const transport = vi.hoisted(() => ({ request: vi.fn(), dispose: vi.fn() }))
const originalRects = Object.getOwnPropertyDescriptor(Range.prototype, 'getClientRects')
const originalBounds = Object.getOwnPropertyDescriptor(Range.prototype, 'getBoundingClientRect')
vi.mock('@/lib/scripting/typescript-worker-client', () => ({
TypeScriptWorkerClient: class { request = transport.request; dispose = transport.dispose },
}))
beforeEach(() => {
vi.clearAllMocks()
transport.request.mockImplementation(async (operation: string) => {
if (operation === 'format') return { edits: [{ from: 7, to: 7, insert: ' ' }, { from: 8, to: 8, insert: ' ' }] }
if (operation === 'definition') return { definitions: [{ file: 'src/helper.ts', from: 0, to: 3 }] }
return { diagnostics: [] }
})
Object.defineProperty(Range.prototype, 'getClientRects', { configurable: true, value: () => [] })
Object.defineProperty(Range.prototype, 'getBoundingClientRect', { configurable: true, value: () => new DOMRect() })
})
afterEach(() => {
if (originalRects) Object.defineProperty(Range.prototype, 'getClientRects', originalRects)
else Reflect.deleteProperty(Range.prototype, 'getClientRects')
if (originalBounds) Object.defineProperty(Range.prototype, 'getBoundingClientRect', originalBounds)
else Reflect.deleteProperty(Range.prototype, 'getBoundingClientRect')
vi.restoreAllMocks()
})
describe('CodeMirror isolated TypeScript source editor', () => {
it('applies formatting as a normal source edit and dispatches cross-file definition navigation', async () => {
const user = userEvent.setup()
const onChange = vi.fn()
const onNavigate = vi.fn()
const files = [{ path: 'src/main.ts', content: 'const x=1;' }, { path: 'src/helper.ts', content: 'export const name = "helper";' }]
const mounted = render(<TypeScriptSourceEditor files={files} active="src/main.ts" readOnly={false} declarations=""
onChange={onChange} onNavigate={onNavigate} />)
expect(screen.getByRole('textbox', { name: 'TypeScript source: src/main.ts' })).toHaveAttribute('contenteditable', 'true')
await user.click(screen.getByRole('button', { name: 'Format source' }))
await waitFor(() => expect(onChange).toHaveBeenCalledWith('const x = 1;'))
await user.click(screen.getByRole('button', { name: 'Go to definition (F12)' }))
await waitFor(() => expect(onNavigate).toHaveBeenCalledWith({ file: 'src/helper.ts', from: 0, to: 3 }))
mounted.unmount()
expect(transport.dispose).toHaveBeenCalledTimes(1)
})
it('keeps read-only content noneditable and refuses formatting', async () => {
const user = userEvent.setup()
render(<TypeScriptSourceEditor files={[{ path: 'src/main.ts', content: 'const x=1;' }]} active="src/main.ts" readOnly declarations=""
onChange={vi.fn()} onNavigate={vi.fn()} />)
expect(screen.getByRole('button', { name: 'Format source' })).toBeDisabled()
expect(screen.getByRole('textbox', { name: 'TypeScript source: src/main.ts' })).toHaveAttribute('contenteditable', 'false')
await user.click(screen.getByRole('button', { name: 'Stop editor analysis' }))
expect(transport.dispose).toHaveBeenCalled()
expect(screen.getByRole('status')).toHaveTextContent('analysis stopped')
})
})
@@ -0,0 +1,239 @@
/*
* Copyright 2026 QRun.IO, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
/** @file CodeMirror TypeScript editor backed by bounded, advisory offline language analysis. */
'use client'
import React, { useEffect, useId, useRef, useState } from 'react'
import { autocompletion, closeBrackets, closeBracketsKeymap, completionKeymap } from '@codemirror/autocomplete'
import { defaultKeymap, history, historyKeymap } from '@codemirror/commands'
import { javascript } from '@codemirror/lang-javascript'
import { bracketMatching, defaultHighlightStyle, syntaxHighlighting } from '@codemirror/language'
import { forceLinting, linter, lintGutter } from '@codemirror/lint'
import { Compartment, EditorState, StateEffect } from '@codemirror/state'
import { EditorView, hoverTooltip, keymap, lineNumbers } from '@codemirror/view'
import { EditorAnalysisBusyError, TypeScriptWorkerClient } from '@/lib/scripting/typescript-worker-client'
import type { IdeDefinition, IdeFile, IdeOperation } from '@/lib/scripting/typescript-session'
import { BUTTON_CLASS } from './ScriptPanels'
/**
* Asks the linter to re-analyse the current buffer without a document change. forceLinting alone only fast-forwards a
* lint that is already scheduled, so a request with nothing scheduled needs this effect (read by the linter's needsRefresh).
*/
const relintRequested = StateEffect.define<null>()
/**
* Re-analyse the current buffer now, even when no document change has scheduled a lint.
* @param view - The editor whose active buffer should be analysed.
*/
function requestLint(view: EditorView) {
view.dispatch({ effects: relintRequested.of(null) })
forceLinting(view)
}
/** Complete project buffers, with source persistence still owned by the parent workspace. */
export interface TypeScriptSourceEditorProps {
files: IdeFile[]
active: string
readOnly: boolean
declarations: string
onChange: (content: string) => void
onNavigate: (target: IdeDefinition) => void
selection?: IdeDefinition | null
}
/**
* Real language-service assistance never executes source or grants publish authority.
* @param props - Current virtual project and editing callbacks.
* @returns Accessible CodeMirror editor and explicit analysis controls.
*/
export function TypeScriptSourceEditor(props: TypeScriptSourceEditorProps) {
const host = useRef<HTMLDivElement>(null)
const helpId = useId()
const viewRef = useRef<EditorView | null>(null)
const propsRef = useRef(props)
propsRef.current = props
const clientRef = useRef<TypeScriptWorkerClient | null>(null)
const readOnly = useRef(new Compartment())
const [analysisError, setAnalysisError] = useState('')
const activeFile = props.files.find((file) => file.path === props.active) ?? props.files[0]
useEffect(() => {
if (!host.current) return
const client = new TypeScriptWorkerClient()
clientRef.current = client
let alive = true
// One coalesced re-lint for the CURRENT buffer after the finite queue refused a diagnostics request.
// It is admission control (nothing was sent, nothing failed), not a retry of timed-out or failed analysis.
let relint: ReturnType<typeof setTimeout> | null = null
const relintLater = () => {
if (relint || !alive) return
relint = setTimeout(() => { relint = null; if (alive && viewRef.current) requestLint(viewRef.current) }, 250)
}
/**
* Capture source identity so stale async analysis never edits a newer buffer.
* @param operation - Requested advisory feature.
* @param position - Cursor offset.
* @returns Current result or null when the buffer changed while computing.
*/
const analyze = async (operation: IdeOperation, position: number) => {
const before = propsRef.current
const source = viewRef.current!.state.doc.toString()
const files = before.files.map((file) => file.path === before.active ? { ...file, content: source } : file)
try {
const result = await client.request(operation, files, before.active, position, before.declarations)
if (!alive || before.files !== propsRef.current.files || before.active !== propsRef.current.active
|| before.declarations !== propsRef.current.declarations || viewRef.current?.state.doc.toString() !== source) return null
setAnalysisError('')
return result
} catch (error) {
if (alive && error instanceof EditorAnalysisBusyError && operation === 'diagnostics') relintLater()
if (alive) setAnalysisError(error instanceof Error ? error.message : 'Editor analysis unavailable')
return null
}
}
const definition = () => {
const position = viewRef.current!.state.selection.main.head
void analyze('definition', position).then((result) => {
const target = result?.definitions?.[0]
if (target) propsRef.current.onNavigate(target)
})
return true
}
const format = () => {
if (propsRef.current.readOnly) return false
void analyze('format', 0).then((result) => {
const view = viewRef.current
if (!view || !result?.edits?.length || propsRef.current.readOnly) return
const edits = result.edits.slice().sort((a, b) => a.from - b.from)
let end = 0
if (edits.some((edit) => {
const invalid = edit.from < end || edit.to < edit.from || edit.to > view.state.doc.length
end = edit.to
return invalid
})) return
view.dispatch({ changes: edits.map((edit) => ({ from: edit.from, to: edit.to, insert: edit.insert })), userEvent: 'input.format' })
})
return true
}
const view: EditorView = new EditorView({
parent: host.current,
state: EditorState.create({
doc: activeFile?.content ?? '',
extensions: [
lineNumbers(), history(), bracketMatching(), closeBrackets(), javascript({ typescript: true }),
syntaxHighlighting(defaultHighlightStyle), lintGutter(),
autocompletion({ override: [async (context) => {
const result = await analyze('completion', context.pos)
const items = result?.completions
if (!items?.length) return null
return { from: items[0].from, to: items[0].to, options: items.map((item) => ({ label: item.label, type: item.type, detail: item.detail })) }
}] }),
linter(async () => (await analyze('diagnostics', 0))?.diagnostics?.map((item) => ({
from: Math.min(item.from, view.state.doc.length), to: Math.min(item.to, view.state.doc.length),
severity: item.severity, message: item.message, source: 'TypeScript ' + item.code,
})) ?? [], { delay: 500, needsRefresh: (update) => update.transactions.some((transaction) => transaction.effects.some((effect) => effect.is(relintRequested))) }),
hoverTooltip(async (_view, position) => {
const info = (await analyze('hover', position))?.hover
if (!info) return null
return { pos: info.from, end: info.to, create: () => {
const dom = document.createElement('pre')
dom.className = 'max-w-lg whitespace-pre-wrap p-2 text-sm'
dom.textContent = info.text
return { dom }
} }
}),
keymap.of([{ key: 'F12', run: definition }, { key: 'Mod-Shift-f', run: format },
...closeBracketsKeymap, ...defaultKeymap, ...historyKeymap, ...completionKeymap]),
readOnly.current.of([EditorState.readOnly.of(props.readOnly), EditorView.editable.of(!props.readOnly)]),
EditorView.contentAttributes.of({ 'aria-label': 'TypeScript source: ' + props.active, 'aria-describedby': helpId }),
EditorView.theme({
'&': { color: 'var(--color-foreground)', backgroundColor: 'var(--color-background)', border: '1px solid var(--color-border)', borderRadius: '0.375rem' },
'.cm-scroller': { fontFamily: 'var(--font-mono, monospace)', overflow: 'auto', maxHeight: '32rem' },
'.cm-content': { minHeight: '22rem' }, '.cm-gutters': { backgroundColor: 'var(--color-muted)', color: 'var(--color-muted-foreground)', borderRight: '1px solid var(--color-border)' },
'.cm-tooltip': { backgroundColor: 'var(--color-popover)', color: 'var(--color-popover-foreground)', border: '1px solid var(--color-border)' },
'.cm-cursor': { borderLeftColor: 'var(--color-foreground)' },
}),
EditorView.updateListener.of((update) => { if (update.docChanged) propsRef.current.onChange(update.state.doc.toString()) }),
],
}),
})
viewRef.current = view
const formatListener = () => { format() }
const definitionListener = () => { definition() }
host.current.addEventListener('qqq-format', formatListener)
host.current.addEventListener('qqq-definition', definitionListener)
const element = host.current
return () => {
alive = false
if (relint) clearTimeout(relint)
element.removeEventListener('qqq-format', formatListener)
element.removeEventListener('qqq-definition', definitionListener)
client.dispose()
clientRef.current = null
view.destroy()
viewRef.current = null
}
// One editor view per active file; controlled updates use the effects below.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [props.active])
// What the last lint was based on besides the active buffer itself: other files and the SDK declarations.
const lintBasis = useRef<{ others: string; declarations: string } | null>(null)
useEffect(() => {
const view = viewRef.current
let external = false
if (view && activeFile && view.state.doc.toString() !== activeFile.content) {
view.dispatch({ changes: { from: 0, to: view.state.doc.length, insert: activeFile.content } })
external = true
}
const others = props.files.filter((file) => file !== activeFile).map((file) => file.path + '\u0000' + file.content).join('\u0001')
const first = lintBasis.current === null // lint once at mount so the cold worker starts while the user is still reading
const basisChanged = !first && (lintBasis.current!.others !== others || lintBasis.current!.declarations !== props.declarations)
lintBasis.current = { others, declarations: props.declarations }
// The editor's own keystrokes are already debounced by the linter; forcing a lint for each one floods the finite queue.
if (view && (first || external || basisChanged)) requestLint(view)
}, [activeFile, props.files, props.declarations])
useEffect(() => {
viewRef.current?.dispatch({ effects: readOnly.current.reconfigure([EditorState.readOnly.of(props.readOnly), EditorView.editable.of(!props.readOnly)]) })
}, [props.readOnly])
useEffect(() => {
const view = viewRef.current
const target = props.selection
if (view && target?.file === props.active) {
const from = Math.min(Math.max(0, target.from), view.state.doc.length)
const to = Math.min(Math.max(from, target.to), view.state.doc.length)
view.dispatch({ selection: { anchor: from, head: to }, scrollIntoView: true })
view.focus()
}
}, [props.selection, props.active])
return <div className="space-y-2">
<div className="flex flex-wrap gap-2">
<button type="button" className={BUTTON_CLASS} onClick={() => host.current?.dispatchEvent(new Event('qqq-definition'))}>Go to definition (F12)</button>
<button type="button" className={BUTTON_CLASS} disabled={props.readOnly} onClick={() => host.current?.dispatchEvent(new Event('qqq-format'))}>Format source</button>
<button type="button" className={BUTTON_CLASS} onClick={() => { clientRef.current?.dispose(); setAnalysisError('Editor analysis stopped. Switch files or reopen the editor to restart.') }}>Stop editor analysis</button>
</div>
<p id={helpId} className="text-xs text-muted-foreground">TypeScript 6.0.3 advisory diagnostics · Ctrl+Space completions · hover for types · F12 definition · Ctrl/Cmd+Shift+F format · Tab moves focus. Server build and test are required separately.</p>
{analysisError && <p role="status" className="text-sm text-muted-foreground">{analysisError}</p>}
<div ref={host} />
</div>
}
+189
View File
@@ -0,0 +1,189 @@
/*
* Copyright 2026 QRun.IO, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React from 'react'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { axe } from 'jest-axe'
import { describe, expect, it, vi } from 'vitest'
import type { QInstance } from '@/types'
import ToolsWidget from './ToolsWidget'
import { connectionUrl, toolsTarget } from './tools-catalog'
import backendCatalog from '../../../tests/e2e/fixtures/tools-catalog.json'
const metadata = {
apps: {},
appTree: [],
widgets: {},
tables: {},
processes: {},
reports: {},
branding: {},
} as QInstance
vi.mock('@/lib/hooks/use-metadata', () => ({ useMetaData: () => ({ data: metadata }) }))
const terminal = {
id: 'terminal',
label: 'CLI & Terminal UI',
summary: 'Use QQQ from a terminal.',
platforms: 'Windows, macOS and Linux',
available: true,
downloadUrl: 'https://example.test/releases',
downloadLabel: 'Get CLI & TUI',
guideUrl: 'https://example.test/guide',
note: 'Standalone includes both.',
steps: ['Install the client.', 'Sign in.'],
commands: ['qqq login --server {server}', 'qqq ui'],
manageApp: 'printing',
}
const payload = {
serverUrl: 'https://example.test/customer',
tools: [terminal, { ...terminal, id: 'scanner', label: 'Mobile Scanners', available: false }],
}
describe('Tools', () => {
it('renders the actual Java payload with omitted empty command lists', () => {
render(<ToolsWidget data={backendCatalog} />)
expect(screen.getByRole('button', { name: 'Set up Print Client' })).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Set up Mobile Scanners' })).toBeInTheDocument()
})
it('renders available downloads, collapses unavailable tools and copies an instance-specific sign-in command', async () => {
const writeText = vi.fn().mockResolvedValue(undefined)
Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { writeText } })
const { container } = render(<ToolsWidget data={payload} />)
expect(screen.getByRole('link', { name: /Get CLI/ })).toHaveAttribute(
'href',
'https://example.test/releases'
)
expect(container.querySelector('[data-qqq-id="tools-unavailable"]')).not.toHaveAttribute('open')
expect(container.querySelector('[data-qqq-id="tools-card-scanner"] a')).toBeNull()
fireEvent.click(screen.getByRole('button', { name: 'Set up CLI & Terminal UI' }))
fireEvent.click(screen.getByLabelText('Copy CLI & Terminal UI command 1'))
await waitFor(() =>
expect(writeText).toHaveBeenCalledWith('qqq login --server "https://example.test/customer"')
)
expect(screen.queryByText(/Open Printing/)).not.toBeInTheDocument()
expect(await axe(container)).toHaveNoViolations()
})
it('switches the setup workspace without hiding other tools', () => {
render(<ToolsWidget data={backendCatalog} />)
fireEvent.click(screen.getByRole('button', { name: 'Set up Print Client' }))
expect(screen.getByRole('heading', { name: 'Print Client' })).toBeInTheDocument()
expect(screen.getByRole('link', { name: /Get Print Client/ })).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Set up CLI & Terminal UI' })).toHaveAttribute(
'aria-pressed',
'false'
)
fireEvent.click(screen.getByRole('button', { name: 'Set up Mobile Scanners' }))
expect(screen.getByRole('heading', { name: 'Mobile Scanners' })).toBeInTheDocument()
expect(screen.queryByRole('link', { name: /Get Print Client/ })).not.toBeInTheDocument()
})
it('switches platform installers and copies the selected command', async () => {
const writeText = vi.fn().mockResolvedValue(undefined)
Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { writeText } })
render(<ToolsWidget data={backendCatalog} />)
fireEvent.change(screen.getByLabelText('Install platform'), { target: { value: 'windows' } })
expect(screen.getByText('scoop install qqq')).toBeInTheDocument()
fireEvent.click(screen.getByLabelText('Copy Scoop install command 2'))
await waitFor(() => expect(writeText).toHaveBeenCalledWith('scoop install qqq'))
fireEvent.change(screen.getByLabelText('Install platform'), { target: { value: 'macos' } })
expect(screen.getByText('brew install qrun-io/tap/qqq')).toBeInTheDocument()
fireEvent.change(screen.getByLabelText('Install method'), {
target: { value: 'curl installer' },
})
expect(screen.getByText(/curl -fsSL/)).toBeInTheDocument()
fireEvent.change(screen.getByLabelText('Install platform'), { target: { value: 'linux' } })
expect(screen.getByLabelText('Install method')).toHaveValue('curl installer')
})
it('copies complete printer and mobile installer scripts with preserved newlines', async () => {
const writeText = vi.fn().mockResolvedValue(undefined)
Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { writeText } })
render(<ToolsWidget data={backendCatalog} />)
for (const [label, id] of [
['Print Client', 'printing'],
['Mobile Scanners', 'scanner'],
]) {
fireEvent.click(screen.getByRole('button', { name: `Set up ${label}` }))
fireEvent.change(screen.getByLabelText('Install platform'), { target: { value: 'windows' } })
fireEvent.click(screen.getByRole('button', { name: 'Copy install script' }))
const script = backendCatalog.tools.find((tool) => tool.id === id)!.installOptions[0]
.commands![0]
await waitFor(() => expect(writeText).toHaveBeenLastCalledWith(script))
expect(script).toContain('\n')
}
fireEvent.change(screen.getByLabelText('Install platform'), { target: { value: 'android' } })
expect(screen.queryByRole('button', { name: 'Copy install script' })).not.toBeInTheDocument()
expect(screen.getByText(/In the handheld's Files app/)).toBeInTheDocument()
})
it('shows a recovery path when clipboard access fails', async () => {
Object.defineProperty(navigator, 'clipboard', {
configurable: true,
value: { writeText: vi.fn().mockRejectedValue(new Error('denied')) },
})
render(<ToolsWidget data={payload} />)
fireEvent.click(screen.getByLabelText('Copy server address'))
expect(
await screen.findByText('Could not copy. Select and copy the text above.')
).toBeInTheDocument()
})
it('rejects malformed payloads and unsafe download links', () => {
const view = render(<ToolsWidget data={{}} />)
expect(screen.getByRole('alert')).toHaveTextContent('could not be loaded')
view.rerender(
<ToolsWidget
data={{ ...payload, tools: [{ ...terminal, downloadUrl: 'javascript:alert(1)' }] }}
/>
)
expect(screen.queryByRole('link', { name: /Get CLI/ })).not.toBeInTheDocument()
})
it('resolves public roots without losing proxy paths or accepting credentials and shell syntax', () => {
expect(connectionUrl(null, '/tenant/qqq/v1', 'https://example.test')).toBe(
'https://example.test/tenant'
)
expect(connectionUrl(null, 'https://api.test/qqq/v1/', 'https://example.test')).toBe(
'https://api.test'
)
expect(connectionUrl(null, '/unknown', 'https://example.test')).toBeUndefined()
expect(connectionUrl(null, '/qqq/v1', 'http://localhost:8077')).toBe('http://localhost:8077')
for (const value of [
'https://user:pass@example.test',
'https://example.test?token=secret',
'http://example.test',
'https://example.test/$HOME',
]) {
expect(connectionUrl(value, '/qqq/v1', 'https://example.test')).toBeUndefined()
}
})
it('finds the menu shortcut by visible widget metadata, including renamed apps', () => {
const instance = {
...metadata,
apps: { downloads: { name: 'downloads', label: 'Company Tools', widgets: ['catalog'] } },
widgets: {
catalog: { name: 'catalog', label: 'Tools', type: 'QQQ_TOOLS', hasPermission: true },
},
appTree: [{ name: 'downloads', label: 'Company Tools', type: 'APP' as const }],
}
expect(toolsTarget(instance)?.path).toBe('/app/downloads')
expect(toolsTarget({ ...instance, appTree: [] })).toBeUndefined()
instance.widgets.catalog.hasPermission = false
expect(toolsTarget(instance)).toBeUndefined()
})
})
+458
View File
@@ -0,0 +1,458 @@
/*
* Copyright 2026 QRun.IO, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
/** @file Companion tools with a compact chooser and focused setup workspace. */
'use client'
import React, { useState } from 'react'
import Link from 'next/link'
import {
ArrowDownToLine,
ArrowUpRight,
Check,
CheckCircle2,
Copy,
Info,
Monitor,
Printer,
Smartphone,
Terminal,
Wrench,
} from 'lucide-react'
import apiClient from '@/lib/api/client'
import { useMetaData } from '@/lib/hooks/use-metadata'
import { useAppTreeRoutes } from '@/lib/hooks/use-routes'
import { connectionUrl, downloadLink, toolsCatalogSchema, type Tool } from './tools-catalog'
const actionClass =
'inline-flex min-h-10 items-center justify-center gap-2 rounded-lg px-4 py-2 text-sm font-medium transition-colors focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring'
/** Copy without breaking commands across lines.
* @param props - Text, accessible label and optional terminal treatment.
* @returns Scrollable text and clipboard feedback.
*/
function CopyValue({
value,
label,
terminal = false,
}: {
value: string
label: string
terminal?: boolean
}) {
const [status, setStatus] = useState('')
const multiline = value.includes('\n')
return (
<div className="min-w-0">
<div
className={`flex min-w-0 gap-2 ${multiline ? 'flex-col items-stretch' : 'items-center'} ${terminal ? 'py-2' : 'rounded-lg border border-border bg-background px-3 py-2'}`}
>
<code
className={`min-w-0 flex-1 max-h-64 overflow-auto whitespace-pre py-1 text-xs leading-6 ${terminal ? 'text-slate-100' : 'text-foreground'}`}
tabIndex={0}
aria-label={label}
>
{value}
</code>
<button
type="button"
aria-label={multiline ? 'Copy install script' : `Copy ${label}`}
title={`Copy ${label}`}
className={`flex h-9 shrink-0 items-center justify-center gap-2 rounded-md ${multiline ? 'w-full border border-slate-600 px-3 text-xs' : 'w-9'} transition-colors focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring ${terminal ? 'text-slate-300 hover:bg-white/10 hover:text-white' : 'text-muted-foreground hover:bg-muted hover:text-foreground'}`}
data-qqq-id={`tools-copy-${label.replace(/ /g, '-')}`}
onClick={async () => {
try {
await navigator.clipboard.writeText(value)
setStatus('Copied')
} catch {
setStatus('Could not copy. Select and copy the text above.')
}
}}
>
{status === 'Copied' ? (
<Check className="h-4 w-4" aria-hidden="true" />
) : (
<Copy className="h-4 w-4" aria-hidden="true" />
)}
{multiline &&
(status === 'Copied' ? 'Copied � paste into your terminal' : 'Copy install script')}
</button>
</div>
<span
role="status"
className={
status && status !== 'Copied'
? `block py-1 text-xs ${terminal ? 'text-slate-200' : 'text-muted-foreground'}`
: 'sr-only'
}
>
{status}
</span>
</div>
)
}
/** Stable product icon with a fallback for future catalog entries.
* @param props - Tool identifier and icon classes.
* @returns Product icon.
*/
function ToolIcon({ id, className }: { id: string; className?: string }) {
const Icon =
(
{ terminal: Terminal, scanner: Smartphone, printing: Printer } as Record<
string,
typeof Wrench
>
)[id] ?? Wrench
return <Icon className={className} aria-hidden="true" />
}
/** Platform and package-manager choices supplied by the catalog.
* @param props - Selected tool with supported install methods.
* @returns In-app installation instructions and copyable commands.
*/
function InstallInstructions({ tool }: { tool: Tool }) {
const [platform, setPlatform] = useState(() => {
const ua = typeof navigator === 'undefined' ? '' : navigator.userAgent.toLowerCase()
return ua.includes('win')
? 'windows'
: ua.includes('mac')
? 'macos'
: ua.includes('android')
? 'android'
: 'linux'
})
const [method, setMethod] = useState('')
const options = tool.installOptions
if (options.length === 0) return null
const platforms = options.filter(
(option, index) => options.findIndex((other) => other.platform === option.platform) === index
)
const selectedPlatform = platforms.some((option) => option.platform === platform)
? platform
: platforms[0].platform
const methods = options.filter((option) => option.platform === selectedPlatform)
const selected = methods.find((option) => option.method === method) ?? methods[0]
return (
<div className="mb-6 border-b border-border pb-6" data-qqq-id="tools-install-instructions">
<h3 className="mb-3 text-sm font-semibold">Install {tool.label}</h3>
<div className="mb-3 flex flex-wrap gap-3">
<label className="flex min-w-0 flex-1 flex-col gap-1 text-xs text-muted-foreground">
Platform
<select
aria-label="Install platform"
value={selectedPlatform}
onChange={(event) => {
setPlatform(event.target.value)
setMethod('')
}}
data-qqq-id="tools-install-platform"
className="min-h-10 min-w-0 w-full rounded-lg border border-border bg-background px-3 text-sm text-foreground focus-visible:outline-ring"
>
{platforms.map((option) => (
<option key={option.platform} value={option.platform}>
{option.platformLabel}
</option>
))}
</select>
</label>
<label className="flex min-w-0 flex-1 flex-col gap-1 text-xs text-muted-foreground">
Install with
<select
aria-label="Install method"
value={selected.method}
onChange={(event) => setMethod(event.target.value)}
data-qqq-id="tools-install-method"
className="min-h-10 min-w-0 w-full rounded-lg border border-border bg-background px-3 text-sm text-foreground focus-visible:outline-ring"
>
{methods.map((option) => (
<option key={option.method}>{option.method}</option>
))}
</select>
</label>
</div>
<p className="mb-3 text-xs leading-relaxed text-muted-foreground">{selected.instructions}</p>
{selected.commands.length > 0 && (
<div className="min-w-0 overflow-hidden rounded-xl bg-slate-950 p-3">
<p className="mb-1 text-[10px] font-semibold uppercase tracking-wider text-slate-400">
{selected.shell}
</p>
{selected.commands.map((command, index) => (
<CopyValue
key={command}
terminal
value={command}
label={`${selected.method} install command ${index + 1}`}
/>
))}
</div>
)}
</div>
)
}
/** Render an available tool's setup in one consistently sized workspace.
* @param props - Selected tool, public server and visible management destination.
* @returns Setup panel.
*/
function SetupPanel({
tool,
server,
management,
}: {
tool: Tool
server?: string
management?: { path: string; label: string }
}) {
const download = downloadLink(tool.downloadUrl)
const guide = downloadLink(tool.guideUrl)
return (
<section
id="tools-setup-panel"
aria-labelledby="tools-setup-heading"
className="overflow-hidden rounded-2xl border border-border bg-card shadow-sm"
data-qqq-id={`tools-setup-${tool.id}`}
>
<header className="flex flex-wrap items-start justify-between gap-5 border-b border-border px-5 py-5 sm:px-6">
<div className="min-w-0 flex-1 basis-64">
<p className="mb-1 text-[10px] font-semibold uppercase tracking-[0.18em] text-muted-foreground">
Setup guide
</p>
<h2 id="tools-setup-heading" className="text-xl font-semibold tracking-tight">
{tool.label}
</h2>
<p className="mt-2 max-w-xl text-sm leading-relaxed text-muted-foreground">
{tool.summary}
</p>
</div>
<div className="flex flex-wrap items-center gap-2">
{download && (
<a
className={`${actionClass} bg-primary text-primary-foreground shadow-sm hover:bg-primary/90`}
href={download}
target="_blank"
rel="noopener noreferrer"
data-qqq-id={`tools-download-${tool.id}`}
>
<ArrowDownToLine className="h-4 w-4" aria-hidden="true" />
{tool.downloadLabel}
<span className="sr-only"> (opens in a new tab)</span>
</a>
)}
{guide && (
<a
className={`${actionClass} border border-border hover:bg-muted`}
href={guide}
target="_blank"
rel="noopener noreferrer"
data-qqq-id={`tools-guide-${tool.id}`}
>
Installation guide
<ArrowUpRight className="h-4 w-4" aria-hidden="true" />
<span className="sr-only"> (opens in a new tab)</span>
</a>
)}
</div>
</header>
<div className="grid min-w-0 xl:grid-cols-2">
<div className="min-w-0 px-5 py-6 sm:px-6">
<InstallInstructions tool={tool} />
<h3 className="mb-5 text-sm font-semibold">Connect and start using it</h3>
<ol className="space-y-6">
{tool.steps.map((step, index) => (
<li key={index} className="flex gap-3">
<span
className="flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-primary/10 text-xs font-semibold text-primary"
aria-hidden="true"
>
{index + 1}
</span>
<span className="pt-0.5 text-sm leading-relaxed">
<span className="sr-only">Step {index + 1}. </span>
{step}
</span>
</li>
))}
</ol>
{management && (
<Link
href={management.path}
className={`${actionClass} mt-5 border border-border hover:bg-muted`}
data-qqq-id={`tools-manage-${tool.id}`}
>
Open {management.label}
<ArrowUpRight className="h-4 w-4" aria-hidden="true" />
</Link>
)}
</div>
<div
role="group"
className="min-w-0 space-y-5 border-t border-border bg-muted/25 px-5 py-6 sm:px-6 xl:border-l xl:border-t-0"
aria-label="Connection details"
>
<div>
<h3 className="mb-1 flex items-center gap-2 text-sm font-semibold">
<Monitor className="h-4 w-4 text-muted-foreground" aria-hidden="true" />
Connect to this instance
</h3>
<p className="mb-3 text-xs leading-relaxed text-muted-foreground">
Use this server address and sign in with your QQQ account.
</p>
{server ? (
<CopyValue key={server} value={server} label="server address" />
) : (
<p className="text-sm text-muted-foreground">
Ask your administrator for the public HTTPS server address before connecting a
client.
</p>
)}
{server && /^http:\/\//.test(server) && (
<p className="mt-2 text-xs text-muted-foreground">
Local development only. Other devices need a reachable HTTPS address.
</p>
)}
</div>
{tool.commands.length > 0 && (
<div className="min-w-0 overflow-hidden rounded-xl bg-slate-950 p-4 text-slate-100">
<div className="mb-2 flex items-center gap-2 border-b border-slate-700 pb-3 text-xs font-medium text-slate-300">
<Terminal className="h-4 w-4" aria-hidden="true" />
Terminal commands
</div>
{tool.commands.map((command, index) =>
command.includes('{server}') && !server ? null : (
<CopyValue
key={`${command}-${server}`}
terminal
label={`${tool.label} command ${index + 1}`}
value={command.replace(/\{server\}/g, `"${server}"`)}
/>
)
)}
</div>
)}
<p className="flex items-start gap-2 text-xs leading-relaxed text-muted-foreground">
<CheckCircle2 className="mt-0.5 h-4 w-4 shrink-0" aria-hidden="true" />
Your existing permissions apply. Each device signs in separately.
</p>
</div>
</div>
<footer className="flex items-start gap-2 border-t border-border px-5 py-4 text-xs leading-relaxed text-muted-foreground sm:px-6">
<Info className="mt-0.5 h-4 w-4 shrink-0" aria-hidden="true" />
{tool.note}
</footer>
</section>
)
}
/** Render the catalog with one selected tool, keeping every available tool visible.
* @param props - Standard widget payload.
* @returns Tool chooser and setup panel, or a recoverable error.
*/
export default function ToolsWidget({ data }: { data: unknown }) {
const metadata = useMetaData()
const { navTargets } = useAppTreeRoutes(metadata.data)
const [selectedId, setSelectedId] = useState<string>()
const parsed = toolsCatalogSchema.safeParse(data)
if (!parsed.success)
return (
<p role="alert">
Tools information could not be loaded. Refresh the page or contact your administrator.
</p>
)
const catalog = parsed.data
const server =
typeof window === 'undefined'
? undefined
: connectionUrl(
catalog.serverUrl,
apiClient.getInstance().defaults.baseURL ?? '',
window.location.origin
)
const available = catalog.tools.filter((tool) => tool.available)
const unavailable = catalog.tools.filter((tool) => !tool.available)
const selected = available.find((tool) => tool.id === selectedId) ?? available[0]
return (
<div className="space-y-5" data-qqq-id="tools-catalog">
<p className="max-w-2xl text-sm leading-relaxed text-muted-foreground">
Your workspace, beyond the browser. Choose a tool to download it and connect to this
instance.
</p>
<div className="grid gap-3 md:grid-cols-3" aria-label="Choose a tool">
{available.map((tool) => (
<button
key={tool.id}
type="button"
aria-pressed={selected?.id === tool.id}
aria-controls="tools-setup-panel"
aria-label={`Set up ${tool.label}`}
data-qqq-id={`tools-card-${tool.id}`}
onClick={() => setSelectedId(tool.id)}
className={`group flex min-w-0 items-center gap-3 rounded-xl border p-4 text-left transition-all focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring md:items-start ${selected?.id === tool.id ? 'border-primary bg-primary/5 shadow-[inset_0_0_0_1px_var(--primary)]' : 'border-border bg-card hover:border-primary/40 hover:bg-muted/30'}`}
>
<span
className={`flex h-10 w-10 shrink-0 items-center justify-center rounded-lg ${selected?.id === tool.id ? 'bg-primary text-primary-foreground' : 'bg-muted text-muted-foreground'}`}
>
<ToolIcon id={tool.id} className="h-5 w-5" />
</span>
<span className="min-w-0 flex-1">
<span className="block text-sm font-semibold">{tool.label}</span>
<span className="mt-1 block text-xs leading-relaxed text-muted-foreground">
{tool.platforms}
</span>
</span>
{selected?.id === tool.id && (
<Check className="h-4 w-4 shrink-0 text-primary" aria-hidden="true" />
)}
</button>
))}
</div>
{selected && (
<SetupPanel
key={selected.id}
tool={selected}
server={server}
management={navTargets.find(
(target) => target.nodeType === 'APP' && target.key === selected.manageApp
)}
/>
)}
{unavailable.length > 0 && (
<details
className="rounded-xl border border-dashed border-border px-4 py-3"
data-qqq-id="tools-unavailable"
>
<summary className="cursor-pointer text-xs font-medium text-muted-foreground">
More tools � {unavailable.length} not enabled
</summary>
<div className="mt-3 grid gap-3 md:grid-cols-3">
{unavailable.map((tool) => (
<div
key={tool.id}
className="rounded-lg bg-muted/40 p-3"
data-qqq-id={`tools-card-${tool.id}`}
>
<h2 className="text-sm font-medium">{tool.label}</h2>
<p className="mt-1 text-xs leading-relaxed text-muted-foreground">
Not enabled on this instance. Ask your administrator about enabling this tool.
</p>
</div>
))}
</div>
</details>
)}
</div>
)
}
+111
View File
@@ -0,0 +1,111 @@
/*
* Copyright 2026 QRun.IO, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
/** @file Tools catalog contract and metadata-based navigation. */
import { z } from 'zod'
import type { QInstance } from '@/types'
import { buildRouteMap } from '@/lib/hooks/use-routes'
export const toolsCatalogSchema = z.object({
serverUrl: z.string().nullish(),
tools: z.array(
z.object({
id: z.string(),
label: z.string(),
summary: z.string(),
platforms: z.string(),
available: z.boolean(),
downloadUrl: z.string().nullish(),
downloadLabel: z.string(),
guideUrl: z.string(),
manageApp: z.string().nullish(),
note: z.string(),
steps: z.array(z.string()),
commands: z.array(z.string()).default([]),
installOptions: z
.array(
z.object({
platform: z.string(),
platformLabel: z.string(),
method: z.string(),
shell: z.string().default(''),
instructions: z.string(),
commands: z.array(z.string()).default([]),
})
)
.default([]),
})
),
})
export type Tool = z.infer<typeof toolsCatalogSchema>['tools'][number]
/** Find the visible app that hosts the tools widget, independent of app naming.
* @param metadata - Permission-filtered instance metadata.
* @returns The Tools destination, if visible.
*/
export function toolsTarget(metadata: QInstance | undefined) {
return buildRouteMap(metadata).navTargets.find(
(target) =>
target.nodeType === 'APP' &&
metadata?.apps[target.key]?.widgets?.some(
(name) =>
metadata.widgets[name]?.type === 'QQQ_TOOLS' &&
metadata.widgets[name]?.hasPermission !== false
)
)
}
/** HTTPS external links only.
* @param value - Configured download or guide URL.
* @returns Safe external URL, or undefined.
*/
export function downloadLink(value: string | null | undefined): string | undefined {
if (!value) return undefined
try {
const url = new URL(value)
return url.protocol === 'https:' && !url.username && !url.password ? url.href : undefined
} catch {
return undefined
}
}
/** Resolve the client-facing server root, retaining reverse-proxy path prefixes.
* @param configured - Optional explicit public server root.
* @param apiBase - Configured UI API base.
* @param origin - Browser origin for relative API paths.
* @returns Safe server root, or undefined when configuration is ambiguous.
*/
export function connectionUrl(
configured: string | null | undefined,
apiBase: string,
origin: string
): string | undefined {
try {
const url = new URL(configured || apiBase, origin)
if (!configured) {
if (!url.pathname.replace(/\/+$/, '').endsWith('/qqq/v1')) return undefined
url.pathname = url.pathname.replace(/\/qqq\/v1\/*$/, '')
}
const loopback = ['localhost', '127.0.0.1', '[::1]'].includes(url.hostname)
if (url.protocol !== 'https:' && !(url.protocol === 'http:' && loopback)) return undefined
if (url.username || url.password || url.search || url.hash) return undefined
const value = url.href.replace(/\/+$/, '')
// Safe inside quoted shell commands on Windows, macOS and Linux.
return /^https?:\/\/[A-Za-z0-9.:/_[\]~%+-]+$/.test(value) ? value : undefined
} catch {
return undefined
}
}
+12
View File
@@ -66,6 +66,18 @@ describe('AppHome', () => {
server.use(http.post('*/table/carrier/count', () => HttpResponse.json({ count: 11 })))
})
it('does not request unsafe totals for authorization-filtered tables even when count capability is present', async () => {
const filteredCount = vi.fn(() => HttpResponse.json({ count: 999 }))
server.use(http.post('*/table/history/count', filteredCount))
renderHome({ name: 'historyApp', label: 'History', sections: [{ name: 'records', label: 'Records', tables: ['carrier', 'history'] }] }, {
...instance,
tables: { ...instance.tables, history: table('history', 'History', { supplementalMetaData: { queryPagination: { authorizationFiltered: true } } }) },
})
expect(await screen.findByText('11 total records')).toBeInTheDocument()
expect(screen.getByRole('link', { name: 'History –' })).toBeInTheDocument()
expect(filteredCount).not.toHaveBeenCalled()
})
it.each([
{ widgets: ['navigation'], hint: undefined, apps: false, sections: true },
{ widgets: undefined, hint: undefined, apps: true, sections: true },
+3 -2
View File
@@ -36,6 +36,7 @@ import { canAccessProcess, canReadRecords, canRunReport } from '@/lib/auth/permi
import { sanitizeQqqId } from '@/lib/utils/qqq-id'
import { cn } from '@/lib/utils/cn'
import { cssMaxWidth } from '@/lib/utils/responsive-grid'
import { authorizationFilteredPagination } from '@/lib/utils/query-pagination'
import { LazyFavoriteToggle } from '@/components/collaboration/LazyFavoriteToggle'
import { MetadataIcon, type MetadataIconKind } from '@/components/layout/MetadataIcon'
import { useWidgetAnchor } from '@/lib/hooks/use-widget-anchor'
@@ -109,7 +110,7 @@ function resolveEntries(names: string[] | undefined, type: QAppTreeNode['type'],
/**
* Renders "N total records" for a table, counted by the backend.
*
* Tables without the `TABLE_COUNT` capability or read permission show a dash.
* Tables without count permission, or with authorization-filtered pages, show a dash.
*
* @param props - Component properties.
* @param props.tableName - Table to count.
@@ -118,7 +119,7 @@ function resolveEntries(names: string[] | undefined, type: QAppTreeNode['type'],
*/
function TableCount({ tableName, instance }: { tableName: string; instance: QInstance }) {
const table = instance.tables?.[tableName]
const countable = Boolean(table?.readPermission && table.capabilities?.includes('TABLE_COUNT'))
const countable = Boolean(table?.readPermission && table.capabilities?.includes('TABLE_COUNT')) && !authorizationFilteredPagination(table)
const { data, isError, isPending } = useQuery({
queryKey: queryKeys.tableCount(tableName, '{}'),
queryFn: () => countRecords(tableName, { filter: {} }),
+14 -1
View File
@@ -242,6 +242,19 @@ interface DynamicComponentProps {
qfmdBridge: QfmdBridge
}
/**
* Material's `QWidgetMetaData.defaultValues` is a `Map`, and bundles such as the QSequentialWorkflow editor read it with
* `defaultValues.get(name)`. Hand them a Map that also keeps plain property access, so existing readers keep working.
*
* @param values - The widget's default values as delivered by the API.
* @returns A Map of the same entries that is also readable as an object.
*/
function materialDefaultValues(values: Record<string, unknown> | undefined): Map<string, unknown> & Record<string, unknown> {
const map = new Map<string, unknown>(Object.entries(values ?? {}))
for (const [key, value] of map) if (!(key in map)) Object.defineProperty(map, key, { value, enumerable: true })
return map as Map<string, unknown> & Record<string, unknown>
}
/**
* Renders a QQQ `customComponent`: loads the bundle at the widget's
* `componentSourceUrl` default value and renders `window[componentName][componentName]`
@@ -284,7 +297,7 @@ export function QqqCustomComponentWidget({ widgetMetaData, data, recordContext }
const sx = widgetMetaData.defaultValues?.sx
return (
<div data-qqq-id={`custom-component-${widgetMetaData.name}`} style={isPlainObject(sx) ? (sx as React.CSSProperties) : undefined}>
<Component props={{ widgetMetaData, widgetData: data, record: recordContext?.record }} qContext={qContext ?? {}} qfmdBridge={qfmdBridge} />
<Component props={{ widgetMetaData: { ...widgetMetaData, defaultValues: materialDefaultValues(widgetMetaData.defaultValues) }, widgetData: data, record: recordContext?.record }} qContext={qContext ?? {}} qfmdBridge={qfmdBridge} />
</div>
)
}
@@ -28,6 +28,12 @@ vi.mock('@/lib/api/tables', () => ({
queryRecords: vi.fn(),
}))
vi.mock('@/components/scripts/IsolatedScriptEditor', () => ({
IsolatedScriptEditor: ({ scriptId, label }: { scriptId: string | number; label: string }) => (
<div data-testid="isolated-editor" data-script-id={scriptId}>{label}</div>
),
}))
const metaState = vi.hoisted(() => ({ processes: {} as Record<string, unknown>, tables: {} as Record<string, unknown> }))
vi.mock('@/lib/hooks/use-metadata', () => ({
useMetaData: () => ({ data: { processes: metaState.processes, tables: metaState.tables, apps: {} } }),
@@ -108,6 +114,42 @@ describe('ScriptViewerWidget', () => {
metaState.tables = {}
})
it('dispatches isolated metadata to the isolated editor without reading legacy tables', async () => {
getRecordMock.mockResolvedValue({ ...script, values: { ...script.values, executionMode: 'ISOLATED' } })
renderWidget()
expect(await screen.findByTestId('isolated-editor')).toHaveAttribute('data-script-id', '1')
expect(screen.getByTestId('isolated-editor')).toHaveTextContent('Owned script')
expect(getRecordMock).toHaveBeenCalledTimes(1)
expect(getRecordMock).toHaveBeenCalledWith('script', '1')
expect(queryRecordsMock).not.toHaveBeenCalled()
})
it('refuses unknown execution modes without loading legacy tables', async () => {
getRecordMock.mockResolvedValue({ ...script, values: { ...script.values, executionMode: 'FUTURE' } })
renderWidget()
expect(await screen.findByRole('alert')).toHaveTextContent('execution mode is not supported')
expect(screen.queryByTestId('isolated-editor')).not.toBeInTheDocument()
expect(getRecordMock).toHaveBeenCalledTimes(1)
expect(queryRecordsMock).not.toHaveBeenCalled()
})
it('refuses a returned script with a different identity before mounting either editor', async () => {
getRecordMock.mockResolvedValue({ ...script, values: { ...script.values, id: 2, executionMode: 'ISOLATED' } })
renderWidget()
expect(await screen.findByRole('alert')).toHaveTextContent('Error loading script')
expect(screen.queryByTestId('isolated-editor')).not.toBeInTheDocument()
expect(getRecordMock).toHaveBeenCalledTimes(1)
expect(queryRecordsMock).not.toHaveBeenCalled()
})
it('shows a failed record lookup without mounting an editor or querying legacy tables', async () => {
getRecordMock.mockRejectedValue(new Error('Unavailable'))
renderWidget()
expect(await screen.findByRole('alert')).toHaveTextContent('Error loading script')
expect(screen.queryByTestId('isolated-editor')).not.toBeInTheDocument()
expect(queryRecordsMock).not.toHaveBeenCalled()
})
it('selects the current revision by default and shows its highlighted file', async () => {
mockScripts()
const { container } = renderWidget()
@@ -64,8 +64,11 @@ export function ScriptViewerWidget({ widgetMetaData, data, recordContext }: Widg
enabled: Boolean(scriptId),
retry: false,
})
const matchesIdentity = scriptQuery.data != null && String(scriptQuery.data.values.id) === String(scriptId)
const executionMode = scriptQuery.data?.values.executionMode
const isLegacy = !executionMode || executionMode === 'LEGACY'
const scriptTypeId = scriptQuery.data?.values.scriptTypeId
const hasScriptType = typeof scriptTypeId === 'string' || typeof scriptTypeId === 'number'
const hasScriptType = matchesIdentity && isLegacy && (typeof scriptTypeId === 'string' || typeof scriptTypeId === 'number')
const scriptTypeQuery = useQuery({
queryKey: ['widget', 'scriptViewer', 'scriptType', scriptTypeId],
queryFn: () => getRecordOrNull(SCRIPT_TYPE_TABLE, scriptTypeId as string | number),
@@ -80,7 +83,7 @@ export function ScriptViewerWidget({ widgetMetaData, data, recordContext }: Widg
body = <p className="text-sm text-muted-foreground" aria-busy="true">Loading script…</p>
} else if (scriptQuery.data === null) {
body = <p role="alert" className="text-sm text-muted-foreground">Script could not be found.</p>
} else if (scriptQuery.isError) {
} else if (scriptQuery.isError || !matchesIdentity) {
body = <p role="alert" className="text-sm text-destructive">Error loading script.</p>
} else {
body = (
Loaded 100 of 182 files, more files were not shown because too many files have changed in this diff. Show more