Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 6 additions & 16 deletions cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -308,14 +308,16 @@ async function restartDev(
const workerDidStart = await waitForWorkerReady(workerOrigin, worker)
if (!workerDidStart) {
console.warn(
`Main worker did not become ready within ${workerReadyTimeoutMs}ms.`,
`Main worker did not become ready within ${workerReadyTimeoutMs}ms; ` +
`check the dev:worker output above before using ${workerOrigin}.`,
)
}
devChildren = [client, worker]

if (announce) {
console.log(dim('\nRestarted dev servers.'))
logAppRunning(() => workerOrigin)
// Only claim the app is running when /health actually responded.
if (workerDidStart) logAppRunning(() => workerOrigin)
}
}

Expand Down Expand Up @@ -431,13 +433,6 @@ async function attachCloudflareMock(
console.log(dim(`Cloudflare mock base URL ${baseUrl}`))
}

async function attachOptionalMocksInParallel(
mockEnv: Record<string, string>,
anchorPort: number,
) {
await Promise.all([attachCloudflareMock(mockEnv, anchorPort)])
}

async function ensureMockServers() {
const canReuseCachedCloudflareEnv =
isChildRunning(mockCloudflareProcess) &&
Expand All @@ -454,7 +449,7 @@ async function ensureMockServers() {
cloudflareForAnchor.port || String(defaultMockPort + 240),
10,
)
await attachOptionalMocksInParallel(mockEnvOverrides, anchorFromReuse)
await attachCloudflareMock(mockEnvOverrides, anchorFromReuse)
return mockEnvOverrides
}

Expand All @@ -473,12 +468,7 @@ async function ensureMockServers() {
const mockPort = await getPort({ port: portRange })
mockEnvOverrides = {}

const optionalMocksReady = attachOptionalMocksInParallel(
mockEnvOverrides,
mockPort,
)

await optionalMocksReady
await attachCloudflareMock(mockEnvOverrides, mockPort)

return mockEnvOverrides
}
Expand Down
4 changes: 2 additions & 2 deletions e2e/auth-test-user.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import { type APIRequestContext } from '@playwright/test'
import { seedUserInE2eDatabase } from './d1-utils.ts'

export const primaryTestUser = {
Expand All @@ -7,6 +6,7 @@ export const primaryTestUser = {
password: 'ilikecode',
}

export async function ensurePrimaryUserExists(_request: APIRequestContext) {
/** Seed the primary test user straight into the local E2E D1 database. */
export async function ensurePrimaryUserExists() {
await seedUserInE2eDatabase(primaryTestUser)
}
6 changes: 2 additions & 4 deletions e2e/community-frames.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,8 @@ const staleListing = {

test('community listings frame reloads fresh data on back-navigation', async ({
page,
request,
}) => {
await ensurePrimaryUserExists(request)
await ensurePrimaryUserExists()
await seedCommunityListingInE2eDatabase({
...staleListing,
ownerEmail: primaryTestUser.email,
Expand Down Expand Up @@ -67,9 +66,8 @@ test('community listings frame reloads fresh data on back-navigation', async ({

test('community detail frame reloads fresh fork counts on return navigation', async ({
page,
request,
}) => {
await ensurePrimaryUserExists(request)
await ensurePrimaryUserExists()
await seedCommunityListingInE2eDatabase({
...staleListing,
listingId: 'e2e-frame-detail-stale-listing',
Expand Down
27 changes: 2 additions & 25 deletions e2e/d1-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import path from 'node:path'
import { createPasswordHash } from '@kody-internal/shared/password-hash.ts'
import { hashVerificationToken } from '../packages/worker/src/app/email-verification.ts'
import { createStableUserIdFromEmail } from '../packages/worker/src/user-id.ts'
import { buildRoleAssignmentSql, buildSeedUserSql } from '../tools/seed-sql.ts'

const projectRoot = path.resolve(import.meta.dirname, '..')

Expand Down Expand Up @@ -56,25 +57,6 @@ export function executeE2eD1Command(sql: string) {
throw new Error(`Failed to execute E2E D1 command:\n${lastFailure}`)
}

function buildSeedUserSql(input: {
email: string
username: string
passwordHash: string
}) {
return `
INSERT INTO users (username, email, password_hash, email_verified_at)
VALUES (${quoteSqlString(input.username)}, ${quoteSqlString(input.email)}, ${quoteSqlString(input.passwordHash)}, CURRENT_TIMESTAMP)
ON CONFLICT(email) DO UPDATE SET
username = excluded.username,
password_hash = excluded.password_hash,
email_verified_at = COALESCE(users.email_verified_at, excluded.email_verified_at),
updated_at = CURRENT_TIMESTAMP;
INSERT OR IGNORE INTO user_roles (user_id, role_id)
SELECT u.id, r.id
FROM users u, roles r
WHERE u.email = ${quoteSqlString(input.email)} AND r.name = 'user';`.trim()
}

export async function seedUserInE2eDatabase(input: {
email: string
username: string
Expand All @@ -91,12 +73,7 @@ export async function seedUserInE2eDatabase(input: {
}

export function assignRoleInE2eDatabase(email: string, role: string) {
const sql = `
INSERT OR IGNORE INTO user_roles (user_id, role_id)
SELECT u.id, r.id
FROM users u, roles r
WHERE u.email = ${quoteSqlString(email)} AND r.name = ${quoteSqlString(role)};`.trim()
executeE2eD1Command(sql)
executeE2eD1Command(buildRoleAssignmentSql({ email, role }))
}

export function clearAuthRateLimitsInE2eDatabase() {
Expand Down
108 changes: 54 additions & 54 deletions e2e/playwright-utils.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { test as base } from '@playwright/test'
import { test as base, type APIRequestContext } from '@playwright/test'
import * as setCookieParser from 'set-cookie-parser'
import {
assignRoleInE2eDatabase,
Expand Down Expand Up @@ -43,37 +43,12 @@ export const test = base.extend<{
email === primaryTestUser.email &&
password === primaryTestUser.password
) {
await ensurePrimaryUserExists(page.request)
await ensurePrimaryUserExists()
return { email, username: primaryTestUser.username, password }
}

clearAuthRateLimitsInE2eDatabase()
const response = await page.request.post('/auth', {
data: { email, username, password, mode: 'signup' },
headers: { 'Content-Type': 'application/json' },
})

if (response.status() === 409) {
const detail = await readResponseDetail(response)
if (detail !== 'Email already registered.') {
throw new Error(
`Failed to seed user (${response.status()}): ${detail}`,
)
}
const loginResponse = await page.request.post('/auth', {
data: { email, password, mode: 'login' },
headers: { 'Content-Type': 'application/json' },
})
if (!loginResponse.ok()) {
throw new Error(
`Failed to seed user (${response.status()}): ${detail}`,
)
}
} else if (!response.ok()) {
throw new Error(
`Failed to seed user (${response.status()}): ${await readResponseDetail(response)}`,
)
}
await signupOrLoginViaAuth(page.request, { email, username, password })

return { email, username, password }
})
Expand Down Expand Up @@ -117,35 +92,18 @@ export const test = base.extend<{
)
}
} else {
response = await page.request.post('/auth', {
data: { email, username, password, mode: 'signup' },
headers: { 'Content-Type': 'application/json' },
response = await signupOrLoginViaAuth(page.request, {
email,
username,
password,
})

if (response.status() === 409) {
const detail = await readResponseDetail(response)
if (detail !== 'Email already registered.') {
throw new Error(
`Failed to seed user (${response.status()}): ${detail}`,
)
}
response = await page.request.post('/auth', {
data: { email, password, mode: 'login' },
headers: { 'Content-Type': 'application/json' },
})

if (!response.ok()) {
throw new Error(
`Failed to login user (${response.status()}): ${await readResponseDetail(response)}`,
)
}
} else if (!response.ok()) {
throw new Error(
`Failed to seed user (${response.status()}): ${await readResponseDetail(response)}`,
)
}
}

// `page.request` already shares the browser context's cookie jar, so
// the session cookie is registered for the 127.0.0.1 base URL by the
// /auth response itself. This extra registration makes the same
// session valid on `localhost` too, which the passkey specs need
// because WebAuthn relying party ids must be domains, not IPs.
const setCookieHeader = response.headers()['set-cookie']
if (setCookieHeader) {
const parsed = setCookieParser.parseString(setCookieHeader)
Expand All @@ -166,6 +124,48 @@ export const test = base.extend<{
},
})

/**
* Sign the user up via `/auth`, falling back to a login when the email is
* already registered. Returns the response whose Set-Cookie header carries
* the auth session.
*/
async function signupOrLoginViaAuth(
request: APIRequestContext,
credentials: { email: string; username: string; password: string },
) {
const { email, username, password } = credentials
const signupResponse = await request.post('/auth', {
data: { email, username, password, mode: 'signup' },
headers: { 'Content-Type': 'application/json' },
})

if (signupResponse.status() === 409) {
const signupDetail = await readResponseDetail(signupResponse)
if (signupDetail !== 'Email already registered.') {
throw new Error(
`Failed to seed user (${signupResponse.status()}): ${signupDetail}`,
)
}
const loginResponse = await request.post('/auth', {
data: { email, password, mode: 'login' },
headers: { 'Content-Type': 'application/json' },
})
if (!loginResponse.ok()) {
throw new Error(
`Failed to login existing user (${loginResponse.status()}): ${await readResponseDetail(loginResponse)}`,
)
}
return loginResponse
}

if (!signupResponse.ok()) {
throw new Error(
`Failed to seed user (${signupResponse.status()}): ${await readResponseDetail(signupResponse)}`,
)
}
return signupResponse
}

async function readResponseDetail(response: { json(): Promise<unknown> }) {
const payload = await response.json().catch(() => null)
if (
Expand Down
2 changes: 1 addition & 1 deletion e2e/smoke.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { ensurePrimaryUserExists, primaryTestUser } from './auth-test-user.ts'
import { clearAuthRateLimitsInE2eDatabase } from './d1-utils.ts'

test('smoke test covers shell, auth redirect, and login', async ({ page }) => {
await ensurePrimaryUserExists(page.request)
await ensurePrimaryUserExists()
await page.context().clearCookies()

await page.goto('/')
Expand Down
2 changes: 1 addition & 1 deletion e2e/ssr-hydration.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ test('SSR community HTML hydrates SPA navigation and client search', async ({
page,
request,
}) => {
await ensurePrimaryUserExists(request)
await ensurePrimaryUserExists()
await seedCommunityListingInE2eDatabase({
...alphaListing,
ownerEmail: primaryTestUser.email,
Expand Down
24 changes: 24 additions & 0 deletions packages/shared/src/chunk.node.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { expect, test } from 'vitest'
import { chunkArray, maxD1BoundParameters } from './chunk.ts'

test('splits into chunks of at most the requested size', () => {
expect(chunkArray([1, 2, 3, 4, 5], 2)).toEqual([[1, 2], [3, 4], [5]])
expect(chunkArray([1, 2], 2)).toEqual([[1, 2]])
expect(chunkArray([], 3)).toEqual([])
})

test('rejects a non-positive chunk size', () => {
expect(() => chunkArray([1], 0)).toThrow('Chunk size must be >= 1')
})

test('a full admin page fits D1 statement limits when chunked', () => {
// 100 user ids with one extra fixed binding (the month) must split so no
// statement exceeds the D1 bound parameter cap.
const ids = Array.from({ length: 100 }, (_, index) => `user-${index}`)
const chunks = chunkArray(ids, maxD1BoundParameters - 1)
expect(chunks.length).toBe(2)
for (const chunk of chunks) {
expect(chunk.length + 1).toBeLessThanOrEqual(maxD1BoundParameters)
}
expect(chunks.flat()).toEqual(ids)
})
22 changes: 22 additions & 0 deletions packages/shared/src/chunk.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
/**
* Split `items` into consecutive chunks of at most `size` elements.
*
* The main consumer is D1 query building: D1 caps a statement at 100 bound
* parameters, so `IN (...)` lists over caller-controlled collections (for
* example `pageSize=100` admin pages) must be chunked or the query fails with
* `too many SQL variables`.
*/
export function chunkArray<T>(items: ReadonlyArray<T>, size: number) {
if (size < 1) throw new Error(`Chunk size must be >= 1, got ${size}.`)
const chunks: Array<Array<T>> = []
for (let index = 0; index < items.length; index += size) {
chunks.push(items.slice(index, index + size))
}
return chunks
}

/**
* D1's documented per-statement bound parameter cap. Queries binding a
* dynamic list must keep `list length + fixed bindings` at or below this.
*/
export const maxD1BoundParameters = 100
12 changes: 12 additions & 0 deletions packages/shared/src/date-keys.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,3 +15,15 @@ export function utcMonthKey(date: Date = new Date()) {
export function isoTimestampDayKey(isoTimestamp: string) {
return isoTimestamp.slice(0, isoDayKeyLength)
}

const sqliteTimestampLength = 'YYYY-MM-DD HH:MM:SS'.length

/**
* UTC timestamp matching SQLite's CURRENT_TIMESTAMP format, for example
* `2026-07-08 07:41:00`. Use for columns whose schema default is
* CURRENT_TIMESTAMP so values written from JS sort consistently with values
* written by the database.
*/
export function utcSqliteTimestamp(date: Date = new Date()) {
return date.toISOString().replace('T', ' ').slice(0, sqliteTimestampLength)
}
20 changes: 20 additions & 0 deletions packages/shared/src/tags-json.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
/**
* Parse a `tags_json` DB column into a clean string array. Corrupt JSON or
* non-array payloads degrade to an empty list instead of throwing, so one bad
* row cannot take down list/detail reads.
*/
export function parseTagsJson(raw: unknown): Array<string> {
if (raw == null) return []
let parsed: unknown
try {
parsed = JSON.parse(String(raw))
} catch {
return []
}
return Array.isArray(parsed)
? parsed
.filter((value): value is string => typeof value === 'string')
.map((value) => value.trim())
.filter((value) => value.length > 0)
: []
}
Loading
Loading