@interop/byoe-react-template 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.github/workflows/ci.yml +44 -0
- package/.github/workflows/publish.yml +38 -0
- package/CHANGELOG.md +20 -0
- package/CLAUDE.md +55 -0
- package/LICENSE.md +20 -0
- package/README.md +217 -0
- package/_spec/phase2-handoff.md +143 -0
- package/eslint.config.js +40 -0
- package/index.html +12 -0
- package/package.json +60 -0
- package/playwright.config.ts +38 -0
- package/playwright.wallet.config.ts +82 -0
- package/playwright.was.config.ts +56 -0
- package/pnpm-workspace.yaml +11 -0
- package/prettier.config.js +9 -0
- package/scripts/provision-dev-grants.ts +42 -0
- package/src/App.tsx +34 -0
- package/src/app.config.ts +73 -0
- package/src/components/AppShell.tsx +58 -0
- package/src/components/DevGate.tsx +51 -0
- package/src/dev/bootstrap.ts +54 -0
- package/src/dev/devSeed.ts +13 -0
- package/src/dev/devSync.ts +80 -0
- package/src/main.tsx +24 -0
- package/src/pages/LoginPage.tsx +76 -0
- package/src/pages/NotesPage.tsx +166 -0
- package/src/stores/notes.ts +41 -0
- package/src/vite-env.d.ts +1 -0
- package/test/browser/notes.spec.ts +82 -0
- package/test/browser-wallet/walletLogin.spec.ts +312 -0
- package/test/browser-was/globalSetup.ts +6 -0
- package/test/browser-was/globalTeardown.ts +6 -0
- package/test/browser-was/replication.spec.ts +154 -0
- package/test/browser-was/serverLifecycle.ts +92 -0
- package/test/node/notesStore.test.ts +149 -0
- package/tsconfig.dev.json +17 -0
- package/tsconfig.json +24 -0
- package/vite.config.ts +18 -0
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WAS replication suite: real end-to-end sync against a local
|
|
3
|
+
* was-teaching-server (booted + provisioned in globalSetup). Each browser
|
|
4
|
+
* context is an independent IndexedDB replica; they all share the SAME dev seed
|
|
5
|
+
* and grants, so they replicate through one WAS Space.
|
|
6
|
+
*
|
|
7
|
+
* The pull side is poll-based (no server-side live stream): a fresh context
|
|
8
|
+
* pulls on open, and an already-open context re-pulls on the app's periodic
|
|
9
|
+
* reSync (tuned low in this config), so the tests wait rather than force a
|
|
10
|
+
* reload. Expect timeouts are generous because each hop is a poll interval plus
|
|
11
|
+
* a round trip. Note texts are unique per run because the Space is shared and
|
|
12
|
+
* long-lived. Tests are serialized (one shared Space).
|
|
13
|
+
*/
|
|
14
|
+
import { test, expect, type Page, type BrowserContext } from '@playwright/test'
|
|
15
|
+
|
|
16
|
+
/** The add-note input. MUI puts the aria-label on the field wrapper, so target
|
|
17
|
+
* the actual <input> by its placeholder. */
|
|
18
|
+
function addInput(page: Page) {
|
|
19
|
+
return page.getByPlaceholder('A new note...')
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** The list row locator for a note by its exact text on `page`. */
|
|
23
|
+
function noteRow(page: Page, text: string) {
|
|
24
|
+
return page.getByTestId('notes-list').getByText(text, { exact: true })
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Opens a fresh profile (isolated IndexedDB) and waits for it to be ready. */
|
|
28
|
+
async function openProfile(context: BrowserContext): Promise<Page> {
|
|
29
|
+
const page = await context.newPage()
|
|
30
|
+
await page.goto('/')
|
|
31
|
+
await expect(addInput(page)).toBeVisible({ timeout: 30_000 })
|
|
32
|
+
return page
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Adds a note with the given text on `page` and waits for its row. */
|
|
36
|
+
async function addNote(page: Page, text: string): Promise<void> {
|
|
37
|
+
await addInput(page).fill(text)
|
|
38
|
+
await page.getByRole('button', { name: 'Add' }).click()
|
|
39
|
+
await expect(noteRow(page, text)).toBeVisible()
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Edits the note with `from` text to `to` via the row's inline editor. */
|
|
43
|
+
async function editNote(page: Page, from: string, to: string): Promise<void> {
|
|
44
|
+
await page.getByRole('button', { name: `edit ${from}` }).click()
|
|
45
|
+
const field = page.getByLabel('edit note').locator('input')
|
|
46
|
+
await field.fill(to)
|
|
47
|
+
await page.getByRole('button', { name: 'Save' }).click()
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Waits for the sync-status chip to settle on the synced state. */
|
|
51
|
+
async function expectSynced(page: Page): Promise<void> {
|
|
52
|
+
await expect(page.getByTestId('sync-status-chip')).toHaveAttribute(
|
|
53
|
+
'data-sync-state',
|
|
54
|
+
'synced',
|
|
55
|
+
{ timeout: 30_000 }
|
|
56
|
+
)
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
test('the app boots into dev-sync and reaches the synced state', async ({
|
|
60
|
+
browser
|
|
61
|
+
}) => {
|
|
62
|
+
const context = await browser.newContext()
|
|
63
|
+
const page = await openProfile(context)
|
|
64
|
+
|
|
65
|
+
// Replication started against the WAS server and settled: proof the dev
|
|
66
|
+
// grants provisioned in globalSetup are accepted end to end.
|
|
67
|
+
await expectSynced(page)
|
|
68
|
+
|
|
69
|
+
await context.close()
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
test('a note replicates to a second fresh profile', async ({ browser }) => {
|
|
73
|
+
const text = `repl-${Date.now()}`
|
|
74
|
+
const ctxA = await browser.newContext()
|
|
75
|
+
const a = await openProfile(ctxA)
|
|
76
|
+
|
|
77
|
+
await addNote(a, text)
|
|
78
|
+
await expectSynced(a)
|
|
79
|
+
|
|
80
|
+
// A fresh profile with the same seed + grants pulls the envelope on open and
|
|
81
|
+
// hydrates the note, with no manual reload.
|
|
82
|
+
const ctxB = await browser.newContext()
|
|
83
|
+
const b = await openProfile(ctxB)
|
|
84
|
+
await expect(noteRow(b, text)).toBeVisible({ timeout: 30_000 })
|
|
85
|
+
|
|
86
|
+
await ctxA.close()
|
|
87
|
+
await ctxB.close()
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
test('a note added in one profile converges to an already-open profile', async ({
|
|
91
|
+
browser
|
|
92
|
+
}) => {
|
|
93
|
+
const text = `live-${Date.now()}`
|
|
94
|
+
const ctxA = await browser.newContext()
|
|
95
|
+
const ctxB = await browser.newContext()
|
|
96
|
+
const a = await openProfile(ctxA)
|
|
97
|
+
const b = await openProfile(ctxB)
|
|
98
|
+
|
|
99
|
+
// B is already open (past its initial pull) when A creates the note, so B can
|
|
100
|
+
// only see it via the app's periodic reSync polling the changes feed.
|
|
101
|
+
await addNote(a, text)
|
|
102
|
+
await expectSynced(a)
|
|
103
|
+
await expect(noteRow(b, text)).toBeVisible({ timeout: 30_000 })
|
|
104
|
+
|
|
105
|
+
await ctxA.close()
|
|
106
|
+
await ctxB.close()
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
test('an edit replicates to a second fresh profile', async ({ browser }) => {
|
|
110
|
+
const original = `edit-${Date.now()}`
|
|
111
|
+
const edited = `${original}-v2`
|
|
112
|
+
const ctxA = await browser.newContext()
|
|
113
|
+
const a = await openProfile(ctxA)
|
|
114
|
+
|
|
115
|
+
await addNote(a, original)
|
|
116
|
+
await expectSynced(a)
|
|
117
|
+
|
|
118
|
+
// Edit in place. The push is a conditional overwrite of the same envelope;
|
|
119
|
+
// a version-skew 412 is resolved by the payload LWW (updatedAt, deviceId)
|
|
120
|
+
// and re-pushed, so the new text must land server-side.
|
|
121
|
+
await editNote(a, original, edited)
|
|
122
|
+
await expect(noteRow(a, edited)).toBeVisible()
|
|
123
|
+
|
|
124
|
+
const ctxB = await browser.newContext()
|
|
125
|
+
const b = await openProfile(ctxB)
|
|
126
|
+
await expect(noteRow(b, edited)).toBeVisible({ timeout: 30_000 })
|
|
127
|
+
await expect(noteRow(b, original)).toHaveCount(0)
|
|
128
|
+
|
|
129
|
+
await ctxA.close()
|
|
130
|
+
await ctxB.close()
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
test('a delete replicates to a second fresh profile', async ({
|
|
134
|
+
browser
|
|
135
|
+
}) => {
|
|
136
|
+
const text = `del-${Date.now()}`
|
|
137
|
+
const ctxA = await browser.newContext()
|
|
138
|
+
const a = await openProfile(ctxA)
|
|
139
|
+
|
|
140
|
+
await addNote(a, text)
|
|
141
|
+
await expectSynced(a)
|
|
142
|
+
|
|
143
|
+
// Soft-delete tombstone, pushed as a conditional server-side delete.
|
|
144
|
+
await a.getByRole('button', { name: `delete ${text}` }).click()
|
|
145
|
+
await expect(noteRow(a, text)).toHaveCount(0)
|
|
146
|
+
|
|
147
|
+
const ctxB = await browser.newContext()
|
|
148
|
+
const b = await openProfile(ctxB)
|
|
149
|
+
await expectSynced(b)
|
|
150
|
+
await expect(noteRow(b, text)).toHaveCount(0)
|
|
151
|
+
|
|
152
|
+
await ctxA.close()
|
|
153
|
+
await ctxB.close()
|
|
154
|
+
})
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared lifecycle for the WAS-backed Playwright suite: boot a local
|
|
3
|
+
* was-teaching-server, provision the dev Space + collections + grants against
|
|
4
|
+
* it, and tear the server down afterwards. globalSetup and globalTeardown run
|
|
5
|
+
* in the same Playwright runner process, so the child handle is stashed on
|
|
6
|
+
* `globalThis`.
|
|
7
|
+
*
|
|
8
|
+
* The was-teaching-server checkout is READ-ONLY and run as-is
|
|
9
|
+
* (`tsx src/start.ts`); we only set its SERVER_URL / PORT env, which MUST match
|
|
10
|
+
* the URL the client builds from the granted zcaps' invocationTargets. The
|
|
11
|
+
* server directory defaults to the sibling checkout and is overridable via
|
|
12
|
+
* WAS_SERVER_DIR.
|
|
13
|
+
*/
|
|
14
|
+
import { spawn, execFile, type ChildProcess } from 'node:child_process'
|
|
15
|
+
import { promisify } from 'node:util'
|
|
16
|
+
import { dirname, join } from 'node:path'
|
|
17
|
+
import { fileURLToPath } from 'node:url'
|
|
18
|
+
|
|
19
|
+
const execFileAsync = promisify(execFile)
|
|
20
|
+
|
|
21
|
+
const here = dirname(fileURLToPath(import.meta.url))
|
|
22
|
+
const repoRoot = join(here, '..', '..')
|
|
23
|
+
|
|
24
|
+
export const WAS_PORT = Number(process.env.WAS_E2E_PORT ?? 3102)
|
|
25
|
+
export const WAS_SERVER_URL = `http://localhost:${WAS_PORT}`
|
|
26
|
+
export const WAS_SERVER_DIR =
|
|
27
|
+
process.env.WAS_SERVER_DIR ?? join(repoRoot, '..', 'was-teaching-server')
|
|
28
|
+
|
|
29
|
+
const HANDLE_KEY = Symbol.for('byoe-react-template.was-e2e.server')
|
|
30
|
+
|
|
31
|
+
interface Stash {
|
|
32
|
+
child?: ChildProcess
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function stash(): Stash {
|
|
36
|
+
const g = globalThis as unknown as Record<symbol, Stash>
|
|
37
|
+
g[HANDLE_KEY] ??= {}
|
|
38
|
+
return g[HANDLE_KEY]
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Polls the server root until it answers, or throws after `timeoutMs`. */
|
|
42
|
+
async function waitForServer(timeoutMs = 20000): Promise<void> {
|
|
43
|
+
const deadline = Date.now() + timeoutMs
|
|
44
|
+
while (Date.now() < deadline) {
|
|
45
|
+
try {
|
|
46
|
+
const response = await fetch(`${WAS_SERVER_URL}/`)
|
|
47
|
+
if (response.ok || response.status === 404) {
|
|
48
|
+
return
|
|
49
|
+
}
|
|
50
|
+
} catch {
|
|
51
|
+
/* not up yet */
|
|
52
|
+
}
|
|
53
|
+
await new Promise(resolve => setTimeout(resolve, 300))
|
|
54
|
+
}
|
|
55
|
+
throw new Error(`was-teaching-server did not come up at ${WAS_SERVER_URL}`)
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Boots the server and runs the provisioning script. Called by globalSetup. */
|
|
59
|
+
export async function startServerAndProvision(): Promise<void> {
|
|
60
|
+
const child = spawn('npx', ['tsx', 'src/start.ts'], {
|
|
61
|
+
cwd: WAS_SERVER_DIR,
|
|
62
|
+
env: {
|
|
63
|
+
...process.env,
|
|
64
|
+
SERVER_URL: WAS_SERVER_URL,
|
|
65
|
+
PORT: String(WAS_PORT),
|
|
66
|
+
STORAGE_LIMIT_PER_SPACE: 'unlimited'
|
|
67
|
+
},
|
|
68
|
+
detached: true,
|
|
69
|
+
stdio: 'ignore'
|
|
70
|
+
})
|
|
71
|
+
child.unref()
|
|
72
|
+
stash().child = child
|
|
73
|
+
|
|
74
|
+
await waitForServer()
|
|
75
|
+
|
|
76
|
+
await execFileAsync('npx', ['tsx', 'scripts/provision-dev-grants.ts'], {
|
|
77
|
+
cwd: repoRoot,
|
|
78
|
+
env: { ...process.env, SERVER_URL: WAS_SERVER_URL }
|
|
79
|
+
})
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Stops the server (its whole detached process group). Called by globalTeardown. */
|
|
83
|
+
export async function stopServer(): Promise<void> {
|
|
84
|
+
const child = stash().child
|
|
85
|
+
if (child?.pid) {
|
|
86
|
+
try {
|
|
87
|
+
process.kill(-child.pid, 'SIGTERM')
|
|
88
|
+
} catch {
|
|
89
|
+
/* already gone */
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Round-trips the notes entity store through the encrypted local replica: opens
|
|
3
|
+
* the app's own LocalStore (its COLLECTIONS, the dev seed) on fake-indexeddb,
|
|
4
|
+
* installs it, then drives useNotes insert / update / remove and asserts the
|
|
5
|
+
* registry hydrate re-reads the persisted, decrypted rows. A fresh replaceAll([])
|
|
6
|
+
* before each hydrate proves the docs come back from the replica, not stale
|
|
7
|
+
* in-memory Map state.
|
|
8
|
+
*
|
|
9
|
+
* @vitest-environment node
|
|
10
|
+
*/
|
|
11
|
+
import 'fake-indexeddb/auto'
|
|
12
|
+
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
|
13
|
+
import {
|
|
14
|
+
clearLocalStore,
|
|
15
|
+
hasStore,
|
|
16
|
+
LocalStore,
|
|
17
|
+
setLocalStore
|
|
18
|
+
} from '@interop/was-react'
|
|
19
|
+
import { COLLECTIONS } from '../../src/app.config'
|
|
20
|
+
import { DEV_SEED } from '../../src/dev/devSeed'
|
|
21
|
+
import { registry, useNotes, type Note } from '../../src/stores/notes'
|
|
22
|
+
|
|
23
|
+
let dbCounter = 0
|
|
24
|
+
let store: LocalStore | null = null
|
|
25
|
+
let dbName = ''
|
|
26
|
+
|
|
27
|
+
const notesEntry = registry.notes
|
|
28
|
+
if (!notesEntry) {
|
|
29
|
+
throw new Error('the notes registry entry is missing')
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function makeNote(text: string): Note {
|
|
33
|
+
const now = new Date().toISOString()
|
|
34
|
+
return {
|
|
35
|
+
id: crypto.randomUUID(),
|
|
36
|
+
text,
|
|
37
|
+
createdAt: now,
|
|
38
|
+
updatedAt: now,
|
|
39
|
+
deviceId: 'test-device'
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
async function openStore(name: string): Promise<LocalStore> {
|
|
44
|
+
const opened = await LocalStore.init({
|
|
45
|
+
seed: DEV_SEED,
|
|
46
|
+
collections: COLLECTIONS,
|
|
47
|
+
dbName: name
|
|
48
|
+
})
|
|
49
|
+
setLocalStore(opened)
|
|
50
|
+
return opened
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
beforeEach(async () => {
|
|
54
|
+
dbName = `byoe-notes-test-${++dbCounter}`
|
|
55
|
+
store = await openStore(dbName)
|
|
56
|
+
useNotes.getState().replaceAll([])
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
afterEach(async () => {
|
|
60
|
+
if (store) {
|
|
61
|
+
await store.close()
|
|
62
|
+
store = null
|
|
63
|
+
}
|
|
64
|
+
if (hasStore()) {
|
|
65
|
+
clearLocalStore()
|
|
66
|
+
}
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
describe('notes store round-trip through the encrypted LocalStore', () => {
|
|
70
|
+
it('installs the store the entity actions reach for', () => {
|
|
71
|
+
expect(hasStore()).toBe(true)
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
it('inserts a note and hydrates it back from the replica', async () => {
|
|
75
|
+
const note = makeNote('Buy distinctive-oat-milk-token')
|
|
76
|
+
await useNotes.getState().insert(note)
|
|
77
|
+
|
|
78
|
+
// Drop the in-memory Map; hydrate must repopulate it from the replica.
|
|
79
|
+
useNotes.getState().replaceAll([])
|
|
80
|
+
expect(useNotes.getState().byId.size).toBe(0)
|
|
81
|
+
|
|
82
|
+
await notesEntry.hydrate()
|
|
83
|
+
|
|
84
|
+
const byId = useNotes.getState().byId
|
|
85
|
+
expect(byId.size).toBe(1)
|
|
86
|
+
expect(byId.get(note.id)).toEqual(note)
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
it('updates a note in place, reflected after a fresh hydrate', async () => {
|
|
90
|
+
const note = makeNote('First text')
|
|
91
|
+
await useNotes.getState().insert(note)
|
|
92
|
+
|
|
93
|
+
const updated: Note = {
|
|
94
|
+
...note,
|
|
95
|
+
text: 'Second text',
|
|
96
|
+
updatedAt: new Date().toISOString()
|
|
97
|
+
}
|
|
98
|
+
await useNotes.getState().update(updated)
|
|
99
|
+
|
|
100
|
+
useNotes.getState().replaceAll([])
|
|
101
|
+
await notesEntry.hydrate()
|
|
102
|
+
|
|
103
|
+
const byId = useNotes.getState().byId
|
|
104
|
+
expect(byId.size).toBe(1)
|
|
105
|
+
expect(byId.get(note.id)).toEqual(updated)
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
it('removes a note, gone after a fresh hydrate', async () => {
|
|
109
|
+
const keep = makeNote('Keep me')
|
|
110
|
+
const drop = makeNote('Ephemeral')
|
|
111
|
+
await useNotes.getState().insert(keep)
|
|
112
|
+
await useNotes.getState().insert(drop)
|
|
113
|
+
|
|
114
|
+
await useNotes.getState().remove(drop.id)
|
|
115
|
+
|
|
116
|
+
useNotes.getState().replaceAll([])
|
|
117
|
+
await notesEntry.hydrate()
|
|
118
|
+
|
|
119
|
+
const byId = useNotes.getState().byId
|
|
120
|
+
expect(byId.size).toBe(1)
|
|
121
|
+
expect(byId.has(drop.id)).toBe(false)
|
|
122
|
+
expect(byId.get(keep.id)).toEqual(keep)
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
it('encrypts at rest: no note text in the stored envelope', async () => {
|
|
126
|
+
const note = makeNote('distinctive-oat-milk-token')
|
|
127
|
+
await useNotes.getState().insert(note)
|
|
128
|
+
|
|
129
|
+
const rows = await store!.rxCollection('notes').find().exec()
|
|
130
|
+
expect(rows).toHaveLength(1)
|
|
131
|
+
const raw = JSON.stringify(rows[0]!.toMutableJSON())
|
|
132
|
+
expect(raw).not.toContain('distinctive-oat-milk-token')
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
it('persists across a store reopen (survives a reload)', async () => {
|
|
136
|
+
const note = makeNote('Durable note')
|
|
137
|
+
await useNotes.getState().insert(note)
|
|
138
|
+
|
|
139
|
+
await store!.close()
|
|
140
|
+
clearLocalStore()
|
|
141
|
+
|
|
142
|
+
store = await openStore(dbName)
|
|
143
|
+
|
|
144
|
+
useNotes.getState().replaceAll([])
|
|
145
|
+
await notesEntry.hydrate()
|
|
146
|
+
|
|
147
|
+
expect(useNotes.getState().byId.get(note.id)).toEqual(note)
|
|
148
|
+
})
|
|
149
|
+
})
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
{
|
|
2
|
+
"extends": "./tsconfig.json",
|
|
3
|
+
"compilerOptions": {
|
|
4
|
+
"noEmit": true,
|
|
5
|
+
"allowImportingTsExtensions": true
|
|
6
|
+
},
|
|
7
|
+
"include": [
|
|
8
|
+
"src/**/*",
|
|
9
|
+
"test/**/*.ts",
|
|
10
|
+
"vite.config.ts",
|
|
11
|
+
"playwright.config.ts",
|
|
12
|
+
"playwright.was.config.ts",
|
|
13
|
+
"playwright.wallet.config.ts",
|
|
14
|
+
"scripts/**/*.ts"
|
|
15
|
+
],
|
|
16
|
+
"exclude": ["node_modules", "dist"]
|
|
17
|
+
}
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"strict": true,
|
|
4
|
+
"target": "ES2022",
|
|
5
|
+
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
|
6
|
+
"module": "ESNext",
|
|
7
|
+
"moduleResolution": "Bundler",
|
|
8
|
+
"jsx": "react-jsx",
|
|
9
|
+
"sourceMap": true,
|
|
10
|
+
"allowSyntheticDefaultImports": true,
|
|
11
|
+
"esModuleInterop": true,
|
|
12
|
+
"skipLibCheck": true,
|
|
13
|
+
"resolveJsonModule": true,
|
|
14
|
+
"isolatedModules": true,
|
|
15
|
+
"verbatimModuleSyntax": true,
|
|
16
|
+
"noUncheckedIndexedAccess": true,
|
|
17
|
+
"noEmit": true,
|
|
18
|
+
"paths": {
|
|
19
|
+
"@/*": ["./src/*"]
|
|
20
|
+
}
|
|
21
|
+
},
|
|
22
|
+
"include": ["src/**/*"],
|
|
23
|
+
"exclude": ["node_modules", "dist"]
|
|
24
|
+
}
|
package/vite.config.ts
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/// <reference types="vitest/config" />
|
|
2
|
+
import { fileURLToPath } from 'node:url'
|
|
3
|
+
import { defineConfig } from 'vite'
|
|
4
|
+
import react from '@vitejs/plugin-react'
|
|
5
|
+
|
|
6
|
+
const srcDir = fileURLToPath(new URL('./src', import.meta.url))
|
|
7
|
+
|
|
8
|
+
export default defineConfig({
|
|
9
|
+
plugins: [react()],
|
|
10
|
+
resolve: {
|
|
11
|
+
alias: {
|
|
12
|
+
'@': srcDir
|
|
13
|
+
}
|
|
14
|
+
},
|
|
15
|
+
test: {
|
|
16
|
+
include: ['test/node/**/*.test.ts', 'src/**/*.test.ts']
|
|
17
|
+
}
|
|
18
|
+
})
|