@quatrain/app 1.1.17 → 1.1.19
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/package.json +13 -14
- package/src/Bootloader.test.ts +173 -0
- package/src/CodeGenerator.test.ts +125 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@quatrain/app",
|
|
3
|
-
"version": "1.1.
|
|
3
|
+
"version": "1.1.19",
|
|
4
4
|
"license": "AGPL-3.0-only",
|
|
5
5
|
"description": "Quatrain App Bootloader and configuration helpers",
|
|
6
6
|
"main": "dist/index.js",
|
|
@@ -21,20 +21,19 @@
|
|
|
21
21
|
"author": "Quatrain Développement SAS <developers@quatrain.com>",
|
|
22
22
|
"dependencies": {
|
|
23
23
|
"@quatrain/api": "^1.1.5",
|
|
24
|
-
"@quatrain/api-client": "^1.1.
|
|
24
|
+
"@quatrain/api-client": "^1.1.5",
|
|
25
25
|
"@quatrain/api-server": "^1.1.9",
|
|
26
|
-
"@quatrain/api-server-express": "^1.1.
|
|
27
|
-
"@quatrain/auth": "^1.2.
|
|
28
|
-
"@quatrain/auth-basic": "^1.0.
|
|
29
|
-
"@quatrain/auth-oidc": "^1.1.
|
|
30
|
-
"@quatrain/backend": "^1.2.
|
|
31
|
-
"@quatrain/backend-migrations": "^1.1.
|
|
32
|
-
"@quatrain/core": "^1.2.
|
|
33
|
-
"@quatrain/log": "^1.2.
|
|
34
|
-
"@quatrain/messaging": "^1.1.
|
|
35
|
-
"@quatrain/queue": "^1.2.
|
|
36
|
-
"@quatrain/storage": "^1.2.
|
|
37
|
-
"@quatrain/ui-form-react": "^1.1.5",
|
|
26
|
+
"@quatrain/api-server-express": "^1.1.13",
|
|
27
|
+
"@quatrain/auth": "^1.2.3",
|
|
28
|
+
"@quatrain/auth-basic": "^1.0.3",
|
|
29
|
+
"@quatrain/auth-oidc": "^1.1.5",
|
|
30
|
+
"@quatrain/backend": "^1.2.12",
|
|
31
|
+
"@quatrain/backend-migrations": "^1.1.6",
|
|
32
|
+
"@quatrain/core": "^1.2.15",
|
|
33
|
+
"@quatrain/log": "^1.2.3",
|
|
34
|
+
"@quatrain/messaging": "^1.1.4",
|
|
35
|
+
"@quatrain/queue": "^1.2.3",
|
|
36
|
+
"@quatrain/storage": "^1.2.9",
|
|
38
37
|
"yaml": "^2.4.0"
|
|
39
38
|
},
|
|
40
39
|
"devDependencies": {
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
import fs from 'node:fs'
|
|
2
|
+
import path from 'node:path'
|
|
3
|
+
import { AppBootloader } from './Bootloader'
|
|
4
|
+
import { Backend } from '@quatrain/backend'
|
|
5
|
+
import { Auth } from '@quatrain/auth'
|
|
6
|
+
import { Queue } from '@quatrain/queue'
|
|
7
|
+
import { Storage } from '@quatrain/storage'
|
|
8
|
+
import { Messaging } from '@quatrain/messaging'
|
|
9
|
+
import { Log } from '@quatrain/log'
|
|
10
|
+
|
|
11
|
+
// Mock dynamic require targets as virtual modules
|
|
12
|
+
jest.mock('mock-backend', () => ({
|
|
13
|
+
MockBackend: jest.fn().mockImplementation(() => ({ name: 'mock-backend' }))
|
|
14
|
+
}), { virtual: true })
|
|
15
|
+
|
|
16
|
+
jest.mock('mock-auth', () => ({
|
|
17
|
+
MockAuth: jest.fn().mockImplementation(() => ({ name: 'mock-auth' }))
|
|
18
|
+
}), { virtual: true })
|
|
19
|
+
|
|
20
|
+
jest.mock('mock-queue', () => ({
|
|
21
|
+
MockQueue: jest.fn().mockImplementation(() => ({ name: 'mock-queue' }))
|
|
22
|
+
}), { virtual: true })
|
|
23
|
+
|
|
24
|
+
jest.mock('mock-storage', () => ({
|
|
25
|
+
MockStorage: jest.fn().mockImplementation(() => ({ name: 'mock-storage' }))
|
|
26
|
+
}), { virtual: true })
|
|
27
|
+
|
|
28
|
+
jest.mock('mock-messaging', () => ({
|
|
29
|
+
MockMessaging: jest.fn().mockImplementation(() => ({ name: 'mock-messaging' }))
|
|
30
|
+
}), { virtual: true })
|
|
31
|
+
|
|
32
|
+
describe('AppBootloader', () => {
|
|
33
|
+
describe('resolveEnv', () => {
|
|
34
|
+
beforeEach(() => {
|
|
35
|
+
process.env.TEST_VAR = 'value1'
|
|
36
|
+
process.env.ANOTHER_VAR = 'value2'
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
afterEach(() => {
|
|
40
|
+
delete process.env.TEST_VAR
|
|
41
|
+
delete process.env.ANOTHER_VAR
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
it('should parse env(VAR) strings to environment variables values', () => {
|
|
45
|
+
const resolved = (AppBootloader as any).resolveEnv('env(TEST_VAR)')
|
|
46
|
+
expect(resolved).toBe('value1')
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
it('should parse raw strings as is', () => {
|
|
50
|
+
const resolved = (AppBootloader as any).resolveEnv('just-a-string')
|
|
51
|
+
expect(resolved).toBe('just-a-string')
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
it('should resolve env markers nested inside arrays', () => {
|
|
55
|
+
const input = ['just-a-string', 'env(TEST_VAR)', 'env(ANOTHER_VAR)']
|
|
56
|
+
const resolved = (AppBootloader as any).resolveEnv(input)
|
|
57
|
+
expect(resolved).toEqual(['just-a-string', 'value1', 'value2'])
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
it('should resolve env markers nested inside objects', () => {
|
|
61
|
+
const input = {
|
|
62
|
+
plain: 'string',
|
|
63
|
+
secret: 'env(TEST_VAR)',
|
|
64
|
+
nested: {
|
|
65
|
+
key: 'env(ANOTHER_VAR)'
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
const resolved = (AppBootloader as any).resolveEnv(input)
|
|
69
|
+
expect(resolved).toEqual({
|
|
70
|
+
plain: 'string',
|
|
71
|
+
secret: 'value1',
|
|
72
|
+
nested: {
|
|
73
|
+
key: 'value2'
|
|
74
|
+
}
|
|
75
|
+
})
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
it('should return default empty strings for undefined environment variables', () => {
|
|
79
|
+
const resolved = (AppBootloader as any).resolveEnv('env(UNDEFINED_VAR)')
|
|
80
|
+
expect(resolved).toBe('')
|
|
81
|
+
})
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
describe('bootstrap', () => {
|
|
85
|
+
let existsSpy: jest.SpyInstance
|
|
86
|
+
let readSpy: jest.SpyInstance
|
|
87
|
+
|
|
88
|
+
beforeEach(() => {
|
|
89
|
+
existsSpy = jest.spyOn(fs, 'existsSync').mockReturnValue(true)
|
|
90
|
+
readSpy = jest.spyOn(fs, 'readFileSync').mockReturnValue('{}')
|
|
91
|
+
|
|
92
|
+
// Spy on manager classes to prevent real registrations polluting other test runs
|
|
93
|
+
jest.spyOn(Backend, 'addBackend').mockImplementation(() => {})
|
|
94
|
+
jest.spyOn(Auth, 'addProvider').mockImplementation(() => {})
|
|
95
|
+
jest.spyOn(Queue, 'addQueue').mockImplementation(() => {})
|
|
96
|
+
jest.spyOn(Storage, 'addStorage').mockImplementation(() => {})
|
|
97
|
+
jest.spyOn(Messaging, 'addMessager').mockImplementation(() => {})
|
|
98
|
+
jest.spyOn(Log, 'info').mockImplementation(() => {})
|
|
99
|
+
jest.spyOn(Log, 'error').mockImplementation(() => {})
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
afterEach(() => {
|
|
103
|
+
jest.restoreAllMocks()
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
it('should throw an error if configuration file is not found', async () => {
|
|
107
|
+
existsSpy.mockReturnValue(false)
|
|
108
|
+
await expect(AppBootloader.bootstrap('missing.json')).rejects.toThrow(
|
|
109
|
+
'[Bootloader] Configuration file not found'
|
|
110
|
+
)
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
it('should bootstrap active configured adapters from configuration JSON', async () => {
|
|
114
|
+
const config = {
|
|
115
|
+
logLevel: 'debug',
|
|
116
|
+
backend: {
|
|
117
|
+
package: 'mock-backend',
|
|
118
|
+
adapter: 'MockBackend',
|
|
119
|
+
config: { db: 'test' }
|
|
120
|
+
},
|
|
121
|
+
auth: {
|
|
122
|
+
package: 'mock-auth',
|
|
123
|
+
adapter: 'MockAuth',
|
|
124
|
+
config: {}
|
|
125
|
+
},
|
|
126
|
+
queue: {
|
|
127
|
+
package: 'mock-queue',
|
|
128
|
+
adapter: 'MockQueue',
|
|
129
|
+
config: {}
|
|
130
|
+
},
|
|
131
|
+
storage: {
|
|
132
|
+
package: 'mock-storage',
|
|
133
|
+
adapter: 'MockStorage',
|
|
134
|
+
config: {}
|
|
135
|
+
},
|
|
136
|
+
messaging: {
|
|
137
|
+
package: 'mock-messaging',
|
|
138
|
+
adapter: 'MockMessaging',
|
|
139
|
+
config: {}
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
readSpy.mockReturnValue(JSON.stringify(config))
|
|
144
|
+
|
|
145
|
+
await AppBootloader.bootstrap('quatrain-test.json')
|
|
146
|
+
|
|
147
|
+
expect(Log.info).toHaveBeenCalledWith(expect.stringContaining('[Bootloader] Initializing application'))
|
|
148
|
+
expect(Backend.addBackend).toHaveBeenCalled()
|
|
149
|
+
expect(Auth.addProvider).toHaveBeenCalled()
|
|
150
|
+
expect(Queue.addQueue).toHaveBeenCalled()
|
|
151
|
+
expect(Storage.addStorage).toHaveBeenCalled()
|
|
152
|
+
expect(Messaging.addMessager).toHaveBeenCalled()
|
|
153
|
+
})
|
|
154
|
+
|
|
155
|
+
it('should catch and log loading errors gracefully on package load failures', async () => {
|
|
156
|
+
const config = {
|
|
157
|
+
backend: {
|
|
158
|
+
package: 'invalid-backend-package',
|
|
159
|
+
adapter: 'InvalidBackend',
|
|
160
|
+
config: {}
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
readSpy.mockReturnValue(JSON.stringify(config))
|
|
165
|
+
|
|
166
|
+
await AppBootloader.bootstrap('quatrain-test.json')
|
|
167
|
+
|
|
168
|
+
expect(Log.error).toHaveBeenCalledWith(
|
|
169
|
+
expect.stringContaining('[Bootloader] Failed to load backend')
|
|
170
|
+
)
|
|
171
|
+
})
|
|
172
|
+
})
|
|
173
|
+
})
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import * as fs from 'node:fs'
|
|
2
|
+
import * as path from 'node:path'
|
|
3
|
+
import { CodeGenerator } from './CodeGenerator'
|
|
4
|
+
import { Log } from '@quatrain/log'
|
|
5
|
+
|
|
6
|
+
describe('CodeGenerator', () => {
|
|
7
|
+
const targetTestDir = path.resolve(__dirname, 'temp_codegen_test')
|
|
8
|
+
|
|
9
|
+
beforeEach(() => {
|
|
10
|
+
jest.spyOn(Log, 'info').mockImplementation(() => {})
|
|
11
|
+
if (fs.existsSync(targetTestDir)) {
|
|
12
|
+
fs.rmSync(targetTestDir, { recursive: true, force: true })
|
|
13
|
+
}
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
afterEach(() => {
|
|
17
|
+
if (fs.existsSync(targetTestDir)) {
|
|
18
|
+
fs.rmSync(targetTestDir, { recursive: true, force: true })
|
|
19
|
+
}
|
|
20
|
+
jest.restoreAllMocks()
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
it('should generate a complete, valid application with isolated directories', () => {
|
|
24
|
+
const mockConfig = {
|
|
25
|
+
name: 'TestApp',
|
|
26
|
+
authMode: 'basic',
|
|
27
|
+
backend: {
|
|
28
|
+
adapter: 'SQLiteAdapter'
|
|
29
|
+
},
|
|
30
|
+
models: [
|
|
31
|
+
{
|
|
32
|
+
name: 'Book',
|
|
33
|
+
collectionName: 'books',
|
|
34
|
+
properties: [
|
|
35
|
+
{ name: 'title', type: 'StringProperty', mandatory: true, htmlType: 'text' },
|
|
36
|
+
{ name: 'price', type: 'NumberProperty', mandatory: false }
|
|
37
|
+
]
|
|
38
|
+
}
|
|
39
|
+
],
|
|
40
|
+
widgets: [
|
|
41
|
+
{
|
|
42
|
+
modelName: 'Book',
|
|
43
|
+
widgetType: 'form',
|
|
44
|
+
layout: [
|
|
45
|
+
{ type: 'field', name: 'title' },
|
|
46
|
+
{ type: 'group', fields: ['price'] }
|
|
47
|
+
]
|
|
48
|
+
}
|
|
49
|
+
]
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
CodeGenerator.generate(mockConfig, targetTestDir)
|
|
53
|
+
|
|
54
|
+
// Assert basic configuration files exist
|
|
55
|
+
expect(fs.existsSync(path.join(targetTestDir, 'package.json'))).toBe(true)
|
|
56
|
+
expect(fs.existsSync(path.join(targetTestDir, 'tsconfig.json'))).toBe(true)
|
|
57
|
+
|
|
58
|
+
// Assert server-side model and API routes exist
|
|
59
|
+
expect(fs.existsSync(path.join(targetTestDir, 'src/models/Book.ts'))).toBe(true)
|
|
60
|
+
expect(fs.existsSync(path.join(targetTestDir, 'src/api/BookApi.ts'))).toBe(true)
|
|
61
|
+
expect(fs.existsSync(path.join(targetTestDir, 'src/index.ts'))).toBe(true)
|
|
62
|
+
|
|
63
|
+
// Assert database migrations generated
|
|
64
|
+
const migrationsDir = path.join(targetTestDir, 'data/migrations/default')
|
|
65
|
+
expect(fs.existsSync(migrationsDir)).toBe(true)
|
|
66
|
+
const migrationFiles = fs.readdirSync(migrationsDir)
|
|
67
|
+
expect(migrationFiles.length).toBe(1)
|
|
68
|
+
expect(migrationFiles[0]).toMatch(/_init\.ts$/)
|
|
69
|
+
|
|
70
|
+
// Assert frontend codebases scaffolded correctly
|
|
71
|
+
const webDir = path.join(targetTestDir, 'web')
|
|
72
|
+
expect(fs.existsSync(path.join(webDir, 'package.json'))).toBe(true)
|
|
73
|
+
expect(fs.existsSync(path.join(webDir, 'tsconfig.json'))).toBe(true)
|
|
74
|
+
expect(fs.existsSync(path.join(webDir, 'vite.config.ts'))).toBe(true)
|
|
75
|
+
expect(fs.existsSync(path.join(webDir, 'index.html'))).toBe(true)
|
|
76
|
+
expect(fs.existsSync(path.join(webDir, 'src/main.tsx'))).toBe(true)
|
|
77
|
+
expect(fs.existsSync(path.join(webDir, 'src/api.ts'))).toBe(true)
|
|
78
|
+
expect(fs.existsSync(path.join(webDir, 'src/App.tsx'))).toBe(true)
|
|
79
|
+
|
|
80
|
+
// Assert frontend React CRUD pages compiled
|
|
81
|
+
const pagesDir = path.join(webDir, 'src/pages')
|
|
82
|
+
expect(fs.existsSync(path.join(pagesDir, 'BookList.tsx'))).toBe(true)
|
|
83
|
+
expect(fs.existsSync(path.join(pagesDir, 'BookForm.tsx'))).toBe(true)
|
|
84
|
+
|
|
85
|
+
// Assert Docker integration exists
|
|
86
|
+
expect(fs.existsSync(path.join(webDir, 'Dockerfile'))).toBe(true)
|
|
87
|
+
expect(fs.existsSync(path.join(webDir, 'nginx.conf'))).toBe(true)
|
|
88
|
+
|
|
89
|
+
// Let's verify layout code inside BookForm.tsx
|
|
90
|
+
const formCode = fs.readFileSync(path.join(pagesDir, 'BookForm.tsx'), 'utf8')
|
|
91
|
+
expect(formCode).toContain('TextInput label="title"')
|
|
92
|
+
expect(formCode).toContain('TextInput label="price"')
|
|
93
|
+
expect(formCode).toContain('<Group grow align="flex-start">')
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
it('should generate fallback CoreForm components when layout is missing', () => {
|
|
97
|
+
const mockConfig = {
|
|
98
|
+
name: 'FallbackApp',
|
|
99
|
+
authMode: 'oauth',
|
|
100
|
+
backend: {
|
|
101
|
+
adapter: 'PostgresAdapter'
|
|
102
|
+
},
|
|
103
|
+
models: [
|
|
104
|
+
{
|
|
105
|
+
name: 'Customer',
|
|
106
|
+
collectionName: 'customers',
|
|
107
|
+
properties: [
|
|
108
|
+
{ name: 'fullName', type: 'string', mandatory: true }
|
|
109
|
+
]
|
|
110
|
+
}
|
|
111
|
+
],
|
|
112
|
+
widgets: [] // Empty widgets list should trigger dynamic fallback
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
CodeGenerator.generate(mockConfig, targetTestDir)
|
|
116
|
+
|
|
117
|
+
const formCode = fs.readFileSync(path.join(targetTestDir, 'web/src/pages/CustomerForm.tsx'), 'utf8')
|
|
118
|
+
expect(formCode).toContain('CoreForm')
|
|
119
|
+
expect(formCode).toContain('CustomerForm')
|
|
120
|
+
|
|
121
|
+
const packageCode = fs.readFileSync(path.join(targetTestDir, 'package.json'), 'utf8')
|
|
122
|
+
expect(packageCode).toContain('@quatrain/auth-oidc')
|
|
123
|
+
expect(packageCode).toContain('@quatrain/backend-postgres')
|
|
124
|
+
})
|
|
125
|
+
})
|