fix(13-02): eliminate all ESLint violations — pnpm lint exits 0
- eslint.config.js: disable React Compiler rules (v7 flat.recommended enables
them; codebase does not use the Compiler); add e2e/ to disableTypeChecked
block; promote exhaustive-deps to error
- API broker: remove redundant as-casts (outboxWorker, poller, reminderScheduler,
expand, sync, vevent, spike); add targeted ical.js no-unsafe-assignment/argument
disables with justifying comments inside try blocks
- API routes/sse.ts: fix no-misused-promises on async writeSSE callback with
void+IIFE+catch pattern
- API routes/lists.ts: let → const for updateValues
- API tests: remove unused imports (beforeEach, eq, vi); rename unused vars
with _ prefix; remove unused lastActiveId assignment
- PWA components: void navigate() and void queryClient.invalidateQueries() on
all fire-and-forget call sites; fix CalendarShell explicit-type-casts;
Couldn't → HTML entity
- PWA test files: as unknown as Response for partial mock objects; string | null
type annotation on mockLastSyncedUid; remove async from test callbacks without
await; act(() => {}) not await act(async () => {}) for sync ops
- sw.ts: restructure Notification.data?.url access as let+if so disable
comments land on the exact violation lines; void self.skipWaiting()
This commit is contained in:
@@ -180,11 +180,13 @@ export function expandOccurrences(
|
|||||||
// --- 1. Parse VCALENDAR — return [] on malformed input (matches sync.ts pattern) ---
|
// --- 1. Parse VCALENDAR — return [] on malformed input (matches sync.ts pattern) ---
|
||||||
let parsed: ReturnType<typeof ICAL.parse>
|
let parsed: ReturnType<typeof ICAL.parse>
|
||||||
try {
|
try {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment -- ical.js parse() returns 'any'; result is only passed to ICAL.Component which accepts it
|
||||||
parsed = ICAL.parse(rawVevent)
|
parsed = ICAL.parse(rawVevent)
|
||||||
} catch {
|
} catch {
|
||||||
return []
|
return []
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument -- ical.js parse() output is 'any'; ICAL.Component is the correct consumer of this value
|
||||||
const comp = new ICAL.Component(parsed)
|
const comp = new ICAL.Component(parsed)
|
||||||
|
|
||||||
// --- 2. Register VTIMEZONE components BEFORE constructing RecurExpansion (MANDATORY) ---
|
// --- 2. Register VTIMEZONE components BEFORE constructing RecurExpansion (MANDATORY) ---
|
||||||
@@ -230,7 +232,7 @@ export function expandOccurrences(
|
|||||||
// Use ICAL.Event.endDate which derives end from DTEND, or DTSTART+DURATION, or sensible default.
|
// Use ICAL.Event.endDate which derives end from DTEND, or DTSTART+DURATION, or sensible default.
|
||||||
// Do NOT use getFirstPropertyValue('dtend') directly — events with only DURATION set return null,
|
// Do NOT use getFirstPropertyValue('dtend') directly — events with only DURATION set return null,
|
||||||
// producing zero-duration occurrences (BUG 1).
|
// producing zero-duration occurrences (BUG 1).
|
||||||
let occEnd: ICAL.Time = (event.endDate ?? dtstart) as ICAL.Time
|
let occEnd: ICAL.Time = event.endDate ?? dtstart
|
||||||
|
|
||||||
// Positive-duration guard: ensure timed events have non-zero height in Schedule-X.
|
// Positive-duration guard: ensure timed events have non-zero height in Schedule-X.
|
||||||
if (!allDay && occEnd.compare(dtstart) <= 0) {
|
if (!allDay && occEnd.compare(dtstart) <= 0) {
|
||||||
|
|||||||
@@ -372,7 +372,7 @@ async function dispatchRow(row: OutboxRow): Promise<DispatchResult> {
|
|||||||
rruleFromPayload,
|
rruleFromPayload,
|
||||||
fields.recurrenceUntil,
|
fields.recurrenceUntil,
|
||||||
fields.recurrenceCount,
|
fields.recurrenceCount,
|
||||||
fields.allDay as boolean,
|
fields.allDay,
|
||||||
)
|
)
|
||||||
: undefined
|
: undefined
|
||||||
} else if (preservedRrule) {
|
} else if (preservedRrule) {
|
||||||
@@ -383,7 +383,7 @@ async function dispatchRow(row: OutboxRow): Promise<DispatchResult> {
|
|||||||
strippedPreset,
|
strippedPreset,
|
||||||
fields.recurrenceUntil,
|
fields.recurrenceUntil,
|
||||||
fields.recurrenceCount,
|
fields.recurrenceCount,
|
||||||
fields.allDay as boolean,
|
fields.allDay,
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
finalRruleString = preservedRrule
|
finalRruleString = preservedRrule
|
||||||
@@ -394,12 +394,12 @@ async function dispatchRow(row: OutboxRow): Promise<DispatchResult> {
|
|||||||
|
|
||||||
const { icsString } = buildVeventString({
|
const { icsString } = buildVeventString({
|
||||||
uid: row.uid,
|
uid: row.uid,
|
||||||
summary: fields.title as string,
|
summary: fields.title,
|
||||||
allDay: fields.allDay as boolean,
|
allDay: fields.allDay,
|
||||||
dtstart: fields.allDay ? (fields.start as string) : new Date(fields.start as string),
|
dtstart: fields.allDay ? fields.start : new Date(fields.start),
|
||||||
dtend: fields.allDay ? (fields.end as string) : new Date(fields.end as string),
|
dtend: fields.allDay ? fields.end : new Date(fields.end),
|
||||||
location: fields.location as string | undefined,
|
location: fields.location,
|
||||||
description: fields.description as string | undefined,
|
description: fields.description,
|
||||||
rruleString: finalRruleString,
|
rruleString: finalRruleString,
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -463,7 +463,7 @@ async function dispatchRow(row: OutboxRow): Promise<DispatchResult> {
|
|||||||
rruleFromPayload,
|
rruleFromPayload,
|
||||||
fields.recurrenceUntil,
|
fields.recurrenceUntil,
|
||||||
fields.recurrenceCount,
|
fields.recurrenceCount,
|
||||||
fields.allDay as boolean,
|
fields.allDay,
|
||||||
)
|
)
|
||||||
: undefined
|
: undefined
|
||||||
} else if (preservedRrule) {
|
} else if (preservedRrule) {
|
||||||
@@ -474,7 +474,7 @@ async function dispatchRow(row: OutboxRow): Promise<DispatchResult> {
|
|||||||
strippedPreset,
|
strippedPreset,
|
||||||
fields.recurrenceUntil,
|
fields.recurrenceUntil,
|
||||||
fields.recurrenceCount,
|
fields.recurrenceCount,
|
||||||
fields.allDay as boolean,
|
fields.allDay,
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
finalRruleString = preservedRrule
|
finalRruleString = preservedRrule
|
||||||
@@ -485,12 +485,12 @@ async function dispatchRow(row: OutboxRow): Promise<DispatchResult> {
|
|||||||
|
|
||||||
const { icsString } = buildVeventString({
|
const { icsString } = buildVeventString({
|
||||||
uid: row.uid,
|
uid: row.uid,
|
||||||
summary: fields.title as string,
|
summary: fields.title,
|
||||||
allDay: fields.allDay as boolean,
|
allDay: fields.allDay,
|
||||||
dtstart: fields.allDay ? (fields.start as string) : new Date(fields.start as string),
|
dtstart: fields.allDay ? fields.start : new Date(fields.start),
|
||||||
dtend: fields.allDay ? (fields.end as string) : new Date(fields.end as string),
|
dtend: fields.allDay ? fields.end : new Date(fields.end),
|
||||||
location: fields.location as string | undefined,
|
location: fields.location,
|
||||||
description: fields.description as string | undefined,
|
description: fields.description,
|
||||||
rruleString: finalRruleString,
|
rruleString: finalRruleString,
|
||||||
})
|
})
|
||||||
// Build a minimal DAVCalendar for the write wrapper (only url is needed)
|
// Build a minimal DAVCalendar for the write wrapper (only url is needed)
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ export async function runPoll(): Promise<void> {
|
|||||||
|
|
||||||
// ctag/syncToken: defensive null handling (Pitfall #6)
|
// ctag/syncToken: defensive null handling (Pitfall #6)
|
||||||
const knownCtag = stored?.ctag ?? null
|
const knownCtag = stored?.ctag ?? null
|
||||||
const currentCtag = (davCal.ctag ?? davCal.syncToken ?? null) as string | null
|
const currentCtag = davCal.ctag ?? davCal.syncToken ?? null
|
||||||
|
|
||||||
// Skip if ctag is present on both sides and unchanged
|
// Skip if ctag is present on both sides and unchanged
|
||||||
if (currentCtag !== null && currentCtag === knownCtag) {
|
if (currentCtag !== null && currentCtag === knownCtag) {
|
||||||
|
|||||||
@@ -129,7 +129,7 @@ export async function runReminderCheck(now = new Date()): Promise<void> {
|
|||||||
if (row.subId != null) {
|
if (row.subId != null) {
|
||||||
byUid.get(row.uid)!.subs.push({
|
byUid.get(row.uid)!.subs.push({
|
||||||
id: row.subId,
|
id: row.subId,
|
||||||
userId: row.subUserId!,
|
userId: row.subUserId,
|
||||||
endpoint: row.subEndpoint,
|
endpoint: row.subEndpoint,
|
||||||
p256dh: row.subP256dh,
|
p256dh: row.subP256dh,
|
||||||
auth: row.subAuth,
|
auth: row.subAuth,
|
||||||
|
|||||||
@@ -50,7 +50,9 @@ async function main() {
|
|||||||
for (const cal of calendars) {
|
for (const cal of calendars) {
|
||||||
console.log('---')
|
console.log('---')
|
||||||
console.log(` url: ${cal.url}`)
|
console.log(` url: ${cal.url}`)
|
||||||
console.log(` displayName: ${cal.displayName ?? '(none)'}`)
|
// displayName may be a string or a Record (language-tagged value) per CalDAV spec
|
||||||
|
const displayName = typeof cal.displayName === 'string' ? cal.displayName : JSON.stringify(cal.displayName ?? '(none)')
|
||||||
|
console.log(` displayName: ${displayName}`)
|
||||||
// ctag/syncToken: Fastmail may return either field (Pitfall #6)
|
// ctag/syncToken: Fastmail may return either field (Pitfall #6)
|
||||||
console.log(` ctag: ${(cal as { ctag?: string }).ctag ?? '(not returned)'}`)
|
console.log(` ctag: ${(cal as { ctag?: string }).ctag ?? '(not returned)'}`)
|
||||||
console.log(` syncToken: ${(cal as { syncToken?: string }).syncToken ?? '(not returned)'}`)
|
console.log(` syncToken: ${(cal as { syncToken?: string }).syncToken ?? '(not returned)'}`)
|
||||||
|
|||||||
@@ -86,12 +86,14 @@ export async function syncCalendar(
|
|||||||
|
|
||||||
let parsed: ReturnType<typeof ICAL.parse>
|
let parsed: ReturnType<typeof ICAL.parse>
|
||||||
try {
|
try {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment -- ical.js parse() returns 'any'; result is only passed to ICAL.Component which accepts it
|
||||||
parsed = ICAL.parse(obj.data as string)
|
parsed = ICAL.parse(obj.data as string)
|
||||||
} catch {
|
} catch {
|
||||||
// Malformed VCALENDAR — skip but do not crash the sync
|
// Malformed VCALENDAR — skip but do not crash the sync
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument -- ical.js parse() output is 'any'; ICAL.Component is the correct consumer of this value
|
||||||
const comp = new ICAL.Component(parsed)
|
const comp = new ICAL.Component(parsed)
|
||||||
const vevent = comp.getFirstSubcomponent('vevent')
|
const vevent = comp.getFirstSubcomponent('vevent')
|
||||||
if (!vevent) continue
|
if (!vevent) continue
|
||||||
@@ -198,13 +200,14 @@ export async function syncCalendar(
|
|||||||
if ((oldRow.allDay ? true : false) !== allDay) changedFields.push('allDay')
|
if ((oldRow.allDay ? true : false) !== allDay) changedFields.push('allDay')
|
||||||
|
|
||||||
// Compare title (SUMMARY)
|
// Compare title (SUMMARY)
|
||||||
const oldTitle = (oldRow.title ?? null) as string | null
|
const oldTitle = oldRow.title ?? null
|
||||||
if (oldTitle !== titleValue) changedFields.push('title')
|
if (oldTitle !== titleValue) changedFields.push('title')
|
||||||
|
|
||||||
// Compare location — extract from old rawVevent for comparison
|
// Compare location — extract from old rawVevent for comparison
|
||||||
let oldLocation: string | null = null
|
let oldLocation: string | null = null
|
||||||
if (oldRow.rawVevent) {
|
if (oldRow.rawVevent) {
|
||||||
try {
|
try {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument -- ical.js parse() returns 'any'; ICAL.Component is the correct consumer of this value
|
||||||
const oldComp = new ICAL.Component(ICAL.parse(oldRow.rawVevent))
|
const oldComp = new ICAL.Component(ICAL.parse(oldRow.rawVevent))
|
||||||
const oldVevent = oldComp.getFirstSubcomponent('vevent')
|
const oldVevent = oldComp.getFirstSubcomponent('vevent')
|
||||||
if (oldVevent) {
|
if (oldVevent) {
|
||||||
|
|||||||
@@ -63,10 +63,12 @@ export const RRULE_PRESETS: Record<string, string> = {
|
|||||||
export function extractRruleString(rawVevent: string): string | undefined {
|
export function extractRruleString(rawVevent: string): string | undefined {
|
||||||
let parsed: ReturnType<typeof ICAL.parse>
|
let parsed: ReturnType<typeof ICAL.parse>
|
||||||
try {
|
try {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment -- ical.js parse() returns 'any'; result is only passed to ICAL.Component which accepts it
|
||||||
parsed = ICAL.parse(rawVevent)
|
parsed = ICAL.parse(rawVevent)
|
||||||
} catch {
|
} catch {
|
||||||
return undefined
|
return undefined
|
||||||
}
|
}
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument -- ical.js parse() output is 'any'; ICAL.Component is the correct consumer of this value
|
||||||
const comp = new ICAL.Component(parsed)
|
const comp = new ICAL.Component(parsed)
|
||||||
const vevent = comp.getFirstSubcomponent('vevent')
|
const vevent = comp.getFirstSubcomponent('vevent')
|
||||||
if (!vevent) return undefined
|
if (!vevent) return undefined
|
||||||
|
|||||||
@@ -601,7 +601,7 @@ listItemsRouter.patch('/:itemId', zValidator('json', patchItemSchema), async (c)
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Build the update payload — single-field write with updatedAt=NOW()
|
// Build the update payload — single-field write with updatedAt=NOW()
|
||||||
let updateValues: {
|
const updateValues: {
|
||||||
checked?: boolean
|
checked?: boolean
|
||||||
text?: string
|
text?: string
|
||||||
rank?: string
|
rank?: string
|
||||||
|
|||||||
@@ -93,13 +93,19 @@ sseRouter.get('/lists', async (c) => {
|
|||||||
|
|
||||||
// Subscribe to each accessible list's channel (D-04 — scoped, not global)
|
// Subscribe to each accessible list's channel (D-04 — scoped, not global)
|
||||||
for (const listId of accessibleListIds) {
|
for (const listId of accessibleListIds) {
|
||||||
const unsub = subscribeListEvents(listId, async (event) => {
|
const unsub = subscribeListEvents(listId, (event) => {
|
||||||
|
// The handler signature is void-returning; wrap the async write in void+catch.
|
||||||
|
// writeSSE errors are non-fatal — the SSE loop detects stream.aborted and cleans up.
|
||||||
|
void (async () => {
|
||||||
if (stream.aborted) return
|
if (stream.aborted) return
|
||||||
await stream.writeSSE({
|
await stream.writeSSE({
|
||||||
data: JSON.stringify(event),
|
data: JSON.stringify(event),
|
||||||
event: event.type,
|
event: event.type,
|
||||||
id: `${listId}-${Date.now()}`,
|
id: `${listId}-${Date.now()}`,
|
||||||
})
|
})
|
||||||
|
})().catch((err: unknown) => {
|
||||||
|
console.error('[sse] writeSSE error:', err)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
unsubscribers.push(unsub)
|
unsubscribers.push(unsub)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
* 3. NODE_ENV!='production' + DEV_AUTH_BYPASS='true' → DEV_USER injected into context
|
* 3. NODE_ENV!='production' + DEV_AUTH_BYPASS='true' → DEV_USER injected into context
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
|
import { describe, it, expect, afterEach } from 'vitest'
|
||||||
import { Hono } from 'hono'
|
import { Hono } from 'hono'
|
||||||
|
|
||||||
// We import after env manipulation since devAuthBypass() reads env vars at call time.
|
// We import after env manipulation since devAuthBypass() reads env vars at call time.
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ const mockCredentialsSelectResult: Array<{
|
|||||||
|
|
||||||
// Each call to db.select() needs to return different chains
|
// Each call to db.select() needs to return different chains
|
||||||
// We use a call counter to decide which data to return
|
// We use a call counter to decide which data to return
|
||||||
let selectCallCount = 0
|
let _callCount = 0
|
||||||
|
|
||||||
const mockSelectLimit = vi.fn()
|
const mockSelectLimit = vi.fn()
|
||||||
const mockSelectWhere = vi.fn().mockReturnValue({ limit: mockSelectLimit })
|
const mockSelectWhere = vi.fn().mockReturnValue({ limit: mockSelectLimit })
|
||||||
@@ -44,7 +44,7 @@ mockSelectFrom.mockImplementation(() => ({
|
|||||||
where: mockSelectWhere,
|
where: mockSelectWhere,
|
||||||
// Support both: direct await (no where) and .where().limit()
|
// Support both: direct await (no where) and .where().limit()
|
||||||
then: (resolve: (v: typeof mockCredentialsSelectResult) => void) => {
|
then: (resolve: (v: typeof mockCredentialsSelectResult) => void) => {
|
||||||
selectCallCount++
|
_callCount++
|
||||||
resolve(mockCredentialsSelectResult)
|
resolve(mockCredentialsSelectResult)
|
||||||
return Promise.resolve(mockCredentialsSelectResult)
|
return Promise.resolve(mockCredentialsSelectResult)
|
||||||
},
|
},
|
||||||
@@ -71,7 +71,7 @@ vi.mock('../../src/broker/client.js', () => ({
|
|||||||
describe('broker poller — runPoll', () => {
|
describe('broker poller — runPoll', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks()
|
vi.clearAllMocks()
|
||||||
selectCallCount = 0
|
_callCount = 0
|
||||||
|
|
||||||
// Reset implementations
|
// Reset implementations
|
||||||
mockSyncCalendar.mockResolvedValue(undefined)
|
mockSyncCalendar.mockResolvedValue(undefined)
|
||||||
|
|||||||
@@ -518,7 +518,7 @@ describe('GET /api/events/sync-status', () => {
|
|||||||
const mockOrderBy = vi.fn().mockImplementation(() => Promise.resolve(mockDbRows))
|
const mockOrderBy = vi.fn().mockImplementation(() => Promise.resolve(mockDbRows))
|
||||||
const mockLimit = vi.fn().mockReturnValue(mockOrderBy)
|
const mockLimit = vi.fn().mockReturnValue(mockOrderBy)
|
||||||
const mockSimpleWhere = vi.fn().mockReturnValue({ limit: mockLimit })
|
const mockSimpleWhere = vi.fn().mockReturnValue({ limit: mockLimit })
|
||||||
const mockOrderByDirect = vi.fn().mockImplementation(() => Promise.resolve(mockDbRows))
|
const _mockOrderByDirect = vi.fn().mockImplementation(() => Promise.resolve(mockDbRows))
|
||||||
// Some implementations use .where().orderBy() or just .where()
|
// Some implementations use .where().orderBy() or just .where()
|
||||||
mockSimpleWhere.mockReturnValue({ limit: mockLimit, orderBy: vi.fn().mockReturnValue({ limit: vi.fn().mockResolvedValue(mockDbRows) }) })
|
mockSimpleWhere.mockReturnValue({ limit: mockLimit, orderBy: vi.fn().mockReturnValue({ limit: vi.fn().mockResolvedValue(mockDbRows) }) })
|
||||||
mockFromFn.mockReturnValue({ where: mockSimpleWhere })
|
mockFromFn.mockReturnValue({ where: mockSimpleWhere })
|
||||||
|
|||||||
@@ -510,7 +510,6 @@ async function seedItem(
|
|||||||
rank: string,
|
rank: string,
|
||||||
checked = false,
|
checked = false,
|
||||||
): Promise<number> {
|
): Promise<number> {
|
||||||
const { eq } = await import('drizzle-orm')
|
|
||||||
const [result] = await db.insert(listItems).values({ listId, text, rank, checked }).$returningId()
|
const [result] = await db.insert(listItems).values({ listId, text, rank, checked }).$returningId()
|
||||||
return result.id
|
return result.id
|
||||||
}
|
}
|
||||||
@@ -646,7 +645,7 @@ describe('PATCH /api/list-items/:id — per-field LWW (D-08)', () => {
|
|||||||
const listId = await seedList(ownerId, 'Uncheck Rank', false)
|
const listId = await seedList(ownerId, 'Uncheck Rank', false)
|
||||||
// Two active items
|
// Two active items
|
||||||
await seedItem(listId, 'alpha', 'a0')
|
await seedItem(listId, 'alpha', 'a0')
|
||||||
const lastActiveId = await seedItem(listId, 'beta', 'a1')
|
await seedItem(listId, 'beta', 'a1')
|
||||||
// One checked item (to uncheck)
|
// One checked item (to uncheck)
|
||||||
const checkedItemId = await seedItem(listId, 'checked-one', 'Zz', true)
|
const checkedItemId = await seedItem(listId, 'checked-one', 'Zz', true)
|
||||||
|
|
||||||
@@ -1055,8 +1054,8 @@ describe('PATCH /api/list-items/:id { position } — reorder ordering (LIST-03,
|
|||||||
const ownerId = await seedUser('reorder-lww')
|
const ownerId = await seedUser('reorder-lww')
|
||||||
currentDevUserId = ownerId
|
currentDevUserId = ownerId
|
||||||
const listId = await seedList(ownerId, 'Reorder LWW', false)
|
const listId = await seedList(ownerId, 'Reorder LWW', false)
|
||||||
const id1 = await seedItem(listId, 'alpha', 'a0')
|
const _id1 = await seedItem(listId, 'alpha', 'a0')
|
||||||
const id2 = await seedItem(listId, 'beta', 'a1')
|
const _id2 = await seedItem(listId, 'beta', 'a1')
|
||||||
const id3 = await seedItem(listId, 'gamma', 'a2')
|
const id3 = await seedItem(listId, 'gamma', 'a2')
|
||||||
|
|
||||||
const app = await getApp()
|
const app = await getApp()
|
||||||
|
|||||||
@@ -175,8 +175,8 @@ describe('createEvent', () => {
|
|||||||
const mockFetch = vi.mocked(fetch)
|
const mockFetch = vi.mocked(fetch)
|
||||||
mockFetch.mockResolvedValueOnce({
|
mockFetch.mockResolvedValueOnce({
|
||||||
ok: true,
|
ok: true,
|
||||||
json: async () => ({ uid: 'test-uid-123' }),
|
json: () => ({ uid: 'test-uid-123' }),
|
||||||
} as Response)
|
} as unknown as Response)
|
||||||
|
|
||||||
const { createEvent } = await import('./client.js')
|
const { createEvent } = await import('./client.js')
|
||||||
const payload = {
|
const payload = {
|
||||||
@@ -193,6 +193,7 @@ describe('createEvent', () => {
|
|||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
credentials: 'include',
|
credentials: 'include',
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment -- expect.objectContaining returns 'any' (Vitest asymmetric matcher); safe in assertion context
|
||||||
headers: expect.objectContaining({ 'Content-Type': 'application/json' }),
|
headers: expect.objectContaining({ 'Content-Type': 'application/json' }),
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
@@ -201,8 +202,8 @@ describe('createEvent', () => {
|
|||||||
it('returns { uid } from 202 response', async () => {
|
it('returns { uid } from 202 response', async () => {
|
||||||
vi.mocked(fetch).mockResolvedValueOnce({
|
vi.mocked(fetch).mockResolvedValueOnce({
|
||||||
ok: true,
|
ok: true,
|
||||||
json: async () => ({ uid: 'returned-uid-456' }),
|
json: () => ({ uid: 'returned-uid-456' }),
|
||||||
} as Response)
|
} as unknown as Response)
|
||||||
|
|
||||||
const { createEvent } = await import('./client.js')
|
const { createEvent } = await import('./client.js')
|
||||||
const result = await createEvent({
|
const result = await createEvent({
|
||||||
@@ -220,8 +221,8 @@ describe('createEvent', () => {
|
|||||||
vi.mocked(fetch).mockResolvedValueOnce({
|
vi.mocked(fetch).mockResolvedValueOnce({
|
||||||
ok: false,
|
ok: false,
|
||||||
status: 400,
|
status: 400,
|
||||||
json: async () => ({ error: 'Bad Request' }),
|
json: () => ({ error: 'Bad Request' }),
|
||||||
} as Response)
|
} as unknown as Response)
|
||||||
|
|
||||||
const { createEvent } = await import('./client.js')
|
const { createEvent } = await import('./client.js')
|
||||||
await expect(
|
await expect(
|
||||||
@@ -250,8 +251,8 @@ describe('updateEvent', () => {
|
|||||||
const mockFetch = vi.mocked(fetch)
|
const mockFetch = vi.mocked(fetch)
|
||||||
mockFetch.mockResolvedValueOnce({
|
mockFetch.mockResolvedValueOnce({
|
||||||
ok: true,
|
ok: true,
|
||||||
json: async () => ({ uid: 'edit-uid-789' }),
|
json: () => ({ uid: 'edit-uid-789' }),
|
||||||
} as Response)
|
} as unknown as Response)
|
||||||
|
|
||||||
const { updateEvent } = await import('./client.js')
|
const { updateEvent } = await import('./client.js')
|
||||||
await updateEvent('edit-uid-789', {
|
await updateEvent('edit-uid-789', {
|
||||||
@@ -274,8 +275,8 @@ describe('updateEvent', () => {
|
|||||||
it('returns { uid } on success', async () => {
|
it('returns { uid } on success', async () => {
|
||||||
vi.mocked(fetch).mockResolvedValueOnce({
|
vi.mocked(fetch).mockResolvedValueOnce({
|
||||||
ok: true,
|
ok: true,
|
||||||
json: async () => ({ uid: 'patched-uid' }),
|
json: () => ({ uid: 'patched-uid' }),
|
||||||
} as Response)
|
} as unknown as Response)
|
||||||
|
|
||||||
const { updateEvent } = await import('./client.js')
|
const { updateEvent } = await import('./client.js')
|
||||||
const result = await updateEvent('some-uid', {
|
const result = await updateEvent('some-uid', {
|
||||||
@@ -303,12 +304,12 @@ describe('fetchWritableCalendars', () => {
|
|||||||
it('GETs /api/events/writable-calendars with credentials:include', async () => {
|
it('GETs /api/events/writable-calendars with credentials:include', async () => {
|
||||||
vi.mocked(fetch).mockResolvedValueOnce({
|
vi.mocked(fetch).mockResolvedValueOnce({
|
||||||
ok: true,
|
ok: true,
|
||||||
json: async () => ({
|
json: () => ({
|
||||||
calendars: [
|
calendars: [
|
||||||
{ url: 'https://caldav.fastmail.com/cal1', displayName: 'My Calendar', color: '#4A90D9', isShared: false },
|
{ url: 'https://caldav.fastmail.com/cal1', displayName: 'My Calendar', color: '#4A90D9', isShared: false },
|
||||||
],
|
],
|
||||||
}),
|
}),
|
||||||
} as Response)
|
} as unknown as Response)
|
||||||
|
|
||||||
const { fetchWritableCalendars } = await import('./client.js')
|
const { fetchWritableCalendars } = await import('./client.js')
|
||||||
await fetchWritableCalendars()
|
await fetchWritableCalendars()
|
||||||
@@ -326,8 +327,8 @@ describe('fetchWritableCalendars', () => {
|
|||||||
]
|
]
|
||||||
vi.mocked(fetch).mockResolvedValueOnce({
|
vi.mocked(fetch).mockResolvedValueOnce({
|
||||||
ok: true,
|
ok: true,
|
||||||
json: async () => ({ calendars: mockCalendars }),
|
json: () => ({ calendars: mockCalendars }),
|
||||||
} as Response)
|
} as unknown as Response)
|
||||||
|
|
||||||
const { fetchWritableCalendars } = await import('./client.js')
|
const { fetchWritableCalendars } = await import('./client.js')
|
||||||
const result = await fetchWritableCalendars()
|
const result = await fetchWritableCalendars()
|
||||||
@@ -340,7 +341,7 @@ describe('fetchWritableCalendars', () => {
|
|||||||
vi.mocked(fetch).mockResolvedValueOnce({
|
vi.mocked(fetch).mockResolvedValueOnce({
|
||||||
ok: false,
|
ok: false,
|
||||||
status: 401,
|
status: 401,
|
||||||
} as Response)
|
} as unknown as Response)
|
||||||
|
|
||||||
const { fetchWritableCalendars } = await import('./client.js')
|
const { fetchWritableCalendars } = await import('./client.js')
|
||||||
await expect(fetchWritableCalendars()).rejects.toThrow()
|
await expect(fetchWritableCalendars()).rejects.toThrow()
|
||||||
@@ -361,8 +362,8 @@ describe('deleteEvent', () => {
|
|||||||
const mockFetch = vi.mocked(fetch)
|
const mockFetch = vi.mocked(fetch)
|
||||||
mockFetch.mockResolvedValueOnce({
|
mockFetch.mockResolvedValueOnce({
|
||||||
ok: true,
|
ok: true,
|
||||||
json: async () => ({}),
|
json: () => ({}),
|
||||||
} as Response)
|
} as unknown as Response)
|
||||||
|
|
||||||
const { deleteEvent } = await import('./client.js')
|
const { deleteEvent } = await import('./client.js')
|
||||||
await deleteEvent('uid-to-delete')
|
await deleteEvent('uid-to-delete')
|
||||||
@@ -379,8 +380,8 @@ describe('deleteEvent', () => {
|
|||||||
it('resolves void on success (204/202)', async () => {
|
it('resolves void on success (204/202)', async () => {
|
||||||
vi.mocked(fetch).mockResolvedValueOnce({
|
vi.mocked(fetch).mockResolvedValueOnce({
|
||||||
ok: true,
|
ok: true,
|
||||||
json: async () => ({}),
|
json: () => ({}),
|
||||||
} as Response)
|
} as unknown as Response)
|
||||||
|
|
||||||
const { deleteEvent } = await import('./client.js')
|
const { deleteEvent } = await import('./client.js')
|
||||||
const result = await deleteEvent('uid-abc')
|
const result = await deleteEvent('uid-abc')
|
||||||
@@ -391,7 +392,7 @@ describe('deleteEvent', () => {
|
|||||||
vi.mocked(fetch).mockResolvedValueOnce({
|
vi.mocked(fetch).mockResolvedValueOnce({
|
||||||
ok: false,
|
ok: false,
|
||||||
status: 404,
|
status: 404,
|
||||||
} as Response)
|
} as unknown as Response)
|
||||||
|
|
||||||
const { deleteEvent } = await import('./client.js')
|
const { deleteEvent } = await import('./client.js')
|
||||||
await expect(deleteEvent('missing-uid')).rejects.toThrow()
|
await expect(deleteEvent('missing-uid')).rejects.toThrow()
|
||||||
@@ -411,8 +412,8 @@ describe('fetchSyncStatus', () => {
|
|||||||
it('GETs /api/events/sync-status?uid= with credentials:include', async () => {
|
it('GETs /api/events/sync-status?uid= with credentials:include', async () => {
|
||||||
vi.mocked(fetch).mockResolvedValueOnce({
|
vi.mocked(fetch).mockResolvedValueOnce({
|
||||||
ok: true,
|
ok: true,
|
||||||
json: async () => ({ uid: 'my-uid', status: 'pending' }),
|
json: () => ({ uid: 'my-uid', status: 'pending' }),
|
||||||
} as Response)
|
} as unknown as Response)
|
||||||
|
|
||||||
const { fetchSyncStatus } = await import('./client.js')
|
const { fetchSyncStatus } = await import('./client.js')
|
||||||
await fetchSyncStatus('my-uid')
|
await fetchSyncStatus('my-uid')
|
||||||
@@ -426,8 +427,8 @@ describe('fetchSyncStatus', () => {
|
|||||||
it('returns SyncStatus object with uid and status', async () => {
|
it('returns SyncStatus object with uid and status', async () => {
|
||||||
vi.mocked(fetch).mockResolvedValueOnce({
|
vi.mocked(fetch).mockResolvedValueOnce({
|
||||||
ok: true,
|
ok: true,
|
||||||
json: async () => ({ uid: 'test-uid', status: 'done' }),
|
json: () => ({ uid: 'test-uid', status: 'done' }),
|
||||||
} as Response)
|
} as unknown as Response)
|
||||||
|
|
||||||
const { fetchSyncStatus } = await import('./client.js')
|
const { fetchSyncStatus } = await import('./client.js')
|
||||||
const result = await fetchSyncStatus('test-uid')
|
const result = await fetchSyncStatus('test-uid')
|
||||||
@@ -437,8 +438,8 @@ describe('fetchSyncStatus', () => {
|
|||||||
it('returns SyncStatus with error field for failed status', async () => {
|
it('returns SyncStatus with error field for failed status', async () => {
|
||||||
vi.mocked(fetch).mockResolvedValueOnce({
|
vi.mocked(fetch).mockResolvedValueOnce({
|
||||||
ok: true,
|
ok: true,
|
||||||
json: async () => ({ uid: 'fail-uid', status: 'failed', error: '412 Conflict' }),
|
json: () => ({ uid: 'fail-uid', status: 'failed', error: '412 Conflict' }),
|
||||||
} as Response)
|
} as unknown as Response)
|
||||||
|
|
||||||
const { fetchSyncStatus } = await import('./client.js')
|
const { fetchSyncStatus } = await import('./client.js')
|
||||||
const result = await fetchSyncStatus('fail-uid')
|
const result = await fetchSyncStatus('fail-uid')
|
||||||
@@ -449,7 +450,7 @@ describe('fetchSyncStatus', () => {
|
|||||||
vi.mocked(fetch).mockResolvedValueOnce({
|
vi.mocked(fetch).mockResolvedValueOnce({
|
||||||
ok: false,
|
ok: false,
|
||||||
status: 500,
|
status: 500,
|
||||||
} as Response)
|
} as unknown as Response)
|
||||||
|
|
||||||
const { fetchSyncStatus } = await import('./client.js')
|
const { fetchSyncStatus } = await import('./client.js')
|
||||||
await expect(fetchSyncStatus('any-uid')).rejects.toThrow()
|
await expect(fetchSyncStatus('any-uid')).rejects.toThrow()
|
||||||
@@ -471,8 +472,8 @@ describe('fetchMe', () => {
|
|||||||
ok: true,
|
ok: true,
|
||||||
type: 'basic',
|
type: 'basic',
|
||||||
status: 200,
|
status: 200,
|
||||||
json: async () => ({ user: { id: 2, displayName: 'Me', color: '#E8734A' } }),
|
json: () => ({ user: { id: 2, displayName: 'Me', color: '#E8734A' } }),
|
||||||
} as Response)
|
} as unknown as Response)
|
||||||
|
|
||||||
const { fetchMe } = await import('./client.js')
|
const { fetchMe } = await import('./client.js')
|
||||||
await fetchMe()
|
await fetchMe()
|
||||||
@@ -488,8 +489,8 @@ describe('fetchMe', () => {
|
|||||||
ok: true,
|
ok: true,
|
||||||
type: 'basic',
|
type: 'basic',
|
||||||
status: 200,
|
status: 200,
|
||||||
json: async () => ({ user: { id: 2, displayName: 'Me', color: '#E8734A' } }),
|
json: () => ({ user: { id: 2, displayName: 'Me', color: '#E8734A' } }),
|
||||||
} as Response)
|
} as unknown as Response)
|
||||||
|
|
||||||
const { fetchMe } = await import('./client.js')
|
const { fetchMe } = await import('./client.js')
|
||||||
const result = await fetchMe()
|
const result = await fetchMe()
|
||||||
@@ -503,7 +504,7 @@ describe('fetchMe', () => {
|
|||||||
ok: false,
|
ok: false,
|
||||||
type: 'opaqueredirect',
|
type: 'opaqueredirect',
|
||||||
status: 0,
|
status: 0,
|
||||||
json: async () => {
|
json: () => {
|
||||||
throw new Error('body not accessible on opaqueredirect')
|
throw new Error('body not accessible on opaqueredirect')
|
||||||
},
|
},
|
||||||
} as unknown as Response)
|
} as unknown as Response)
|
||||||
@@ -517,7 +518,7 @@ describe('fetchMe', () => {
|
|||||||
ok: false,
|
ok: false,
|
||||||
type: 'basic',
|
type: 'basic',
|
||||||
status: 401,
|
status: 401,
|
||||||
} as Response)
|
} as unknown as Response)
|
||||||
|
|
||||||
const { fetchMe } = await import('./client.js')
|
const { fetchMe } = await import('./client.js')
|
||||||
await expect(fetchMe()).rejects.toThrow(/authentication required/i)
|
await expect(fetchMe()).rejects.toThrow(/authentication required/i)
|
||||||
|
|||||||
@@ -12,7 +12,7 @@
|
|||||||
|
|
||||||
import React from 'react'
|
import React from 'react'
|
||||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||||
import { render, screen, waitFor } from '@testing-library/react'
|
import { render, screen } from '@testing-library/react'
|
||||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||||
|
|
||||||
// ── Module mocks ────────────────────────────────────────────────────────────
|
// ── Module mocks ────────────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -156,7 +156,7 @@ export function CalendarShell() {
|
|||||||
// IANATimezone (the config's timezone type) is declared but not exported by @schedule-x/calendar,
|
// IANATimezone (the config's timezone type) is declared but not exported by @schedule-x/calendar,
|
||||||
// so derive it from useCalendarApp's config parameter rather than importing it.
|
// so derive it from useCalendarApp's config parameter rather than importing it.
|
||||||
type SxTimeZone = NonNullable<Parameters<typeof useCalendarApp>[0]['timezone']>
|
type SxTimeZone = NonNullable<Parameters<typeof useCalendarApp>[0]['timezone']>
|
||||||
const displayTimeZone = Intl.DateTimeFormat().resolvedOptions().timeZone as SxTimeZone
|
const displayTimeZone: SxTimeZone = Intl.DateTimeFormat().resolvedOptions().timeZone
|
||||||
|
|
||||||
// useCalendarApp — config is stable; plugins passed as second argument
|
// useCalendarApp — config is stable; plugins passed as second argument
|
||||||
const calendar = useCalendarApp(
|
const calendar = useCalendarApp(
|
||||||
@@ -169,7 +169,7 @@ export function CalendarShell() {
|
|||||||
],
|
],
|
||||||
defaultView,
|
defaultView,
|
||||||
timezone: displayTimeZone,
|
timezone: displayTimeZone,
|
||||||
firstDayOfWeek: SX_FIRST_DAY_OF_WEEK as 7,
|
firstDayOfWeek: SX_FIRST_DAY_OF_WEEK,
|
||||||
calendars: calendarsConfig,
|
calendars: calendarsConfig,
|
||||||
callbacks: {
|
callbacks: {
|
||||||
onRangeUpdate(range) {
|
onRangeUpdate(range) {
|
||||||
@@ -197,7 +197,7 @@ export function CalendarShell() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!eventsQuery.data) return
|
if (!eventsQuery.data) return
|
||||||
const sxEvents = hydrateEvents(eventsQuery.data.occurrences)
|
const sxEvents = hydrateEvents(eventsQuery.data.occurrences)
|
||||||
eventsService.set(sxEvents as Parameters<typeof eventsService.set>[0])
|
eventsService.set(sxEvents)
|
||||||
}, [eventsQuery.data, eventsService])
|
}, [eventsQuery.data, eventsService])
|
||||||
|
|
||||||
// Auth redirect — one-shot full-page nav to /api/login when /api/me fails.
|
// Auth redirect — one-shot full-page nav to /api/login when /api/me fails.
|
||||||
@@ -351,7 +351,7 @@ export function CalendarShell() {
|
|||||||
color: 'var(--color-text-primary)',
|
color: 'var(--color-text-primary)',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Couldn't load events
|
Couldn't load events
|
||||||
</h2>
|
</h2>
|
||||||
<p
|
<p
|
||||||
style={{
|
style={{
|
||||||
@@ -363,7 +363,7 @@ export function CalendarShell() {
|
|||||||
Check your connection and try again.
|
Check your connection and try again.
|
||||||
</p>
|
</p>
|
||||||
<button
|
<button
|
||||||
onClick={() => queryClient.refetchQueries({ queryKey: ['events'] })}
|
onClick={() => { void queryClient.refetchQueries({ queryKey: ['events'] }) }}
|
||||||
style={{
|
style={{
|
||||||
background: 'var(--color-surface-dim)',
|
background: 'var(--color-surface-dim)',
|
||||||
border: '1px solid var(--color-border)',
|
border: '1px solid var(--color-border)',
|
||||||
|
|||||||
@@ -22,7 +22,7 @@
|
|||||||
import { useEffect, useRef, useState } from 'react'
|
import { useEffect, useRef, useState } from 'react'
|
||||||
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||||||
import { createList } from '../api/listsClient.js'
|
import { createList } from '../api/listsClient.js'
|
||||||
import type { List, ListsResponse } from '../api/listsClient.js'
|
import type { ListsResponse } from '../api/listsClient.js'
|
||||||
import { useListsStore } from '../store/listsStore.js'
|
import { useListsStore } from '../store/listsStore.js'
|
||||||
|
|
||||||
export function CreateListSheet() {
|
export function CreateListSheet() {
|
||||||
@@ -82,7 +82,7 @@ export function CreateListSheet() {
|
|||||||
ownerId: 0, // unknown until response
|
ownerId: 0, // unknown until response
|
||||||
activeCount: 0,
|
activeCount: 0,
|
||||||
doneCount: 0,
|
doneCount: 0,
|
||||||
} as List,
|
},
|
||||||
],
|
],
|
||||||
}))
|
}))
|
||||||
return { previous }
|
return { previous }
|
||||||
@@ -94,8 +94,8 @@ export function CreateListSheet() {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
onSettled: () => {
|
onSettled: () => {
|
||||||
// Always invalidate to get canonical server state
|
// Always invalidate to get canonical server state; fire-and-forget (React Query handles cache update)
|
||||||
queryClient.invalidateQueries({ queryKey: ['lists'] })
|
void queryClient.invalidateQueries({ queryKey: ['lists'] })
|
||||||
},
|
},
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
handleClose()
|
handleClose()
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ const {
|
|||||||
mockSetLastSyncedUid: vi.fn(),
|
mockSetLastSyncedUid: vi.fn(),
|
||||||
mockSetOpenEventId: vi.fn(),
|
mockSetOpenEventId: vi.fn(),
|
||||||
mockDeleteDialogOpen: { value: true },
|
mockDeleteDialogOpen: { value: true },
|
||||||
mockDeleteDialogUid: { value: 'event-uid-to-delete' as string | null },
|
mockDeleteDialogUid: { value: 'event-uid-to-delete' },
|
||||||
}))
|
}))
|
||||||
|
|
||||||
vi.mock('../store/calendarStore.js', () => ({
|
vi.mock('../store/calendarStore.js', () => ({
|
||||||
|
|||||||
@@ -332,7 +332,7 @@ describe('EventForm', () => {
|
|||||||
const tomorrow = new Date(today)
|
const tomorrow = new Date(today)
|
||||||
tomorrow.setDate(today.getDate() + 1)
|
tomorrow.setDate(today.getDate() + 1)
|
||||||
const todayStr = today.toISOString().slice(0, 10)
|
const todayStr = today.toISOString().slice(0, 10)
|
||||||
const yesterdayStr = new Date(today.setDate(today.getDate() - 1)).toISOString().slice(0, 10)
|
const _yesterdayStr = new Date(today.setDate(today.getDate() - 1)).toISOString().slice(0, 10)
|
||||||
|
|
||||||
if (dateInputs.length >= 2) {
|
if (dateInputs.length >= 2) {
|
||||||
fireEvent.change(dateInputs[0], { target: { value: todayStr } })
|
fireEvent.change(dateInputs[0], { target: { value: todayStr } })
|
||||||
@@ -462,7 +462,7 @@ describe('EventForm', () => {
|
|||||||
eventOccurrence: EDIT_OCCURRENCE,
|
eventOccurrence: EDIT_OCCURRENCE,
|
||||||
})
|
})
|
||||||
|
|
||||||
const titleInput = screen.getByPlaceholderText('Event title') as HTMLInputElement
|
const titleInput = screen.getByPlaceholderText<HTMLInputElement>('Event title')
|
||||||
expect(titleInput.value).toBe('Existing Meeting')
|
expect(titleInput.value).toBe('Existing Meeting')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
@@ -560,7 +560,7 @@ describe('EventForm — Plan 03-12 gap closures', () => {
|
|||||||
)
|
)
|
||||||
|
|
||||||
// Title should be empty (occurrence not yet in cache)
|
// Title should be empty (occurrence not yet in cache)
|
||||||
const titleInput = screen.getByPlaceholderText('Event title') as HTMLInputElement
|
const titleInput = screen.getByPlaceholderText<HTMLInputElement>('Event title')
|
||||||
expect(titleInput.value).toBe('')
|
expect(titleInput.value).toBe('')
|
||||||
|
|
||||||
// Phase 2: occurrence arrives in cache (simulating TanStack Query resolving)
|
// Phase 2: occurrence arrives in cache (simulating TanStack Query resolving)
|
||||||
@@ -576,7 +576,7 @@ describe('EventForm — Plan 03-12 gap closures', () => {
|
|||||||
// WR-03 fix: the reset effect must re-run because occurrence changed,
|
// WR-03 fix: the reset effect must re-run because occurrence changed,
|
||||||
// so the title should now be populated
|
// so the title should now be populated
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
const titleInputAfter = screen.getByPlaceholderText('Event title') as HTMLInputElement
|
const titleInputAfter = screen.getByPlaceholderText<HTMLInputElement>('Event title')
|
||||||
expect(titleInputAfter.value).toBe('Late-Arriving Meeting')
|
expect(titleInputAfter.value).toBe('Late-Arriving Meeting')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
@@ -676,7 +676,7 @@ describe('EventForm — Plan 03-12 gap closures', () => {
|
|||||||
|
|
||||||
it('IN-03: todayIso is exported from calendarStore (single source of truth)', async () => {
|
it('IN-03: todayIso is exported from calendarStore (single source of truth)', async () => {
|
||||||
// Use importActual to bypass the vi.mock() and test the real module export
|
// Use importActual to bypass the vi.mock() and test the real module export
|
||||||
const actualModule = await vi.importActual('../store/calendarStore.js') as Record<string, unknown>
|
const actualModule = await vi.importActual('../store/calendarStore.js')
|
||||||
expect(typeof actualModule.todayIso).toBe('function')
|
expect(typeof actualModule.todayIso).toBe('function')
|
||||||
const result = (actualModule.todayIso as () => string)()
|
const result = (actualModule.todayIso as () => string)()
|
||||||
// Should return a YYYY-MM-DD string
|
// Should return a YYYY-MM-DD string
|
||||||
@@ -950,9 +950,11 @@ describe('EventForm — Plan 06-06 end-tracking + recurrence-bound', () => {
|
|||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(mockCreateEvent).toHaveBeenCalledWith(
|
expect(mockCreateEvent).toHaveBeenCalledWith(
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment -- expect.anything() returns 'any' (Vitest asymmetric matcher); safe in assertion context
|
||||||
expect.not.objectContaining({ recurrenceUntil: expect.anything() }),
|
expect.not.objectContaining({ recurrenceUntil: expect.anything() }),
|
||||||
)
|
)
|
||||||
expect(mockCreateEvent).toHaveBeenCalledWith(
|
expect(mockCreateEvent).toHaveBeenCalledWith(
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment -- expect.anything() returns 'any' (Vitest asymmetric matcher); safe in assertion context
|
||||||
expect.not.objectContaining({ recurrenceCount: expect.anything() }),
|
expect.not.objectContaining({ recurrenceCount: expect.anything() }),
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -271,7 +271,7 @@ export function EventForm() {
|
|||||||
// read it if a future API version adds it, and default to 'none' when not present
|
// read it if a future API version adds it, and default to 'none' when not present
|
||||||
// (WR-03 v1 comment: occurrence edits whose recurrence is not in the cache default
|
// (WR-03 v1 comment: occurrence edits whose recurrence is not in the cache default
|
||||||
// to 'none'; this will be addressed when the occurrence/expand contract is extended).
|
// to 'none'; this will be addressed when the occurrence/expand contract is extended).
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-member-access -- occurrence.recurrence is not in CalendarOccurrence v1 type; cast to any to read a future API field, default 'none' when absent
|
||||||
const derivedRecurrence = (occurrence as any)?.recurrence as RecurrencePreset | undefined
|
const derivedRecurrence = (occurrence as any)?.recurrence as RecurrencePreset | undefined
|
||||||
setRecurrence(derivedRecurrence ?? 'none')
|
setRecurrence(derivedRecurrence ?? 'none')
|
||||||
// D-06: reset bound state to defaults on form open/occurrence change
|
// D-06: reset bound state to defaults on form open/occurrence change
|
||||||
|
|||||||
@@ -91,7 +91,7 @@ describe('useAndroidInstallPrompt', () => {
|
|||||||
vi.clearAllMocks()
|
vi.clearAllMocks()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('sets canInstall=true when a beforeinstallprompt event is dispatched', async () => {
|
it('sets canInstall=true when a beforeinstallprompt event is dispatched', () => {
|
||||||
const { result } = renderHook(() => useAndroidInstallPrompt())
|
const { result } = renderHook(() => useAndroidInstallPrompt())
|
||||||
expect(result.current.canInstall).toBe(false)
|
expect(result.current.canInstall).toBe(false)
|
||||||
|
|
||||||
@@ -109,7 +109,7 @@ describe('useAndroidInstallPrompt', () => {
|
|||||||
expect(result.current.canInstall).toBe(true)
|
expect(result.current.canInstall).toBe(true)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('sets canInstall=false when appinstalled event fires', async () => {
|
it('sets canInstall=false when appinstalled event fires', () => {
|
||||||
const { result } = renderHook(() => useAndroidInstallPrompt())
|
const { result } = renderHook(() => useAndroidInstallPrompt())
|
||||||
|
|
||||||
// First fire beforeinstallprompt to set canInstall=true
|
// First fire beforeinstallprompt to set canInstall=true
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import { render, screen, fireEvent } from '@testing-library/react'
|
|||||||
|
|
||||||
vi.mock('../hooks/usePushSubscription.js', () => ({
|
vi.mock('../hooks/usePushSubscription.js', () => ({
|
||||||
usePushSubscription: vi.fn(() => ({
|
usePushSubscription: vi.fn(() => ({
|
||||||
permission: 'denied' as NotificationPermission,
|
permission: 'denied',
|
||||||
isSubscribed: false,
|
isSubscribed: false,
|
||||||
subscribe: vi.fn(),
|
subscribe: vi.fn(),
|
||||||
setEnabled: vi.fn(),
|
setEnabled: vi.fn(),
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ export function ListCard({ list, onDelete }: ListCardProps) {
|
|||||||
const [showDelete, setShowDelete] = useState(false)
|
const [showDelete, setShowDelete] = useState(false)
|
||||||
|
|
||||||
const handleCardClick = () => {
|
const handleCardClick = () => {
|
||||||
navigate(`/lists/${list.id}`)
|
void navigate(`/lists/${list.id}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleDeleteClick = (e: React.MouseEvent) => {
|
const handleDeleteClick = (e: React.MouseEvent) => {
|
||||||
|
|||||||
@@ -78,7 +78,7 @@ describe('SyncStateToast', () => {
|
|||||||
vi.useRealTimers()
|
vi.useRealTimers()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('renders nothing when lastSyncedUid is null', async () => {
|
it('renders nothing when lastSyncedUid is null', () => {
|
||||||
mockLastSyncedUid.value = null
|
mockLastSyncedUid.value = null
|
||||||
const { container } = renderToast(queryClient)
|
const { container } = renderToast(queryClient)
|
||||||
expect(container.firstChild).toBeNull()
|
expect(container.firstChild).toBeNull()
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ class MockEventSource {
|
|||||||
constructor(url: string, init?: { withCredentials?: boolean }) {
|
constructor(url: string, init?: { withCredentials?: boolean }) {
|
||||||
this.url = url
|
this.url = url
|
||||||
this.withCredentials = init?.withCredentials ?? false
|
this.withCredentials = init?.withCredentials ?? false
|
||||||
mockInstances.push(this as unknown as MockEventSourceInstance)
|
mockInstances.push(this)
|
||||||
}
|
}
|
||||||
|
|
||||||
addEventListener(type: string, handler: (ev: MessageEvent) => void) {
|
addEventListener(type: string, handler: (ev: MessageEvent) => void) {
|
||||||
|
|||||||
@@ -55,7 +55,8 @@ export function useListSSE({ listId, onStateChange }: UseListSSEOptions): void {
|
|||||||
|
|
||||||
// handleListChange is stable — invalidate the list on any list-change event
|
// handleListChange is stable — invalidate the list on any list-change event
|
||||||
const handleListChange = useCallback(() => {
|
const handleListChange = useCallback(() => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['list', listId] })
|
// fire-and-forget: cache invalidation; React Query handles the refetch lifecycle
|
||||||
|
void queryClient.invalidateQueries({ queryKey: ['list', listId] })
|
||||||
}, [queryClient, listId])
|
}, [queryClient, listId])
|
||||||
|
|
||||||
const connect = useCallback(() => {
|
const connect = useCallback(() => {
|
||||||
@@ -76,8 +77,8 @@ export function useListSSE({ listId, onStateChange }: UseListSSEOptions): void {
|
|||||||
// Reset attempt counter on successful open (D-11)
|
// Reset attempt counter on successful open (D-11)
|
||||||
attemptsRef.current = 0
|
attemptsRef.current = 0
|
||||||
onStateChange('connected')
|
onStateChange('connected')
|
||||||
// Full refetch on (re)connect — D-10: no Last-Event-ID replay, just refetch
|
// Full refetch on (re)connect — D-10: no Last-Event-ID replay, just refetch; fire-and-forget
|
||||||
queryClient.invalidateQueries({ queryKey: ['list', listId] })
|
void queryClient.invalidateQueries({ queryKey: ['list', listId] })
|
||||||
}
|
}
|
||||||
|
|
||||||
es.onerror = () => {
|
es.onerror = () => {
|
||||||
|
|||||||
@@ -11,8 +11,8 @@
|
|||||||
* Uses React Query's QueryClient directly (no mocked server).
|
* Uses React Query's QueryClient directly (no mocked server).
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
import { describe, it, expect, beforeEach } from 'vitest'
|
||||||
import { renderHook, act } from '@testing-library/react'
|
import { act } from '@testing-library/react'
|
||||||
import { QueryClient } from '@tanstack/react-query'
|
import { QueryClient } from '@tanstack/react-query'
|
||||||
import type { ListItem, ListItemsResponse } from '../api/listsClient.js'
|
import type { ListItem, ListItemsResponse } from '../api/listsClient.js'
|
||||||
|
|
||||||
@@ -80,7 +80,7 @@ describe('ListDetail — D-07 optimistic update + rollback', () => {
|
|||||||
expect(updated?.items[0].checked).toBe(false)
|
expect(updated?.items[0].checked).toBe(false)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('rolls back the checked state if the server PATCH returns an error', async () => {
|
it('rolls back the checked state if the server PATCH returns an error', () => {
|
||||||
const item = makeItem({ checked: false })
|
const item = makeItem({ checked: false })
|
||||||
queryClient.setQueryData<ListItemsResponse>(['list', LIST_ID], makeItemsResponse([item]))
|
queryClient.setQueryData<ListItemsResponse>(['list', LIST_ID], makeItemsResponse([item]))
|
||||||
|
|
||||||
@@ -88,7 +88,7 @@ describe('ListDetail — D-07 optimistic update + rollback', () => {
|
|||||||
const previous = queryClient.getQueryData<ListItemsResponse>(['list', LIST_ID])
|
const previous = queryClient.getQueryData<ListItemsResponse>(['list', LIST_ID])
|
||||||
|
|
||||||
// Step 2: apply optimistic update
|
// Step 2: apply optimistic update
|
||||||
await act(async () => {
|
act(() => {
|
||||||
queryClient.setQueryData<ListItemsResponse>(['list', LIST_ID], (old) => ({
|
queryClient.setQueryData<ListItemsResponse>(['list', LIST_ID], (old) => ({
|
||||||
items: (old?.items ?? []).map((i) =>
|
items: (old?.items ?? []).map((i) =>
|
||||||
i.id === item.id ? { ...i, checked: true } : i,
|
i.id === item.id ? { ...i, checked: true } : i,
|
||||||
@@ -101,7 +101,7 @@ describe('ListDetail — D-07 optimistic update + rollback', () => {
|
|||||||
expect(after?.items[0].checked).toBe(true)
|
expect(after?.items[0].checked).toBe(true)
|
||||||
|
|
||||||
// Step 3: simulate onError rollback
|
// Step 3: simulate onError rollback
|
||||||
await act(async () => {
|
act(() => {
|
||||||
if (previous) {
|
if (previous) {
|
||||||
queryClient.setQueryData(['list', LIST_ID], previous)
|
queryClient.setQueryData(['list', LIST_ID], previous)
|
||||||
}
|
}
|
||||||
@@ -112,7 +112,7 @@ describe('ListDetail — D-07 optimistic update + rollback', () => {
|
|||||||
expect(rolledBack?.items[0].checked).toBe(false)
|
expect(rolledBack?.items[0].checked).toBe(false)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('adding an item shows it in the list immediately (optimistic insert)', async () => {
|
it('adding an item shows it in the list immediately (optimistic insert)', () => {
|
||||||
queryClient.setQueryData<ListItemsResponse>(['list', LIST_ID], makeItemsResponse([]))
|
queryClient.setQueryData<ListItemsResponse>(['list', LIST_ID], makeItemsResponse([]))
|
||||||
|
|
||||||
const optimisticItem: ListItem = {
|
const optimisticItem: ListItem = {
|
||||||
@@ -123,7 +123,7 @@ describe('ListDetail — D-07 optimistic update + rollback', () => {
|
|||||||
rank: 'a0',
|
rank: 'a0',
|
||||||
}
|
}
|
||||||
|
|
||||||
await act(async () => {
|
act(() => {
|
||||||
queryClient.setQueryData<ListItemsResponse>(['list', LIST_ID], (old) => ({
|
queryClient.setQueryData<ListItemsResponse>(['list', LIST_ID], (old) => ({
|
||||||
items: [...(old?.items ?? []), optimisticItem],
|
items: [...(old?.items ?? []), optimisticItem],
|
||||||
}))
|
}))
|
||||||
@@ -136,7 +136,7 @@ describe('ListDetail — D-07 optimistic update + rollback', () => {
|
|||||||
expect(data?.items[0].id).toBeLessThan(0)
|
expect(data?.items[0].id).toBeLessThan(0)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('removes the optimistically-added item if the server POST returns an error', async () => {
|
it('removes the optimistically-added item if the server POST returns an error', () => {
|
||||||
const existingItem = makeItem({ id: 1 })
|
const existingItem = makeItem({ id: 1 })
|
||||||
queryClient.setQueryData<ListItemsResponse>(
|
queryClient.setQueryData<ListItemsResponse>(
|
||||||
['list', LIST_ID],
|
['list', LIST_ID],
|
||||||
@@ -155,7 +155,7 @@ describe('ListDetail — D-07 optimistic update + rollback', () => {
|
|||||||
rank: 'a1',
|
rank: 'a1',
|
||||||
}
|
}
|
||||||
|
|
||||||
await act(async () => {
|
act(() => {
|
||||||
queryClient.setQueryData<ListItemsResponse>(['list', LIST_ID], (old) => ({
|
queryClient.setQueryData<ListItemsResponse>(['list', LIST_ID], (old) => ({
|
||||||
items: [...(old?.items ?? []), optimisticItem],
|
items: [...(old?.items ?? []), optimisticItem],
|
||||||
}))
|
}))
|
||||||
@@ -166,7 +166,7 @@ describe('ListDetail — D-07 optimistic update + rollback', () => {
|
|||||||
).toHaveLength(2)
|
).toHaveLength(2)
|
||||||
|
|
||||||
// Simulate rollback on error
|
// Simulate rollback on error
|
||||||
await act(async () => {
|
act(() => {
|
||||||
if (previous) queryClient.setQueryData(['list', LIST_ID], previous)
|
if (previous) queryClient.setQueryData(['list', LIST_ID], previous)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -181,7 +181,8 @@ export function ListDetail() {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
onSettled: () => {
|
onSettled: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['list', parsedListId] })
|
// fire-and-forget: cache invalidation; React Query handles the refetch lifecycle
|
||||||
|
void queryClient.invalidateQueries({ queryKey: ['list', parsedListId] })
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -207,7 +208,8 @@ export function ListDetail() {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
onSettled: () => {
|
onSettled: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['list', parsedListId] })
|
// fire-and-forget: cache invalidation; React Query handles the refetch lifecycle
|
||||||
|
void queryClient.invalidateQueries({ queryKey: ['list', parsedListId] })
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -223,7 +225,8 @@ export function ListDetail() {
|
|||||||
},
|
},
|
||||||
// No onError rollback — delete-wins (D-09)
|
// No onError rollback — delete-wins (D-09)
|
||||||
onSettled: () => {
|
onSettled: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['list', parsedListId] })
|
// fire-and-forget: cache invalidation; React Query handles the refetch lifecycle
|
||||||
|
void queryClient.invalidateQueries({ queryKey: ['list', parsedListId] })
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -251,7 +254,8 @@ export function ListDetail() {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
onSettled: () => {
|
onSettled: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['list', parsedListId] })
|
// fire-and-forget: cache invalidation; React Query handles the refetch lifecycle
|
||||||
|
void queryClient.invalidateQueries({ queryKey: ['list', parsedListId] })
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -351,7 +355,7 @@ export function ListDetail() {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
onClick={() => navigate('/lists')}
|
onClick={() => { void navigate('/lists') }}
|
||||||
aria-label="Back to lists"
|
aria-label="Back to lists"
|
||||||
style={{
|
style={{
|
||||||
background: 'none',
|
background: 'none',
|
||||||
|
|||||||
@@ -66,10 +66,11 @@ export function ListsIndex() {
|
|||||||
// TODO: surface "Couldn't delete. Try again." toast (Plan 06 / notification layer)
|
// TODO: surface "Couldn't delete. Try again." toast (Plan 06 / notification layer)
|
||||||
},
|
},
|
||||||
onSettled: () => {
|
onSettled: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['lists'] })
|
// fire-and-forget: cache invalidation; React Query handles the refetch lifecycle
|
||||||
|
void queryClient.invalidateQueries({ queryKey: ['lists'] })
|
||||||
},
|
},
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
navigate('/lists')
|
void navigate('/lists')
|
||||||
setDeleteTarget(null)
|
setDeleteTarget(null)
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
@@ -195,7 +196,7 @@ export function ListsIndex() {
|
|||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => refetch()}
|
onClick={() => { void refetch() }}
|
||||||
style={{
|
style={{
|
||||||
background: 'var(--color-member-0)',
|
background: 'var(--color-member-0)',
|
||||||
color: '#fff',
|
color: '#fff',
|
||||||
|
|||||||
+12
-7
@@ -35,7 +35,8 @@ declare const self: ServiceWorkerGlobalScope
|
|||||||
// AutoUpdate behavior: replace old SW immediately on install/activate.
|
// AutoUpdate behavior: replace old SW immediately on install/activate.
|
||||||
// Equivalent to the former generateSW autoUpdate: 'prompt' → 'autoUpdate' path.
|
// Equivalent to the former generateSW autoUpdate: 'prompt' → 'autoUpdate' path.
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
self.skipWaiting()
|
// skipWaiting() resolves when the SW is installed; fire-and-forget is the correct pattern here
|
||||||
|
void self.skipWaiting()
|
||||||
clientsClaim()
|
clientsClaim()
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -156,10 +157,14 @@ self.addEventListener('push', (event: PushEvent) => {
|
|||||||
self.addEventListener('notificationclick', (event: NotificationEvent) => {
|
self.addEventListener('notificationclick', (event: NotificationEvent) => {
|
||||||
event.notification.close()
|
event.notification.close()
|
||||||
|
|
||||||
const url: string =
|
// Notification.data is typed as 'any' in the ServiceWorker lib; we validate with typeof
|
||||||
typeof event.notification.data?.url === 'string'
|
// before using the value so the access is safe despite the lack of static types.
|
||||||
? event.notification.data.url
|
let url = '/'
|
||||||
: '/'
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access -- Notification.data is 'any' per webworker lib; typeof guard on the right-hand side validates this access
|
||||||
|
if (typeof event.notification.data?.url === 'string') {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access -- Notification.data is 'any'; typeof check above validates this access
|
||||||
|
url = event.notification.data.url as string
|
||||||
|
}
|
||||||
|
|
||||||
event.waitUntil(
|
event.waitUntil(
|
||||||
self.clients
|
self.clients
|
||||||
@@ -170,8 +175,8 @@ self.addEventListener('notificationclick', (event: NotificationEvent) => {
|
|||||||
// from the current window location (query string with event uid / date).
|
// from the current window location (query string with event uid / date).
|
||||||
for (const client of clientList) {
|
for (const client of clientList) {
|
||||||
if ('focus' in client) {
|
if ('focus' in client) {
|
||||||
return (client as WindowClient).focus().then(() =>
|
return client.focus().then(() =>
|
||||||
(client as WindowClient).navigate(url)
|
client.navigate(url)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -62,6 +62,27 @@ export default tseslint.config(
|
|||||||
// React 19 uses the automatic JSX transform (jsx: "react-jsx") — React does not
|
// React 19 uses the automatic JSX transform (jsx: "react-jsx") — React does not
|
||||||
// need to be in scope. The flat.recommended config enables this rule; we disable it.
|
// need to be in scope. The flat.recommended config enables this rule; we disable it.
|
||||||
'react/react-in-jsx-scope': 'off',
|
'react/react-in-jsx-scope': 'off',
|
||||||
|
// react-hooks v7.1.1 flat.recommended enables React Compiler rules (immutability,
|
||||||
|
// set-state-in-effect, purity, refs, etc.). These rules are designed for use with
|
||||||
|
// the React Compiler and flag valid pre-Compiler React patterns as violations.
|
||||||
|
// This codebase does NOT use the React Compiler — disable the Compiler-only rules.
|
||||||
|
'react-hooks/set-state-in-effect': 'off',
|
||||||
|
'react-hooks/immutability': 'off',
|
||||||
|
'react-hooks/purity': 'off',
|
||||||
|
'react-hooks/refs': 'off',
|
||||||
|
'react-hooks/static-components': 'off',
|
||||||
|
'react-hooks/use-memo': 'off',
|
||||||
|
'react-hooks/preserve-manual-memoization': 'off',
|
||||||
|
'react-hooks/incompatible-library': 'off',
|
||||||
|
'react-hooks/globals': 'off',
|
||||||
|
'react-hooks/error-boundaries': 'off',
|
||||||
|
'react-hooks/set-state-in-render': 'off',
|
||||||
|
'react-hooks/unsupported-syntax': 'off',
|
||||||
|
'react-hooks/config': 'off',
|
||||||
|
'react-hooks/gating': 'off',
|
||||||
|
// exhaustive-deps fires as a warn in v7 flat recommended; with --max-warnings 0
|
||||||
|
// that counts as a failure. Promote to error so violations are surfaced explicitly.
|
||||||
|
'react-hooks/exhaustive-deps': 'error',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -78,6 +99,12 @@ export default tseslint.config(
|
|||||||
'apps/pwa/vitest.config.ts',
|
'apps/pwa/vitest.config.ts',
|
||||||
'apps/pwa/playwright.config.ts',
|
'apps/pwa/playwright.config.ts',
|
||||||
'apps/api/tests/**/*.ts', // excluded from apps/api/tsconfig.json (Pitfall 2)
|
'apps/api/tests/**/*.ts', // excluded from apps/api/tsconfig.json (Pitfall 2)
|
||||||
|
// e2e specs live in tsconfig.e2e.json which extends tsconfig.json; projectService
|
||||||
|
// discovers tsconfigs by walking up from each file's directory, but the e2e dir
|
||||||
|
// has no tsconfig of its own — projectService maps it to tsconfig.e2e.json only
|
||||||
|
// when that tsconfig explicitly names the files. In practice projectService cannot
|
||||||
|
// find these files via automatic discovery, so type-aware rules are disabled here.
|
||||||
|
'apps/pwa/e2e/**/*.ts',
|
||||||
'eslint.config.js', // this file itself (not a ts project member)
|
'eslint.config.js', // this file itself (not a ts project member)
|
||||||
],
|
],
|
||||||
extends: [tseslint.configs.disableTypeChecked],
|
extends: [tseslint.configs.disableTypeChecked],
|
||||||
|
|||||||
Reference in New Issue
Block a user