feat: integrate issued collaboration presence leases
Use server-issued presence handles, preserve drafts and unavailable states, and invalidate scoped realtime hints. Align the bundled collaboration 1.3 contract and retain focused/browser acceptance coverage. Carry forward current main CI and navigation test fixes. Co-Authored-By: Codex <noreply@openai.com>
This commit is contained in:
26 files changed
+4478
-3193
No files matched your search
@@ -1,12 +1,12 @@
|
||||
version: 2.1
|
||||
setup: true
|
||||
orbs:
|
||||
qqq-orb: kingsrook/qqq-orb@0.7.12
|
||||
qqq-orb: kingsrook/qqq-orb@0.7.17
|
||||
workflows:
|
||||
setup:
|
||||
jobs:
|
||||
- qqq-orb/generate_pipeline:
|
||||
orb_version: 0.7.12
|
||||
orb_version: 0.7.17
|
||||
filters:
|
||||
branches:
|
||||
ignore: /.*/
|
||||
|
||||
@@ -10,6 +10,8 @@
|
||||
|
||||
# next.js
|
||||
/.next/
|
||||
/.next-collaboration/
|
||||
/.next-presence-live/
|
||||
/out/
|
||||
|
||||
# production
|
||||
|
||||
+1
-1
@@ -1,3 +1,3 @@
|
||||
profile: frontend-next
|
||||
orb_version: 0.7.12
|
||||
orb_version: 0.7.17
|
||||
default_branch: main
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -49,11 +49,9 @@ vi.mock('@/lib/hooks/use-metadata', () => ({
|
||||
}))
|
||||
vi.mock('@/lib/api/tables', () => ({
|
||||
getRecord: vi.fn(),
|
||||
queryRecords: vi
|
||||
.fn()
|
||||
.mockResolvedValue({
|
||||
records: [{ tableName: 'person', values: { id: 1 }, recordLabel: 'Alice' }],
|
||||
}),
|
||||
queryRecords: vi.fn().mockResolvedValue({
|
||||
records: [{ tableName: 'person', values: { id: 1 }, recordLabel: 'Alice' }],
|
||||
}),
|
||||
}))
|
||||
vi.mock('@/lib/api/collaboration', () => ({
|
||||
searchPeople: vi.fn(),
|
||||
@@ -66,6 +64,7 @@ vi.mock('@/lib/api/collaboration', () => ({
|
||||
createTask: vi.fn(),
|
||||
patchTask: vi.fn(),
|
||||
getMyWork: vi.fn(),
|
||||
markMentionsRead: vi.fn().mockResolvedValue({ success: true }),
|
||||
listMyTasks: vi.fn(),
|
||||
}))
|
||||
const record = { table: 'person', pk: '1' }
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -136,10 +136,12 @@ test.describe('command palette and search', () => {
|
||||
await page.locator('[data-qqq-id="button-search"]').click()
|
||||
const dialog = page.getByRole('dialog', { name: 'Command palette' })
|
||||
await dialog.getByRole('combobox').fill('clone')
|
||||
const clone = dialog.getByRole('option', { name: /Clone People/ })
|
||||
// Help arrives asynchronously and can share the process label; it is not a local page match.
|
||||
await expect(dialog.locator('[data-qqq-id="help-result-process:clonePeople;slot:help"]')).toBeVisible()
|
||||
const clone = dialog.locator('[data-qqq-id="command-item-clonePeople"]')
|
||||
await expect(clone).toHaveCount(1)
|
||||
await expect(clone).toContainText('People App')
|
||||
await expect(dialog.getByRole('option')).toHaveCount(2)
|
||||
await expect(dialog.locator('[data-qqq-id^="command-item-"]')).toHaveCount(1)
|
||||
await expect(dialog.locator('[data-qqq-id="command-search-all"]')).toContainText('Search everything for “clone”')
|
||||
await expectTouchTargets(dialog)
|
||||
await expect(clone).toHaveAttribute('aria-selected', 'true')
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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