@antelopejs/dms-database 0.1.1 → 0.2.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/README.md +1 -1
- package/dist/pages/schemas.js +2 -1
- package/dist/pages/schemas.js.map +1 -1
- package/dist/routes/tables.d.ts +1 -0
- package/dist/routes/tables.js +35 -10
- package/dist/routes/tables.js.map +1 -1
- package/frontend-vue/app/build/composables/useDataBrowserGrid.ts +4 -38
- package/frontend-vue/app/build/composables/useDatabaseHealth.ts +7 -4
- package/frontend-vue/app/build/composables/useDatabaseSchemas.ts +1 -2
- package/frontend-vue/app/build/composables/useDiagramPersistence.ts +49 -16
- package/frontend-vue/app/build/composables/useQueryStore.ts +59 -27
- package/frontend-vue/app/build/composables/useTableCounts.ts +50 -0
- package/frontend-vue/app/build/query/runs.ts +7 -27
- package/frontend-vue/app/build/schemas/useSchemasListing.ts +10 -33
- package/frontend-vue/app/components/ConnectionStatus.vue +14 -11
- package/frontend-vue/app/components/DataBrowserSidebar.vue +20 -42
- package/frontend-vue/app/components/DataBrowserTabBar.vue +1 -1
- package/frontend-vue/app/components/DataGrid.vue +43 -89
- package/frontend-vue/app/components/InstancePicker.vue +63 -55
- package/frontend-vue/app/components/QueryConsole.vue +47 -37
- package/frontend-vue/app/components/QueryLibrary.vue +20 -17
- package/frontend-vue/app/components/RowDrawer.vue +15 -18
- package/frontend-vue/app/components/SchemaDiagram.vue +7 -7
- package/frontend-vue/app/components/TableInspector.vue +134 -88
- package/frontend-vue/package.json +2 -1
- package/frontend-vue/pnpm-lock.yaml +883 -2587
- package/frontend-vue/tests/fixtures/inertia.ts +8 -0
- package/frontend-vue/tests/fixtures/serverRender.ts +219 -0
- package/frontend-vue/tests/fixtures/vueI18n.ts +4 -0
- package/frontend-vue/tests/helpers.test.ts +1 -0
- package/frontend-vue/tests/locales.test.ts +24 -0
- package/frontend-vue/tests/onDemandReads.test.ts +106 -0
- package/frontend-vue/tests/registration.test.ts +0 -12
- package/frontend-vue/tests/serverRender.test.ts +235 -0
- package/frontend-vue/vitest.config.ts +24 -0
- package/package.json +6 -5
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
// What the renderer imports of Inertia, which a server render of a component
|
|
2
|
+
// does not reach: the browser's page, links and visits.
|
|
3
|
+
export const Link = {}
|
|
4
|
+
export const router = {
|
|
5
|
+
visit: () => undefined,
|
|
6
|
+
on: () => () => undefined,
|
|
7
|
+
}
|
|
8
|
+
export const usePage = () => ({ url: undefined })
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Server renders of the module's components on the renderer's real data
|
|
3
|
+
* layer (`#dms/frontend-module`) and the DMS's real `useApiData`, as the
|
|
4
|
+
* generated application runs them: what each render reads from the backend,
|
|
5
|
+
* and what it hands the browser with the page.
|
|
6
|
+
*/
|
|
7
|
+
import * as vue from 'vue'
|
|
8
|
+
import { createSSRApp, h, type Component } from 'vue'
|
|
9
|
+
import { renderToString } from 'vue/server-renderer'
|
|
10
|
+
import { vi } from 'vitest'
|
|
11
|
+
import type { DmsPayload } from '#dms/frontend-module'
|
|
12
|
+
|
|
13
|
+
const engine = await import('#dms/frontend-module')
|
|
14
|
+
const regional = await import('#dms-core/app/utils/regional')
|
|
15
|
+
|
|
16
|
+
/** A backend request a render made. */
|
|
17
|
+
export interface BackendRead {
|
|
18
|
+
url: string
|
|
19
|
+
method: string
|
|
20
|
+
query: Record<string, unknown>
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export type Backend = (read: BackendRead) => unknown
|
|
24
|
+
|
|
25
|
+
interface FetchOptions {
|
|
26
|
+
method?: string
|
|
27
|
+
query?: Record<string, unknown>
|
|
28
|
+
signal?: AbortSignal
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
let backend: Backend = () => ({})
|
|
32
|
+
const reads: BackendRead[] = []
|
|
33
|
+
/** The options each read was made with, as `useDmsFetch` passed them. */
|
|
34
|
+
const readOptions = new WeakMap<BackendRead, FetchOptions>()
|
|
35
|
+
|
|
36
|
+
const $authFetch = async (url: string, options: FetchOptions = {}) => {
|
|
37
|
+
const read = {
|
|
38
|
+
url,
|
|
39
|
+
method: (options.method ?? 'GET').toUpperCase(),
|
|
40
|
+
query: options.query ?? {},
|
|
41
|
+
}
|
|
42
|
+
reads.push(read)
|
|
43
|
+
readOptions.set(read, options)
|
|
44
|
+
return backend(read)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const noop = () => undefined
|
|
48
|
+
|
|
49
|
+
// The application's auto-imports: Vue's API, the renderer's, the DMS
|
|
50
|
+
// layers' the module calls, and the module's own composables.
|
|
51
|
+
for (const [name, value] of Object.entries(vue))
|
|
52
|
+
if (/^[a-z]/.test(name)) vi.stubGlobal(name, value)
|
|
53
|
+
vi.stubGlobal('useDmsFetch', engine.useDmsFetch)
|
|
54
|
+
vi.stubGlobal('useDmsAsyncData', engine.useDmsAsyncData)
|
|
55
|
+
vi.stubGlobal('useDmsState', engine.useDmsState)
|
|
56
|
+
vi.stubGlobal('useDmsRoute', engine.useDmsRoute)
|
|
57
|
+
vi.stubGlobal('useDmsRouter', engine.useDmsRouter)
|
|
58
|
+
const { useApiData } = await import('#dms-core/app/composables/auth/useApiData')
|
|
59
|
+
vi.stubGlobal('useApiData', useApiData)
|
|
60
|
+
// `useFetchAuth`: the former name of `useApiData`.
|
|
61
|
+
vi.stubGlobal('useAuthFetch', () => ({ $authFetch, useFetchAuth: useApiData }))
|
|
62
|
+
vi.stubGlobal('useI18n', () => ({
|
|
63
|
+
t: (key: string, params?: Record<string, unknown>) =>
|
|
64
|
+
params ? `${key} ${Object.values(params).join(' ')}` : key,
|
|
65
|
+
n: (value: number) => String(value),
|
|
66
|
+
locale: vue.ref('en'),
|
|
67
|
+
}))
|
|
68
|
+
vi.stubGlobal('useRegionalFormat', () => ({
|
|
69
|
+
locale: vue.ref('en'),
|
|
70
|
+
dateTimeFormat: (options: Intl.DateTimeFormatOptions) =>
|
|
71
|
+
regional.regionalDateTimeFormat('en', options),
|
|
72
|
+
}))
|
|
73
|
+
vi.stubGlobal('regionalDayKey', regional.regionalDayKey)
|
|
74
|
+
vi.stubGlobal('regionalDayNumber', regional.regionalDayNumber)
|
|
75
|
+
vi.stubGlobal('useToast', () => ({ add: noop }))
|
|
76
|
+
vi.stubGlobal('useConfirm', () => ({ confirm: async () => false }))
|
|
77
|
+
vi.stubGlobal('useDrawer', () => ({ open: noop }))
|
|
78
|
+
vi.stubGlobal('useUnsavedChanges', noop)
|
|
79
|
+
vi.stubGlobal('useDmsUserSession', () => ({
|
|
80
|
+
user: vue.ref({ _id: 'me' }),
|
|
81
|
+
loggedIn: vue.ref(true),
|
|
82
|
+
}))
|
|
83
|
+
for (const [name, module] of Object.entries({
|
|
84
|
+
useDataBrowserGrid:
|
|
85
|
+
await import('../../app/build/composables/useDataBrowserGrid'),
|
|
86
|
+
useDataBrowserTabs:
|
|
87
|
+
await import('../../app/build/composables/useDataBrowserTabs'),
|
|
88
|
+
useStagedEdits: await import('../../app/build/data/stagedEdits'),
|
|
89
|
+
useDatabaseSchemas:
|
|
90
|
+
await import('../../app/build/composables/useDatabaseSchemas'),
|
|
91
|
+
}))
|
|
92
|
+
vi.stubGlobal(name, (module as Record<string, unknown>)[name])
|
|
93
|
+
|
|
94
|
+
// The DMS's and Nuxt UI's components the module's templates use: each
|
|
95
|
+
// renders its default slot, its props as attributes.
|
|
96
|
+
const UI_COMPONENTS = [
|
|
97
|
+
'DmsAutoLink',
|
|
98
|
+
'DmsCard',
|
|
99
|
+
'DmsChart',
|
|
100
|
+
'DmsCodeSnippet',
|
|
101
|
+
'DmsEmptyState',
|
|
102
|
+
'DmsEyebrow',
|
|
103
|
+
'DmsFieldRow',
|
|
104
|
+
'DmsFlowCanvas',
|
|
105
|
+
'DmsFormErrorText',
|
|
106
|
+
'DmsFormRequiredLegend',
|
|
107
|
+
'DmsInputCode',
|
|
108
|
+
'DmsInputText',
|
|
109
|
+
'DmsSaveBar',
|
|
110
|
+
'DmsSegmented',
|
|
111
|
+
'DmsStatusSummary',
|
|
112
|
+
'DmsSwitch',
|
|
113
|
+
'DmsTextarea',
|
|
114
|
+
'I18nT',
|
|
115
|
+
'UAlert',
|
|
116
|
+
'UAvatar',
|
|
117
|
+
'UBadge',
|
|
118
|
+
'UButton',
|
|
119
|
+
'UFormField',
|
|
120
|
+
'UIcon',
|
|
121
|
+
'UInput',
|
|
122
|
+
'UKbd',
|
|
123
|
+
'UModal',
|
|
124
|
+
'UPopover',
|
|
125
|
+
'UProgress',
|
|
126
|
+
'USelect',
|
|
127
|
+
'USelectMenu',
|
|
128
|
+
'USkeleton',
|
|
129
|
+
'USlideover',
|
|
130
|
+
'UTabs',
|
|
131
|
+
'UTooltip',
|
|
132
|
+
]
|
|
133
|
+
|
|
134
|
+
const uiStub = (name: string): Component =>
|
|
135
|
+
vue.defineComponent({
|
|
136
|
+
name,
|
|
137
|
+
setup:
|
|
138
|
+
(_, { slots }) =>
|
|
139
|
+
() =>
|
|
140
|
+
h('div', { 'data-stub': name }, slots.default?.()),
|
|
141
|
+
})
|
|
142
|
+
|
|
143
|
+
export interface ServerRender {
|
|
144
|
+
html: string
|
|
145
|
+
payload: DmsPayload
|
|
146
|
+
/** Every backend request the render made. */
|
|
147
|
+
reads: BackendRead[]
|
|
148
|
+
/** The requests made whose response the payload does not carry. */
|
|
149
|
+
untransferred: BackendRead[]
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* The key a `useApiData(url)` read is transferred under, as the renderer
|
|
154
|
+
* gives it (`dmsRequestKey`): a digest of the request, which the payload
|
|
155
|
+
* shows instead of the URL.
|
|
156
|
+
*/
|
|
157
|
+
function transferKey(read: BackendRead): string | null {
|
|
158
|
+
const { method, query } = readOptions.get(read) ?? {}
|
|
159
|
+
return engine.dmsRequestKey(read.url, { method, query }) ?? null
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Renders `component` with `props` as a server renders the page `url`,
|
|
164
|
+
* answering each backend request with `respond`.
|
|
165
|
+
*/
|
|
166
|
+
export async function serverRender(
|
|
167
|
+
component: Component,
|
|
168
|
+
props: Record<string, unknown> = {},
|
|
169
|
+
options: { url?: string; respond?: Backend } = {},
|
|
170
|
+
): Promise<ServerRender> {
|
|
171
|
+
backend = options.respond ?? (() => ({}))
|
|
172
|
+
reads.length = 0
|
|
173
|
+
const runtime = engine.createDmsFrontendRuntime(undefined, {}, true)
|
|
174
|
+
runtime.route.fullPath = options.url ?? '/'
|
|
175
|
+
const app = createSSRApp({ render: () => h(component, props) })
|
|
176
|
+
engine.provideDmsFrontendRuntime(app, runtime)
|
|
177
|
+
app.component('DmsClientOnly', engine.DmsClientOnly)
|
|
178
|
+
for (const name of UI_COMPONENTS) app.component(name, uiStub(name))
|
|
179
|
+
app.config.warnHandler = noop
|
|
180
|
+
const html = await renderToString(app)
|
|
181
|
+
const payload = engine.serializeDmsPayload(runtime)
|
|
182
|
+
const transferred = new Set(
|
|
183
|
+
Object.keys({ ...payload.data, ...payload.errors }),
|
|
184
|
+
)
|
|
185
|
+
const made = [...reads]
|
|
186
|
+
return {
|
|
187
|
+
html,
|
|
188
|
+
payload,
|
|
189
|
+
reads: made,
|
|
190
|
+
untransferred: made.filter(
|
|
191
|
+
(read) => !transferred.has(transferKey(read) ?? ''),
|
|
192
|
+
),
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** What a browser's app context gives a composable, and what it read. */
|
|
197
|
+
export interface BrowserContext<T> {
|
|
198
|
+
value: T
|
|
199
|
+
/** Every backend request made since, live. */
|
|
200
|
+
reads: BackendRead[]
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Runs `setup` in a browser's app context (no server render to hydrate),
|
|
205
|
+
* answering each backend request with `respond`.
|
|
206
|
+
*/
|
|
207
|
+
export function inBrowser<T>(
|
|
208
|
+
setup: () => T,
|
|
209
|
+
respond: Backend,
|
|
210
|
+
): BrowserContext<T> {
|
|
211
|
+
backend = respond
|
|
212
|
+
reads.length = 0
|
|
213
|
+
const runtime = engine.createDmsFrontendRuntime(undefined, undefined, false)
|
|
214
|
+
const app = createSSRApp({ render: () => null })
|
|
215
|
+
engine.provideDmsFrontendRuntime(app, runtime)
|
|
216
|
+
const scope = vue.effectScope()
|
|
217
|
+
const value = app.runWithContext(() => scope.run(setup) as T)
|
|
218
|
+
return { value, reads }
|
|
219
|
+
}
|
|
@@ -151,6 +151,7 @@ describe('query console helpers', () => {
|
|
|
151
151
|
entry({ id: 'b', executedAt: '2026-10-06T10:00:00' }),
|
|
152
152
|
],
|
|
153
153
|
(run) => run.executedAt,
|
|
154
|
+
(date) => date.toISOString().slice(0, 10),
|
|
154
155
|
)
|
|
155
156
|
expect(groups.map((group) => group.items.map((run) => run.id))).toEqual([
|
|
156
157
|
['a'],
|
|
@@ -54,3 +54,27 @@ describe('locale messages', () => {
|
|
|
54
54
|
)
|
|
55
55
|
})
|
|
56
56
|
})
|
|
57
|
+
|
|
58
|
+
// `<i18n-t>` looks for its component's own messages first; the module's
|
|
59
|
+
// components have none, and vue-i18n warns on each page that renders one
|
|
60
|
+
// ("Not found parent scope"). The module's messages are global: say so.
|
|
61
|
+
const COMPONENTS_DIR = join(import.meta.dirname, '../app')
|
|
62
|
+
|
|
63
|
+
function vueFiles(dir: string): string[] {
|
|
64
|
+
return readdirSync(dir, { withFileTypes: true }).flatMap((entry) => {
|
|
65
|
+
const path = join(dir, entry.name)
|
|
66
|
+
if (entry.isDirectory()) return vueFiles(path)
|
|
67
|
+
return entry.name.endsWith('.vue') ? [path] : []
|
|
68
|
+
})
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
describe('translated templates', () => {
|
|
72
|
+
it('reads every <i18n-t> from the global messages', () => {
|
|
73
|
+
const unscoped = vueFiles(COMPONENTS_DIR).flatMap((file) =>
|
|
74
|
+
(readFileSync(file, 'utf8').match(/<i18n-t\b[^>]*>/g) ?? [])
|
|
75
|
+
.filter((tag) => !/\sscope="global"/.test(tag))
|
|
76
|
+
.map((tag) => `${file.slice(COMPONENTS_DIR.length)}: ${tag}`),
|
|
77
|
+
)
|
|
78
|
+
expect(unscoped).toEqual([])
|
|
79
|
+
})
|
|
80
|
+
})
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import {
|
|
3
|
+
inBrowser,
|
|
4
|
+
serverRender,
|
|
5
|
+
type BackendRead,
|
|
6
|
+
} from './fixtures/serverRender'
|
|
7
|
+
import { useDiagramPersistence } from '../app/build/composables/useDiagramPersistence'
|
|
8
|
+
import { useQueryStore } from '../app/build/composables/useQueryStore'
|
|
9
|
+
import InstancePicker from '../app/components/InstancePicker.vue'
|
|
10
|
+
import RowDrawer from '../app/components/RowDrawer.vue'
|
|
11
|
+
|
|
12
|
+
// The reads the module makes on demand go through `useApiData` too
|
|
13
|
+
// (`immediate: false` + `execute()`): nothing on the server, nothing before
|
|
14
|
+
// they are asked for, and a fresh read each time they are.
|
|
15
|
+
|
|
16
|
+
const NOTE = {
|
|
17
|
+
id: 'n1',
|
|
18
|
+
schemaName: 'shop',
|
|
19
|
+
text: 'Orders',
|
|
20
|
+
x: 0,
|
|
21
|
+
y: 0,
|
|
22
|
+
width: 200,
|
|
23
|
+
height: 100,
|
|
24
|
+
color: null,
|
|
25
|
+
createdAt: '2026-10-01T10:00:00.000Z',
|
|
26
|
+
updatedAt: '2026-10-01T10:00:00.000Z',
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
let layoutVersion = 0
|
|
30
|
+
|
|
31
|
+
function backend({ url }: BackendRead): unknown {
|
|
32
|
+
if (url === '/api/database/diagram/layout/shop')
|
|
33
|
+
return { positions: { orders: { x: layoutVersion++, y: 0 } } }
|
|
34
|
+
if (url === '/api/database/diagram/notes') return { items: [NOTE] }
|
|
35
|
+
if (url === '/api/database/diagram/layout/broken')
|
|
36
|
+
throw Object.assign(new Error('Unavailable'), { statusCode: 503 })
|
|
37
|
+
if (url === '/api/database/query/history/run-1')
|
|
38
|
+
return { id: 'run-1', source: 'db.schema("shop")' }
|
|
39
|
+
return { items: [] }
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
describe('reads on demand', () => {
|
|
43
|
+
it("the diagram reads a schema's layout and notes when the canvas asks, each time", async () => {
|
|
44
|
+
layoutVersion = 0
|
|
45
|
+
const browser = inBrowser(useDiagramPersistence, backend)
|
|
46
|
+
expect(browser.reads).toEqual([])
|
|
47
|
+
const first = await browser.value.readDiagram('shop')
|
|
48
|
+
expect(first).toEqual({
|
|
49
|
+
schema: 'shop',
|
|
50
|
+
positions: { orders: { x: 0, y: 0 } },
|
|
51
|
+
notes: [NOTE],
|
|
52
|
+
})
|
|
53
|
+
expect(browser.reads.map((read) => read.url).sort()).toEqual([
|
|
54
|
+
'/api/database/diagram/layout/shop',
|
|
55
|
+
'/api/database/diagram/notes',
|
|
56
|
+
])
|
|
57
|
+
expect(browser.reads[1]!.query).toEqual({ schema: 'shop' })
|
|
58
|
+
// The canvas edits a working copy: a new load reads what is stored now.
|
|
59
|
+
const second = await browser.value.readDiagram('shop')
|
|
60
|
+
expect(second.positions).toEqual({ orders: { x: 1, y: 0 } })
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
it('the diagram says so when its layout cannot be read', async () => {
|
|
64
|
+
const browser = inBrowser(useDiagramPersistence, backend)
|
|
65
|
+
await expect(browser.value.readDiagram('broken')).rejects.toMatchObject({
|
|
66
|
+
statusCode: 503,
|
|
67
|
+
})
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
it('the console reads a linked run when it opens it, not before', async () => {
|
|
71
|
+
const browser = inBrowser(useQueryStore, backend)
|
|
72
|
+
await browser.value.libraryRead
|
|
73
|
+
const before = browser.reads.length
|
|
74
|
+
const entry = await browser.value.fetchHistoryEntry('run-1')
|
|
75
|
+
expect(entry).toMatchObject({ id: 'run-1' })
|
|
76
|
+
expect(browser.reads.slice(before).map((read) => read.url)).toEqual([
|
|
77
|
+
'/api/database/query/history/run-1',
|
|
78
|
+
])
|
|
79
|
+
})
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
describe('server render: what only the browser reads', () => {
|
|
83
|
+
it("a row's drawer leaves its references to the browser", async () => {
|
|
84
|
+
const page = await serverRender(
|
|
85
|
+
RowDrawer,
|
|
86
|
+
{
|
|
87
|
+
tab: { id: 't', schema: 'shop', table: 'orders', instance: '' },
|
|
88
|
+
rowId: 'r1',
|
|
89
|
+
row: { _id: 'r1' },
|
|
90
|
+
columns: [],
|
|
91
|
+
onStage: () => undefined,
|
|
92
|
+
},
|
|
93
|
+
{ respond: backend },
|
|
94
|
+
)
|
|
95
|
+
expect(page.reads).toEqual([])
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
it('the instance picker reads nothing until its menu opens', async () => {
|
|
99
|
+
const page = await serverRender(
|
|
100
|
+
InstancePicker,
|
|
101
|
+
{ modelValue: { kind: 'default' }, schema: 'shop' },
|
|
102
|
+
{ respond: backend },
|
|
103
|
+
)
|
|
104
|
+
expect(page.reads).toEqual([])
|
|
105
|
+
})
|
|
106
|
+
})
|
|
@@ -6,18 +6,6 @@ function source(path: string): string {
|
|
|
6
6
|
return readFileSync(new URL(path, import.meta.url), 'utf8')
|
|
7
7
|
}
|
|
8
8
|
|
|
9
|
-
it('uses the DMS client-only wrapper for the Vue Flow schema diagram', () => {
|
|
10
|
-
const diagram = source('../app/components/SchemaDiagram.vue')
|
|
11
|
-
expect(diagram).toContain('<DmsClientOnly')
|
|
12
|
-
expect(diagram).not.toContain('<ClientOnly>')
|
|
13
|
-
})
|
|
14
|
-
|
|
15
|
-
it('does not route relation labels through Vue Flow EdgeText measurement', () => {
|
|
16
|
-
const edge = source('../app/components/DiagramRelationEdge.vue')
|
|
17
|
-
expect(edge).toContain(':label="undefined"')
|
|
18
|
-
expect(edge).toContain('<text')
|
|
19
|
-
})
|
|
20
|
-
|
|
21
9
|
it('registers every component under the module prefix the backend names', async () => {
|
|
22
10
|
const registerComponent = vi.fn()
|
|
23
11
|
const registerPage = vi.fn()
|
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it, vi } from 'vitest'
|
|
2
|
+
import { defineComponent, h } from 'vue'
|
|
3
|
+
import { serverRender, type BackendRead } from './fixtures/serverRender'
|
|
4
|
+
import ConnectionStatus from '../app/components/ConnectionStatus.vue'
|
|
5
|
+
import DataBrowser from '../app/components/DataBrowser.vue'
|
|
6
|
+
import QueryConsole from '../app/components/QueryConsole.vue'
|
|
7
|
+
import SchemaDiagram from '../app/components/SchemaDiagram.vue'
|
|
8
|
+
import SchemasFilterBar from '../app/components/SchemasFilterBar.vue'
|
|
9
|
+
import TableInspector from '../app/components/TableInspector.vue'
|
|
10
|
+
|
|
11
|
+
// What a server render of each page reads, and that the browser is handed
|
|
12
|
+
// every response it read: a read the page does not hand over is made again
|
|
13
|
+
// by the browser, after a server request for nothing.
|
|
14
|
+
|
|
15
|
+
const table = (name: string) => ({
|
|
16
|
+
name,
|
|
17
|
+
fields: { _id: { kind: 'string' } },
|
|
18
|
+
indexes: {},
|
|
19
|
+
relations: [],
|
|
20
|
+
})
|
|
21
|
+
const schema = (id: string, instances: string[] = []) => ({
|
|
22
|
+
id,
|
|
23
|
+
options: {},
|
|
24
|
+
tables: [table('orders'), table('customers')],
|
|
25
|
+
stats: { tableCount: 2, elementCount: 3, instanceCount: instances.length },
|
|
26
|
+
instances,
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
function backend({ url, query }: BackendRead): unknown {
|
|
30
|
+
switch (url) {
|
|
31
|
+
case '/api/database/schemas':
|
|
32
|
+
return { schemas: [schema('audit'), schema('shop', ['eu'])] }
|
|
33
|
+
case '/api/database/browse/tables':
|
|
34
|
+
return {
|
|
35
|
+
items: [
|
|
36
|
+
{ name: 'orders', elementCount: query.filter_instance ? 7 : 4242 },
|
|
37
|
+
{ name: 'customers', elementCount: 31 },
|
|
38
|
+
],
|
|
39
|
+
}
|
|
40
|
+
case '/api/database/health':
|
|
41
|
+
return { status: 'ok', latencyMs: 3, driver: 'MongoDB', storage: [] }
|
|
42
|
+
case '/api/database/query/saved':
|
|
43
|
+
return {
|
|
44
|
+
items:
|
|
45
|
+
query.scope === 'shared'
|
|
46
|
+
? [query_('team-query', 'Teammate query', 'other')]
|
|
47
|
+
: [query_('own-query', 'My saved query', 'me')],
|
|
48
|
+
}
|
|
49
|
+
case '/api/database/query/history':
|
|
50
|
+
return {
|
|
51
|
+
items: [
|
|
52
|
+
{
|
|
53
|
+
id: 'run-1',
|
|
54
|
+
userId: 'me',
|
|
55
|
+
query: {},
|
|
56
|
+
source: 'db.schema("shop").table("orders")',
|
|
57
|
+
language: 'aql',
|
|
58
|
+
executedAt: '2026-10-14T08:00:00.000Z',
|
|
59
|
+
durationMs: 12,
|
|
60
|
+
rowCount: 3,
|
|
61
|
+
status: 'ok',
|
|
62
|
+
mutation: false,
|
|
63
|
+
},
|
|
64
|
+
],
|
|
65
|
+
}
|
|
66
|
+
case '/api/database/browse/count':
|
|
67
|
+
return { total: 9876 }
|
|
68
|
+
case '/api/database/tables/source':
|
|
69
|
+
return { total: 12, all: 40, results: [{ _id: 'shop::orders' }] }
|
|
70
|
+
default:
|
|
71
|
+
return {}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function query_(id: string, name: string, userId: string) {
|
|
76
|
+
return {
|
|
77
|
+
id,
|
|
78
|
+
userId,
|
|
79
|
+
name,
|
|
80
|
+
description: '',
|
|
81
|
+
query: {},
|
|
82
|
+
source: `db.schema("shop") // ${name}`,
|
|
83
|
+
language: 'aql',
|
|
84
|
+
shared: userId !== 'me',
|
|
85
|
+
createdAt: '2026-10-01T10:00:00.000Z',
|
|
86
|
+
updatedAt: '2026-10-01T10:00:00.000Z',
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const render = (
|
|
91
|
+
component: Parameters<typeof serverRender>[0],
|
|
92
|
+
props: Record<string, unknown> = {},
|
|
93
|
+
url = '/',
|
|
94
|
+
) => serverRender(component, props, { url, respond: backend })
|
|
95
|
+
|
|
96
|
+
describe('server render: every read is handed to the browser', () => {
|
|
97
|
+
it('data browser: the table counts of the first schema', async () => {
|
|
98
|
+
const page = await render(DataBrowser, {}, '/modules/database/data')
|
|
99
|
+
expect(page.reads.map((read) => read.url)).toEqual([
|
|
100
|
+
'/api/database/schemas',
|
|
101
|
+
'/api/database/browse/tables',
|
|
102
|
+
])
|
|
103
|
+
expect(page.reads[1]!.query).toEqual({ filter_schema: 'is:audit' })
|
|
104
|
+
expect(page.untransferred).toEqual([])
|
|
105
|
+
expect(page.html).toContain('4242')
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
it('data browser: a table link reads its schema and instance', async () => {
|
|
109
|
+
const page = await render(
|
|
110
|
+
DataBrowser,
|
|
111
|
+
{},
|
|
112
|
+
'/modules/database/data?schema=shop&table=orders&instance=eu',
|
|
113
|
+
)
|
|
114
|
+
expect(page.reads[1]!.query).toEqual({
|
|
115
|
+
filter_schema: 'is:shop',
|
|
116
|
+
filter_instance: 'is:eu',
|
|
117
|
+
})
|
|
118
|
+
expect(page.untransferred).toEqual([])
|
|
119
|
+
expect(page.html).toContain('>7<')
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
it('query console: the library, saved, shared and run', async () => {
|
|
123
|
+
const page = await render(QueryConsole, {}, '/modules/database/query')
|
|
124
|
+
expect(page.reads.map((read) => read.url).sort()).toEqual([
|
|
125
|
+
'/api/database/query/history',
|
|
126
|
+
'/api/database/query/saved',
|
|
127
|
+
'/api/database/query/saved',
|
|
128
|
+
'/api/database/schemas',
|
|
129
|
+
])
|
|
130
|
+
expect(page.untransferred).toEqual([])
|
|
131
|
+
expect(page.html).toContain(
|
|
132
|
+
'db.schema("shop").table("orders")',
|
|
133
|
+
)
|
|
134
|
+
})
|
|
135
|
+
|
|
136
|
+
it('diagram: no layout or notes, which only the browser draws', async () => {
|
|
137
|
+
const page = await render(SchemaDiagram, {}, '/modules/database/diagram')
|
|
138
|
+
expect(page.reads.map((read) => read.url)).toEqual([
|
|
139
|
+
'/api/database/schemas',
|
|
140
|
+
])
|
|
141
|
+
expect(page.untransferred).toEqual([])
|
|
142
|
+
})
|
|
143
|
+
|
|
144
|
+
it('diagram: no layout or notes either once the schemas are in', async () => {
|
|
145
|
+
// A block of the page read the schemas before the diagram's setup ran.
|
|
146
|
+
const SchemasFirst = defineComponent({
|
|
147
|
+
async setup() {
|
|
148
|
+
await useApiData('/api/database/schemas')
|
|
149
|
+
return () => h(SchemaDiagram)
|
|
150
|
+
},
|
|
151
|
+
})
|
|
152
|
+
const page = await render(SchemasFirst, {}, '/modules/database/diagram')
|
|
153
|
+
expect(page.reads.map((read) => read.url)).toEqual([
|
|
154
|
+
'/api/database/schemas',
|
|
155
|
+
])
|
|
156
|
+
expect(page.untransferred).toEqual([])
|
|
157
|
+
})
|
|
158
|
+
|
|
159
|
+
it('table inspector: the rows of the table in every instance', async () => {
|
|
160
|
+
const page = await render(
|
|
161
|
+
TableInspector,
|
|
162
|
+
{ rowData: { schema: 'shop', name: 'orders' } },
|
|
163
|
+
'/modules/database/schemas',
|
|
164
|
+
)
|
|
165
|
+
expect(page.reads.map((read) => read.url).sort()).toEqual([
|
|
166
|
+
'/api/database/browse/count',
|
|
167
|
+
'/api/database/schemas',
|
|
168
|
+
])
|
|
169
|
+
expect(page.untransferred).toEqual([])
|
|
170
|
+
expect(page.html).toContain('9876')
|
|
171
|
+
})
|
|
172
|
+
|
|
173
|
+
it('schemas filter bar: the tables listed out of all', async () => {
|
|
174
|
+
const page = await render(
|
|
175
|
+
SchemasFilterBar,
|
|
176
|
+
{},
|
|
177
|
+
'/modules/database/schemas?scope=shop',
|
|
178
|
+
)
|
|
179
|
+
expect(page.reads).toContainEqual({
|
|
180
|
+
url: '/api/database/tables/source',
|
|
181
|
+
method: 'GET',
|
|
182
|
+
query: { filter_scope: 'is:shop' },
|
|
183
|
+
})
|
|
184
|
+
expect(page.untransferred).toEqual([])
|
|
185
|
+
expect(page.html).toMatch(/12[^<]*40/)
|
|
186
|
+
// The counts only, not the rows the route lists.
|
|
187
|
+
expect(Object.values(page.payload.data)).toContainEqual({
|
|
188
|
+
total: 12,
|
|
189
|
+
all: 40,
|
|
190
|
+
})
|
|
191
|
+
})
|
|
192
|
+
|
|
193
|
+
it('overview: the connection health', async () => {
|
|
194
|
+
const page = await render(
|
|
195
|
+
ConnectionStatus,
|
|
196
|
+
{},
|
|
197
|
+
'/modules/database/overview',
|
|
198
|
+
)
|
|
199
|
+
expect(page.reads.map((read) => read.url)).toEqual(['/api/database/health'])
|
|
200
|
+
expect(page.untransferred).toEqual([])
|
|
201
|
+
})
|
|
202
|
+
})
|
|
203
|
+
|
|
204
|
+
describe('server render: what the browser hydrates the same', () => {
|
|
205
|
+
afterEach(() => {
|
|
206
|
+
vi.useRealTimers()
|
|
207
|
+
})
|
|
208
|
+
|
|
209
|
+
it('overview: the probe age, whenever the page renders', async () => {
|
|
210
|
+
// The browser hydrates a few seconds after the server rendered: the age
|
|
211
|
+
// of the probe must not follow the clock before the page is mounted.
|
|
212
|
+
const checkedAt = '2026-10-15T20:00:00.000Z'
|
|
213
|
+
const health = (read: BackendRead) =>
|
|
214
|
+
read.url === '/api/database/health'
|
|
215
|
+
? { ...(backend(read) as object), checkedAt }
|
|
216
|
+
: backend(read)
|
|
217
|
+
const renderAt = async (time: string) => {
|
|
218
|
+
vi.useFakeTimers({ toFake: ['Date'] })
|
|
219
|
+
vi.setSystemTime(new Date(time))
|
|
220
|
+
const page = await serverRender(
|
|
221
|
+
ConnectionStatus,
|
|
222
|
+
{},
|
|
223
|
+
{
|
|
224
|
+
url: '/modules/database/overview',
|
|
225
|
+
respond: health,
|
|
226
|
+
},
|
|
227
|
+
)
|
|
228
|
+
vi.useRealTimers()
|
|
229
|
+
return page.html
|
|
230
|
+
}
|
|
231
|
+
const rendered = await renderAt('2026-10-15T20:00:00.400Z')
|
|
232
|
+
expect(rendered).toContain('checked_seconds 0')
|
|
233
|
+
expect(await renderAt('2026-10-15T20:00:07.000Z')).toBe(rendered)
|
|
234
|
+
})
|
|
235
|
+
})
|
|
@@ -1,5 +1,29 @@
|
|
|
1
|
+
import { resolve } from 'node:path'
|
|
2
|
+
import vue from '@vitejs/plugin-vue'
|
|
1
3
|
import { defineConfig } from 'vitest/config'
|
|
2
4
|
|
|
5
|
+
// The renderer and the DMS core layer the server-render tests run on: the
|
|
6
|
+
// ones the repository installs at its root (`@antelopejs/dms-frontend`,
|
|
7
|
+
// `@antelopejs/dms`), as `pnpm test:frontend` builds against them.
|
|
8
|
+
const root = resolve(__dirname, '..', 'node_modules', '@antelopejs')
|
|
9
|
+
|
|
3
10
|
export default defineConfig({
|
|
11
|
+
plugins: [vue()],
|
|
12
|
+
resolve: {
|
|
13
|
+
alias: {
|
|
14
|
+
'#dms/frontend-module': resolve(
|
|
15
|
+
root,
|
|
16
|
+
'dms-frontend/templates/vue/frontend-module.ts',
|
|
17
|
+
),
|
|
18
|
+
'#dms-core': resolve(root, 'dms/frontend-vue/layers/dms-core'),
|
|
19
|
+
// The renderer's own imports, from this package's dependencies: one
|
|
20
|
+
// Vue for the renderer and the components.
|
|
21
|
+
vue: resolve(__dirname, 'node_modules/vue'),
|
|
22
|
+
'@unhead/vue': resolve(__dirname, 'node_modules/@unhead/vue'),
|
|
23
|
+
// What the renderer reads of Inertia and vue-i18n in the browser only.
|
|
24
|
+
'@inertiajs/vue3': resolve(__dirname, 'tests/fixtures/inertia.ts'),
|
|
25
|
+
'vue-i18n': resolve(__dirname, 'tests/fixtures/vueI18n.ts'),
|
|
26
|
+
},
|
|
27
|
+
},
|
|
4
28
|
test: { environment: 'node', include: ['tests/**/*.test.ts'] },
|
|
5
29
|
})
|