@@ -132,6 +134,7 @@ const user = computed(() => users.value.find(user => user.id === zero.value.user
- {{ user === 'anon' ? '' : `Logged in as ${user}` }}
+ {{ user ? `Logged in as ${user}` : '' }}
diff --git a/test/fixtures/vue/src/zero.ts b/test/fixtures/vue/src/zero.ts
index 97210ac..c2d0f03 100644
--- a/test/fixtures/vue/src/zero.ts
+++ b/test/fixtures/vue/src/zero.ts
@@ -1,124 +1,24 @@
-import {
- defineMutator,
- defineMutators,
- defineQueries,
- defineQuery,
- escapeLike,
-} from '@rocicorp/zero'
import { useCookies } from '@vueuse/integrations/useCookies'
import { decodeJwt } from 'jose'
import { createZeroComposables } from 'zero-vue'
-import z from 'zod'
-import { schema, zql } from '#fx/db/schema'
+import { mutators, schema } from '#fx/db/schema'
-const cookies = useCookies()
-
-export interface ZeroContext {
- userID: string
-}
-
-declare module '@rocicorp/zero' {
- interface DefaultTypes {
- context: ZeroContext
- }
-}
+export { mutators, queries } from '#fx/db/schema'
-export const mutators = defineMutators({
- message: {
- insert: defineMutator(
- z.object({
- mediumID: z.string(),
- body: z.string(),
- id: z.string(),
- timestamp: z.number(),
- }),
- async ({ tx, ctx: { userID }, args: { mediumID, body, id, timestamp } }) => {
- return tx.mutate.message.insert({
- senderID: userID,
- mediumID,
- body,
- id,
- timestamp,
- })
- },
- ),
- update: defineMutator(
- z.object({ id: z.string(), body: z.string() }),
- async ({ tx, ctx: { userID }, args: { id, body } }) => {
- const messageToEdit = await tx.run(
- zql.message.where('id', id).one(),
- )
- if (!messageToEdit) {
- throw new Error(`Message with id ${id} not found`)
- }
-
- if (messageToEdit.senderID !== userID) {
- throw new Error(`You aren't allowed to edit this message`)
- }
-
- return tx.mutate.message.update({ id, body })
- },
- ),
- delete: defineMutator(
- z.object({ id: z.string() }),
- async ({ tx, ctx: { userID }, args: { id } }) => {
- if (!userID) {
- throw new Error('You must be logged in to delete')
- }
-
- return tx.mutate.message.delete({ id })
- },
- ),
- },
-})
-
-export const queries = defineQueries({
- messages: {
- all: defineQuery(() => zql.message),
- filtered: defineQuery(
- z.object({
- filterUser: z.string().optional(),
- filterText: z.string().optional(),
- }),
- ({ args: { filterUser, filterText } }) => {
- let filtered = zql.message
- .related('medium', medium => medium.one())
- .related('sender', sender => sender.one())
- .orderBy('timestamp', 'desc')
-
- if (filterUser) {
- filtered = filtered.where('senderID', filterUser)
- }
-
- if (filterText) {
- filtered = filtered.where('body', 'LIKE', `%${escapeLike(filterText)}%`)
- }
-
- return filtered
- },
- ),
- },
- users: {
- all: defineQuery(() => zql.user),
- },
- mediums: {
- all: defineQuery(() => zql.medium),
- },
-
-})
+const cookies = useCookies()
export const { useZero, useQuery } = createZeroComposables(() => {
const encodedJWT = cookies.get('jwt')
const decodedJWT = encodedJWT && decodeJwt(encodedJWT)
- const userID = decodedJWT?.sub ? (decodedJWT.sub as string) : 'anon'
+ const userID = typeof decodedJWT?.sub === 'string' ? decodedJWT.sub : undefined
return {
userID,
- context: {
- userID,
- },
+ context: { userID },
cacheURL: import.meta.env.VITE_PUBLIC_ZERO_CACHE_URL,
+ queryURL: import.meta.env.VITE_PUBLIC_ZERO_QUERY_URL,
+ mutateURL: import.meta.env.VITE_PUBLIC_ZERO_MUTATE_URL,
schema,
mutators,
// This is often easier to develop with if you're frequently changing
diff --git a/test/fixtures/vue/test/e2e.test.ts b/test/fixtures/vue/test/e2e.test.ts
new file mode 100644
index 0000000..a23bfea
--- /dev/null
+++ b/test/fixtures/vue/test/e2e.test.ts
@@ -0,0 +1,87 @@
+import type { AddressInfo } from 'node:net'
+import type { ViteDevServer } from 'vite'
+import { fileURLToPath } from 'node:url'
+import { jwtVerify } from 'jose'
+import { createServer } from 'vite'
+import { afterAll, beforeAll, describe, expect, it } from 'vitest'
+import { seededUserIDs } from '../../_shared/db/data/seeded-users'
+
+const authSecret = 'authSecret'
+
+let server: ViteDevServer
+let baseURL: string
+
+beforeAll(async () => {
+ server = await createServer({
+ configFile: fileURLToPath(new URL('../vite.config.ts', import.meta.url)),
+ root: fileURLToPath(new URL('..', import.meta.url)),
+ server: { host: '127.0.0.1' },
+ logLevel: 'error',
+ })
+ await server.listen()
+ const { port } = server.httpServer!.address() as AddressInfo
+ baseURL = `http://127.0.0.1:${port}`
+})
+
+afterAll(async () => {
+ await server?.close()
+})
+
+async function login() {
+ const response = await fetch(`${baseURL}/api/login`)
+ expect(response.status).toBe(200)
+
+ const setCookie = response.headers.getSetCookie()
+ const jwtCookie = setCookie.find(c => c.startsWith('jwt='))
+ expect(jwtCookie).toBeDefined()
+ return jwtCookie!.split(';')[0]!
+}
+
+describe('vue fixture', () => {
+ it('renders the index page', async () => {
+ const response = await fetch(baseURL)
+ expect(response.status).toBe(200)
+ const html = await response.text()
+ expect(html).toContain('')
+ })
+
+ it('issues a signed jwt cookie from /api/login', async () => {
+ const cookie = await login()
+ const jwt = cookie.slice('jwt='.length)
+ const { payload } = await jwtVerify(jwt, new TextEncoder().encode(authSecret))
+ expect(typeof payload.sub).toBe('string')
+ expect(seededUserIDs).toContain(payload.sub)
+ })
+
+ it('transforms a named query on /api/zero/query', async () => {
+ const cookie = await login()
+ const response = await fetch(`${baseURL}/api/zero/query`, {
+ method: 'POST',
+ headers: {
+ 'content-type': 'application/json',
+ cookie,
+ },
+ body: JSON.stringify(['transform', [{ id: 'q1', name: 'messages.all', args: [] }]]),
+ })
+ expect(response.status).toBe(200)
+
+ const body = await response.json() as { kind: string, queries: Array<{ id: string, ast?: unknown, error?: string }> }
+ expect(body.kind).toBe('QueryResponse')
+ expect(body.queries).toHaveLength(1)
+ expect(body.queries[0]!.id).toBe('q1')
+ expect(body.queries[0]!.error).toBeUndefined()
+ expect(body.queries[0]!.ast).toBeDefined()
+ })
+
+ it('returns a parse error for a malformed query request', async () => {
+ const response = await fetch(`${baseURL}/api/zero/query`, {
+ method: 'POST',
+ headers: { 'content-type': 'application/json' },
+ body: JSON.stringify({ nonsense: true }),
+ })
+ expect(response.status).toBe(200)
+
+ const body = await response.json() as { kind: string }
+ expect(body.kind).toBe('TransformFailed')
+ })
+})
diff --git a/test/fixtures/vue/tsconfig.node.json b/test/fixtures/vue/tsconfig.node.json
index 73f011e..b7c4468 100644
--- a/test/fixtures/vue/tsconfig.node.json
+++ b/test/fixtures/vue/tsconfig.node.json
@@ -4,12 +4,23 @@
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"module": "ESNext",
"moduleResolution": "Bundler",
+ "paths": {
+ "~/*": [
+ "./src/*"
+ ],
+ "#fx/*": [
+ "../_shared/*"
+ ]
+ },
"types": [
"node"
],
"noEmit": true
},
"include": [
+ "server/**/*",
+ "test/**/*",
+ "../_shared/**/*",
"vite.config.*",
"vitest.config.*",
"cypress.config.*",
diff --git a/test/fixtures/vue/vite.config.ts b/test/fixtures/vue/vite.config.ts
index 4746ed6..07fa3e9 100644
--- a/test/fixtures/vue/vite.config.ts
+++ b/test/fixtures/vue/vite.config.ts
@@ -1,18 +1,49 @@
+import process from 'node:process'
import { fileURLToPath, URL } from 'node:url'
import vue from '@vitejs/plugin-vue'
-import { defineConfig } from 'vite'
+import { toNodeListener } from 'h3'
+import { defineConfig, loadEnv } from 'vite'
// https://vite.dev/config/
-export default defineConfig({
- plugins: [
- vue(),
- ],
- resolve: {
- alias: {
- '~': fileURLToPath(new URL('./src', import.meta.url)),
- '#fx': fileURLToPath(new URL('../_shared', import.meta.url)),
- 'zero-vue': fileURLToPath(new URL('../../../src/index.ts', import.meta.url).href),
+export default defineConfig(({ mode }) => {
+ Object.assign(process.env, loadEnv(mode, process.cwd(), ''))
+
+ return {
+ server: {
+ port: 3000,
+ },
+ plugins: [
+ vue(),
+ {
+ name: 'fixture-server',
+ configureServer(server) {
+ let listener: ReturnType | undefined
+
+ server.middlewares.use(async (req, res, next) => {
+ if (!req.url?.startsWith('/api')) {
+ return next()
+ }
+
+ try {
+ if (!listener) {
+ const mod = await server.ssrLoadModule(fileURLToPath(new URL('./server/index.ts', import.meta.url))) as typeof import('./server')
+ listener = toNodeListener(mod.app)
+ }
+ listener(req, res)
+ }
+ catch (error) {
+ next(error)
+ }
+ })
+ },
+ },
+ ],
+ resolve: {
+ alias: {
+ '~': fileURLToPath(new URL('./src', import.meta.url)),
+ '#fx': fileURLToPath(new URL('../_shared', import.meta.url)),
+ },
},
- },
+ }
})
diff --git a/test/setup.ts b/test/setup.ts
new file mode 100644
index 0000000..f61ec41
--- /dev/null
+++ b/test/setup.ts
@@ -0,0 +1,8 @@
+// Node 25+ exposes localStorage through a warning-producing getter unless a
+// storage file is configured. Unit tests use in-memory Zero clients, so model
+// an environment without browser storage instead.
+Object.defineProperty(globalThis, 'localStorage', {
+ configurable: true,
+ value: undefined,
+ writable: true,
+})
diff --git a/vitest.config.ts b/vitest.config.ts
index ed149b9..4a89ece 100644
--- a/vitest.config.ts
+++ b/vitest.config.ts
@@ -10,6 +10,14 @@ export default defineConfig({
},
},
test: {
+ include: ['src/**/*.test.ts', 'test/index.test.ts'],
+ onConsoleLog(log) {
+ if (log.includes('Zero starting up with no server URL')) {
+ return false
+ }
+ },
+ setupFiles: ['./test/setup.ts'],
+ silent: 'passed-only',
coverage: {
include: ['src'],
reporter: ['text', 'json', 'html'],