Compare commits
13
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ecbe9fcc6e
|
||
|
|
4f9eb7e0ff
|
||
|
|
655a1850f6
|
||
|
|
3f9a9c6e48
|
||
|
|
160cc46bd3
|
||
|
|
f09687cf65
|
||
|
|
83755e4969
|
||
|
|
5a9ccc63a6
|
||
|
|
dce92cfba1
|
||
|
|
8d372142f8
|
||
|
|
0f0ebc524f
|
||
|
|
dad2a79824
|
||
|
|
de5c1bd066
|
No files matched your search
@@ -24,13 +24,14 @@ 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@0f33ddadf88b9a1b940b8883782fe2000ee1d2bd
|
||||
uses: QRun-IO/qqq-orb/.github/workflows/next-ci.yml@3d3d0c60f4df6b01b0a502d0e62c395c92e4a09c
|
||||
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@0f33ddadf88b9a1b940b8883782fe2000ee1d2bd
|
||||
uses: QRun-IO/qqq-orb/.github/workflows/next-acceptance.yml@3d3d0c60f4df6b01b0a502d0e62c395c92e4a09c
|
||||
with:
|
||||
qqq_ref: ${{ inputs.qqq_ref || '' }}
|
||||
# Keep PR, main and tag acceptance on the core paired with this lease protocol.
|
||||
qqq_ref: ${{ inputs.qqq_ref || '33464d37e815e61dc0a08bd5b2eb249718dfe1b5' }}
|
||||
dashboard-image:
|
||||
if: (github.event_name == 'pull_request' && !contains(fromJSON('["labeled","unlabeled"]'),
|
||||
github.event.action)) || (github.event_name == 'push' && startsWith(github.ref,
|
||||
@@ -38,4 +39,4 @@ jobs:
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
uses: QRun-IO/qqq-orb/.github/workflows/next-dashboard-image.yml@0f33ddadf88b9a1b940b8883782fe2000ee1d2bd
|
||||
uses: QRun-IO/qqq-orb/.github/workflows/next-dashboard-image.yml@3d3d0c60f4df6b01b0a502d0e62c395c92e4a09c
|
||||
@@ -10,6 +10,8 @@
|
||||
|
||||
# next.js
|
||||
/.next/
|
||||
/.next-collaboration/
|
||||
/.next-presence-live/
|
||||
/out/
|
||||
|
||||
# production
|
||||
|
||||
@@ -3,6 +3,7 @@ WORKDIR /app
|
||||
ENV NEXT_TELEMETRY_DISABLED=1
|
||||
RUN corepack enable && corepack prepare pnpm@9.15.9 --activate
|
||||
COPY package.json pnpm-lock.yaml ./
|
||||
COPY patches ./patches
|
||||
RUN pnpm install --frozen-lockfile
|
||||
COPY . .
|
||||
ENV QQQ_BACKEND_URL=http://host.docker.internal:8000
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
# Collaboration frontend candidate
|
||||
|
||||
This branch prepares 1.0.0-RC.37 for the coordinated collaboration adoption. A version declaration is not publication evidence; verify the remote artifact before adopting it.
|
||||
|
||||
Runtime code is the previously accepted collaboration frontend at 8d372142. The later dce92cfb commit adds a focused mention-read UI assertion. This branch changes release metadata, not that runtime behavior.
|
||||
|
||||
Use QQQ core `4.1.0-collab-33464d37e-SNAPSHOT` and collaboration `0.1.0-collab-8f348da-SNAPSHOT` together. The issued presence lease protocol requires coordinated frontend/backend adoption. Redis-backed hosts additionally use `qbit-redis:0.1.0-redis-7fe5492-SNAPSHOT`. Presence does not replace versioned database save checks.
|
||||
|
||||
Acceptance must select core source `33464d37e815e61dc0a08bd5b2eb249718dfe1b5`. Previous runtime acceptance is GitHub run 39683690943; a new run will verify this release revision before publication. The canonical CircleCI frontend publisher validates matching npm/Maven/tag versions and runs type, lint, test and static-export checks.
|
||||
|
||||
Track host adoption and release evidence in QRun-IO/qqq#1315. This candidate is not a production deployment or a stable release.
|
||||
|
||||
The caller workflow pins the paired core for PR, main and tag acceptance; an explicit dispatch input can test another revision. This avoids the repository-wide `QQQ_ACCEPTANCE_REF=develop` default selecting a different backend during release checks. Update this pin deliberately with the next coordinated backend adoption.
|
||||
|
||||
Publish through the documented release flow: reviewed merge to main, then a signed version tag on that main revision. Do not tag the preparation branch or use a green dispatch as a substitute for the required PR checks.
|
||||
@@ -0,0 +1,20 @@
|
||||
# Windows static export
|
||||
|
||||
Issue: QRun-IO/qqq#1309.
|
||||
|
||||
The package command used POSIX inline environment assignment, which Windows cmd could not execute. scripts/build-export.mjs now starts the installed Next CLI with the same export/mock-API settings through Node. It forwards build arguments and returns a failing status when the child build or integrity check fails.
|
||||
|
||||
A separate Next16.3.8 exporter defect caused nested segment paths on Windows. collectSegmentPaths returns native relative paths; the exporter passed those directly to a filename encoder that replaces only forward slashes. For example, Preferences was written into nested directories rather than the flat __next.!KGRhc2hib2FyZCk.app.preferences.__PAGE__.txt path the browser requests.
|
||||
|
||||
The pinned pnpm patch normalizes separators before filename encoding in both Next CommonJS and ESM exporters. It changes two lines and adds no dependency version upgrades. The patch hash and peer references are recorded in the lockfile. Revisit this patch on a Next upgrade; remove it only when the upstream package passes the same Windows regression.
|
||||
|
||||
Every successful build:export now checks all public prerendered routes. Each generated segment must have its expected browser-addressable output filename, and its bytes must equal the source segment. Next explicitly skips the internal /_global-error component during static export; the checker mirrors that exclusion. It does not skip public routes or suppress browser diagnostics.
|
||||
|
||||
Verification on Windows Node24.22.0:
|
||||
- Original package command fails before Next starts.
|
||||
- Clean native-variable export reproduces25 missing public-route fragments (plus3 internal error files initially included by the diagnostic).
|
||||
- Patched clean export produces all78 public-route segment files with exact source bytes.
|
||||
- Complete pnpm build:export command exits0 with the integrated integrity check.
|
||||
- Script syntax and git diff checks pass.
|
||||
|
||||
Linux export parity passed at4f9eb7e0: acceptance build job120105016937 ran the complete export and verified all78 public segment paths and bytes. Browser acceptance remains in progress. Both dashboard-image jobs exposed a missing Docker COPY for the patch directory before dependency installation; the follow-up adds that dependency-layer input. The corrected Linux Docker frontend stage builds successfully locally, including frozen dependency installation and the standalone production build; both architecture jobs still require remote revalidation. The existing RC37 acceptance result belongs to its earlier unpatched head and is not claimed for this change.
|
||||
@@ -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
|
||||
|
||||
+9
-4
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "qqq-frontend-next",
|
||||
"version": "1.0.0-RC.36",
|
||||
"version": "1.0.0-RC.37",
|
||||
"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",
|
||||
"build:export": "node scripts/build-export.mjs",
|
||||
"build:jar": "pnpm build:export && mvn -B -q clean package",
|
||||
"start": "next start",
|
||||
"lint": "eslint src && node scripts/check-license-headers.mjs",
|
||||
@@ -104,6 +104,7 @@
|
||||
"storybook": "10.6.0",
|
||||
"tailwindcss": "^4.2.1",
|
||||
"typescript": "^5.9.3",
|
||||
"vite": "7.3.7",
|
||||
"vitest": "^4.1.11"
|
||||
},
|
||||
"msw": {
|
||||
@@ -116,10 +117,14 @@
|
||||
"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.1": "1.16.1"
|
||||
},
|
||||
"ignoredOptionalDependencies": [
|
||||
"electron"
|
||||
]
|
||||
],
|
||||
"patchedDependencies": {
|
||||
"next@16.3.8": "patches/next@16.3.8.patch"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
diff --git a/dist/esm/export/index.js b/dist/esm/export/index.js
|
||||
index 6c35f4f47f704caea869a4f89721e0fc71f7f458..e6cb137651229c7f4e1d8c355391c0d96903e998 100644
|
||||
--- a/dist/esm/export/index.js
|
||||
+++ b/dist/esm/export/index.js
|
||||
@@ -717,7 +717,7 @@ async function exportAppImpl(dir, options, span, staticWorker) {
|
||||
const segmentsDirDest = join(outDir, unnormalizedRoute);
|
||||
const segmentPaths = await collectSegmentPaths(segmentsDir);
|
||||
await Promise.all(segmentPaths.map(async (segmentFileSrc)=>{
|
||||
- const segmentPath = '/' + segmentFileSrc.slice(0, -RSC_SEGMENT_SUFFIX.length);
|
||||
+ const segmentPath = '/' + segmentFileSrc.slice(0, -RSC_SEGMENT_SUFFIX.length).split(sep).join('/');
|
||||
const segmentFilename = convertSegmentPathToStaticExportFilename(segmentPath);
|
||||
const segmentFileDest = join(segmentsDirDest, segmentFilename);
|
||||
await fs.mkdir(dirname(segmentFileDest), {
|
||||
diff --git a/dist/export/index.js b/dist/export/index.js
|
||||
index a19941bec99b5c5673834872eaf8a95deb584a34..0a02e470ea21c8c29a08f01b70d7584040678b1b 100644
|
||||
--- a/dist/export/index.js
|
||||
+++ b/dist/export/index.js
|
||||
@@ -785,7 +785,7 @@ async function exportAppImpl(dir, options, span, staticWorker) {
|
||||
const segmentsDirDest = (0, _path.join)(outDir, unnormalizedRoute);
|
||||
const segmentPaths = await collectSegmentPaths(segmentsDir);
|
||||
await Promise.all(segmentPaths.map(async (segmentFileSrc)=>{
|
||||
- const segmentPath = '/' + segmentFileSrc.slice(0, -_constants.RSC_SEGMENT_SUFFIX.length);
|
||||
+ const segmentPath = '/' + segmentFileSrc.slice(0, -_constants.RSC_SEGMENT_SUFFIX.length).split(_path.sep).join('/');
|
||||
const segmentFilename = (0, _segmentvalueencoding.convertSegmentPathToStaticExportFilename)(segmentPath);
|
||||
const segmentFileDest = (0, _path.join)(segmentsDirDest, segmentFilename);
|
||||
await _fs.promises.mkdir((0, _path.dirname)(segmentFileDest), {
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
Generated
+50
-41
@@ -9,6 +9,12 @@ overrides:
|
||||
'@humanfs/node': '>=0.16.8'
|
||||
'@babel/core': '>=7.29.6 <8'
|
||||
source-map-js: '>=1.2.2'
|
||||
follow-redirects@<1.16.1: 1.16.1
|
||||
|
||||
patchedDependencies:
|
||||
next@16.3.8:
|
||||
hash: 7lgylyvrg3k4lckqgmpbjdieaa
|
||||
path: patches/next@16.3.8.patch
|
||||
|
||||
importers:
|
||||
|
||||
@@ -100,7 +106,7 @@ importers:
|
||||
version: 0.485.0(react@19.2.4)
|
||||
next:
|
||||
specifier: 16.3.8
|
||||
version: 16.3.8(@babel/core@7.29.7)(@playwright/test@1.64.0-alpha-2026-10-01)(@types/node@22.19.11)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
version: 16.3.8(patch_hash=7lgylyvrg3k4lckqgmpbjdieaa)(@babel/core@7.29.7)(@playwright/test@1.64.0-alpha-2026-10-01)(@types/node@22.19.11)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
rapidoc:
|
||||
specifier: 10.1.0
|
||||
version: 10.1.0
|
||||
@@ -155,7 +161,7 @@ importers:
|
||||
version: 10.6.0(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(storybook@10.6.0(@types/react@19.2.14)(prettier@3.8.1)(react@19.2.4))
|
||||
'@storybook/nextjs-vite':
|
||||
specifier: 10.6.0
|
||||
version: 10.6.0(@babel/core@7.29.7)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(next@16.3.8(@babel/core@7.29.7)(@playwright/test@1.64.0-alpha-2026-10-01)(@types/node@22.19.11)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(rollup@4.64.6)(storybook@10.6.0(@types/react@19.2.14)(prettier@3.8.1)(react@19.2.4))(typescript@5.9.3)(vite@7.3.6(@types/node@22.19.11)(jiti@2.7.0)(lightningcss@1.31.1)(terser@5.51.2)(yaml@2.9.1))
|
||||
version: 10.6.0(@babel/core@7.29.7)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(next@16.3.8(patch_hash=7lgylyvrg3k4lckqgmpbjdieaa)(@babel/core@7.29.7)(@playwright/test@1.64.0-alpha-2026-10-01)(@types/node@22.19.11)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(rollup@4.64.6)(storybook@10.6.0(@types/react@19.2.14)(prettier@3.8.1)(react@19.2.4))(typescript@5.9.3)(vite@7.3.7(@types/node@22.19.11)(jiti@2.7.0)(lightningcss@1.31.1)(terser@5.51.2)(yaml@2.9.1))
|
||||
'@storybook/react':
|
||||
specifier: 10.6.0
|
||||
version: 10.6.0(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(storybook@10.6.0(@types/react@19.2.14)(prettier@3.8.1)(react@19.2.4))(typescript@5.9.3)
|
||||
@@ -188,7 +194,7 @@ importers:
|
||||
version: 19.2.3(@types/react@19.2.14)
|
||||
'@vitejs/plugin-react':
|
||||
specifier: ^5.1.4
|
||||
version: 5.1.4(vite@7.3.6(@types/node@22.19.11)(jiti@2.7.0)(lightningcss@1.31.1)(terser@5.51.2)(yaml@2.9.1))
|
||||
version: 5.1.4(vite@7.3.7(@types/node@22.19.11)(jiti@2.7.0)(lightningcss@1.31.1)(terser@5.51.2)(yaml@2.9.1))
|
||||
'@vitest/coverage-v8':
|
||||
specifier: ^4.1.11
|
||||
version: 4.1.11(vitest@4.1.11)
|
||||
@@ -234,9 +240,12 @@ importers:
|
||||
typescript:
|
||||
specifier: ^5.9.3
|
||||
version: 5.9.3
|
||||
vite:
|
||||
specifier: 7.3.7
|
||||
version: 7.3.7(@types/node@22.19.11)(jiti@2.7.0)(lightningcss@1.31.1)(terser@5.51.2)(yaml@2.9.1)
|
||||
vitest:
|
||||
specifier: ^4.1.11
|
||||
version: 4.1.11(@types/node@22.19.11)(@vitest/coverage-v8@4.1.11)(@vitest/ui@4.1.11)(jsdom@26.1.0)(msw@2.12.10(@types/node@22.19.11)(typescript@5.9.3))(vite@7.3.6(@types/node@22.19.11)(jiti@2.7.0)(lightningcss@1.31.1)(terser@5.51.2)(yaml@2.9.1))
|
||||
version: 4.1.11(@types/node@22.19.11)(@vitest/coverage-v8@4.1.11)(@vitest/ui@4.1.11)(jsdom@26.1.0)(msw@2.12.10(@types/node@22.19.11)(typescript@5.9.3))(vite@7.3.7(@types/node@22.19.11)(jiti@2.7.0)(lightningcss@1.31.1)(terser@5.51.2)(yaml@2.9.1))
|
||||
|
||||
packages:
|
||||
|
||||
@@ -3275,8 +3284,8 @@ packages:
|
||||
flatted@3.4.4:
|
||||
resolution: {integrity: sha512-5+ybhBZANEJxaH3X5evAFatUxLfEHSr7n6kYJ+1Qd0mUqr4eu9gIf6GDbWHf8RJijHrjjO8G+la14SlL2SeS1Q==}
|
||||
|
||||
follow-redirects@1.16.0:
|
||||
resolution: {integrity: sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==}
|
||||
follow-redirects@1.16.1:
|
||||
resolution: {integrity: sha512-FNvFGzoMLWmE6Yj9spb/zjd7yiNCHiAW9/Tg9CXrQ8wuu32HtlJOwWO11OJafl5FfY3DxTdQ0vj42zU1kvv5jg==}
|
||||
engines: {node: '>=4.0'}
|
||||
peerDependencies:
|
||||
debug: '*'
|
||||
@@ -5093,8 +5102,8 @@ packages:
|
||||
vite:
|
||||
optional: true
|
||||
|
||||
vite@7.3.6:
|
||||
resolution: {integrity: sha512-4XP60spRGjSZFf1qYH+dJIkK2znL3zQfl9KkOV9MkkRR/3Dls0dxaBsQPTloEc5BLXWPL9vsOxopxyKoMmDueg==}
|
||||
vite@7.3.7:
|
||||
resolution: {integrity: sha512-AHEzTORMbi7/7aXp7w7KZ5+DCUdSeLsJz0grY8xHpc9DHdYb0vQu6DqJfm2tOw3gDExNgPgnJTXxM6syYFHaCA==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
hasBin: true
|
||||
peerDependencies:
|
||||
@@ -5926,11 +5935,11 @@ snapshots:
|
||||
'@types/yargs': 17.0.35
|
||||
chalk: 4.1.2
|
||||
|
||||
'@joshwooding/vite-plugin-react-docgen-typescript@0.7.0(typescript@5.9.3)(vite@7.3.6(@types/node@22.19.11)(jiti@2.7.0)(lightningcss@1.31.1)(terser@5.51.2)(yaml@2.9.1))':
|
||||
'@joshwooding/vite-plugin-react-docgen-typescript@0.7.0(typescript@5.9.3)(vite@7.3.7(@types/node@22.19.11)(jiti@2.7.0)(lightningcss@1.31.1)(terser@5.51.2)(yaml@2.9.1))':
|
||||
dependencies:
|
||||
glob: 13.0.6
|
||||
react-docgen-typescript: 2.4.0(typescript@5.9.3)
|
||||
vite: 7.3.6(@types/node@22.19.11)(jiti@2.7.0)(lightningcss@1.31.1)(terser@5.51.2)(yaml@2.9.1)
|
||||
vite: 7.3.7(@types/node@22.19.11)(jiti@2.7.0)(lightningcss@1.31.1)(terser@5.51.2)(yaml@2.9.1)
|
||||
optionalDependencies:
|
||||
typescript: 5.9.3
|
||||
|
||||
@@ -6738,11 +6747,11 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- '@types/react-dom'
|
||||
|
||||
'@storybook/builder-vite@10.6.0(storybook@10.6.0(@types/react@19.2.14)(prettier@3.8.1)(react@19.2.4))(vite@7.3.6(@types/node@22.19.11)(jiti@2.7.0)(lightningcss@1.31.1)(terser@5.51.2)(yaml@2.9.1))':
|
||||
'@storybook/builder-vite@10.6.0(storybook@10.6.0(@types/react@19.2.14)(prettier@3.8.1)(react@19.2.4))(vite@7.3.7(@types/node@22.19.11)(jiti@2.7.0)(lightningcss@1.31.1)(terser@5.51.2)(yaml@2.9.1))':
|
||||
dependencies:
|
||||
storybook: 10.6.0(@types/react@19.2.14)(prettier@3.8.1)(react@19.2.4)
|
||||
ts-dedent: 2.3.0
|
||||
vite: 7.3.6(@types/node@22.19.11)(jiti@2.7.0)(lightningcss@1.31.1)(terser@5.51.2)(yaml@2.9.1)
|
||||
vite: 7.3.7(@types/node@22.19.11)(jiti@2.7.0)(lightningcss@1.31.1)(terser@5.51.2)(yaml@2.9.1)
|
||||
|
||||
'@storybook/global@5.0.0': {}
|
||||
|
||||
@@ -6750,18 +6759,18 @@ snapshots:
|
||||
dependencies:
|
||||
react: 19.2.4
|
||||
|
||||
'@storybook/nextjs-vite@10.6.0(@babel/core@7.29.7)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(next@16.3.8(@babel/core@7.29.7)(@playwright/test@1.64.0-alpha-2026-10-01)(@types/node@22.19.11)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(rollup@4.64.6)(storybook@10.6.0(@types/react@19.2.14)(prettier@3.8.1)(react@19.2.4))(typescript@5.9.3)(vite@7.3.6(@types/node@22.19.11)(jiti@2.7.0)(lightningcss@1.31.1)(terser@5.51.2)(yaml@2.9.1))':
|
||||
'@storybook/nextjs-vite@10.6.0(@babel/core@7.29.7)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(next@16.3.8(patch_hash=7lgylyvrg3k4lckqgmpbjdieaa)(@babel/core@7.29.7)(@playwright/test@1.64.0-alpha-2026-10-01)(@types/node@22.19.11)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(rollup@4.64.6)(storybook@10.6.0(@types/react@19.2.14)(prettier@3.8.1)(react@19.2.4))(typescript@5.9.3)(vite@7.3.7(@types/node@22.19.11)(jiti@2.7.0)(lightningcss@1.31.1)(terser@5.51.2)(yaml@2.9.1))':
|
||||
dependencies:
|
||||
'@storybook/builder-vite': 10.6.0(storybook@10.6.0(@types/react@19.2.14)(prettier@3.8.1)(react@19.2.4))(vite@7.3.6(@types/node@22.19.11)(jiti@2.7.0)(lightningcss@1.31.1)(terser@5.51.2)(yaml@2.9.1))
|
||||
'@storybook/builder-vite': 10.6.0(storybook@10.6.0(@types/react@19.2.14)(prettier@3.8.1)(react@19.2.4))(vite@7.3.7(@types/node@22.19.11)(jiti@2.7.0)(lightningcss@1.31.1)(terser@5.51.2)(yaml@2.9.1))
|
||||
'@storybook/react': 10.6.0(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(storybook@10.6.0(@types/react@19.2.14)(prettier@3.8.1)(react@19.2.4))(typescript@5.9.3)
|
||||
'@storybook/react-vite': 10.6.0(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(rollup@4.64.6)(storybook@10.6.0(@types/react@19.2.14)(prettier@3.8.1)(react@19.2.4))(typescript@5.9.3)(vite@7.3.6(@types/node@22.19.11)(jiti@2.7.0)(lightningcss@1.31.1)(terser@5.51.2)(yaml@2.9.1))
|
||||
next: 16.3.8(@babel/core@7.29.7)(@playwright/test@1.64.0-alpha-2026-10-01)(@types/node@22.19.11)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
'@storybook/react-vite': 10.6.0(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(rollup@4.64.6)(storybook@10.6.0(@types/react@19.2.14)(prettier@3.8.1)(react@19.2.4))(typescript@5.9.3)(vite@7.3.7(@types/node@22.19.11)(jiti@2.7.0)(lightningcss@1.31.1)(terser@5.51.2)(yaml@2.9.1))
|
||||
next: 16.3.8(patch_hash=7lgylyvrg3k4lckqgmpbjdieaa)(@babel/core@7.29.7)(@playwright/test@1.64.0-alpha-2026-10-01)(@types/node@22.19.11)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
react: 19.2.4
|
||||
react-dom: 19.2.4(react@19.2.4)
|
||||
storybook: 10.6.0(@types/react@19.2.14)(prettier@3.8.1)(react@19.2.4)
|
||||
styled-jsx: 5.1.6(@babel/core@7.29.7)(react@19.2.4)
|
||||
vite: 7.3.6(@types/node@22.19.11)(jiti@2.7.0)(lightningcss@1.31.1)(terser@5.51.2)(yaml@2.9.1)
|
||||
vite-plugin-storybook-nextjs: 10.6.0(next@16.3.8(@babel/core@7.29.7)(@playwright/test@1.64.0-alpha-2026-10-01)(@types/node@22.19.11)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(rollup@4.64.6)(storybook@10.6.0(@types/react@19.2.14)(prettier@3.8.1)(react@19.2.4))(typescript@5.9.3)(vite@7.3.6(@types/node@22.19.11)(jiti@2.7.0)(lightningcss@1.31.1)(terser@5.51.2)(yaml@2.9.1))
|
||||
vite: 7.3.7(@types/node@22.19.11)(jiti@2.7.0)(lightningcss@1.31.1)(terser@5.51.2)(yaml@2.9.1)
|
||||
vite-plugin-storybook-nextjs: 10.6.0(next@16.3.8(patch_hash=7lgylyvrg3k4lckqgmpbjdieaa)(@babel/core@7.29.7)(@playwright/test@1.64.0-alpha-2026-10-01)(@types/node@22.19.11)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(rollup@4.64.6)(storybook@10.6.0(@types/react@19.2.14)(prettier@3.8.1)(react@19.2.4))(typescript@5.9.3)(vite@7.3.7(@types/node@22.19.11)(jiti@2.7.0)(lightningcss@1.31.1)(terser@5.51.2)(yaml@2.9.1))
|
||||
optionalDependencies:
|
||||
'@types/react': 19.2.14
|
||||
'@types/react-dom': 19.2.3(@types/react@19.2.14)
|
||||
@@ -6781,11 +6790,11 @@ snapshots:
|
||||
'@types/react': 19.2.14
|
||||
'@types/react-dom': 19.2.3(@types/react@19.2.14)
|
||||
|
||||
'@storybook/react-vite@10.6.0(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(rollup@4.64.6)(storybook@10.6.0(@types/react@19.2.14)(prettier@3.8.1)(react@19.2.4))(typescript@5.9.3)(vite@7.3.6(@types/node@22.19.11)(jiti@2.7.0)(lightningcss@1.31.1)(terser@5.51.2)(yaml@2.9.1))':
|
||||
'@storybook/react-vite@10.6.0(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(rollup@4.64.6)(storybook@10.6.0(@types/react@19.2.14)(prettier@3.8.1)(react@19.2.4))(typescript@5.9.3)(vite@7.3.7(@types/node@22.19.11)(jiti@2.7.0)(lightningcss@1.31.1)(terser@5.51.2)(yaml@2.9.1))':
|
||||
dependencies:
|
||||
'@joshwooding/vite-plugin-react-docgen-typescript': 0.7.0(typescript@5.9.3)(vite@7.3.6(@types/node@22.19.11)(jiti@2.7.0)(lightningcss@1.31.1)(terser@5.51.2)(yaml@2.9.1))
|
||||
'@joshwooding/vite-plugin-react-docgen-typescript': 0.7.0(typescript@5.9.3)(vite@7.3.7(@types/node@22.19.11)(jiti@2.7.0)(lightningcss@1.31.1)(terser@5.51.2)(yaml@2.9.1))
|
||||
'@rollup/pluginutils': 5.4.0(rollup@4.64.6)
|
||||
'@storybook/builder-vite': 10.6.0(storybook@10.6.0(@types/react@19.2.14)(prettier@3.8.1)(react@19.2.4))(vite@7.3.6(@types/node@22.19.11)(jiti@2.7.0)(lightningcss@1.31.1)(terser@5.51.2)(yaml@2.9.1))
|
||||
'@storybook/builder-vite': 10.6.0(storybook@10.6.0(@types/react@19.2.14)(prettier@3.8.1)(react@19.2.4))(vite@7.3.7(@types/node@22.19.11)(jiti@2.7.0)(lightningcss@1.31.1)(terser@5.51.2)(yaml@2.9.1))
|
||||
'@storybook/react': 10.6.0(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(storybook@10.6.0(@types/react@19.2.14)(prettier@3.8.1)(react@19.2.4))(typescript@5.9.3)
|
||||
empathic: 2.1.0
|
||||
magic-string: 1.4.2
|
||||
@@ -6795,7 +6804,7 @@ snapshots:
|
||||
resolve: 1.22.12
|
||||
storybook: 10.6.0(@types/react@19.2.14)(prettier@3.8.1)(react@19.2.4)
|
||||
tsconfig-paths: 4.2.0
|
||||
vite: 7.3.6(@types/node@22.19.11)(jiti@2.7.0)(lightningcss@1.31.1)(terser@5.51.2)(yaml@2.9.1)
|
||||
vite: 7.3.7(@types/node@22.19.11)(jiti@2.7.0)(lightningcss@1.31.1)(terser@5.51.2)(yaml@2.9.1)
|
||||
optionalDependencies:
|
||||
typescript: 5.9.3
|
||||
transitivePeerDependencies:
|
||||
@@ -7252,7 +7261,7 @@ snapshots:
|
||||
'@unrs/resolver-binding-win32-x64-msvc@1.12.2':
|
||||
optional: true
|
||||
|
||||
'@vitejs/plugin-react@5.1.4(vite@7.3.6(@types/node@22.19.11)(jiti@2.7.0)(lightningcss@1.31.1)(terser@5.51.2)(yaml@2.9.1))':
|
||||
'@vitejs/plugin-react@5.1.4(vite@7.3.7(@types/node@22.19.11)(jiti@2.7.0)(lightningcss@1.31.1)(terser@5.51.2)(yaml@2.9.1))':
|
||||
dependencies:
|
||||
'@babel/core': 7.29.7
|
||||
'@babel/plugin-transform-react-jsx-self': 7.27.1(@babel/core@7.29.7)
|
||||
@@ -7260,7 +7269,7 @@ snapshots:
|
||||
'@rolldown/pluginutils': 1.0.0-rc.3
|
||||
'@types/babel__core': 7.20.5
|
||||
react-refresh: 0.18.0
|
||||
vite: 7.3.6(@types/node@22.19.11)(jiti@2.7.0)(lightningcss@1.31.1)(terser@5.51.2)(yaml@2.9.1)
|
||||
vite: 7.3.7(@types/node@22.19.11)(jiti@2.7.0)(lightningcss@1.31.1)(terser@5.51.2)(yaml@2.9.1)
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
@@ -7276,7 +7285,7 @@ snapshots:
|
||||
obug: 2.2.1
|
||||
std-env: 4.2.0
|
||||
tinyrainbow: 3.1.1
|
||||
vitest: 4.1.11(@types/node@22.19.11)(@vitest/coverage-v8@4.1.11)(@vitest/ui@4.1.11)(jsdom@26.1.0)(msw@2.12.10(@types/node@22.19.11)(typescript@5.9.3))(vite@7.3.6(@types/node@22.19.11)(jiti@2.7.0)(lightningcss@1.31.1)(terser@5.51.2)(yaml@2.9.1))
|
||||
vitest: 4.1.11(@types/node@22.19.11)(@vitest/coverage-v8@4.1.11)(@vitest/ui@4.1.11)(jsdom@26.1.0)(msw@2.12.10(@types/node@22.19.11)(typescript@5.9.3))(vite@7.3.7(@types/node@22.19.11)(jiti@2.7.0)(lightningcss@1.31.1)(terser@5.51.2)(yaml@2.9.1))
|
||||
|
||||
'@vitest/expect@3.2.4':
|
||||
dependencies:
|
||||
@@ -7295,14 +7304,14 @@ snapshots:
|
||||
chai: 6.2.2
|
||||
tinyrainbow: 3.1.1
|
||||
|
||||
'@vitest/mocker@4.1.11(msw@2.12.10(@types/node@22.19.11)(typescript@5.9.3))(vite@7.3.6(@types/node@22.19.11)(jiti@2.7.0)(lightningcss@1.31.1)(terser@5.51.2)(yaml@2.9.1))':
|
||||
'@vitest/mocker@4.1.11(msw@2.12.10(@types/node@22.19.11)(typescript@5.9.3))(vite@7.3.7(@types/node@22.19.11)(jiti@2.7.0)(lightningcss@1.31.1)(terser@5.51.2)(yaml@2.9.1))':
|
||||
dependencies:
|
||||
'@vitest/spy': 4.1.11
|
||||
estree-walker: 3.0.3
|
||||
magic-string: 0.30.21
|
||||
optionalDependencies:
|
||||
msw: 2.12.10(@types/node@22.19.11)(typescript@5.9.3)
|
||||
vite: 7.3.6(@types/node@22.19.11)(jiti@2.7.0)(lightningcss@1.31.1)(terser@5.51.2)(yaml@2.9.1)
|
||||
vite: 7.3.7(@types/node@22.19.11)(jiti@2.7.0)(lightningcss@1.31.1)(terser@5.51.2)(yaml@2.9.1)
|
||||
|
||||
'@vitest/pretty-format@3.2.4':
|
||||
dependencies:
|
||||
@@ -7339,7 +7348,7 @@ snapshots:
|
||||
sirv: 3.0.2
|
||||
tinyglobby: 0.2.17
|
||||
tinyrainbow: 3.1.1
|
||||
vitest: 4.1.11(@types/node@22.19.11)(@vitest/coverage-v8@4.1.11)(@vitest/ui@4.1.11)(jsdom@26.1.0)(msw@2.12.10(@types/node@22.19.11)(typescript@5.9.3))(vite@7.3.6(@types/node@22.19.11)(jiti@2.7.0)(lightningcss@1.31.1)(terser@5.51.2)(yaml@2.9.1))
|
||||
vitest: 4.1.11(@types/node@22.19.11)(@vitest/coverage-v8@4.1.11)(@vitest/ui@4.1.11)(jsdom@26.1.0)(msw@2.12.10(@types/node@22.19.11)(typescript@5.9.3))(vite@7.3.7(@types/node@22.19.11)(jiti@2.7.0)(lightningcss@1.31.1)(terser@5.51.2)(yaml@2.9.1))
|
||||
|
||||
'@vitest/utils@3.2.4':
|
||||
dependencies:
|
||||
@@ -7526,7 +7535,7 @@ snapshots:
|
||||
|
||||
axios@1.20.0:
|
||||
dependencies:
|
||||
follow-redirects: 1.16.0
|
||||
follow-redirects: 1.16.1
|
||||
form-data: 4.0.6
|
||||
https-proxy-agent: 5.0.1
|
||||
proxy-from-env: 2.1.0
|
||||
@@ -8328,7 +8337,7 @@ snapshots:
|
||||
|
||||
flatted@3.4.4: {}
|
||||
|
||||
follow-redirects@1.16.0: {}
|
||||
follow-redirects@1.16.1: {}
|
||||
|
||||
for-each@0.3.5:
|
||||
dependencies:
|
||||
@@ -9444,7 +9453,7 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
next@16.3.8(@babel/core@7.29.7)(@playwright/test@1.64.0-alpha-2026-10-01)(@types/node@22.19.11)(react-dom@19.2.4(react@19.2.4))(react@19.2.4):
|
||||
next@16.3.8(patch_hash=7lgylyvrg3k4lckqgmpbjdieaa)(@babel/core@7.29.7)(@playwright/test@1.64.0-alpha-2026-10-01)(@types/node@22.19.11)(react-dom@19.2.4(react@19.2.4))(react@19.2.4):
|
||||
dependencies:
|
||||
'@next/env': 16.3.8
|
||||
'@swc/helpers': 0.5.23
|
||||
@@ -10619,37 +10628,37 @@ snapshots:
|
||||
d3-time: 3.1.0
|
||||
d3-timer: 3.0.1
|
||||
|
||||
vite-plugin-storybook-nextjs@10.6.0(next@16.3.8(@babel/core@7.29.7)(@playwright/test@1.64.0-alpha-2026-10-01)(@types/node@22.19.11)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(rollup@4.64.6)(storybook@10.6.0(@types/react@19.2.14)(prettier@3.8.1)(react@19.2.4))(typescript@5.9.3)(vite@7.3.6(@types/node@22.19.11)(jiti@2.7.0)(lightningcss@1.31.1)(terser@5.51.2)(yaml@2.9.1)):
|
||||
vite-plugin-storybook-nextjs@10.6.0(next@16.3.8(patch_hash=7lgylyvrg3k4lckqgmpbjdieaa)(@babel/core@7.29.7)(@playwright/test@1.64.0-alpha-2026-10-01)(@types/node@22.19.11)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(rollup@4.64.6)(storybook@10.6.0(@types/react@19.2.14)(prettier@3.8.1)(react@19.2.4))(typescript@5.9.3)(vite@7.3.7(@types/node@22.19.11)(jiti@2.7.0)(lightningcss@1.31.1)(terser@5.51.2)(yaml@2.9.1)):
|
||||
dependencies:
|
||||
'@next/env': 16.4.0
|
||||
'@rollup/pluginutils': 5.4.0(rollup@4.64.6)
|
||||
magic-string: 0.30.21
|
||||
module-alias: 2.3.4
|
||||
next: 16.3.8(@babel/core@7.29.7)(@playwright/test@1.64.0-alpha-2026-10-01)(@types/node@22.19.11)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
next: 16.3.8(patch_hash=7lgylyvrg3k4lckqgmpbjdieaa)(@babel/core@7.29.7)(@playwright/test@1.64.0-alpha-2026-10-01)(@types/node@22.19.11)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
pathe: 2.0.3
|
||||
probe-image-size: 7.4.0
|
||||
semver: 7.8.5
|
||||
storybook: 10.6.0(@types/react@19.2.14)(prettier@3.8.1)(react@19.2.4)
|
||||
ts-dedent: 2.3.0
|
||||
vite: 7.3.6(@types/node@22.19.11)(jiti@2.7.0)(lightningcss@1.31.1)(terser@5.51.2)(yaml@2.9.1)
|
||||
vite-tsconfig-paths: 5.1.4(typescript@5.9.3)(vite@7.3.6(@types/node@22.19.11)(jiti@2.7.0)(lightningcss@1.31.1)(terser@5.51.2)(yaml@2.9.1))
|
||||
vite: 7.3.7(@types/node@22.19.11)(jiti@2.7.0)(lightningcss@1.31.1)(terser@5.51.2)(yaml@2.9.1)
|
||||
vite-tsconfig-paths: 5.1.4(typescript@5.9.3)(vite@7.3.7(@types/node@22.19.11)(jiti@2.7.0)(lightningcss@1.31.1)(terser@5.51.2)(yaml@2.9.1))
|
||||
transitivePeerDependencies:
|
||||
- rollup
|
||||
- supports-color
|
||||
- typescript
|
||||
|
||||
vite-tsconfig-paths@5.1.4(typescript@5.9.3)(vite@7.3.6(@types/node@22.19.11)(jiti@2.7.0)(lightningcss@1.31.1)(terser@5.51.2)(yaml@2.9.1)):
|
||||
vite-tsconfig-paths@5.1.4(typescript@5.9.3)(vite@7.3.7(@types/node@22.19.11)(jiti@2.7.0)(lightningcss@1.31.1)(terser@5.51.2)(yaml@2.9.1)):
|
||||
dependencies:
|
||||
debug: 4.4.3
|
||||
globrex: 0.1.2
|
||||
tsconfck: 3.1.6(typescript@5.9.3)
|
||||
optionalDependencies:
|
||||
vite: 7.3.6(@types/node@22.19.11)(jiti@2.7.0)(lightningcss@1.31.1)(terser@5.51.2)(yaml@2.9.1)
|
||||
vite: 7.3.7(@types/node@22.19.11)(jiti@2.7.0)(lightningcss@1.31.1)(terser@5.51.2)(yaml@2.9.1)
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
- typescript
|
||||
|
||||
vite@7.3.6(@types/node@22.19.11)(jiti@2.7.0)(lightningcss@1.31.1)(terser@5.51.2)(yaml@2.9.1):
|
||||
vite@7.3.7(@types/node@22.19.11)(jiti@2.7.0)(lightningcss@1.31.1)(terser@5.51.2)(yaml@2.9.1):
|
||||
dependencies:
|
||||
esbuild: 0.28.2
|
||||
fdir: 6.5.0(picomatch@4.0.7)
|
||||
@@ -10665,10 +10674,10 @@ snapshots:
|
||||
terser: 5.51.2
|
||||
yaml: 2.9.1
|
||||
|
||||
vitest@4.1.11(@types/node@22.19.11)(@vitest/coverage-v8@4.1.11)(@vitest/ui@4.1.11)(jsdom@26.1.0)(msw@2.12.10(@types/node@22.19.11)(typescript@5.9.3))(vite@7.3.6(@types/node@22.19.11)(jiti@2.7.0)(lightningcss@1.31.1)(terser@5.51.2)(yaml@2.9.1)):
|
||||
vitest@4.1.11(@types/node@22.19.11)(@vitest/coverage-v8@4.1.11)(@vitest/ui@4.1.11)(jsdom@26.1.0)(msw@2.12.10(@types/node@22.19.11)(typescript@5.9.3))(vite@7.3.7(@types/node@22.19.11)(jiti@2.7.0)(lightningcss@1.31.1)(terser@5.51.2)(yaml@2.9.1)):
|
||||
dependencies:
|
||||
'@vitest/expect': 4.1.11
|
||||
'@vitest/mocker': 4.1.11(msw@2.12.10(@types/node@22.19.11)(typescript@5.9.3))(vite@7.3.6(@types/node@22.19.11)(jiti@2.7.0)(lightningcss@1.31.1)(terser@5.51.2)(yaml@2.9.1))
|
||||
'@vitest/mocker': 4.1.11(msw@2.12.10(@types/node@22.19.11)(typescript@5.9.3))(vite@7.3.7(@types/node@22.19.11)(jiti@2.7.0)(lightningcss@1.31.1)(terser@5.51.2)(yaml@2.9.1))
|
||||
'@vitest/pretty-format': 4.1.11
|
||||
'@vitest/runner': 4.1.11
|
||||
'@vitest/snapshot': 4.1.11
|
||||
@@ -10685,7 +10694,7 @@ snapshots:
|
||||
tinyexec: 1.3.1
|
||||
tinyglobby: 0.2.17
|
||||
tinyrainbow: 3.1.1
|
||||
vite: 7.3.6(@types/node@22.19.11)(jiti@2.7.0)(lightningcss@1.31.1)(terser@5.51.2)(yaml@2.9.1)
|
||||
vite: 7.3.7(@types/node@22.19.11)(jiti@2.7.0)(lightningcss@1.31.1)(terser@5.51.2)(yaml@2.9.1)
|
||||
why-is-node-running: 2.3.0
|
||||
optionalDependencies:
|
||||
'@types/node': 22.19.11
|
||||
|
||||
@@ -47,7 +47,7 @@
|
||||
</developers>
|
||||
|
||||
<properties>
|
||||
<revision>1.0.0-RC.36</revision>
|
||||
<revision>1.0.0-RC.37</revision>
|
||||
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
|
||||
<project.build.outputTimestamp>2026-09-24T00:00:00Z</project.build.outputTimestamp>
|
||||
</properties>
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
/*
|
||||
* 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
|
||||
*/
|
||||
|
||||
// Set export options without platform-specific shell assignment syntax.
|
||||
import { spawn } from 'node:child_process'
|
||||
import { createRequire } from 'node:module'
|
||||
|
||||
const require = createRequire(import.meta.url)
|
||||
const build = spawn(process.execPath, [require.resolve('next/dist/bin/next'), 'build', ...process.argv.slice(2)], {
|
||||
stdio: 'inherit',
|
||||
env: { ...process.env, QQQ_NEXT_OUTPUT: 'export', NEXT_PUBLIC_MOCK_API: 'false' },
|
||||
})
|
||||
for (const signal of ['SIGINT', 'SIGTERM']) process.on(signal, () => build.kill('SIGTERM'))
|
||||
build.on('error', (error) => {
|
||||
console.error(error.message)
|
||||
process.exitCode = 1
|
||||
})
|
||||
build.on('exit', async (code) => {
|
||||
if (code !== 0) process.exit(code ?? 1)
|
||||
await import('./check-export-segments.mjs')
|
||||
})
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
import assert from 'node:assert/strict'
|
||||
import { existsSync, readdirSync, readFileSync } from 'node:fs'
|
||||
import path from 'node:path'
|
||||
|
||||
const dist = process.env.QQQ_NEXT_DIST_DIR || '.next'
|
||||
const manifest = JSON.parse(readFileSync(path.join(dist, 'prerender-manifest.json'), 'utf8'))
|
||||
let checked = 0
|
||||
const missing = []
|
||||
|
||||
/** Return every generated segment beneath a route's source directory. */
|
||||
function segments(directory) {
|
||||
return readdirSync(directory, { withFileTypes: true }).flatMap((entry) => {
|
||||
const filename = path.join(directory, entry.name)
|
||||
return entry.isDirectory() ? segments(filename) : entry.name.endsWith('.segment.rsc') ? [filename] : []
|
||||
})
|
||||
}
|
||||
|
||||
for (const [route, metadata] of Object.entries(manifest.routes)) {
|
||||
// Next explicitly excludes this internal error component from static export.
|
||||
if (route === '/_global-error') continue
|
||||
if (!metadata.dataRoute?.endsWith('.rsc')) continue
|
||||
const directory = path.join(dist, 'server/app', metadata.dataRoute.slice(1, -4) + '.segments')
|
||||
assert.ok(existsSync(directory), 'Missing generated segments for ' + route)
|
||||
for (const source of segments(directory)) {
|
||||
const relative = path.relative(directory, source).split(path.sep).join('/')
|
||||
const filename = '__next.' + relative.slice(0, -'.segment.rsc'.length).replaceAll('/', '.') + '.txt'
|
||||
const destination = path.join('out', route.slice(1), filename)
|
||||
if (!existsSync(destination)) {
|
||||
missing.push(destination)
|
||||
continue
|
||||
}
|
||||
assert.deepEqual(readFileSync(destination), readFileSync(source), 'Export bytes differ: ' + destination)
|
||||
checked++
|
||||
}
|
||||
}
|
||||
assert.ok(checked > 0, 'No exported segments were checked')
|
||||
assert.equal(missing.length, 0, 'Missing browser-addressable segment files:\n' + missing.join('\n'))
|
||||
console.log('Verified ' + checked + ' browser-addressable segment files and their exact bytes')
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -1,309 +1,318 @@
|
||||
/*
|
||||
* 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, act } from '@testing-library/react'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { beforeEach, expect, it, vi } from 'vitest'
|
||||
import * as api from '@/lib/api/collaboration'
|
||||
import { getRecord } from '@/lib/api/tables'
|
||||
import type { RealtimeEvent } from '@/lib/realtime/client'
|
||||
import { ShareDialog } from './ShareDialog'
|
||||
import { RequestAccess } from './RequestAccess'
|
||||
import { TasksTab } from './TasksTab'
|
||||
import { CollaborationEdit } from './CollaborationEdit'
|
||||
import { MyWork } from './MyWork'
|
||||
import { qInstance } from '@/mocks/fixtures/q-instance'
|
||||
let receive: ((event: RealtimeEvent) => void) | undefined
|
||||
vi.mock('@/lib/hooks/use-realtime-channel', () => ({
|
||||
useRealtimeChannel: (_channel: string, callback?: typeof receive) => {
|
||||
receive = callback
|
||||
},
|
||||
}))
|
||||
vi.mock('@/lib/hooks/use-collaboration-presence', () => ({
|
||||
useCollaborationPresence: () => ({ viewers: [] }),
|
||||
}))
|
||||
vi.mock('@/lib/context/q-context', async () => {
|
||||
const { createContext } = await import('react')
|
||||
return { useQContext: () => ({ userId: 'me' }), QContext: createContext({ userId: 'me' }) }
|
||||
})
|
||||
vi.mock('@/lib/hooks/use-collaboration-core', () => ({
|
||||
useCollaborationAvailable: () => ({ data: true }),
|
||||
}))
|
||||
vi.mock('@/lib/hooks/use-metadata', () => ({
|
||||
useMetaData: () => ({ data: qInstance }),
|
||||
useTableMetaData: () => ({ data: qInstance.tables.person }),
|
||||
}))
|
||||
vi.mock('@/lib/api/tables', () => ({
|
||||
getRecord: vi.fn(),
|
||||
queryRecords: vi
|
||||
.fn()
|
||||
.mockResolvedValue({
|
||||
records: [{ tableName: 'person', values: { id: 1 }, recordLabel: 'Alice' }],
|
||||
}),
|
||||
}))
|
||||
vi.mock('@/lib/api/collaboration', () => ({
|
||||
searchPeople: vi.fn(),
|
||||
mentionCandidates: vi.fn(),
|
||||
getRecordLink: vi.fn(),
|
||||
previewShares: vi.fn(),
|
||||
shareRecord: vi.fn(),
|
||||
requestAccess: vi.fn(),
|
||||
listTasks: vi.fn(),
|
||||
createTask: vi.fn(),
|
||||
patchTask: vi.fn(),
|
||||
getMyWork: vi.fn(),
|
||||
listMyTasks: vi.fn(),
|
||||
}))
|
||||
const record = { table: 'person', pk: '1' }
|
||||
function mount(node: React.ReactNode) {
|
||||
const client = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
|
||||
})
|
||||
return render(<QueryClientProvider client={client}>{node}</QueryClientProvider>)
|
||||
}
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.mocked(api.searchPeople).mockResolvedValue({
|
||||
items: [
|
||||
{ userId: 'sam', displayName: 'Sam Parker', email: null },
|
||||
{ userId: 'alex', displayName: 'Alex Rivera', email: null },
|
||||
{ userId: 'unknown', displayName: 'Taylor', email: null },
|
||||
],
|
||||
nextCursor: null,
|
||||
})
|
||||
vi.mocked(api.previewShares).mockImplementation(async (_record, ids) => ({
|
||||
recipients: ids.map((id) => ({
|
||||
userId: id,
|
||||
displayName: id === 'sam' ? 'Sam Parker' : id,
|
||||
canRead: id === 'unknown' ? null : id === 'alex',
|
||||
})),
|
||||
}))
|
||||
vi.mocked(api.mentionCandidates).mockResolvedValue({
|
||||
items: [
|
||||
{ person: { userId: 'sam', displayName: 'Sam Parker' }, canRead: 'false' },
|
||||
{ person: { userId: 'alex', displayName: 'Alex Rivera' }, canRead: 'true' },
|
||||
],
|
||||
nextCursor: null,
|
||||
})
|
||||
vi.mocked(api.getRecordLink).mockResolvedValue({ url: 'https://example.test/app/person/1' })
|
||||
vi.mocked(api.shareRecord).mockResolvedValue({
|
||||
items: [
|
||||
{ id: 1, toUserId: 'sam', recipientCouldRead: false },
|
||||
{ id: 2, toUserId: 'alex', recipientCouldRead: true },
|
||||
],
|
||||
})
|
||||
vi.mocked(api.listTasks).mockResolvedValue({
|
||||
items: [{ id: 1, title: 'Review order', status: 'OPEN' }],
|
||||
nextCursor: null,
|
||||
})
|
||||
vi.mocked(api.listMyTasks).mockResolvedValue({ items: [], nextCursor: null })
|
||||
vi.mocked(api.createTask).mockResolvedValue({ id: 2 })
|
||||
vi.mocked(api.patchTask).mockResolvedValue({ id: 1 })
|
||||
vi.mocked(getRecord).mockResolvedValue({
|
||||
tableName: 'person',
|
||||
values: { id: 1, firstName: 'Their value', modifyDate: 'v2' },
|
||||
})
|
||||
})
|
||||
it('previews inaccessible and unknown recipients, then reports notified and offered access', async () => {
|
||||
mount(<ShareDialog record={record} canShare onClose={vi.fn()} />)
|
||||
expect(await screen.findByText("can't open this record")).toBeVisible()
|
||||
expect(screen.getByText('unknown')).toBeVisible()
|
||||
fireEvent.click(screen.getByLabelText(/Sam Parker/))
|
||||
fireEvent.click(screen.getByLabelText(/Alex Rivera/))
|
||||
await waitFor(() => expect(screen.getByRole('button', { name: 'Send' })).toBeEnabled())
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Send' }))
|
||||
expect(await screen.findByText('Sam Parker: Offered access')).toBeVisible()
|
||||
expect(screen.getByText('alex: Notified')).toBeVisible()
|
||||
})
|
||||
it('leaves the signed-in user out of share recipients even when the service returns them', async () => {
|
||||
vi.mocked(api.searchPeople).mockResolvedValueOnce({
|
||||
items: [
|
||||
{ userId: 'ME', displayName: 'Me Myself', email: null },
|
||||
{ userId: 'x9', displayName: 'Mail Match', email: 'Me' },
|
||||
{ userId: 'alex', displayName: 'Alex Rivera', email: null },
|
||||
],
|
||||
nextCursor: null,
|
||||
})
|
||||
mount(<ShareDialog record={record} canShare onClose={vi.fn()} />)
|
||||
expect(await screen.findByLabelText(/Alex Rivera/)).toBeVisible()
|
||||
expect(screen.queryByLabelText(/Me Myself/)).toBeNull()
|
||||
expect(screen.queryByLabelText(/Mail Match/)).toBeNull()
|
||||
})
|
||||
it('uses identical non-disclosing access copy even when the server reports missing record', async () => {
|
||||
vi.mocked(api.requestAccess).mockRejectedValue(
|
||||
new Error('Record does not exist: sensitive detail')
|
||||
)
|
||||
mount(<RequestAccess tableName="person" recordId="missing" shareId={12} />)
|
||||
fireEvent.change(screen.getByRole('textbox', { name: 'Message' }), {
|
||||
target: { value: 'Please review' },
|
||||
})
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Request access' }))
|
||||
expect(await screen.findByRole('alert')).not.toHaveTextContent('sensitive detail')
|
||||
expect(document.body).not.toHaveTextContent('does not exist')
|
||||
expect(api.requestAccess).toHaveBeenCalledWith({
|
||||
tableName: 'person',
|
||||
recordId: 'missing',
|
||||
shareId: 12,
|
||||
message: 'Please review',
|
||||
})
|
||||
})
|
||||
it('shows a stream warning, compares theirs and mine, and uses the same dialog on 409', async () => {
|
||||
const props = {
|
||||
table: qInstance.tables.person,
|
||||
pk: '1',
|
||||
available: true,
|
||||
realtime: true,
|
||||
error: null,
|
||||
getValues: () => ({ firstName: 'My value' }),
|
||||
onViewers: vi.fn(),
|
||||
onOverwrite: vi.fn(),
|
||||
onReload: vi.fn(),
|
||||
pending: false,
|
||||
}
|
||||
mount(<CollaborationEdit {...props} />)
|
||||
act(() =>
|
||||
receive?.({
|
||||
event: 'record.changed',
|
||||
data: { table: 'person', pk: '1', changedBy: 'sam', fields: ['firstName'] },
|
||||
})
|
||||
)
|
||||
expect(await screen.findByText(/Sam Parker saved changes/)).toBeVisible()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Review changes' }))
|
||||
expect(await screen.findByText('Their value')).toBeVisible()
|
||||
expect(screen.getByText('My value')).toBeVisible()
|
||||
expect(screen.queryByRole('button', { name: 'Keep mine (overwrite)' })).toBeNull()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Use theirs' }))
|
||||
expect(props.onReload).toHaveBeenCalled()
|
||||
})
|
||||
it('opens 409 review automatically and overwrites only with the refreshed version', async () => {
|
||||
const overwrite = vi.fn()
|
||||
const error = Object.assign(new Error('Conflict'), {
|
||||
status: 409,
|
||||
response: {
|
||||
status: 409,
|
||||
data: { currentVersion: 'v2', changedFields: ['firstName'], modifiedByUserId: 'sam' },
|
||||
},
|
||||
})
|
||||
mount(
|
||||
<CollaborationEdit
|
||||
table={qInstance.tables.person}
|
||||
pk="1"
|
||||
available
|
||||
realtime
|
||||
error={error}
|
||||
getValues={() => ({ firstName: 'Mine' })}
|
||||
onViewers={vi.fn()}
|
||||
onOverwrite={overwrite}
|
||||
onReload={vi.fn()}
|
||||
pending={false}
|
||||
/>
|
||||
)
|
||||
expect(await screen.findByText('Their value')).toBeVisible()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Keep mine (overwrite)' }))
|
||||
expect(overwrite).toHaveBeenCalledWith('v2')
|
||||
})
|
||||
it('refuses unreadable assignees, creates and toggles a task', async () => {
|
||||
mount(<TasksTab record={record} features={{ capabilities: { task: true } }} />)
|
||||
fireEvent.click(await screen.findByRole('checkbox', { name: 'Complete Review order' }))
|
||||
await waitFor(() => expect(api.patchTask).toHaveBeenCalledWith(1, { status: 'DONE' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Create task' }))
|
||||
await waitFor(() => expect(screen.getByLabelText(/Sam Parker/)).toBeDisabled())
|
||||
await waitFor(() => expect(screen.getByLabelText(/Alex Rivera/)).toBeEnabled())
|
||||
expect(api.previewShares).not.toHaveBeenCalled()
|
||||
fireEvent.change(screen.getByRole('textbox', { name: 'Title' }), {
|
||||
target: { value: 'New task' },
|
||||
})
|
||||
fireEvent.click(screen.getByLabelText(/Alex Rivera/))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Save task' }))
|
||||
await waitFor(() =>
|
||||
expect(api.createTask).toHaveBeenCalledWith(
|
||||
record,
|
||||
expect.objectContaining({ title: 'New task', assigneeUserId: 'alex' })
|
||||
)
|
||||
)
|
||||
})
|
||||
it('names task people by display name and falls back to the user id', async () => {
|
||||
vi.mocked(api.listTasks).mockResolvedValue({
|
||||
items: [
|
||||
{
|
||||
id: 1,
|
||||
title: 'Named',
|
||||
status: 'DONE',
|
||||
assigneeUserId: 'alex',
|
||||
assigneeDisplayName: 'Alex Rivera',
|
||||
createdByUserId: 'morgan',
|
||||
createdByDisplayName: 'Morgan Lee',
|
||||
completedByUserId: 'sam',
|
||||
completedByDisplayName: 'Sam Parker',
|
||||
},
|
||||
{ id: 2, title: 'Unnamed', status: 'OPEN', assigneeUserId: 'u7', createdByUserId: 'u8' },
|
||||
],
|
||||
nextCursor: null,
|
||||
})
|
||||
mount(<TasksTab record={record} features={{ capabilities: { task: true } }} />)
|
||||
expect(await screen.findByText('Assigned to u7')).toBeVisible()
|
||||
fireEvent.click(screen.getByRole('checkbox', { name: 'Show done' }))
|
||||
expect(await screen.findByText('Assigned to Alex Rivera')).toBeVisible()
|
||||
expect(screen.getByText('Created by Morgan Lee · Completed by Sam Parker')).toBeVisible()
|
||||
expect(screen.getByText('Assigned to u7')).toBeVisible()
|
||||
expect(screen.getByText('Created by u8')).toBeVisible()
|
||||
})
|
||||
it('renders My work with overdue task, assigned table and unread mentions links', async () => {
|
||||
vi.mocked(api.getMyWork).mockResolvedValue({
|
||||
openTaskCount: 1,
|
||||
tasks: [
|
||||
{
|
||||
id: 1,
|
||||
title: 'Review order',
|
||||
status: 'OPEN',
|
||||
tableName: 'person',
|
||||
recordId: '1',
|
||||
dueAt: '2020-01-01T00:00:00Z',
|
||||
},
|
||||
],
|
||||
assignedRecordsByTable: { person: 1 },
|
||||
unreadMentionCount: 3,
|
||||
})
|
||||
vi.mocked(api.listMyTasks).mockResolvedValue({
|
||||
items: [
|
||||
{
|
||||
id: 1,
|
||||
title: 'Review order',
|
||||
status: 'OPEN',
|
||||
tableName: 'person',
|
||||
recordId: '1',
|
||||
dueAt: '2020-01-01T00:00:00Z',
|
||||
},
|
||||
],
|
||||
nextCursor: null,
|
||||
})
|
||||
mount(<MyWork />)
|
||||
expect(await screen.findByRole('link', { name: 'Review order' })).toHaveAttribute(
|
||||
'href',
|
||||
'/app/person/1'
|
||||
)
|
||||
expect(await screen.findByRole('link', { name: 'Alice' })).toHaveAttribute(
|
||||
'href',
|
||||
'/app/person/1'
|
||||
)
|
||||
expect(screen.getByText(/Overdue:/)).toBeVisible()
|
||||
expect(screen.getByRole('link', { name: '3 unread mentions' })).toBeVisible()
|
||||
expect(screen.getByRole('link', { name: /People \(1\)|Person \(1\)/ })).toHaveAttribute(
|
||||
'href',
|
||||
expect.stringContaining('CurrentUser')
|
||||
)
|
||||
})
|
||||
/*
|
||||
* 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, act } from '@testing-library/react'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { beforeEach, expect, it, vi } from 'vitest'
|
||||
import * as api from '@/lib/api/collaboration'
|
||||
import { getRecord } from '@/lib/api/tables'
|
||||
import type { RealtimeEvent } from '@/lib/realtime/client'
|
||||
import { ShareDialog } from './ShareDialog'
|
||||
import { RequestAccess } from './RequestAccess'
|
||||
import { TasksTab } from './TasksTab'
|
||||
import { CollaborationEdit } from './CollaborationEdit'
|
||||
import { MyWork } from './MyWork'
|
||||
import { qInstance } from '@/mocks/fixtures/q-instance'
|
||||
let receive: ((event: RealtimeEvent) => void) | undefined
|
||||
vi.mock('@/lib/hooks/use-realtime-channel', () => ({
|
||||
useRealtimeChannel: (_channel: string, callback?: typeof receive) => {
|
||||
receive = callback
|
||||
},
|
||||
}))
|
||||
vi.mock('@/lib/hooks/use-collaboration-presence', () => ({
|
||||
useCollaborationPresence: () => ({ viewers: [] }),
|
||||
}))
|
||||
vi.mock('@/lib/context/q-context', async () => {
|
||||
const { createContext } = await import('react')
|
||||
return { useQContext: () => ({ userId: 'me' }), QContext: createContext({ userId: 'me' }) }
|
||||
})
|
||||
vi.mock('@/lib/hooks/use-collaboration-core', () => ({
|
||||
useCollaborationAvailable: () => ({ data: true }),
|
||||
}))
|
||||
vi.mock('@/lib/hooks/use-metadata', () => ({
|
||||
useMetaData: () => ({ data: qInstance }),
|
||||
useTableMetaData: () => ({ data: qInstance.tables.person }),
|
||||
}))
|
||||
vi.mock('@/lib/api/tables', () => ({
|
||||
getRecord: vi.fn(),
|
||||
queryRecords: vi.fn().mockResolvedValue({
|
||||
records: [{ tableName: 'person', values: { id: 1 }, recordLabel: 'Alice' }],
|
||||
}),
|
||||
}))
|
||||
vi.mock('@/lib/api/collaboration', () => ({
|
||||
searchPeople: vi.fn(),
|
||||
mentionCandidates: vi.fn(),
|
||||
getRecordLink: vi.fn(),
|
||||
previewShares: vi.fn(),
|
||||
shareRecord: vi.fn(),
|
||||
requestAccess: vi.fn(),
|
||||
listTasks: vi.fn(),
|
||||
createTask: vi.fn(),
|
||||
patchTask: vi.fn(),
|
||||
getMyWork: vi.fn(),
|
||||
markMentionsRead: vi.fn().mockResolvedValue({ success: true }),
|
||||
listMyTasks: vi.fn(),
|
||||
}))
|
||||
const record = { table: 'person', pk: '1' }
|
||||
function mount(node: React.ReactNode) {
|
||||
const client = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
|
||||
})
|
||||
return render(<QueryClientProvider client={client}>{node}</QueryClientProvider>)
|
||||
}
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.mocked(api.searchPeople).mockResolvedValue({
|
||||
items: [
|
||||
{ userId: 'sam', displayName: 'Sam Parker', email: null },
|
||||
{ userId: 'alex', displayName: 'Alex Rivera', email: null },
|
||||
{ userId: 'unknown', displayName: 'Taylor', email: null },
|
||||
],
|
||||
nextCursor: null,
|
||||
})
|
||||
vi.mocked(api.previewShares).mockImplementation(async (_record, ids) => ({
|
||||
recipients: ids.map((id) => ({
|
||||
userId: id,
|
||||
displayName: id === 'sam' ? 'Sam Parker' : id,
|
||||
canRead: id === 'unknown' ? null : id === 'alex',
|
||||
})),
|
||||
}))
|
||||
vi.mocked(api.mentionCandidates).mockResolvedValue({
|
||||
items: [
|
||||
{ person: { userId: 'sam', displayName: 'Sam Parker' }, canRead: 'false' },
|
||||
{ person: { userId: 'alex', displayName: 'Alex Rivera' }, canRead: 'true' },
|
||||
],
|
||||
nextCursor: null,
|
||||
})
|
||||
vi.mocked(api.getRecordLink).mockResolvedValue({ url: 'https://example.test/app/person/1' })
|
||||
vi.mocked(api.shareRecord).mockResolvedValue({
|
||||
items: [
|
||||
{ id: 1, toUserId: 'sam', recipientCouldRead: false },
|
||||
{ id: 2, toUserId: 'alex', recipientCouldRead: true },
|
||||
],
|
||||
})
|
||||
vi.mocked(api.listTasks).mockResolvedValue({
|
||||
items: [{ id: 1, title: 'Review order', status: 'OPEN' }],
|
||||
nextCursor: null,
|
||||
})
|
||||
vi.mocked(api.listMyTasks).mockResolvedValue({ items: [], nextCursor: null })
|
||||
vi.mocked(api.createTask).mockResolvedValue({ id: 2 })
|
||||
vi.mocked(api.patchTask).mockResolvedValue({ id: 1 })
|
||||
vi.mocked(getRecord).mockResolvedValue({
|
||||
tableName: 'person',
|
||||
values: { id: 1, firstName: 'Their value', modifyDate: 'v2' },
|
||||
})
|
||||
})
|
||||
it('previews inaccessible and unknown recipients, then reports notified and offered access', async () => {
|
||||
mount(<ShareDialog record={record} canShare onClose={vi.fn()} />)
|
||||
expect(await screen.findByText("can't open this record")).toBeVisible()
|
||||
expect(screen.getByText('unknown')).toBeVisible()
|
||||
fireEvent.click(screen.getByLabelText(/Sam Parker/))
|
||||
fireEvent.click(screen.getByLabelText(/Alex Rivera/))
|
||||
await waitFor(() => expect(screen.getByRole('button', { name: 'Send' })).toBeEnabled())
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Send' }))
|
||||
expect(await screen.findByText('Sam Parker: Offered access')).toBeVisible()
|
||||
expect(screen.getByText('alex: Notified')).toBeVisible()
|
||||
})
|
||||
it('leaves the signed-in user out of share recipients even when the service returns them', async () => {
|
||||
vi.mocked(api.searchPeople).mockResolvedValueOnce({
|
||||
items: [
|
||||
{ userId: 'ME', displayName: 'Me Myself', email: null },
|
||||
{ userId: 'x9', displayName: 'Mail Match', email: 'Me' },
|
||||
{ userId: 'alex', displayName: 'Alex Rivera', email: null },
|
||||
],
|
||||
nextCursor: null,
|
||||
})
|
||||
mount(<ShareDialog record={record} canShare onClose={vi.fn()} />)
|
||||
expect(await screen.findByLabelText(/Alex Rivera/)).toBeVisible()
|
||||
expect(screen.queryByLabelText(/Me Myself/)).toBeNull()
|
||||
expect(screen.queryByLabelText(/Mail Match/)).toBeNull()
|
||||
})
|
||||
it('uses identical non-disclosing access copy even when the server reports missing record', async () => {
|
||||
vi.mocked(api.requestAccess).mockRejectedValue(
|
||||
new Error('Record does not exist: sensitive detail')
|
||||
)
|
||||
mount(<RequestAccess tableName="person" recordId="missing" shareId={12} />)
|
||||
fireEvent.change(screen.getByRole('textbox', { name: 'Message' }), {
|
||||
target: { value: 'Please review' },
|
||||
})
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Request access' }))
|
||||
expect(await screen.findByRole('alert')).not.toHaveTextContent('sensitive detail')
|
||||
expect(document.body).not.toHaveTextContent('does not exist')
|
||||
expect(api.requestAccess).toHaveBeenCalledWith({
|
||||
tableName: 'person',
|
||||
recordId: 'missing',
|
||||
shareId: 12,
|
||||
message: 'Please review',
|
||||
})
|
||||
})
|
||||
it('shows a stream warning, compares theirs and mine, and uses the same dialog on 409', async () => {
|
||||
const props = {
|
||||
table: qInstance.tables.person,
|
||||
pk: '1',
|
||||
available: true,
|
||||
realtime: true,
|
||||
error: null,
|
||||
getValues: () => ({ firstName: 'My value' }),
|
||||
onViewers: vi.fn(),
|
||||
onOverwrite: vi.fn(),
|
||||
onReload: vi.fn(),
|
||||
pending: false,
|
||||
}
|
||||
mount(<CollaborationEdit {...props} />)
|
||||
act(() =>
|
||||
receive?.({
|
||||
event: 'record.changed',
|
||||
data: { table: 'person', pk: '1', changedBy: 'sam', fields: ['firstName'] },
|
||||
})
|
||||
)
|
||||
expect(await screen.findByText(/Sam Parker saved changes/)).toBeVisible()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Review changes' }))
|
||||
expect(await screen.findByText('Their value')).toBeVisible()
|
||||
expect(screen.getByText('My value')).toBeVisible()
|
||||
expect(screen.queryByRole('button', { name: 'Keep mine (overwrite)' })).toBeNull()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Use theirs' }))
|
||||
expect(props.onReload).toHaveBeenCalled()
|
||||
})
|
||||
it('opens 409 review automatically and overwrites only with the refreshed version', async () => {
|
||||
const overwrite = vi.fn()
|
||||
const error = Object.assign(new Error('Conflict'), {
|
||||
status: 409,
|
||||
response: {
|
||||
status: 409,
|
||||
data: { currentVersion: 'v2', changedFields: ['firstName'], modifiedByUserId: 'sam' },
|
||||
},
|
||||
})
|
||||
mount(
|
||||
<CollaborationEdit
|
||||
table={qInstance.tables.person}
|
||||
pk="1"
|
||||
available
|
||||
realtime
|
||||
error={error}
|
||||
getValues={() => ({ firstName: 'Mine' })}
|
||||
onViewers={vi.fn()}
|
||||
onOverwrite={overwrite}
|
||||
onReload={vi.fn()}
|
||||
pending={false}
|
||||
/>
|
||||
)
|
||||
expect(await screen.findByText('Their value')).toBeVisible()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Keep mine (overwrite)' }))
|
||||
expect(overwrite).toHaveBeenCalledWith('v2')
|
||||
})
|
||||
it('refuses unreadable assignees, creates and toggles a task', async () => {
|
||||
mount(<TasksTab record={record} features={{ capabilities: { task: true } }} />)
|
||||
fireEvent.click(await screen.findByRole('checkbox', { name: 'Complete Review order' }))
|
||||
await waitFor(() => expect(api.patchTask).toHaveBeenCalledWith(1, { status: 'DONE' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Create task' }))
|
||||
await waitFor(() => expect(screen.getByLabelText(/Sam Parker/)).toBeDisabled())
|
||||
await waitFor(() => expect(screen.getByLabelText(/Alex Rivera/)).toBeEnabled())
|
||||
expect(api.previewShares).not.toHaveBeenCalled()
|
||||
fireEvent.change(screen.getByRole('textbox', { name: 'Title' }), {
|
||||
target: { value: 'New task' },
|
||||
})
|
||||
fireEvent.click(screen.getByLabelText(/Alex Rivera/))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Save task' }))
|
||||
await waitFor(() =>
|
||||
expect(api.createTask).toHaveBeenCalledWith(
|
||||
record,
|
||||
expect.objectContaining({ title: 'New task', assigneeUserId: 'alex' })
|
||||
)
|
||||
)
|
||||
})
|
||||
it('names task people by display name and falls back to the user id', async () => {
|
||||
vi.mocked(api.listTasks).mockResolvedValue({
|
||||
items: [
|
||||
{
|
||||
id: 1,
|
||||
title: 'Named',
|
||||
status: 'DONE',
|
||||
assigneeUserId: 'alex',
|
||||
assigneeDisplayName: 'Alex Rivera',
|
||||
createdByUserId: 'morgan',
|
||||
createdByDisplayName: 'Morgan Lee',
|
||||
completedByUserId: 'sam',
|
||||
completedByDisplayName: 'Sam Parker',
|
||||
},
|
||||
{ id: 2, title: 'Unnamed', status: 'OPEN', assigneeUserId: 'u7', createdByUserId: 'u8' },
|
||||
],
|
||||
nextCursor: null,
|
||||
})
|
||||
mount(<TasksTab record={record} features={{ capabilities: { task: true } }} />)
|
||||
expect(await screen.findByText('Assigned to u7')).toBeVisible()
|
||||
fireEvent.click(screen.getByRole('checkbox', { name: 'Show done' }))
|
||||
expect(await screen.findByText('Assigned to Alex Rivera')).toBeVisible()
|
||||
expect(screen.getByText('Created by Morgan Lee · Completed by Sam Parker')).toBeVisible()
|
||||
expect(screen.getByText('Assigned to u7')).toBeVisible()
|
||||
expect(screen.getByText('Created by u8')).toBeVisible()
|
||||
})
|
||||
it('renders My work with overdue task, assigned table and unread mentions links', async () => {
|
||||
vi.mocked(api.getMyWork).mockResolvedValue({
|
||||
openTaskCount: 1,
|
||||
tasks: [
|
||||
{
|
||||
id: 1,
|
||||
title: 'Review order',
|
||||
status: 'OPEN',
|
||||
tableName: 'person',
|
||||
recordId: '1',
|
||||
dueAt: '2020-01-01T00:00:00Z',
|
||||
},
|
||||
],
|
||||
assignedRecordsByTable: { person: 1 },
|
||||
unreadMentionCount: 3,
|
||||
})
|
||||
vi.mocked(api.listMyTasks).mockResolvedValue({
|
||||
items: [
|
||||
{
|
||||
id: 1,
|
||||
title: 'Review order',
|
||||
status: 'OPEN',
|
||||
tableName: 'person',
|
||||
recordId: '1',
|
||||
dueAt: '2020-01-01T00:00:00Z',
|
||||
},
|
||||
],
|
||||
nextCursor: null,
|
||||
})
|
||||
mount(<MyWork />)
|
||||
expect(await screen.findByRole('link', { name: 'Review order' })).toHaveAttribute(
|
||||
'href',
|
||||
'/app/person/1'
|
||||
)
|
||||
expect(await screen.findByRole('link', { name: 'Alice' })).toHaveAttribute(
|
||||
'href',
|
||||
'/app/person/1'
|
||||
)
|
||||
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')
|
||||
)
|
||||
})
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -334,7 +334,6 @@ export function CommandMenu({ open, onClose, navTargets, metaData, helpOnly: ope
|
||||
|
||||
{searchEnabled && <>
|
||||
{!search && <Command.Item value="help:search" data-qqq-id="command-search-help" className={ITEM_CLASS} onSelect={() => { setHelpOnly(true); dialogRef.current?.querySelector('input')?.focus() }}>Search help…</Command.Item>}
|
||||
<HelpSearchResults search={search} palette onSelect={onClose} />
|
||||
</>}
|
||||
{!helpOnly && screen && visibleActions.length > 0 && (
|
||||
<Command.Group heading={<GroupHeading>{screen.table.label} Actions</GroupHeading>} data-qqq-id="command-group-actions">
|
||||
@@ -442,6 +441,8 @@ export function CommandMenu({ open, onClose, navTargets, metaData, helpOnly: ope
|
||||
</Command.Item>
|
||||
</Command.Group>
|
||||
)}
|
||||
{/* Async help results must not move navigation targets during a click. */}
|
||||
{searchEnabled && <HelpSearchResults search={search} palette onSelect={onClose} />}
|
||||
</Command.List>
|
||||
|
||||
{/* Footer hint */}
|
||||
|
||||
@@ -511,7 +511,6 @@ export function SearchPage() {
|
||||
<div className={cn(showSplit && 'grid grid-cols-[minmax(0,1fr)_minmax(24rem,34rem)] items-start gap-6')}>
|
||||
{/* delegated click: remembers the search when a result link is followed; ↑/↓ move between results */}
|
||||
<div className="min-w-0 space-y-5" onClick={rememberOnOpen} onKeyDown={handleResultsKeyDown}>
|
||||
<HelpSearchResults search={term} />
|
||||
{view !== 'pages' && !singleTable && pages.length > 0 && (
|
||||
<PagesSection pages={pages.slice(0, OVERVIEW_PAGES)} term={term} moreHref={pages.length > OVERVIEW_PAGES ? filterHref({ view: 'pages' }) : undefined} total={pages.length} />
|
||||
)}
|
||||
@@ -595,6 +594,8 @@ export function SearchPage() {
|
||||
</ul>
|
||||
</section>
|
||||
)}
|
||||
{/* Keep delayed help below already-clickable page and record results. */}
|
||||
<HelpSearchResults search={term} />
|
||||
</div>
|
||||
{showSplit && previewed && (
|
||||
<aside
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
|
||||
import React from 'react'
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { act, render, screen } from '@testing-library/react'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
|
||||
vi.mock('@/lib/api/tables', () => ({ getRecord: vi.fn(() => new Promise(() => {})), queryRecords: vi.fn(() => new Promise(() => {})) }))
|
||||
@@ -87,6 +87,8 @@ describe('WidgetRenderer', () => {
|
||||
<WidgetRenderer widgetMetaData={{ name: 'untyped', label: 'Untyped', hasPermission: true }} data={{ type: 'chart', title: 'Persons', chartData: { labels: ['Jan'], datasets: [{ label: 'People', data: [17] }] } }} />
|
||||
</QueryClientProvider>
|
||||
)
|
||||
// Await the real lazy chunk before checking content under a busy full-suite worker pool.
|
||||
await act(async () => { await vi.dynamicImportSettled() })
|
||||
expect(await screen.findByText('Persons', { selector: '[data-qqq-id="chart-title-untyped"]' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
|
||||
@@ -46,14 +46,16 @@ it('searches the directory with a bounded page and normalizes each person', asyn
|
||||
expect(result.items[1].userId).toBeNull()
|
||||
})
|
||||
|
||||
it('sends the last presence lease as a JSON beacon, and tolerates browsers without one', () => {
|
||||
const sendBeacon = vi.fn()
|
||||
Object.defineProperty(navigator, 'sendBeacon', { value: sendBeacon, configurable: true })
|
||||
beaconPresence({ table: 'person', pk: '1', mode: 'VIEWING' } as never)
|
||||
expect(sendBeacon).toHaveBeenCalledWith('/qqq/v1/qbit/collaboration/presence', expect.any(Blob))
|
||||
expect((sendBeacon.mock.calls[0][1] as Blob).type).toBe('application/json')
|
||||
Object.defineProperty(navigator, 'sendBeacon', { value: undefined, configurable: true })
|
||||
expect(() => beaconPresence({ table: 'person', pk: '1', mode: 'VIEWING' } as never)).not.toThrow()
|
||||
it('sends an ordered DELETE that survives page teardown and tolerates network failure', async () => {
|
||||
const fetch = vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response())
|
||||
beaconPresence({ connectionId: 'tab', table: 'person', pk: '1', mode: 'VIEWING', sequence: 4 })
|
||||
expect(fetch).toHaveBeenCalledWith('/qqq/v1/qbit/collaboration/presence', expect.objectContaining({
|
||||
method: 'DELETE', credentials: 'include', keepalive: true,
|
||||
body: JSON.stringify({ connectionId: 'tab', table: 'person', pk: '1', sequence: 4 }),
|
||||
}))
|
||||
fetch.mockRejectedValue(new Error('offline'))
|
||||
expect(() => beaconPresence({ connectionId: 'tab', table: 'person', mode: 'VIEWING', sequence: 5 })).not.toThrow()
|
||||
await Promise.resolve()
|
||||
})
|
||||
|
||||
it('reads open personal tasks and rejects a task without an identifier', async () => {
|
||||
|
||||
@@ -649,28 +649,47 @@ export async function listTimeline(record: CollaborationRecord, cursor?: string,
|
||||
* @returns Current merged viewers.
|
||||
*/
|
||||
export const renewPresence = (input: PresenceRequest) =>
|
||||
apiClient.post<PresenceSnapshot>(`${BASE}/presence`, input)
|
||||
apiClient.post<PresenceSnapshot>(`${BASE}/presence/leases/renew`, input)
|
||||
/** Reserves an invisible lease for activation by a visible surface.
|
||||
* @param target - Authorized table or record.
|
||||
* @returns Server-issued connection handle.
|
||||
*/
|
||||
export const openPresenceLease = (target: { table: string; pk?: string }) =>
|
||||
apiClient.post<{ connectionId: string }>(`${BASE}/presence/leases`, target)
|
||||
/** Leaves a target.
|
||||
* @param input - Lease identity.
|
||||
* @returns Acknowledgement.
|
||||
*/
|
||||
export const leavePresence = (input: PresenceTarget) =>
|
||||
apiClient.delete(`${BASE}/presence`, { data: input })
|
||||
|
||||
/** Acknowledges only the caller's currently visible unread mentions.
|
||||
* @returns Mutation acknowledgement.
|
||||
*/
|
||||
export const markMentionsRead = () => apiClient.post(`${BASE}/my-work/mentions/read`, {})
|
||||
|
||||
/** Reads current viewers.
|
||||
* @param target - Target identity.
|
||||
* @returns Snapshot.
|
||||
*/
|
||||
export const getPresence = (target: { table: string; pk?: string }) =>
|
||||
apiClient.get<PresenceSnapshot>(`${BASE}/presence`, { params: target })
|
||||
/** Sends the last heartbeat on pagehide. Beacon only supports POST; DELETE remains the
|
||||
* unmount cleanup, and the server's 45-second TTL handles a closed document.
|
||||
/** Sends a best-effort departure that survives page teardown; expiry covers network loss.
|
||||
* @param input - Last presence lease.
|
||||
*/
|
||||
export function beaconPresence(input: PresenceRequest) {
|
||||
navigator.sendBeacon?.(
|
||||
`${process.env.NEXT_PUBLIC_API_BASE_URL || '/qqq/v1'}${BASE}/presence`,
|
||||
new Blob([JSON.stringify(input)], { type: 'application/json' })
|
||||
)
|
||||
void fetch(`${process.env.NEXT_PUBLIC_API_BASE_URL || '/qqq/v1'}${BASE}/presence`, {
|
||||
method: 'DELETE',
|
||||
credentials: 'include',
|
||||
keepalive: true,
|
||||
headers: { 'Content-Type': 'application/json', 'X-QQQ-Client': 'next-ui' },
|
||||
body: JSON.stringify({
|
||||
connectionId: input.connectionId,
|
||||
table: input.table,
|
||||
pk: input.pk,
|
||||
sequence: input.sequence,
|
||||
}),
|
||||
}).catch(() => undefined)
|
||||
}
|
||||
/** Reads directory candidates.
|
||||
* @param q - Search text.
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,84 @@
|
||||
/*
|
||||
* 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
|
||||
*/
|
||||
|
||||
import React from 'react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { act, render, screen } from '@testing-library/react'
|
||||
|
||||
vi.mock('@/lib/api/ui-api-version', () => ({ checkUiApiCompatibility: vi.fn(async () => ({ compatible: true, info: { apiVersion: '1.1.0', features: [], extensions: [], preSemver: false } })) }))
|
||||
vi.mock('@/lib/api/auth', () => ({
|
||||
getAuthenticationMetaData: vi.fn(async () => ({ name: 'auth0', type: 'AUTH_0' })),
|
||||
manageSession: vi.fn(), resumeSession: vi.fn(), logout: vi.fn(), clearAuthMetadataCache: vi.fn(),
|
||||
}))
|
||||
|
||||
import { manageSession, resumeSession } from '@/lib/api/auth'
|
||||
import { AuthProvider } from './auth-provider'
|
||||
import { useAuth } from './use-auth'
|
||||
import { getStoredUser, setSessionHint, storeUser } from './auth-storage'
|
||||
import * as oidc from './oidc'
|
||||
|
||||
let auth: ReturnType<typeof useAuth> | null = null
|
||||
|
||||
/** Exposes the real provider state for callback and reload assertions. */
|
||||
function Probe() {
|
||||
auth = useAuth()
|
||||
if (auth.isLoading) return <p>loading</p>
|
||||
return <p>{auth.isAuthenticated ? `${auth.user?.name} <${auth.user?.email}>` : 'signed out'}</p>
|
||||
}
|
||||
|
||||
describe('Auth0 identity with a server-issued user ID', () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear()
|
||||
sessionStorage.clear()
|
||||
vi.mocked(manageSession).mockReset()
|
||||
vi.mocked(resumeSession).mockReset()
|
||||
auth = null
|
||||
})
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
localStorage.clear()
|
||||
sessionStorage.clear()
|
||||
})
|
||||
|
||||
it('keeps the provider display identity and server ID after sign-in', async () => {
|
||||
const payload = btoa(JSON.stringify({ name: 'Dana Owner (OIDC)', email: 'dana@qrun.example' }))
|
||||
vi.spyOn(oidc, 'exchangeAuth0Code').mockResolvedValue({ access_token: 'test-access', id_token: `h.${payload}.s` })
|
||||
vi.mocked(manageSession).mockResolvedValue({ uuid: 'session', values: { user: { id: 'oidc|dana' } } })
|
||||
render(<AuthProvider><Probe /></AuthProvider>)
|
||||
await screen.findByText('signed out')
|
||||
sessionStorage.setItem(oidc.PKCE_STORAGE.state, 'expected-state')
|
||||
await act(async () => { await auth?.handleOAuthCallback('code', 'expected-state', 'verifier') })
|
||||
expect(screen.getByText('Dana Owner (OIDC) <dana@qrun.example>')).toBeInTheDocument()
|
||||
expect(getStoredUser()).toEqual({ id: 'oidc|dana', name: 'Dana Owner (OIDC)', email: 'dana@qrun.example' })
|
||||
})
|
||||
|
||||
it('retains the display identity on reload when the server supplies the same ID', async () => {
|
||||
storeUser({ id: 'oidc|dana', name: 'Dana Owner (OIDC)', email: 'dana@qrun.example' })
|
||||
setSessionHint(true)
|
||||
vi.mocked(resumeSession).mockResolvedValue({ uuid: 'session', values: { user: { id: 'oidc|dana' } } })
|
||||
render(<AuthProvider><Probe /></AuthProvider>)
|
||||
expect(await screen.findByText('Dana Owner (OIDC) <dana@qrun.example>')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('prefers server display fields and does not reuse a different user profile', async () => {
|
||||
storeUser({ id: 'oidc|dana', name: 'Dana Owner (OIDC)', email: 'dana@qrun.example' })
|
||||
setSessionHint(true)
|
||||
vi.mocked(resumeSession).mockResolvedValue({ uuid: 'session', values: { user: { id: 'oidc|eli', name: 'Eli' } } })
|
||||
render(<AuthProvider><Probe /></AuthProvider>)
|
||||
expect(await screen.findByText('Eli <undefined>')).toBeInTheDocument()
|
||||
expect(auth?.user).toEqual({ id: 'oidc|eli', name: 'Eli', email: undefined })
|
||||
})
|
||||
|
||||
it('does not reuse a cached profile when an ID-only session changes user', async () => {
|
||||
storeUser({ id: 'oidc|dana', name: 'Dana Owner (OIDC)', email: 'dana@qrun.example' })
|
||||
setSessionHint(true)
|
||||
vi.mocked(resumeSession).mockResolvedValue({ uuid: 'session', values: { user: { id: 'oidc|eli' } } })
|
||||
render(<AuthProvider><Probe /></AuthProvider>)
|
||||
await screen.findByText('undefined <undefined>')
|
||||
expect(auth?.user?.id).toBe('oidc|eli')
|
||||
})
|
||||
})
|
||||
@@ -185,11 +185,17 @@ function signInErrorMessage(error: unknown): string {
|
||||
* The identity to display for a session.
|
||||
*
|
||||
* @param response - The manageSession response.
|
||||
* @param fallback - Identity to use when the session carries none.
|
||||
* @param fallback - Display identity when the session carries no display fields.
|
||||
* @returns The user.
|
||||
*/
|
||||
function sessionUser(response: SessionResponse, fallback: AuthUser | null): AuthUser {
|
||||
return userFromSessionValues(response.values) ?? fallback ?? {}
|
||||
const session = userFromSessionValues(response.values)
|
||||
if (!session) return fallback ?? {}
|
||||
if (session.name || session.email) return session
|
||||
// An ID-only backend identity must retain its authoritative ID without dropping
|
||||
// the provider's display profile. Never reuse a profile for a different user ID.
|
||||
if (session.id && fallback?.id && session.id !== fallback.id) return session
|
||||
return { ...session, name: fallback?.name, email: fallback?.email }
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -43,6 +43,16 @@ afterEach(() => {
|
||||
})
|
||||
|
||||
describe('auth-storage', () => {
|
||||
it('preserves the canonical identity independently of the display email', () => {
|
||||
const user = userFromSessionValues({
|
||||
user: { id: 'alice', name: 'Alice', email: 'alice@example.test' },
|
||||
})
|
||||
expect(user).toEqual({ id: 'alice', name: 'Alice', email: 'alice@example.test' })
|
||||
storeUser(user!)
|
||||
expect(getStoredUser()?.id).toBe('alice')
|
||||
expect(userFromSessionValues({ user: { id: 'service-user' } })?.id).toBe('service-user')
|
||||
})
|
||||
|
||||
it.each([null, 'true', 'false'])('preserves appearance preference %s on logout', (preference) => {
|
||||
if (preference !== null) localStorage.setItem('qqq-dark-mode', preference)
|
||||
clearUserClientData()
|
||||
@@ -50,14 +60,22 @@ describe('auth-storage', () => {
|
||||
})
|
||||
|
||||
it('reads the displayed user from manageSession values (QRun-IO/qqq#332)', () => {
|
||||
expect(userFromSessionValues({ user: { name: 'Alice (sample)', email: 'sample:alice' } })).toEqual({ name: 'Alice (sample)', email: 'sample:alice' })
|
||||
expect(
|
||||
userFromSessionValues({ user: { name: 'Alice (sample)', email: 'sample:alice' } })
|
||||
).toEqual({ name: 'Alice (sample)', email: 'sample:alice' })
|
||||
expect(userFromSessionValues({ user: { name: '', email: 7 } })).toBeNull()
|
||||
expect(userFromSessionValues(undefined)).toBeNull()
|
||||
})
|
||||
|
||||
it('shows a TABLE_BASED username as the identity line (QRun-IO/qqq#700)', () => {
|
||||
expect(userFromSessionValues({ user: { name: 'Tess Table', username: 'tess.table' } })).toEqual({ name: 'Tess Table', email: 'tess.table' })
|
||||
expect(userFromSessionValues({ user: { name: 'Tess Table', email: 'tess@example.test', username: 'tess.table' } })).toEqual({ name: 'Tess Table', email: 'tess@example.test' })
|
||||
expect(userFromSessionValues({ user: { name: 'Tess Table', username: 'tess.table' } })).toEqual(
|
||||
{ name: 'Tess Table', email: 'tess.table' }
|
||||
)
|
||||
expect(
|
||||
userFromSessionValues({
|
||||
user: { name: 'Tess Table', email: 'tess@example.test', username: 'tess.table' },
|
||||
})
|
||||
).toEqual({ name: 'Tess Table', email: 'tess@example.test' })
|
||||
})
|
||||
|
||||
it('persists and validates the stored user', () => {
|
||||
@@ -70,7 +88,10 @@ describe('auth-storage', () => {
|
||||
})
|
||||
|
||||
it('keeps the full session values for analytics identity until sign-out (QRun-IO/qqq#730)', () => {
|
||||
const values = { user: { name: 'Alice (sample)', email: 'sample:alice' }, analyticsValues: { user_id: 'u-1' } }
|
||||
const values = {
|
||||
user: { name: 'Alice (sample)', email: 'sample:alice' },
|
||||
analyticsValues: { user_id: 'u-1' },
|
||||
}
|
||||
storeSessionValues(values)
|
||||
expect(getStoredSessionValues()).toEqual(values)
|
||||
storeSessionValues(undefined)
|
||||
@@ -109,15 +130,19 @@ describe('auth-storage', () => {
|
||||
expect(localStorage.getItem('accessToken')).toBeNull()
|
||||
})
|
||||
|
||||
it('clears every table\'s remembered filters and saved-view selection without resetting machine preferences (QRun-IO/qqq#1006)', () => {
|
||||
it("clears every table's remembered filters and saved-view selection without resetting machine preferences (QRun-IO/qqq#1006)", () => {
|
||||
for (const table of ['person', 'pet']) {
|
||||
writeStoredQueryView(table, {
|
||||
queryFilter: { criteria: [{ fieldName: 'name', operator: 'EQUALS', values: ['Private Alice'] }] },
|
||||
queryFilter: {
|
||||
criteria: [{ fieldName: 'name', operator: 'EQUALS', values: ['Private Alice'] }],
|
||||
},
|
||||
viewIdentity: 'empty',
|
||||
})
|
||||
writeCurrentSavedViewId(table, 7)
|
||||
expect(readStoredQueryView(table)).toMatchObject({
|
||||
queryFilter: { criteria: [{ fieldName: 'name', operator: 'EQUALS', values: ['Private Alice'] }] },
|
||||
queryFilter: {
|
||||
criteria: [{ fieldName: 'name', operator: 'EQUALS', values: ['Private Alice'] }],
|
||||
},
|
||||
})
|
||||
expect(readCurrentSavedViewId(table)).toBe(7)
|
||||
}
|
||||
@@ -136,20 +161,25 @@ describe('auth-storage', () => {
|
||||
expect(readStoredQueryView(table)).toBeNull()
|
||||
expect(readCurrentSavedViewId(table)).toBeNull()
|
||||
}
|
||||
for (const [key, value] of Object.entries(preferences)) expect(localStorage.getItem(key)).toBe(value)
|
||||
for (const [key, value] of Object.entries(preferences))
|
||||
expect(localStorage.getItem(key)).toBe(value)
|
||||
})
|
||||
|
||||
it('keeps remembered query state for the same identity and clears it before another identity claims the browser (QRun-IO/qqq#1006)', () => {
|
||||
claimClientData({ name: 'Alice', email: 'alice@example.test' })
|
||||
writeStoredQueryView('person', {
|
||||
queryFilter: { criteria: [{ fieldName: 'name', operator: 'EQUALS', values: ['Private Alice'] }] },
|
||||
queryFilter: {
|
||||
criteria: [{ fieldName: 'name', operator: 'EQUALS', values: ['Private Alice'] }],
|
||||
},
|
||||
viewIdentity: 'savedView:7',
|
||||
})
|
||||
writeCurrentSavedViewId('person', 7)
|
||||
|
||||
claimClientData({ name: 'Alice', email: 'alice@example.test' })
|
||||
expect(readStoredQueryView('person')).toMatchObject({
|
||||
queryFilter: { criteria: [{ fieldName: 'name', operator: 'EQUALS', values: ['Private Alice'] }] },
|
||||
queryFilter: {
|
||||
criteria: [{ fieldName: 'name', operator: 'EQUALS', values: ['Private Alice'] }],
|
||||
},
|
||||
viewIdentity: 'savedView:7',
|
||||
})
|
||||
expect(readCurrentSavedViewId('person')).toBe(7)
|
||||
@@ -160,9 +190,12 @@ describe('auth-storage', () => {
|
||||
expect(localStorage.getItem('qqq.clientDataOwner')).toBe('bob@example.test')
|
||||
})
|
||||
|
||||
it('clears the previous user\'s data when someone else signs in in this browser (QRun-IO/qqq#696)', () => {
|
||||
it("clears the previous user's data when someone else signs in in this browser (QRun-IO/qqq#696)", () => {
|
||||
claimClientData({ name: 'Alice', email: 'alice@example.com' })
|
||||
localStorage.setItem('qqq-recent-records', JSON.stringify([{ path: '/app/pet/1', recordLabel: 'Alice private' }]))
|
||||
localStorage.setItem(
|
||||
'qqq-recent-records',
|
||||
JSON.stringify([{ path: '/app/pet/1', recordLabel: 'Alice private' }])
|
||||
)
|
||||
storeUser({ name: 'Alice', email: 'alice@example.com' })
|
||||
|
||||
claimClientData({ name: 'Alice', email: 'alice@example.com' })
|
||||
|
||||
@@ -31,6 +31,7 @@ const SESSION_HINT_KEY = 'qqq.sessionHint'
|
||||
|
||||
/** The displayed identity of the signed-in user. */
|
||||
export interface StoredUser {
|
||||
id?: string
|
||||
name?: string
|
||||
email?: string
|
||||
}
|
||||
@@ -102,16 +103,24 @@ export function setSessionHint(present: boolean): void {
|
||||
* @param values - The session values for the frontend.
|
||||
* @returns The user when it has a name or email.
|
||||
*/
|
||||
export function userFromSessionValues(values: Record<string, unknown> | undefined): StoredUser | null {
|
||||
export function userFromSessionValues(
|
||||
values: Record<string, unknown> | undefined
|
||||
): StoredUser | null {
|
||||
const user = values?.user
|
||||
if (!user || typeof user !== 'object') return null
|
||||
const { name, email, username } = user as Record<string, unknown>
|
||||
const identity = typeof email === 'string' && email ? email : typeof username === 'string' && username ? username : undefined
|
||||
const { id, name, email, username } = user as Record<string, unknown>
|
||||
const identity =
|
||||
typeof email === 'string' && email
|
||||
? email
|
||||
: typeof username === 'string' && username
|
||||
? username
|
||||
: undefined
|
||||
const result: StoredUser = {
|
||||
...(typeof id === 'string' && id ? { id } : {}),
|
||||
name: typeof name === 'string' && name ? name : undefined,
|
||||
email: identity,
|
||||
}
|
||||
return result.name || result.email ? result : null
|
||||
return result.id || result.name || result.email ? result : null
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -162,7 +171,9 @@ export function storeSessionValues(values: Record<string, unknown> | undefined):
|
||||
export function getStoredSessionValues(): Record<string, unknown> | null {
|
||||
try {
|
||||
const parsed = JSON.parse(storage('local')?.getItem(SESSION_VALUES_KEY) ?? 'null') as unknown
|
||||
return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed as Record<string, unknown> : null
|
||||
return parsed && typeof parsed === 'object' && !Array.isArray(parsed)
|
||||
? (parsed as Record<string, unknown>)
|
||||
: null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
@@ -179,7 +190,9 @@ export function recordReauthAttempt(now = Date.now()): boolean {
|
||||
const session = storage('session')
|
||||
let attempts: number[] = []
|
||||
try {
|
||||
attempts = (JSON.parse(session?.getItem(REAUTH_KEY) ?? '[]') as number[]).filter((time) => now - time < 60_000)
|
||||
attempts = (JSON.parse(session?.getItem(REAUTH_KEY) ?? '[]') as number[]).filter(
|
||||
(time) => now - time < 60_000
|
||||
)
|
||||
} catch {
|
||||
attempts = []
|
||||
}
|
||||
|
||||
@@ -26,6 +26,7 @@ vi.mock('@/lib/context/q-context', async () => {
|
||||
})
|
||||
vi.mock('./use-realtime-channel', () => ({ useRealtimeChannel: vi.fn() }))
|
||||
vi.mock('@/lib/api/collaboration', () => ({
|
||||
openPresenceLease: vi.fn(),
|
||||
renewPresence: vi.fn(),
|
||||
leavePresence: vi.fn(),
|
||||
getPresence: vi.fn(),
|
||||
@@ -33,6 +34,10 @@ vi.mock('@/lib/api/collaboration', () => ({
|
||||
}))
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
let nextLease = 0
|
||||
vi.mocked(api.openPresenceLease).mockImplementation(async () => ({
|
||||
connectionId: `lease:${++nextLease}`,
|
||||
}))
|
||||
vi.mocked(api.getPresence).mockResolvedValue({ table: 'person', viewers: [] })
|
||||
vi.mocked(api.renewPresence).mockResolvedValue({ table: 'person', viewers: [] })
|
||||
vi.mocked(api.leavePresence).mockResolvedValue({})
|
||||
@@ -50,12 +55,15 @@ it('renews on mount and focus changes, every 20 seconds; beacons and leaves its
|
||||
wrapper,
|
||||
initialProps: { field: 'firstName' },
|
||||
})
|
||||
await act(() => vi.advanceTimersByTimeAsync(0))
|
||||
expect(api.renewPresence).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ mode: 'EDITING', fieldName: 'firstName' })
|
||||
)
|
||||
await act(() => vi.advanceTimersByTimeAsync(20000))
|
||||
expect(api.renewPresence).toHaveBeenCalledTimes(2)
|
||||
expect(vi.mocked(api.renewPresence).mock.calls.map(([input]) => input.sequence)).toEqual([1, 2])
|
||||
hook.rerender({ field: 'lastName' })
|
||||
expect(api.openPresenceLease).toHaveBeenCalledTimes(1)
|
||||
expect(api.renewPresence).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({ fieldName: 'lastName' })
|
||||
)
|
||||
@@ -64,9 +72,9 @@ it('renews on mount and focus changes, every 20 seconds; beacons and leaves its
|
||||
expect.objectContaining({ fieldName: 'lastName' })
|
||||
)
|
||||
hook.unmount()
|
||||
expect(api.leavePresence).toHaveBeenCalledTimes(1)
|
||||
expect(api.leavePresence).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ table: 'person', pk: '1' })
|
||||
expect(api.leavePresence).not.toHaveBeenCalled()
|
||||
expect(api.beaconPresence).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ table: 'person', pk: '1', sequence: 4 })
|
||||
)
|
||||
})
|
||||
it('merges tabs and excludes my own tabs, preferring editors', () => {
|
||||
@@ -81,3 +89,155 @@ it('merges tabs and excludes my own tabs, preferring editors', () => {
|
||||
)
|
||||
).toEqual([{ userId: 'sam', displayName: 'Sam', mode: 'EDITING', fieldName: 'name' }])
|
||||
})
|
||||
|
||||
it('reports failed renewals even when reads succeed, and recovers on a successful renewal', async () => {
|
||||
vi.mocked(api.renewPresence).mockRejectedValueOnce(new Error('Unavailable'))
|
||||
const cache = new QueryClient({ defaultOptions: { queries: { retry: false } } })
|
||||
const wrapper = ({ children }: { children: React.ReactNode }) => (
|
||||
<QueryClientProvider client={cache}>{children}</QueryClientProvider>
|
||||
)
|
||||
const hook = renderHook(() => useCollaborationPresence('person', '1'), { wrapper })
|
||||
await act(() => vi.advanceTimersByTimeAsync(10))
|
||||
expect(hook.result.current.unavailable).toBe(true)
|
||||
expect(hook.result.current.viewers).toEqual([])
|
||||
await act(() => vi.advanceTimersByTimeAsync(20000))
|
||||
expect(hook.result.current.unavailable).toBe(false)
|
||||
expect(api.openPresenceLease).toHaveBeenCalledTimes(1)
|
||||
hook.unmount()
|
||||
})
|
||||
|
||||
it('ignores a failed renewal from the previous record', async () => {
|
||||
let rejectOld: (reason: Error) => void = () => undefined
|
||||
vi.mocked(api.renewPresence).mockReturnValueOnce(
|
||||
new Promise((_, reject) => {
|
||||
rejectOld = reject
|
||||
})
|
||||
)
|
||||
const cache = new QueryClient({ defaultOptions: { queries: { retry: false } } })
|
||||
const wrapper = ({ children }: { children: React.ReactNode }) => (
|
||||
<QueryClientProvider client={cache}>{children}</QueryClientProvider>
|
||||
)
|
||||
const hook = renderHook(({ pk }) => useCollaborationPresence('person', pk), {
|
||||
wrapper,
|
||||
initialProps: { pk: '1' },
|
||||
})
|
||||
await act(() => vi.advanceTimersByTimeAsync(0))
|
||||
hook.rerender({ pk: '2' })
|
||||
await act(() => vi.advanceTimersByTimeAsync(10))
|
||||
await act(async () => {
|
||||
rejectOld(new Error('Old request failed'))
|
||||
})
|
||||
expect(hook.result.current.unavailable).toBe(false)
|
||||
hook.unmount()
|
||||
})
|
||||
|
||||
it('releases a late reservation without activating a closed surface', async () => {
|
||||
let resolveOpen!: (value: { connectionId: string }) => void
|
||||
vi.mocked(api.openPresenceLease).mockReturnValueOnce(
|
||||
new Promise((resolve) => {
|
||||
resolveOpen = resolve
|
||||
})
|
||||
)
|
||||
const cache = new QueryClient({ defaultOptions: { queries: { retry: false } } })
|
||||
const wrapper = ({ children }: { children: React.ReactNode }) => (
|
||||
<QueryClientProvider client={cache}>{children}</QueryClientProvider>
|
||||
)
|
||||
const hook = renderHook(() => useCollaborationPresence('person', '1'), { wrapper })
|
||||
hook.unmount()
|
||||
await act(async () => {
|
||||
resolveOpen({ connectionId: 'lease:late' })
|
||||
})
|
||||
expect(api.renewPresence).not.toHaveBeenCalled()
|
||||
expect(api.leavePresence).toHaveBeenCalledWith({
|
||||
table: 'person',
|
||||
pk: '1',
|
||||
connectionId: 'lease:late',
|
||||
sequence: 1,
|
||||
})
|
||||
})
|
||||
|
||||
it('rejoins once with a new handle after an explicit lease expiry', async () => {
|
||||
vi.mocked(api.renewPresence).mockRejectedValueOnce({
|
||||
isAxiosError: true,
|
||||
response: { status: 409, data: { code: 'PRESENCE_LEASE_EXPIRED' } },
|
||||
})
|
||||
const cache = new QueryClient({ defaultOptions: { queries: { retry: false } } })
|
||||
const wrapper = ({ children }: { children: React.ReactNode }) => (
|
||||
<QueryClientProvider client={cache}>{children}</QueryClientProvider>
|
||||
)
|
||||
const hook = renderHook(() => useCollaborationPresence('person', '1'), { wrapper })
|
||||
await act(() => vi.advanceTimersByTimeAsync(0))
|
||||
expect(api.openPresenceLease).toHaveBeenCalledTimes(2)
|
||||
expect(
|
||||
vi.mocked(api.renewPresence).mock.calls.map(([input]) => [input.connectionId, input.sequence])
|
||||
).toEqual([
|
||||
['lease:1', 1],
|
||||
['lease:2', 1],
|
||||
])
|
||||
expect(hook.result.current.unavailable).toBe(false)
|
||||
hook.unmount()
|
||||
})
|
||||
|
||||
it('opens a fresh lease on pageshow instead of reviving the departed handle', async () => {
|
||||
const cache = new QueryClient({ defaultOptions: { queries: { retry: false } } })
|
||||
const wrapper = ({ children }: { children: React.ReactNode }) => (
|
||||
<QueryClientProvider client={cache}>{children}</QueryClientProvider>
|
||||
)
|
||||
const hook = renderHook(() => useCollaborationPresence('person', '1'), { wrapper })
|
||||
await act(() => vi.advanceTimersByTimeAsync(0))
|
||||
act(() => window.dispatchEvent(new Event('pagehide')))
|
||||
act(() => window.dispatchEvent(new Event('pageshow')))
|
||||
await act(() => vi.advanceTimersByTimeAsync(0))
|
||||
expect(api.openPresenceLease).toHaveBeenCalledTimes(2)
|
||||
expect(api.renewPresence).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({ connectionId: 'lease:2', sequence: 1 })
|
||||
)
|
||||
hook.unmount()
|
||||
})
|
||||
|
||||
it('bounds immediate rejoin attempts when replacement leases also expire', async () => {
|
||||
vi.mocked(api.renewPresence).mockRejectedValue({
|
||||
isAxiosError: true,
|
||||
response: { status: 409, data: { code: 'PRESENCE_LEASE_EXPIRED' } },
|
||||
})
|
||||
const cache = new QueryClient({ defaultOptions: { queries: { retry: false } } })
|
||||
const wrapper = ({ children }: { children: React.ReactNode }) => (
|
||||
<QueryClientProvider client={cache}>{children}</QueryClientProvider>
|
||||
)
|
||||
const hook = renderHook(() => useCollaborationPresence('person', '1'), { wrapper })
|
||||
await act(() => vi.advanceTimersByTimeAsync(0))
|
||||
expect(api.openPresenceLease).toHaveBeenCalledTimes(2)
|
||||
expect(hook.result.current.unavailable).toBe(true)
|
||||
hook.unmount()
|
||||
})
|
||||
|
||||
it('discards an old pending open after hide and show while preserving the new handle', async () => {
|
||||
let resolveOld!: (value: { connectionId: string }) => void
|
||||
vi.mocked(api.openPresenceLease).mockReturnValueOnce(
|
||||
new Promise((resolve) => {
|
||||
resolveOld = resolve
|
||||
})
|
||||
)
|
||||
const cache = new QueryClient({ defaultOptions: { queries: { retry: false } } })
|
||||
const wrapper = ({ children }: { children: React.ReactNode }) => (
|
||||
<QueryClientProvider client={cache}>{children}</QueryClientProvider>
|
||||
)
|
||||
const hook = renderHook(() => useCollaborationPresence('person', '1'), { wrapper })
|
||||
act(() => window.dispatchEvent(new Event('pagehide')))
|
||||
act(() => window.dispatchEvent(new Event('pageshow')))
|
||||
await act(() => vi.advanceTimersByTimeAsync(0))
|
||||
await act(async () => {
|
||||
resolveOld({ connectionId: 'lease:old' })
|
||||
})
|
||||
expect(api.renewPresence).toHaveBeenCalledTimes(1)
|
||||
expect(api.renewPresence).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({ connectionId: 'lease:1', sequence: 1 })
|
||||
)
|
||||
expect(api.leavePresence).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ connectionId: 'lease:old', sequence: 1 })
|
||||
)
|
||||
hook.unmount()
|
||||
expect(api.leavePresence).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({ connectionId: 'lease:1', sequence: 2 })
|
||||
)
|
||||
})
|
||||
@@ -16,10 +16,17 @@
|
||||
|
||||
/** @file Ephemeral presence leases with focus changes, polling fallback and realtime refresh. */
|
||||
'use client'
|
||||
import { useContext, useEffect, useState } from 'react'
|
||||
import { useContext, useEffect, useRef, useState } from 'react'
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { isAxiosError } from 'axios'
|
||||
import type { PresenceSnapshot, PresenceViewer } from '@/types/collaboration'
|
||||
import { renewPresence, leavePresence, getPresence, beaconPresence } from '@/lib/api/collaboration'
|
||||
import {
|
||||
openPresenceLease,
|
||||
renewPresence,
|
||||
leavePresence,
|
||||
getPresence,
|
||||
beaconPresence,
|
||||
} from '@/lib/api/collaboration'
|
||||
import { queryKeys } from '@/lib/query-client'
|
||||
import { QContext } from '@/lib/context/q-context'
|
||||
import { useRealtimeChannel } from './use-realtime-channel'
|
||||
@@ -51,8 +58,11 @@ export function useCollaborationPresence(
|
||||
fieldName?: string,
|
||||
enabled = true
|
||||
) {
|
||||
const [connectionId] = useState(() => crypto.randomUUID())
|
||||
const focus = useRef({ editing, fieldName })
|
||||
focus.current = { editing, fieldName }
|
||||
const refresh = useRef<() => void>(() => undefined)
|
||||
const [ended, setEnded] = useState(false)
|
||||
const [renewalFailed, setRenewalFailed] = useState(false)
|
||||
const userId = useContext(QContext)?.userId
|
||||
const cache = useQueryClient()
|
||||
const key = queryKeys.collaborationResource(table, pk ?? '', 'presence')
|
||||
@@ -65,7 +75,14 @@ export function useCollaborationPresence(
|
||||
useRealtimeChannel(
|
||||
pk ? `record:${table}:${pk}` : `table:${table}`,
|
||||
({ event, data }) => {
|
||||
if (event === 'session-ended') setEnded(true)
|
||||
if (
|
||||
event === 'session-ended' ||
|
||||
(event === 'channel-revoked' &&
|
||||
data.channel === (pk ? `record:${table}:${pk}` : `table:${table}`))
|
||||
) {
|
||||
setEnded(true)
|
||||
cache.setQueryData(key, { table, pk, viewers: [] })
|
||||
}
|
||||
if (
|
||||
event === 'presence.changed' &&
|
||||
data.table === table &&
|
||||
@@ -78,34 +95,112 @@ export function useCollaborationPresence(
|
||||
)
|
||||
useEffect(() => {
|
||||
if (!enabled || ended) return
|
||||
const input = {
|
||||
connectionId,
|
||||
table,
|
||||
pk,
|
||||
mode: editing ? ('EDITING' as const) : ('VIEWING' as const),
|
||||
fieldName,
|
||||
}
|
||||
const heartbeat = () => {
|
||||
void renewPresence(input)
|
||||
.then((snapshot) =>
|
||||
let active = true
|
||||
let visible = document.visibilityState !== 'hidden'
|
||||
let connectionId: string | undefined
|
||||
let sequence = 0
|
||||
let generation = 0
|
||||
let opening = false
|
||||
const heartbeat = async (rejoining = false) => {
|
||||
if (!active || !visible || opening) return
|
||||
const requestGeneration = generation
|
||||
let requestSequence = sequence
|
||||
try {
|
||||
if (!connectionId) {
|
||||
opening = true
|
||||
const lease = await openPresenceLease({ table, pk })
|
||||
if (!active || !visible || generation !== requestGeneration) {
|
||||
void leavePresence({ table, pk, connectionId: lease.connectionId, sequence: 1 }).catch(
|
||||
() => undefined
|
||||
)
|
||||
return
|
||||
}
|
||||
opening = false
|
||||
connectionId = lease.connectionId
|
||||
sequence = 0
|
||||
}
|
||||
requestSequence = ++sequence
|
||||
const snapshot = await renewPresence({
|
||||
table,
|
||||
pk,
|
||||
connectionId,
|
||||
sequence: requestSequence,
|
||||
mode: focus.current.editing ? 'EDITING' : 'VIEWING',
|
||||
fieldName: focus.current.fieldName,
|
||||
})
|
||||
if (active && visible && generation === requestGeneration && sequence === requestSequence) {
|
||||
setRenewalFailed(false)
|
||||
cache.setQueryData(queryKeys.collaborationResource(table, pk ?? '', 'presence'), snapshot)
|
||||
)
|
||||
.catch(() => undefined)
|
||||
}
|
||||
} catch (error) {
|
||||
if (!active || !visible || generation !== requestGeneration || sequence !== requestSequence)
|
||||
return
|
||||
opening = false
|
||||
setRenewalFailed(true)
|
||||
if (
|
||||
isAxiosError(error) &&
|
||||
error.response?.status === 409 &&
|
||||
error.response.data?.code === 'PRESENCE_LEASE_EXPIRED'
|
||||
) {
|
||||
connectionId = undefined
|
||||
generation++
|
||||
if (!rejoining) void heartbeat(true)
|
||||
}
|
||||
}
|
||||
}
|
||||
heartbeat()
|
||||
const timer = setInterval(heartbeat, 20000)
|
||||
const hide = () => beaconPresence(input)
|
||||
refresh.current = () => {
|
||||
void heartbeat()
|
||||
}
|
||||
void heartbeat()
|
||||
const timer = setInterval(() => {
|
||||
void heartbeat()
|
||||
}, 20000)
|
||||
const release = (beacon: boolean) => {
|
||||
generation++
|
||||
opening = false
|
||||
const previous = connectionId
|
||||
connectionId = undefined
|
||||
if (!previous) return
|
||||
const input = { table, pk, connectionId: previous, sequence: ++sequence }
|
||||
if (beacon)
|
||||
beaconPresence({
|
||||
...input,
|
||||
mode: focus.current.editing ? 'EDITING' : 'VIEWING',
|
||||
fieldName: focus.current.fieldName,
|
||||
})
|
||||
else void leavePresence(input).catch(() => undefined)
|
||||
}
|
||||
const hide = () => {
|
||||
visible = false
|
||||
release(true)
|
||||
}
|
||||
const show = () => {
|
||||
visible = document.visibilityState !== 'hidden'
|
||||
void heartbeat()
|
||||
}
|
||||
const visibility = () => (document.visibilityState === 'hidden' ? hide() : show())
|
||||
window.addEventListener('pagehide', hide)
|
||||
window.addEventListener('pageshow', show)
|
||||
document.addEventListener('visibilitychange', visibility)
|
||||
return () => {
|
||||
active = false
|
||||
release(false)
|
||||
refresh.current = () => undefined
|
||||
clearInterval(timer)
|
||||
window.removeEventListener('pagehide', hide)
|
||||
window.removeEventListener('pageshow', show)
|
||||
document.removeEventListener('visibilitychange', visibility)
|
||||
}
|
||||
}, [connectionId, table, pk, editing, fieldName, enabled, ended, cache])
|
||||
}, [table, pk, enabled, ended, cache])
|
||||
useEffect(() => {
|
||||
if (!enabled || ended) return
|
||||
return () => {
|
||||
void leavePresence({ connectionId, table, pk }).catch(() => undefined)
|
||||
}
|
||||
}, [connectionId, table, pk, enabled, ended])
|
||||
return { ...query, viewers: otherViewers(query.data?.viewers ?? [], userId) }
|
||||
refresh.current()
|
||||
}, [editing, fieldName])
|
||||
return {
|
||||
...query,
|
||||
unavailable: enabled && !ended && (query.isError || renewalFailed),
|
||||
viewers:
|
||||
enabled && !ended && !query.isError && !renewalFailed
|
||||
? otherViewers(query.data?.viewers ?? [], userId)
|
||||
: [],
|
||||
}
|
||||
}
|
||||
@@ -122,6 +122,23 @@ it('invalidates records, panel counts, tasks, notifications and resync queries',
|
||||
invalidateRealtime(cache, { event: 'resync', data: {} })
|
||||
expect(invalidate).toHaveBeenLastCalledWith({ queryKey: queryKeys.all() })
|
||||
})
|
||||
it('presence hints invalidate only their record or table room without trusting viewer payloads', () => {
|
||||
const cache = new QueryClient()
|
||||
const invalidate = vi.spyOn(cache, 'invalidateQueries')
|
||||
const recordKey = queryKeys.collaborationResource('person', '1', 'presence')
|
||||
cache.setQueryData(recordKey, { viewers: [] })
|
||||
invalidateRealtime(cache, {
|
||||
event: 'presence.invalidated',
|
||||
data: { table: 'person', pk: 1, viewers: [{ userId: 'untrusted' }] },
|
||||
})
|
||||
expect(invalidate).toHaveBeenCalledExactlyOnceWith({ queryKey: recordKey })
|
||||
expect(cache.getQueryData(recordKey)).toEqual({ viewers: [] })
|
||||
invalidate.mockClear()
|
||||
invalidateRealtime(cache, { event: 'presence.invalidated', data: { table: 'person' } })
|
||||
expect(invalidate).toHaveBeenCalledExactlyOnceWith({
|
||||
queryKey: queryKeys.collaborationResource('person', '', 'presence'),
|
||||
})
|
||||
})
|
||||
class Bus {
|
||||
static peers = new Set<Bus>()
|
||||
onmessage?: (event: MessageEvent) => void
|
||||
|
||||
@@ -41,6 +41,11 @@ export function invalidateRealtime(client: QueryClient, { event, data }: Realtim
|
||||
}
|
||||
if (event === 'task.changed') invalidate(queryKeys.collaborationMyWork())
|
||||
if (typeof data.table !== 'string') return
|
||||
if (event === 'presence.invalidated') {
|
||||
// Hints contain no viewer state; reread through the authenticated presence API.
|
||||
invalidate(queryKeys.collaborationResource(data.table, String(data.pk ?? ''), 'presence'))
|
||||
return
|
||||
}
|
||||
if (event === 'record.changed' || event === 'record.deleted' || event === 'table.changed')
|
||||
invalidate(queryKeys.tableRecords(data.table))
|
||||
if (data.pk === undefined || data.pk === null) return
|
||||
|
||||
@@ -601,6 +601,8 @@ const viewers = [
|
||||
{ userId: 'sam', displayName: 'Sam Parker', mode: 'EDITING', fieldName: 'firstName' },
|
||||
{ userId: 'alex', displayName: 'Alex Rivera', mode: 'VIEWING' },
|
||||
]
|
||||
// Demo peers are fixed fixtures; only the current browser's issued handles are tracked.
|
||||
const presenceLeases = new Map<string, { table: string; pk?: string }>()
|
||||
const taskStore: import('@/types/collaboration').CollaborationTaskV1[] = [
|
||||
{
|
||||
id: 8001,
|
||||
@@ -671,11 +673,28 @@ collaborationHandlers.push(
|
||||
const params = new URL(request.url).searchParams
|
||||
return HttpResponse.json({ table: params.get('table'), pk: params.get('pk'), viewers })
|
||||
}),
|
||||
http.post(`${BASE}/presence`, async ({ request }) => {
|
||||
const input = (await request.json()) as Record<string, unknown>
|
||||
http.post(`${BASE}/presence/leases`, async ({ request }) => {
|
||||
const target = (await request.json()) as { table: string; pk?: string }
|
||||
const connectionId = `lease:${crypto.randomUUID()}`
|
||||
presenceLeases.set(connectionId, target)
|
||||
return HttpResponse.json({ connectionId })
|
||||
}),
|
||||
http.post(`${BASE}/presence/leases/renew`, async ({ request }) => {
|
||||
const input = (await request.json()) as {
|
||||
table: string
|
||||
pk?: string
|
||||
connectionId: string
|
||||
}
|
||||
const target = presenceLeases.get(input.connectionId)
|
||||
if (!target || target.table !== input.table || target.pk !== input.pk)
|
||||
return HttpResponse.json({ code: 'PRESENCE_LEASE_EXPIRED' }, { status: 409 })
|
||||
return HttpResponse.json({ table: input.table, pk: input.pk, viewers })
|
||||
}),
|
||||
http.delete(`${BASE}/presence`, () => HttpResponse.json({ success: true })),
|
||||
http.delete(`${BASE}/presence`, async ({ request }) => {
|
||||
const input = (await request.json()) as { connectionId: string }
|
||||
presenceLeases.delete(input.connectionId)
|
||||
return HttpResponse.json({ success: true })
|
||||
}),
|
||||
http.get(`${BASE}/people`, ({ request }) => {
|
||||
const q = new URL(request.url).searchParams.get('q')?.toLowerCase() ?? ''
|
||||
return HttpResponse.json({
|
||||
@@ -770,7 +789,11 @@ collaborationHandlers.push(
|
||||
completedByDisplayName: nameOf('me'),
|
||||
})
|
||||
if (input.status === 'OPEN')
|
||||
Object.assign(task, { completedAt: null, completedByUserId: null, completedByDisplayName: null })
|
||||
Object.assign(task, {
|
||||
completedAt: null,
|
||||
completedByUserId: null,
|
||||
completedByDisplayName: null,
|
||||
})
|
||||
publishCollaborationEvent('task.changed', {
|
||||
table: task.tableName,
|
||||
pk: task.recordId,
|
||||
|
||||
@@ -597,6 +597,7 @@ export interface PresenceViewer {
|
||||
}
|
||||
/** Presence target and client lease identity. */
|
||||
export interface PresenceTarget {
|
||||
sequence?: number
|
||||
connectionId: string
|
||||
table: string
|
||||
pk?: string
|
||||
|
||||
@@ -123,6 +123,10 @@ test.describe('command palette and search', () => {
|
||||
await expect(pageLinks.filter({ hasText: /^Pet/ })).toHaveText([/^Pet(?! )/, /^Pet Species/, /^Pet Note/])
|
||||
for (const text of await pageLinks.allTextContents()) expect(text).toMatch(/pet/i)
|
||||
}
|
||||
// Help results load above Pages and can move the link during a pointer gesture.
|
||||
// Wait for the actual result, including after reload, before testing navigation.
|
||||
await expect(page.locator('[data-qqq-id="help-search-results"] [data-qqq-id="help-result-table:petNote;slot:help"]')).toBeVisible()
|
||||
await expect(page.locator('[data-qqq-id="help-search-results"]').getByRole('status')).toHaveCount(0)
|
||||
await page.getByRole('region', { name: 'Pages' }).getByRole('link', { name: /^Pet Note/ }).click()
|
||||
await expect(page).toHaveURL(/\/app\/petNote\/?$/)
|
||||
expect(searchStatuses.length).toBeGreaterThan(0)
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
|
||||
/** @file Exercises the real variant lifecycle with only process and filesystem boundaries replaced. */
|
||||
import { EventEmitter } from 'node:events'
|
||||
import path from 'node:path'
|
||||
import { afterEach, expect, it, vi } from 'vitest'
|
||||
|
||||
const state = vi.hoisted(() => ({
|
||||
@@ -39,7 +40,7 @@ vi.mock('node:child_process', () => {
|
||||
// Existing stale export and build files cannot determine the browser's selected server.
|
||||
vi.mock('node:fs', async (original) => {
|
||||
const fs = {
|
||||
...await original<typeof import('node:fs')>(), existsSync: (file: string) => file.endsWith('next-dashboard/index.html') ? state.hasExport : true, mkdirSync: () => {}, rmSync: () => {},
|
||||
...await original<typeof import('node:fs')>(), existsSync: (file: string) => file.endsWith(path.join('next-dashboard', 'index.html')) ? state.hasExport : true, mkdirSync: () => {}, rmSync: () => {},
|
||||
readdirSync: () => ['Fixture.java'],
|
||||
readFileSync: (file: string) => file.endsWith('routes-manifest.json')
|
||||
? JSON.stringify({ rewrites: { beforeFiles: [], afterFiles: ['qqq', 'data', 'widget', 'metaData', 'download', 'processes', 'possibleValues', 'reports', 'manageSession', 'apis.json', 'api', 'qa'].map((prefix) => ({ source: `/${prefix}/:path*`, destination: `http://127.0.0.1:${state.wrongTarget ? '18765' : '18775'}/${prefix}/:path*` })), fallback: [] } })
|
||||
|
||||
@@ -19,9 +19,25 @@ import path from 'node:path'
|
||||
import { test, expect, type Page } from '@playwright/test'
|
||||
|
||||
async function openRecord(page: Page) {
|
||||
const opened = page.waitForResponse(
|
||||
(r) => r.url().endsWith('/presence/leases') && r.request().method() === 'POST'
|
||||
)
|
||||
const renewed = page.waitForResponse(
|
||||
(r) => r.url().endsWith('/presence/leases/renew') && r.request().method() === 'POST'
|
||||
)
|
||||
await page.goto('/app/person/1')
|
||||
expect((await opened).status()).toBe(200)
|
||||
const renewal = await renewed
|
||||
expect(renewal.status()).toBe(200)
|
||||
expect(renewal.request().postDataJSON()).toMatchObject({
|
||||
table: 'person',
|
||||
pk: '1',
|
||||
mode: 'VIEWING',
|
||||
connectionId: expect.stringMatching(/^lease:/),
|
||||
})
|
||||
await expect(page.locator('[data-qqq-id="record-view-title-person"]')).toHaveText('Alice Johnson')
|
||||
await expect(page.locator('[data-qqq-id="presence-sam"]')).toBeVisible()
|
||||
await expect(page.getByText('Presence temporarily unavailable', { exact: true })).toHaveCount(0)
|
||||
}
|
||||
async function concurrentEdit(page: Page) {
|
||||
await page.evaluate(async () => {
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
/*
|
||||
* 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 at https://www.apache.org/licenses/LICENSE-2.0
|
||||
*/
|
||||
// Regression: delayed help must not move a navigation target during a pointer gesture.
|
||||
import { test, expect } from '@playwright/test'
|
||||
import { setupApiMocks } from './api-mocks'
|
||||
|
||||
for (const surface of ['palette', 'search page'] as const) {
|
||||
test(`${surface}: a page remains clickable when help arrives between pointer down and up`, async ({ page, baseURL }) => {
|
||||
await setupApiMocks(page)
|
||||
await page.route('**/qqq/v1/version', (route) => route.fulfill({ json: {
|
||||
apiVersion: '1.4.0', features: ['help'], extensions: [],
|
||||
} }))
|
||||
let releaseHelp!: () => void
|
||||
const held = new Promise<void>((resolve) => { releaseHelp = resolve })
|
||||
await page.route('**/qqq/v1/help/search?**', async (route) => {
|
||||
await held
|
||||
await route.fulfill({ json: { results: [{
|
||||
kind: 'table', key: 'table:person;slot:help', name: 'person',
|
||||
label: 'People help', summary: 'Delayed help result', text: 'People help content',
|
||||
}] } })
|
||||
})
|
||||
try {
|
||||
const requested = page.waitForRequest('**/qqq/v1/help/search?**')
|
||||
const origin = baseURL!.replace('127.0.0.1', 'localhost')
|
||||
if (surface === 'palette') {
|
||||
await page.goto(`${origin}/app`)
|
||||
await page.locator('[data-qqq-id="button-search"]').click()
|
||||
await page.locator('[data-qqq-id="command-menu-search"]').fill('People')
|
||||
} else {
|
||||
await page.goto(`${origin}/app/search?q=People&view=pages`)
|
||||
}
|
||||
await requested
|
||||
const target = surface === 'palette'
|
||||
? page.locator('[data-qqq-id="command-item-person"]')
|
||||
: page.getByRole('region', { name: 'Pages' }).getByRole('link', { name: /People/ })
|
||||
await expect(target).toBeVisible()
|
||||
const box = await target.boundingBox()
|
||||
expect(box).not.toBeNull()
|
||||
await page.mouse.move(box!.x + box!.width / 2, box!.y + box!.height / 2)
|
||||
await page.mouse.down()
|
||||
releaseHelp()
|
||||
await expect(page.locator('[data-qqq-id="help-result-table:person;slot:help"]')).toBeVisible()
|
||||
expect(await target.boundingBox()).toEqual(box)
|
||||
await page.mouse.up()
|
||||
await expect(page).toHaveURL(/\/app\/person\/?$/)
|
||||
} finally {
|
||||
releaseHelp()
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -49,10 +49,12 @@ it('keeps the variant build, static assets and entrypoint after the main build d
|
||||
expect(readFileSync(path.join(staged, 'public/icon.svg'), 'utf8')).toBe('<svg/>')
|
||||
expect(readFileSync(path.join(staged, 'qqq-server.mjs'), 'utf8')).toBe('container entrypoint')
|
||||
expect(readFileSync(path.join(staged, 'branding-assets.mjs'), 'utf8')).toBe('declared branding aliases')
|
||||
expect(readlinkSync(path.join(staged, 'node_modules/example'))).toBe('.pnpm/example')
|
||||
expect(readlinkSync(path.join(staged, 'node_modules/example'))).toBe(path.join('.pnpm', 'example'))
|
||||
expect(readFileSync(path.join(staged, 'node_modules/example/index.js'), 'utf8')).toBe('independent dependency')
|
||||
expect(statSync(path.join(staged, 'server.js')).mode & 0o777).toBe(0o600)
|
||||
expect(statSync(path.join(staged, '.next/static')).mode & 0o777).toBe(0o700)
|
||||
if (process.platform !== 'win32') {
|
||||
expect(statSync(path.join(staged, 'server.js')).mode & 0o777).toBe(0o600)
|
||||
expect(statSync(path.join(staged, '.next/static')).mode & 0o777).toBe(0o700)
|
||||
}
|
||||
})
|
||||
|
||||
it('rejects an old shared-backend rewrite even with a new valid variant marker', () => {
|
||||
|
||||
+5
-1
@@ -37,7 +37,11 @@
|
||||
"**/*.ts",
|
||||
"**/*.tsx",
|
||||
".next/types/**/*.ts",
|
||||
".next/dev/types/**/*.ts"
|
||||
".next/dev/types/**/*.ts",
|
||||
".next-collaboration/types/**/*.ts",
|
||||
".next-collaboration/dev/types/**/*.ts",
|
||||
".next-presence-live/types/**/*.ts",
|
||||
".next-presence-live/dev/types/**/*.ts"
|
||||
],
|
||||
"exclude": [
|
||||
"node_modules"
|
||||
|
||||
Reference in new issue
Block a user