odac 1.4.14 → 1.4.16

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.
Files changed (41) hide show
  1. package/CHANGELOG.md +30 -0
  2. package/bin/odac.js +1 -0
  3. package/client/odac.js +57 -1
  4. package/docs/ai/skills/backend/forms.md +54 -3
  5. package/docs/ai/skills/backend/validation.md +30 -2
  6. package/docs/ai/skills/frontend/core.md +5 -5
  7. package/docs/backend/05-controllers/02-your-trusty-odac-assistant.md +1 -1
  8. package/docs/backend/05-forms/01-custom-forms.md +96 -0
  9. package/docs/backend/09-validation/01-the-validator-service.md +40 -0
  10. package/docs/frontend/01-overview/01-introduction.md +8 -8
  11. package/docs/frontend/02-ajax-navigation/01-quick-start.md +14 -14
  12. package/docs/frontend/02-ajax-navigation/02-configuration.md +4 -4
  13. package/docs/frontend/02-ajax-navigation/03-advanced-usage.md +16 -16
  14. package/docs/frontend/03-forms/01-form-handling.md +84 -31
  15. package/docs/frontend/04-api-requests/01-get-post.md +25 -25
  16. package/docs/frontend/05-streaming/01-client-streaming.md +14 -14
  17. package/docs/frontend/06-websocket/00-overview.md +1 -1
  18. package/index.js +11 -1
  19. package/package.json +2 -1
  20. package/src/Auth.js +252 -24
  21. package/src/Config.js +5 -1
  22. package/src/Odac.js +3 -0
  23. package/src/Request.js +235 -34
  24. package/src/Route/Internal.js +32 -3
  25. package/src/Validator.js +143 -2
  26. package/src/View/Form.js +56 -0
  27. package/src/View/Image.js +15 -6
  28. package/src/View.js +2 -2
  29. package/test/Auth/check.test.js +219 -18
  30. package/test/Auth/verifyMagicLink.test.js +8 -1
  31. package/test/Odac/cache.test.js +5 -2
  32. package/test/Odac/image.test.js +8 -5
  33. package/test/Odac/instance.test.js +5 -2
  34. package/test/Request/cache.test.js +1 -0
  35. package/test/Request/multipart.test.js +164 -0
  36. package/test/Validator/file.test.js +172 -0
  37. package/test/View/Form/generateFieldHtml.test.js +56 -0
  38. package/test/View/Image/serve.test.js +9 -4
  39. package/test/View/Image/url.test.js +10 -4
  40. package/test/View/addNavigateAttribute.test.js +10 -1
  41. package/test/View/print.test.js +10 -1
@@ -0,0 +1,164 @@
1
+ const fs = require('fs')
2
+ const os = require('os')
3
+ const path = require('path')
4
+ const {PassThrough} = require('stream')
5
+ const OdacRequest = require('../../src/Request')
6
+
7
+ const BOUNDARY = '----odacmultiparttest'
8
+
9
+ // Build a raw multipart/form-data body as a Buffer so binary parts (with \r\n
10
+ // and null bytes inside) survive intact — the regression case the old
11
+ // string-splitting parser corrupted.
12
+ function buildBody(parts) {
13
+ const chunks = []
14
+ for (const p of parts) {
15
+ chunks.push(Buffer.from(`--${BOUNDARY}\r\n`))
16
+ if (p.filename !== undefined) {
17
+ chunks.push(Buffer.from(`Content-Disposition: form-data; name="${p.name}"; filename="${p.filename}"\r\n`))
18
+ chunks.push(Buffer.from(`Content-Type: ${p.contentType || 'application/octet-stream'}\r\n\r\n`))
19
+ chunks.push(p.data)
20
+ chunks.push(Buffer.from('\r\n'))
21
+ } else {
22
+ chunks.push(Buffer.from(`Content-Disposition: form-data; name="${p.name}"\r\n\r\n`))
23
+ chunks.push(Buffer.from(`${p.value}\r\n`))
24
+ }
25
+ }
26
+ chunks.push(Buffer.from(`--${BOUNDARY}--\r\n`))
27
+ return Buffer.concat(chunks)
28
+ }
29
+
30
+ function makeReq() {
31
+ const req = new PassThrough()
32
+ req.method = 'POST'
33
+ req.url = '/upload'
34
+ req.headers = {
35
+ host: 'www.example.com',
36
+ 'content-type': `multipart/form-data; boundary=${BOUNDARY}`
37
+ }
38
+ req.connection = {remoteAddress: '127.0.0.1', destroy: jest.fn()}
39
+ return req
40
+ }
41
+
42
+ function makeRes() {
43
+ return {writeHead: jest.fn(), end: jest.fn(), finished: false, on: jest.fn()}
44
+ }
45
+
46
+ // Temp-file cleanup in Request.js uses fire-and-forget fs.unlink(), which
47
+ // completes via libuv's thread pool. A single event-loop tick isn't a
48
+ // reliable signal that it finished, especially under CI I/O contention, so
49
+ // poll for the expected end-state instead of assuming a fixed number of ticks.
50
+ async function waitUntil(conditionFn, {timeoutMs = 2000, intervalMs = 10} = {}) {
51
+ const start = Date.now()
52
+ while (!conditionFn()) {
53
+ if (Date.now() - start > timeoutMs) return false
54
+ await new Promise(resolve => setTimeout(resolve, intervalMs))
55
+ }
56
+ return true
57
+ }
58
+
59
+ describe('Request multipart parsing', () => {
60
+ let tmpDir, request
61
+
62
+ beforeEach(() => {
63
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'odac-mp-'))
64
+ global.Odac = {
65
+ Config: {request: {timeout: 5000, maxBodySize: 1e6, maxFileSize: 5 * 1024 * 1024, maxFiles: 10, uploadDir: tmpDir}},
66
+ Route: {routes: {www: {}}},
67
+ Request: {}
68
+ }
69
+ global.__dir = tmpDir
70
+ })
71
+
72
+ afterEach(() => {
73
+ if (request) request.clearTimeout()
74
+ request = null
75
+ delete global.Odac
76
+ delete global.__dir
77
+ fs.rmSync(tmpDir, {recursive: true, force: true})
78
+ })
79
+
80
+ function run(body) {
81
+ const req = makeReq()
82
+ const res = makeRes()
83
+ request = new OdacRequest('id', req, res, {setTimeout: (fn, ms) => setTimeout(fn, ms)})
84
+ req.end(body)
85
+ return request
86
+ }
87
+
88
+ it('parses text fields into data.post', async () => {
89
+ const r = run(buildBody([{name: 'title', value: 'hello world'}]))
90
+ const val = await r.request('title')
91
+ expect(val).toBe('hello world')
92
+ })
93
+
94
+ it('round-trips binary file content byte-for-byte', async () => {
95
+ // Contains a PNG header, an embedded CRLF, and null bytes — exactly what the
96
+ // old toString()-based parser mangled.
97
+ const binary = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0xff, 0x0d, 0x0a, 0x42])
98
+ const r = run(buildBody([{name: 'avatar', filename: 'photo.png', contentType: 'image/png', data: binary}]))
99
+
100
+ const file = await r.file('avatar')
101
+ expect(file).toBeTruthy()
102
+ expect(file.name).toBe('photo.png')
103
+ expect(file.ext).toBe('png')
104
+ expect(file.mimetype).toBe('image/png')
105
+ expect(file.size).toBe(binary.length)
106
+ expect(file.truncated).toBe(false)
107
+
108
+ const written = fs.readFileSync(file.path)
109
+ expect(Buffer.compare(written, binary)).toBe(0)
110
+ })
111
+
112
+ it('mixes text fields and a file in one request', async () => {
113
+ const r = run(
114
+ buildBody([
115
+ {name: 'name', value: 'Ada'},
116
+ {name: 'doc', filename: 'a.txt', contentType: 'text/plain', data: Buffer.from('content')}
117
+ ])
118
+ )
119
+ expect(await r.request('name')).toBe('Ada')
120
+ const file = await r.file('doc')
121
+ expect(file.name).toBe('a.txt')
122
+ })
123
+
124
+ it('returns null for an untouched (empty filename) file input', async () => {
125
+ const r = run(buildBody([{name: 'avatar', filename: '', contentType: 'application/octet-stream', data: Buffer.alloc(0)}]))
126
+ expect(await r.file('avatar')).toBeNull()
127
+ })
128
+
129
+ it('returns an array when the same field has multiple files', async () => {
130
+ const r = run(
131
+ buildBody([
132
+ {name: 'docs', filename: '1.txt', contentType: 'text/plain', data: Buffer.from('one')},
133
+ {name: 'docs', filename: '2.txt', contentType: 'text/plain', data: Buffer.from('two')}
134
+ ])
135
+ )
136
+ const files = await r.file('docs')
137
+ expect(Array.isArray(files)).toBe(true)
138
+ expect(files).toHaveLength(2)
139
+ expect(files.map(f => f.name).sort()).toEqual(['1.txt', '2.txt'])
140
+ })
141
+
142
+ it('flags an oversize file as truncated with no leftover temp file', async () => {
143
+ global.Odac.Config.request.maxFileSize = 10 // bytes
144
+ const big = Buffer.alloc(1000, 0x41)
145
+ const r = run(buildBody([{name: 'avatar', filename: 'big.bin', contentType: 'application/octet-stream', data: big}]))
146
+
147
+ const file = await r.file('avatar')
148
+ expect(file.truncated).toBe(true)
149
+ expect(file.path).toBeNull()
150
+ // No orphaned odac-* temp file remains in the upload dir.
151
+ const noLeftovers = await waitUntil(() => fs.readdirSync(tmpDir).filter(n => n.startsWith('odac-')).length === 0)
152
+ expect(noLeftovers).toBe(true)
153
+ })
154
+
155
+ it('cleans up unstored temp files after the response ends', async () => {
156
+ const r = run(buildBody([{name: 'avatar', filename: 'x.bin', contentType: 'application/octet-stream', data: Buffer.from('abc')}]))
157
+ const file = await r.file('avatar')
158
+ expect(fs.existsSync(file.path)).toBe(true)
159
+
160
+ r.end('ok')
161
+ const removed = await waitUntil(() => !fs.existsSync(file.path))
162
+ expect(removed).toBe(true)
163
+ })
164
+ })
@@ -0,0 +1,172 @@
1
+ const fs = require('fs')
2
+ const os = require('os')
3
+ const path = require('path')
4
+ const Validator = require('../../src/Validator')
5
+
6
+ // Minimal Odac stub — file rules never touch Var()/Auth(), but other rules do,
7
+ // so provide a small shim to keep the constructor happy.
8
+ const odacStub = {
9
+ Var: () => ({is: () => false})
10
+ }
11
+
12
+ function mockRequest(filesByField) {
13
+ return {
14
+ file: async name => {
15
+ const arr = filesByField[name]
16
+ if (!arr) return null
17
+ return arr.length === 1 ? arr[0] : arr
18
+ }
19
+ }
20
+ }
21
+
22
+ function fileObj(overrides = {}) {
23
+ return {
24
+ field: 'upload',
25
+ name: 'file.bin',
26
+ ext: 'bin',
27
+ mimetype: 'application/octet-stream',
28
+ size: 1000,
29
+ path: null,
30
+ truncated: false,
31
+ stored: false,
32
+ ...overrides
33
+ }
34
+ }
35
+
36
+ describe('Validator file rules', () => {
37
+ let tmpDir
38
+
39
+ beforeAll(() => {
40
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'odac-file-test-'))
41
+ })
42
+
43
+ afterAll(() => {
44
+ fs.rmSync(tmpDir, {recursive: true, force: true})
45
+ })
46
+
47
+ function writeTmp(name, buf) {
48
+ const p = path.join(tmpDir, name)
49
+ fs.writeFileSync(p, buf)
50
+ return p
51
+ }
52
+
53
+ describe('required', () => {
54
+ it('fails when no file is present', async () => {
55
+ const v = new Validator(mockRequest({}), odacStub)
56
+ v.file('avatar').check('required').message('Avatar required')
57
+ expect(await v.error()).toBe(true)
58
+ })
59
+
60
+ it('passes when a file is present', async () => {
61
+ const v = new Validator(mockRequest({avatar: [fileObj()]}), odacStub)
62
+ v.file('avatar').check('required').message('Avatar required')
63
+ expect(await v.error()).toBe(false)
64
+ })
65
+ })
66
+
67
+ describe('maxsize / minsize', () => {
68
+ it('rejects a file above maxsize', async () => {
69
+ const v = new Validator(mockRequest({doc: [fileObj({size: 3 * 1024 * 1024})]}), odacStub)
70
+ v.file('doc').check('maxsize:2MB').message('Too large')
71
+ expect(await v.error()).toBe(true)
72
+ })
73
+
74
+ it('accepts a file at/below maxsize', async () => {
75
+ const v = new Validator(mockRequest({doc: [fileObj({size: 1024 * 1024})]}), odacStub)
76
+ v.file('doc').check('maxsize:2MB').message('Too large')
77
+ expect(await v.error()).toBe(false)
78
+ })
79
+
80
+ it('rejects a file below minsize', async () => {
81
+ const v = new Validator(mockRequest({doc: [fileObj({size: 500})]}), odacStub)
82
+ v.file('doc').check('minsize:1KB').message('Too small')
83
+ expect(await v.error()).toBe(true)
84
+ })
85
+ })
86
+
87
+ describe('ext', () => {
88
+ it('rejects a disallowed extension', async () => {
89
+ const v = new Validator(mockRequest({img: [fileObj({ext: 'gif'})]}), odacStub)
90
+ v.file('img').check('ext:jpg,png').message('Bad ext')
91
+ expect(await v.error()).toBe(true)
92
+ })
93
+
94
+ it('accepts an allowed extension', async () => {
95
+ const v = new Validator(mockRequest({img: [fileObj({ext: 'png'})]}), odacStub)
96
+ v.file('img').check('ext:jpg,png').message('Bad ext')
97
+ expect(await v.error()).toBe(false)
98
+ })
99
+ })
100
+
101
+ describe('maxfiles', () => {
102
+ it('rejects too many files', async () => {
103
+ const files = [fileObj(), fileObj(), fileObj()]
104
+ const v = new Validator(mockRequest({docs: files}), odacStub)
105
+ v.file('docs').check('maxfiles:2').message('Too many')
106
+ expect(await v.error()).toBe(true)
107
+ })
108
+
109
+ it('accepts within the limit', async () => {
110
+ const files = [fileObj(), fileObj()]
111
+ const v = new Validator(mockRequest({docs: files}), odacStub)
112
+ v.file('docs').check('maxfiles:2').message('Too many')
113
+ expect(await v.error()).toBe(false)
114
+ })
115
+ })
116
+
117
+ describe('mimetype', () => {
118
+ const PNG = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0, 0, 0, 0])
119
+
120
+ it('accepts a genuine PNG matching the whitelist', async () => {
121
+ const p = writeTmp('real.png', PNG)
122
+ const file = fileObj({ext: 'png', mimetype: 'image/png', path: p})
123
+ const v = new Validator(mockRequest({avatar: [file]}), odacStub)
124
+ v.file('avatar').check('mimetype:image/png,image/jpeg').message('Bad type')
125
+ expect(await v.error()).toBe(false)
126
+ })
127
+
128
+ it('rejects a spoofed .png whose bytes are not PNG', async () => {
129
+ const p = writeTmp('fake.png', Buffer.from('<script>alert(1)</script>'))
130
+ const file = fileObj({ext: 'png', mimetype: 'image/png', path: p})
131
+ const v = new Validator(mockRequest({avatar: [file]}), odacStub)
132
+ v.file('avatar').check('mimetype:image/png,image/jpeg').message('Bad type')
133
+ expect(await v.error()).toBe(true)
134
+ })
135
+
136
+ it('rejects a type outside the whitelist', async () => {
137
+ const file = fileObj({ext: 'pdf', mimetype: 'application/pdf'})
138
+ const v = new Validator(mockRequest({avatar: [file]}), odacStub)
139
+ v.file('avatar').check('mimetype:image/png,image/jpeg').message('Bad type')
140
+ expect(await v.error()).toBe(true)
141
+ })
142
+
143
+ it('supports image/* wildcard', async () => {
144
+ const p = writeTmp('w.png', PNG)
145
+ const file = fileObj({ext: 'png', mimetype: 'image/png', path: p})
146
+ const v = new Validator(mockRequest({avatar: [file]}), odacStub)
147
+ v.file('avatar').check('accept:image/*').message('Bad type')
148
+ expect(await v.error()).toBe(false)
149
+ })
150
+ })
151
+
152
+ describe('truncated files', () => {
153
+ it('always fails, even with only a size rule that it would pass', async () => {
154
+ const file = fileObj({truncated: true, path: null, size: 0})
155
+ const v = new Validator(mockRequest({big: [file]}), odacStub)
156
+ v.file('big').check('maxsize:2MB').message('File too large')
157
+ expect(await v.error()).toBe(true)
158
+ })
159
+ })
160
+
161
+ describe('parameterized-rule split regression', () => {
162
+ it('parses maxsize suffix correctly via split(/:(.+)/)', async () => {
163
+ const under = new Validator(mockRequest({d: [fileObj({size: 1024})]}), odacStub)
164
+ under.file('d').check('maxsize:2KB').message('x')
165
+ expect(await under.error()).toBe(false)
166
+
167
+ const over = new Validator(mockRequest({d: [fileObj({size: 4096})]}), odacStub)
168
+ over.file('d').check('maxsize:2KB').message('x')
169
+ expect(await over.error()).toBe(true)
170
+ })
171
+ })
172
+ })
@@ -34,4 +34,60 @@ describe('Form.generateFieldHtml()', () => {
34
34
  expect(html).toContain('value="a&amp;b&quot;c&lt;d&gt;e&#39;f"')
35
35
  expect(html).not.toContain('value="a&b"c<d>e\'f"')
36
36
  })
37
+
38
+ test('should render a file input without value/placeholder attributes', () => {
39
+ const html = Form.generateFieldHtml({
40
+ name: 'avatar',
41
+ type: 'file',
42
+ placeholder: 'ignored',
43
+ label: null,
44
+ class: '',
45
+ id: null,
46
+ value: null,
47
+ validations: [],
48
+ extraAttributes: {}
49
+ })
50
+
51
+ expect(html).toContain('type="file"')
52
+ expect(html).toContain('name="avatar"')
53
+ expect(html).not.toContain('value=')
54
+ expect(html).not.toContain('placeholder=')
55
+ })
56
+
57
+ test('should map file validation rules to accept/multiple/data-* attributes', () => {
58
+ const html = Form.generateFieldHtml({
59
+ name: 'photos',
60
+ type: 'file',
61
+ placeholder: '',
62
+ label: null,
63
+ class: '',
64
+ id: null,
65
+ value: null,
66
+ validations: [{rule: 'required|maxsize:2MB|mimetype:image/png,image/jpeg|maxfiles:3', message: 'Bad file'}],
67
+ extraAttributes: {}
68
+ })
69
+
70
+ expect(html).toContain('required')
71
+ expect(html).toContain('accept="image/png,image/jpeg"')
72
+ expect(html).toContain(`data-maxsize="${2 * 1024 * 1024}"`)
73
+ expect(html).toContain('multiple')
74
+ expect(html).toContain('data-maxfiles="3"')
75
+ expect(html).toContain('data-error-maxsize="Bad file"')
76
+ })
77
+
78
+ test('should convert ext rule to a dot-prefixed accept list', () => {
79
+ const html = Form.generateFieldHtml({
80
+ name: 'doc',
81
+ type: 'file',
82
+ placeholder: '',
83
+ label: null,
84
+ class: '',
85
+ id: null,
86
+ value: null,
87
+ validations: [{rule: 'ext:pdf,docx', message: null}],
88
+ extraAttributes: {}
89
+ })
90
+
91
+ expect(html).toContain('accept=".pdf,.docx"')
92
+ })
37
93
  })
@@ -1,21 +1,26 @@
1
1
  const fs = require('fs')
2
2
  const fsPromises = fs.promises
3
+ const os = require('os')
3
4
  const path = require('path')
4
5
  const Image = require('../../../src/View/Image')
5
6
 
6
- const IMG_CACHE_DIR = './storage/.cache/img'
7
-
8
7
  describe('Image.serve()', () => {
9
8
  const testFilename = 'testserve1234567.webp'
10
- const testFilePath = path.join(IMG_CACHE_DIR, testFilename)
9
+ let tmpDir, IMG_CACHE_DIR, testFilePath
11
10
 
12
11
  beforeAll(async () => {
12
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'odac-image-serve-'))
13
+ global.__dir = tmpDir
14
+ IMG_CACHE_DIR = path.join(tmpDir, 'storage/.cache/img')
15
+ testFilePath = path.join(IMG_CACHE_DIR, testFilename)
16
+
13
17
  await fsPromises.mkdir(IMG_CACHE_DIR, {recursive: true})
14
18
  await fsPromises.writeFile(testFilePath, Buffer.from('fake-image-data'))
15
19
  })
16
20
 
17
21
  afterAll(async () => {
18
- await fsPromises.unlink(testFilePath).catch(() => {})
22
+ delete global.__dir
23
+ await fsPromises.rm(tmpDir, {recursive: true, force: true})
19
24
  })
20
25
 
21
26
  test('should return stream, type, and size for a cached file', async () => {
@@ -1,20 +1,26 @@
1
1
  const fs = require('fs')
2
2
  const fsPromises = fs.promises
3
+ const os = require('os')
3
4
  const path = require('path')
4
5
  const Image = require('../../../src/View/Image')
5
6
 
6
7
  describe('Image.url()', () => {
7
- const publicDir = path.join(process.cwd(), 'public')
8
- const testImageDir = path.join(publicDir, 'images')
9
- const testImagePath = path.join(testImageDir, 'url-test.jpg')
8
+ let tmpDir, testImagePath
10
9
 
11
10
  beforeAll(async () => {
11
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'odac-image-url-'))
12
+ global.__dir = tmpDir
13
+
14
+ const testImageDir = path.join(tmpDir, 'public', 'images')
15
+ testImagePath = path.join(testImageDir, 'url-test.jpg')
16
+
12
17
  await fsPromises.mkdir(testImageDir, {recursive: true})
13
18
  await fsPromises.writeFile(testImagePath, Buffer.from('fake-jpg-data'))
14
19
  })
15
20
 
16
21
  afterAll(async () => {
17
- await fsPromises.unlink(testImagePath).catch(() => {})
22
+ delete global.__dir
23
+ await fsPromises.rm(tmpDir, {recursive: true, force: true})
18
24
  })
19
25
 
20
26
  test('should return empty string for empty src', async () => {
@@ -1,13 +1,20 @@
1
1
  const fs = require('fs').promises
2
+ const fsSync = require('fs')
3
+ const os = require('os')
2
4
  const path = require('path')
3
5
  const View = require('../../src/View')
4
6
 
5
7
  describe('View.#addNavigateAttribute()', () => {
6
8
  let view
7
9
  let mockOdac
10
+ let tmpDir
11
+ let originalCwd
8
12
 
9
13
  beforeEach(() => {
10
- global.__dir = path.resolve(__dirname, '../../')
14
+ tmpDir = fsSync.mkdtempSync(path.join(os.tmpdir(), 'odac-view-navattr-'))
15
+ global.__dir = tmpDir
16
+ originalCwd = process.cwd()
17
+ process.chdir(tmpDir)
11
18
  mockOdac = {
12
19
  Config: {view: {earlyHints: {enabled: false}}},
13
20
  View: {},
@@ -28,6 +35,8 @@ describe('View.#addNavigateAttribute()', () => {
28
35
  afterEach(() => {
29
36
  jest.restoreAllMocks()
30
37
  delete global.__dir
38
+ process.chdir(originalCwd)
39
+ fsSync.rmSync(tmpDir, {recursive: true, force: true})
31
40
  })
32
41
 
33
42
  it('should not wrap placeholder in display:contents if it is the first child of an element', async () => {
@@ -1,13 +1,20 @@
1
1
  const fs = require('fs').promises
2
+ const fsSync = require('fs')
3
+ const os = require('os')
2
4
  const path = require('path')
3
5
  const View = require('../../src/View')
4
6
 
5
7
  describe('View.print()', () => {
6
8
  let view
7
9
  let mockOdac
10
+ let tmpDir
11
+ let originalCwd
8
12
 
9
13
  beforeEach(() => {
10
- global.__dir = path.resolve(__dirname, '../../')
14
+ tmpDir = fsSync.mkdtempSync(path.join(os.tmpdir(), 'odac-view-print-'))
15
+ global.__dir = tmpDir
16
+ originalCwd = process.cwd()
17
+ process.chdir(tmpDir)
11
18
  mockOdac = {
12
19
  Config: {view: {earlyHints: {enabled: false}}},
13
20
  View: {},
@@ -28,6 +35,8 @@ describe('View.print()', () => {
28
35
  afterEach(() => {
29
36
  jest.restoreAllMocks()
30
37
  delete global.__dir
38
+ process.chdir(originalCwd)
39
+ fsSync.rmSync(tmpDir, {recursive: true, force: true})
31
40
  })
32
41
 
33
42
  it('should be a function', () => {