chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,197 @@
|
||||
import { afterAll, afterEach, beforeAll, expect, test } from 'vitest'
|
||||
import { http, HttpResponse } from 'msw'
|
||||
import { setupServer } from 'msw/node'
|
||||
|
||||
import { Template } from '../../src'
|
||||
import { TEST_API_KEY, apiUrl } from '../setup'
|
||||
|
||||
// Hold the request open until the caller aborts. If the signal is already
|
||||
// aborted by the time the handler runs, `addEventListener('abort', …)` would
|
||||
// never fire — so check `aborted` first to avoid hanging.
|
||||
function holdUntilAborted(signal: AbortSignal): Promise<never> {
|
||||
return new Promise<never>((_, reject) => {
|
||||
const abort = () => reject(new DOMException('aborted', 'AbortError'))
|
||||
if (signal.aborted) {
|
||||
abort()
|
||||
return
|
||||
}
|
||||
signal.addEventListener('abort', abort, { once: true })
|
||||
})
|
||||
}
|
||||
|
||||
const restHandlers = [
|
||||
http.post(apiUrl('/v3/templates'), async ({ request }) => {
|
||||
await holdUntilAborted(request.signal)
|
||||
return HttpResponse.json({})
|
||||
}),
|
||||
http.get(apiUrl('/templates/aliases/:alias'), async ({ request }) => {
|
||||
await holdUntilAborted(request.signal)
|
||||
return HttpResponse.json({})
|
||||
}),
|
||||
http.get(
|
||||
apiUrl('/templates/:templateID/builds/:buildID/status'),
|
||||
async ({ request }) => {
|
||||
await holdUntilAborted(request.signal)
|
||||
return HttpResponse.json({})
|
||||
}
|
||||
),
|
||||
http.post(apiUrl('/templates/tags'), async ({ request }) => {
|
||||
await holdUntilAborted(request.signal)
|
||||
return HttpResponse.json({})
|
||||
}),
|
||||
http.delete(apiUrl('/templates/tags'), async ({ request }) => {
|
||||
await holdUntilAborted(request.signal)
|
||||
return HttpResponse.json({})
|
||||
}),
|
||||
http.get(apiUrl('/templates/:templateID/tags'), async ({ request }) => {
|
||||
await holdUntilAborted(request.signal)
|
||||
return HttpResponse.json([])
|
||||
}),
|
||||
]
|
||||
|
||||
const server = setupServer(...restHandlers)
|
||||
|
||||
beforeAll(() =>
|
||||
server.listen({
|
||||
onUnhandledRequest: 'bypass',
|
||||
})
|
||||
)
|
||||
|
||||
afterAll(() => server.close())
|
||||
|
||||
afterEach(() => server.resetHandlers())
|
||||
|
||||
// Resolves once MSW has dispatched the next request, so tests can abort
|
||||
// deterministically instead of guessing with `setTimeout`.
|
||||
function nextRequestStart(): Promise<void> {
|
||||
return new Promise<void>((resolve) => {
|
||||
const listener = () => {
|
||||
server.events.removeListener('request:start', listener)
|
||||
resolve()
|
||||
}
|
||||
server.events.on('request:start', listener)
|
||||
})
|
||||
}
|
||||
|
||||
test('Template.build rejects when AbortSignal is aborted', async () => {
|
||||
const controller = new AbortController()
|
||||
const requestStarted = nextRequestStart()
|
||||
|
||||
const template = Template().fromBaseImage()
|
||||
const promise = Template.build(template, 'test-template', {
|
||||
apiKey: TEST_API_KEY,
|
||||
signal: controller.signal,
|
||||
})
|
||||
|
||||
await requestStarted
|
||||
controller.abort()
|
||||
|
||||
await expect(promise).rejects.toThrow()
|
||||
})
|
||||
|
||||
test('Template.build rejects immediately when signal is already aborted', async () => {
|
||||
const controller = new AbortController()
|
||||
controller.abort()
|
||||
|
||||
const template = Template().fromBaseImage()
|
||||
await expect(
|
||||
Template.build(template, 'test-template', {
|
||||
apiKey: TEST_API_KEY,
|
||||
signal: controller.signal,
|
||||
})
|
||||
).rejects.toThrow()
|
||||
})
|
||||
|
||||
test('Template.buildInBackground rejects when AbortSignal is aborted', async () => {
|
||||
const controller = new AbortController()
|
||||
const requestStarted = nextRequestStart()
|
||||
|
||||
const template = Template().fromBaseImage()
|
||||
const promise = Template.buildInBackground(template, 'test-template', {
|
||||
apiKey: TEST_API_KEY,
|
||||
signal: controller.signal,
|
||||
})
|
||||
|
||||
await requestStarted
|
||||
controller.abort()
|
||||
|
||||
await expect(promise).rejects.toThrow()
|
||||
})
|
||||
|
||||
test('Template.exists rejects when AbortSignal is aborted', async () => {
|
||||
const controller = new AbortController()
|
||||
const requestStarted = nextRequestStart()
|
||||
|
||||
const promise = Template.exists('some-template', {
|
||||
apiKey: TEST_API_KEY,
|
||||
signal: controller.signal,
|
||||
})
|
||||
|
||||
await requestStarted
|
||||
controller.abort()
|
||||
|
||||
await expect(promise).rejects.toThrow()
|
||||
})
|
||||
|
||||
test('Template.getBuildStatus rejects when AbortSignal is aborted', async () => {
|
||||
const controller = new AbortController()
|
||||
const requestStarted = nextRequestStart()
|
||||
|
||||
const promise = Template.getBuildStatus(
|
||||
{ templateId: 'tpl-1', buildId: 'build-1' },
|
||||
{
|
||||
apiKey: TEST_API_KEY,
|
||||
signal: controller.signal,
|
||||
}
|
||||
)
|
||||
|
||||
await requestStarted
|
||||
controller.abort()
|
||||
|
||||
await expect(promise).rejects.toThrow()
|
||||
})
|
||||
|
||||
test('Template.assignTags rejects when AbortSignal is aborted', async () => {
|
||||
const controller = new AbortController()
|
||||
const requestStarted = nextRequestStart()
|
||||
|
||||
const promise = Template.assignTags('some-template:v1', 'stable', {
|
||||
apiKey: TEST_API_KEY,
|
||||
signal: controller.signal,
|
||||
})
|
||||
|
||||
await requestStarted
|
||||
controller.abort()
|
||||
|
||||
await expect(promise).rejects.toThrow()
|
||||
})
|
||||
|
||||
test('Template.removeTags rejects when AbortSignal is aborted', async () => {
|
||||
const controller = new AbortController()
|
||||
const requestStarted = nextRequestStart()
|
||||
|
||||
const promise = Template.removeTags('some-template', 'stable', {
|
||||
apiKey: TEST_API_KEY,
|
||||
signal: controller.signal,
|
||||
})
|
||||
|
||||
await requestStarted
|
||||
controller.abort()
|
||||
|
||||
await expect(promise).rejects.toThrow()
|
||||
})
|
||||
|
||||
test('Template.getTags rejects when AbortSignal is aborted', async () => {
|
||||
const controller = new AbortController()
|
||||
const requestStarted = nextRequestStart()
|
||||
|
||||
const promise = Template.getTags('some-template', {
|
||||
apiKey: TEST_API_KEY,
|
||||
signal: controller.signal,
|
||||
})
|
||||
|
||||
await requestStarted
|
||||
controller.abort()
|
||||
|
||||
await expect(promise).rejects.toThrow()
|
||||
})
|
||||
@@ -0,0 +1,25 @@
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { expect, test } from 'vitest'
|
||||
import { Template, waitForTimeout } from '../../src'
|
||||
|
||||
test('build template in background', async () => {
|
||||
const template = Template()
|
||||
.fromImage('ubuntu:22.04')
|
||||
.skipCache()
|
||||
.runCmd('sleep 5') // Add a delay to ensure build takes time
|
||||
.setStartCmd('echo "Hello"', waitForTimeout(10_000))
|
||||
|
||||
const name = `e2b-test:v1-${randomUUID()}`
|
||||
|
||||
const buildInfo = await Template.buildInBackground(template, name, {
|
||||
cpuCount: 1,
|
||||
memoryMB: 1024,
|
||||
})
|
||||
|
||||
// Should return quickly (within a few seconds), not wait for the full build
|
||||
expect(buildInfo).toBeDefined()
|
||||
|
||||
// Verify the build is actually running
|
||||
const status = await Template.getBuildStatus(buildInfo)
|
||||
expect(status.status).toEqual('building')
|
||||
}, 10_000)
|
||||
@@ -0,0 +1,74 @@
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import { afterAll, beforeAll } from 'vitest'
|
||||
import { defaultBuildLogger, Template, waitForTimeout } from '../../src'
|
||||
import { buildTemplateTest } from '../setup'
|
||||
|
||||
const folderPath = path.join(__dirname, 'folder')
|
||||
|
||||
beforeAll(async () => {
|
||||
fs.mkdirSync(folderPath, { recursive: true })
|
||||
fs.writeFileSync(path.join(folderPath, 'test.txt'), 'This is a test file.')
|
||||
|
||||
// Create relative symlink
|
||||
fs.symlinkSync('test.txt', path.join(folderPath, 'symlink.txt'))
|
||||
|
||||
// Create absolute symlink
|
||||
fs.symlinkSync(
|
||||
path.join(folderPath, 'test.txt'),
|
||||
path.join(folderPath, 'symlink2.txt')
|
||||
)
|
||||
|
||||
// Create a symlink to a file that does not exist
|
||||
fs.symlinkSync('12345test.txt', path.join(folderPath, 'symlink3.txt'))
|
||||
})
|
||||
|
||||
afterAll(() => {
|
||||
fs.rmSync(folderPath, { recursive: true })
|
||||
})
|
||||
|
||||
buildTemplateTest('build template', async ({ buildTemplate }) => {
|
||||
const template = Template()
|
||||
// using base image to avoid re-building ubuntu:22.04 image
|
||||
.fromBaseImage()
|
||||
.copy('folder/*', 'folder', { forceUpload: true })
|
||||
.runCmd('cat folder/test.txt')
|
||||
.setWorkdir('/app')
|
||||
.setStartCmd('echo "Hello, world!"', waitForTimeout(10_000))
|
||||
|
||||
await buildTemplate(template, { skipCache: true }, defaultBuildLogger())
|
||||
})
|
||||
|
||||
buildTemplateTest(
|
||||
'build template from base template',
|
||||
async ({ buildTemplate }) => {
|
||||
const template = Template().fromTemplate('base')
|
||||
await buildTemplate(template, { skipCache: true })
|
||||
}
|
||||
)
|
||||
|
||||
buildTemplateTest('build template with symlinks', async ({ buildTemplate }) => {
|
||||
const template = Template()
|
||||
.fromImage('ubuntu:22.04')
|
||||
.skipCache()
|
||||
.copy('folder/*', 'folder', { forceUpload: true })
|
||||
.runCmd('cat folder/symlink.txt')
|
||||
|
||||
await buildTemplate(template)
|
||||
})
|
||||
|
||||
buildTemplateTest(
|
||||
'build template with resolveSymlinks',
|
||||
async ({ buildTemplate }) => {
|
||||
const template = Template()
|
||||
.fromImage('ubuntu:22.04')
|
||||
.skipCache()
|
||||
.copy('folder/symlink.txt', 'folder/symlink.txt', {
|
||||
forceUpload: true,
|
||||
resolveSymlinks: true,
|
||||
})
|
||||
.runCmd('cat folder/symlink.txt')
|
||||
|
||||
await buildTemplate(template)
|
||||
}
|
||||
)
|
||||
@@ -0,0 +1,14 @@
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { expect, test } from 'vitest'
|
||||
import { Template } from '../../src'
|
||||
|
||||
test('check if base template name exists', async () => {
|
||||
const exists = await Template.exists('base')
|
||||
expect(exists).toBe(true)
|
||||
})
|
||||
|
||||
test('check non existing name', async () => {
|
||||
const nonExistingName = `nonexistent-${randomUUID()}`
|
||||
const exists = await Template.exists(nonExistingName)
|
||||
expect(exists).toBe(false)
|
||||
})
|
||||
@@ -0,0 +1,198 @@
|
||||
import { buildTemplateTest } from '../../setup'
|
||||
import { Template } from '../../../src'
|
||||
import { InstructionType } from '../../../src/template/types'
|
||||
import { assert } from 'vitest'
|
||||
|
||||
buildTemplateTest('fromDockerfile', async () => {
|
||||
const dockerfile = `FROM node:24
|
||||
WORKDIR /app
|
||||
COPY package.json .
|
||||
RUN npm install
|
||||
ENTRYPOINT ["sleep", "20"]`
|
||||
|
||||
const template = Template().fromDockerfile(dockerfile)
|
||||
|
||||
assert.equal(
|
||||
// @ts-expect-error - baseImage is not a property of TemplateBuilder
|
||||
template.baseImage,
|
||||
'node:24'
|
||||
)
|
||||
assert.equal(
|
||||
// @ts-expect-error - instructions is not a property of TemplateBuilder
|
||||
template.instructions[1].type,
|
||||
InstructionType.WORKDIR
|
||||
)
|
||||
assert.equal(
|
||||
// @ts-expect-error - instructions is not a property of TemplateBuilder
|
||||
template.instructions[1].args[0],
|
||||
'/'
|
||||
)
|
||||
assert.equal(
|
||||
// @ts-expect-error - instructions is not a property of TemplateBuilder
|
||||
template.instructions[2].type,
|
||||
InstructionType.WORKDIR
|
||||
)
|
||||
assert.equal(
|
||||
// @ts-expect-error - instructions is not a property of TemplateBuilder
|
||||
template.instructions[2].args[0],
|
||||
'/app'
|
||||
)
|
||||
assert.equal(
|
||||
// @ts-expect-error - instructions is not a property of TemplateBuilder
|
||||
template.instructions[3].type,
|
||||
InstructionType.COPY
|
||||
)
|
||||
assert.equal(
|
||||
// @ts-expect-error - instructions is not a property of TemplateBuilder
|
||||
template.instructions[3].args[0],
|
||||
'package.json'
|
||||
)
|
||||
assert.equal(
|
||||
// @ts-expect-error - instructions is not a property of TemplateBuilder
|
||||
template.instructions[3].args[1],
|
||||
'.'
|
||||
)
|
||||
assert.equal(
|
||||
// @ts-expect-error - instructions is not a property of TemplateBuilder
|
||||
template.instructions[4].type,
|
||||
InstructionType.RUN
|
||||
)
|
||||
assert.equal(
|
||||
// @ts-expect-error - instructions is not a property of TemplateBuilder
|
||||
template.instructions[4].args[0],
|
||||
'npm install'
|
||||
)
|
||||
assert.equal(
|
||||
// @ts-expect-error - instructions is not a property of TemplateBuilder
|
||||
template.instructions[5].type,
|
||||
InstructionType.USER
|
||||
)
|
||||
assert.equal(
|
||||
// @ts-expect-error - instructions is not a property of TemplateBuilder
|
||||
template.instructions[5].args[0],
|
||||
'user'
|
||||
)
|
||||
assert.equal(
|
||||
// @ts-expect-error - startCmd is not a property of TemplateBuilder
|
||||
template.startCmd,
|
||||
'sleep 20'
|
||||
)
|
||||
})
|
||||
|
||||
buildTemplateTest('fromDockerfile with default user and workdir', () => {
|
||||
const dockerfile = 'FROM node:24'
|
||||
const template = Template().fromDockerfile(dockerfile)
|
||||
|
||||
assert.equal(
|
||||
// @ts-expect-error - instructions is not a property of TemplateBuilder
|
||||
template.instructions[template.instructions.length - 2].type,
|
||||
InstructionType.USER
|
||||
)
|
||||
assert.equal(
|
||||
// @ts-expect-error - instructions is not a property of TemplateBuilder
|
||||
template.instructions[template.instructions.length - 2].args[0],
|
||||
'user'
|
||||
)
|
||||
assert.equal(
|
||||
// @ts-expect-error - instructions is not a property of TemplateBuilder
|
||||
template.instructions[template.instructions.length - 1].type,
|
||||
InstructionType.WORKDIR
|
||||
)
|
||||
assert.equal(
|
||||
// @ts-expect-error - instructions is not a property of TemplateBuilder
|
||||
template.instructions[template.instructions.length - 1].args[0],
|
||||
'/home/user'
|
||||
)
|
||||
})
|
||||
|
||||
buildTemplateTest('fromDockerfile with custom user and workdir', () => {
|
||||
const dockerfile = 'FROM node:24\nUSER mish\nWORKDIR /home/mish'
|
||||
const template = Template().fromDockerfile(dockerfile)
|
||||
|
||||
assert.equal(
|
||||
// @ts-expect-error - instructions is not a property of TemplateBuilder
|
||||
template.instructions[template.instructions.length - 2].type,
|
||||
InstructionType.USER
|
||||
)
|
||||
assert.equal(
|
||||
// @ts-expect-error - instructions is not a property of TemplateBuilder
|
||||
template.instructions[template.instructions.length - 2].args[0],
|
||||
'mish'
|
||||
)
|
||||
assert.equal(
|
||||
// @ts-expect-error - instructions is not a property of TemplateBuilder
|
||||
template.instructions[template.instructions.length - 1].type,
|
||||
InstructionType.WORKDIR
|
||||
)
|
||||
assert.equal(
|
||||
// @ts-expect-error - instructions is not a property of TemplateBuilder
|
||||
template.instructions[template.instructions.length - 1].args[0],
|
||||
'/home/mish'
|
||||
)
|
||||
})
|
||||
|
||||
buildTemplateTest('fromDockerfile with multi-source COPY', () => {
|
||||
const dockerfile = `FROM node:24
|
||||
COPY file1.txt file2.txt file3.txt /dest/`
|
||||
|
||||
const template = Template().fromDockerfile(dockerfile)
|
||||
|
||||
// After initial USER root and WORKDIR /, the multi-source COPY should
|
||||
// expand into one COPY instruction per source.
|
||||
// @ts-expect-error - instructions is not a property of TemplateBuilder
|
||||
const copyInstructions = template.instructions.filter(
|
||||
(i: { type: InstructionType }) => i.type === InstructionType.COPY
|
||||
)
|
||||
|
||||
assert.equal(copyInstructions.length, 3)
|
||||
assert.equal(copyInstructions[0].args[0], 'file1.txt')
|
||||
assert.equal(copyInstructions[0].args[1], '/dest/')
|
||||
assert.equal(copyInstructions[1].args[0], 'file2.txt')
|
||||
assert.equal(copyInstructions[1].args[1], '/dest/')
|
||||
assert.equal(copyInstructions[2].args[0], 'file3.txt')
|
||||
assert.equal(copyInstructions[2].args[1], '/dest/')
|
||||
})
|
||||
|
||||
buildTemplateTest('fromDockerfile with multi-source COPY --chown', () => {
|
||||
const dockerfile = `FROM node:24
|
||||
COPY --chown=myuser:mygroup pkg.json pkg-lock.json /app/`
|
||||
|
||||
const template = Template().fromDockerfile(dockerfile)
|
||||
|
||||
// @ts-expect-error - instructions is not a property of TemplateBuilder
|
||||
const copyInstructions = template.instructions.filter(
|
||||
(i: { type: InstructionType }) => i.type === InstructionType.COPY
|
||||
)
|
||||
|
||||
assert.equal(copyInstructions.length, 2)
|
||||
assert.equal(copyInstructions[0].args[0], 'pkg.json')
|
||||
assert.equal(copyInstructions[0].args[1], '/app/')
|
||||
assert.equal(copyInstructions[0].args[2], 'myuser:mygroup')
|
||||
assert.equal(copyInstructions[1].args[0], 'pkg-lock.json')
|
||||
assert.equal(copyInstructions[1].args[1], '/app/')
|
||||
assert.equal(copyInstructions[1].args[2], 'myuser:mygroup')
|
||||
})
|
||||
|
||||
buildTemplateTest('fromDockerfile with COPY --chown', () => {
|
||||
const dockerfile = `FROM node:24
|
||||
COPY --chown=myuser:mygroup app.js /app/
|
||||
COPY --chown=anotheruser config.json /config/`
|
||||
|
||||
const template = Template().fromDockerfile(dockerfile)
|
||||
|
||||
// First COPY instruction (after initial USER root and WORKDIR /)
|
||||
// @ts-expect-error - instructions is not a property of TemplateBuilder
|
||||
const copyInstruction1 = template.instructions[2]
|
||||
assert.equal(copyInstruction1.type, InstructionType.COPY)
|
||||
assert.equal(copyInstruction1.args[0], 'app.js')
|
||||
assert.equal(copyInstruction1.args[1], '/app/')
|
||||
assert.equal(copyInstruction1.args[2], 'myuser:mygroup') // user from --chown
|
||||
|
||||
// Second COPY instruction
|
||||
// @ts-expect-error - instructions is not a property of TemplateBuilder
|
||||
const copyInstruction2 = template.instructions[3]
|
||||
assert.equal(copyInstruction2.type, InstructionType.COPY)
|
||||
assert.equal(copyInstruction2.args[0], 'config.json')
|
||||
assert.equal(copyInstruction2.args[1], '/config/')
|
||||
assert.equal(copyInstruction2.args[2], 'anotheruser') // user from --chown (without group)
|
||||
})
|
||||
@@ -0,0 +1,23 @@
|
||||
import { Template } from '../../../src'
|
||||
import { buildTemplateTest } from '../../setup'
|
||||
|
||||
buildTemplateTest('make symlink', async ({ buildTemplate }) => {
|
||||
const template = Template()
|
||||
.fromImage('ubuntu:22.04')
|
||||
.skipCache()
|
||||
.makeSymlink('.bashrc', '.bashrc.local')
|
||||
.runCmd('test "$(readlink .bashrc.local)" = ".bashrc"')
|
||||
|
||||
await buildTemplate(template)
|
||||
})
|
||||
|
||||
buildTemplateTest('make symlink (force)', async ({ buildTemplate }) => {
|
||||
const template = Template()
|
||||
.fromImage('ubuntu:22.04')
|
||||
.makeSymlink('.bashrc', '.bashrc.local')
|
||||
.skipCache()
|
||||
.makeSymlink('.bashrc', '.bashrc.local', { force: true }) // Overwrite existing symlink
|
||||
.runCmd('test "$(readlink .bashrc.local)" = ".bashrc"')
|
||||
|
||||
await buildTemplate(template)
|
||||
})
|
||||
@@ -0,0 +1,38 @@
|
||||
import { expect } from 'vitest'
|
||||
import { Template } from '../../../src'
|
||||
import { buildTemplateTest } from '../../setup'
|
||||
|
||||
buildTemplateTest('run command', async ({ buildTemplate }) => {
|
||||
const template = Template()
|
||||
.fromImage('ubuntu:22.04')
|
||||
.skipCache()
|
||||
.runCmd('ls -l')
|
||||
|
||||
await buildTemplate(template)
|
||||
})
|
||||
|
||||
buildTemplateTest(
|
||||
'run command as a different user',
|
||||
async ({ buildTemplate }) => {
|
||||
const template = Template()
|
||||
.fromImage('ubuntu:22.04')
|
||||
.skipCache()
|
||||
.runCmd('test "$(whoami)" = "root"', { user: 'root' })
|
||||
|
||||
await buildTemplate(template)
|
||||
}
|
||||
)
|
||||
|
||||
buildTemplateTest(
|
||||
'run command as user that does not exist',
|
||||
async ({ buildTemplate }) => {
|
||||
const template = Template()
|
||||
.fromImage('ubuntu:22.04')
|
||||
.skipCache()
|
||||
.runCmd('whoami', { user: 'root123' })
|
||||
|
||||
await expect(buildTemplate(template)).rejects.toThrow(
|
||||
"failed to run command 'whoami': command failed: unauthenticated: invalid username: 'root123'"
|
||||
)
|
||||
}
|
||||
)
|
||||
@@ -0,0 +1,47 @@
|
||||
import { expect, test } from 'vitest'
|
||||
import { Template } from '../../../src'
|
||||
|
||||
test('toDockerfile', { timeout: 180000 }, async () => {
|
||||
const template = Template()
|
||||
.fromUbuntuImage('24.04')
|
||||
.copy('README.md', '/app/README.md')
|
||||
.runCmd('echo "Hello, World!"')
|
||||
|
||||
const dockerfile = Template.toDockerfile(template)
|
||||
|
||||
const expectedDockerfile = `FROM ubuntu:24.04
|
||||
COPY README.md /app/README.md
|
||||
RUN echo "Hello, World!"
|
||||
`
|
||||
expect(dockerfile).toBe(expectedDockerfile)
|
||||
})
|
||||
|
||||
test('toDockerfile with options', { timeout: 180000 }, async () => {
|
||||
const template = Template()
|
||||
.fromUbuntuImage('24.04')
|
||||
.copy('README.md', '/app/README.md', { user: 'root' })
|
||||
.runCmd('echo "Hello, World!"', { user: 'root' })
|
||||
|
||||
const dockerfile = Template.toDockerfile(template)
|
||||
|
||||
const expectedDockerfile = `FROM ubuntu:24.04
|
||||
COPY README.md /app/README.md
|
||||
RUN echo "Hello, World!"
|
||||
`
|
||||
expect(dockerfile).toBe(expectedDockerfile)
|
||||
})
|
||||
|
||||
test('toDockerfile with ENV instructions', { timeout: 180000 }, async () => {
|
||||
const template = Template()
|
||||
.fromUbuntuImage('24.04')
|
||||
.setEnvs({ NODE_ENV: 'production', PORT: '8080' })
|
||||
.setEnvs({ DEBUG: 'false' })
|
||||
|
||||
const dockerfile = Template.toDockerfile(template)
|
||||
|
||||
const expectedDockerfile = `FROM ubuntu:24.04
|
||||
ENV NODE_ENV=production PORT=8080
|
||||
ENV DEBUG=false
|
||||
`
|
||||
expect(dockerfile).toBe(expectedDockerfile)
|
||||
})
|
||||
@@ -0,0 +1,434 @@
|
||||
import fs from 'node:fs'
|
||||
import { assert, afterAll, afterEach, beforeAll } from 'vitest'
|
||||
|
||||
import { http, HttpResponse } from 'msw'
|
||||
import { setupServer } from 'msw/node'
|
||||
|
||||
import { Template, waitForTimeout } from '../../src'
|
||||
import { apiUrl, buildTemplateTest } from '../setup'
|
||||
import { randomUUID } from 'node:crypto'
|
||||
|
||||
const __fileContent = fs.readFileSync(__filename, 'utf8') // read current file content
|
||||
const nonExistentPath = 'nonexistent/path'
|
||||
|
||||
// map template alias -> failed step index
|
||||
const failureMap: Record<string, number | undefined> = {
|
||||
fromImage: 0,
|
||||
fromTemplate: 0,
|
||||
fromDockerfile: 0,
|
||||
fromImageRegistry: 0,
|
||||
fromAWSRegistry: 0,
|
||||
fromGCPRegistry: 0,
|
||||
copy: undefined,
|
||||
copyItems: undefined,
|
||||
// multi-source copy produces two COPY instructions (steps 1 and 2),
|
||||
// the runCmd after it is step 3
|
||||
multiSourceCopySecondSource: 2,
|
||||
multiSourceCopyNextStep: 3,
|
||||
copyItemsSecondItem: 2,
|
||||
copyItemsNextStep: 3,
|
||||
remove: 1,
|
||||
rename: 1,
|
||||
makeDir: 1,
|
||||
makeSymlink: 1,
|
||||
runCmd: 1,
|
||||
setWorkdir: 1,
|
||||
setUser: 1,
|
||||
pipInstall: 1,
|
||||
npmInstall: 1,
|
||||
aptInstall: 1,
|
||||
gitClone: 1,
|
||||
setStartCmd: 1,
|
||||
addMcpServer: undefined,
|
||||
betaDevContainerPrebuild: 1,
|
||||
betaSetDevContainerStart: 1,
|
||||
}
|
||||
|
||||
export const restHandlers = [
|
||||
http.post(apiUrl('/v3/templates'), async ({ request }) => {
|
||||
const { name } = (await request.clone().json()) as { name: string }
|
||||
return HttpResponse.json({
|
||||
buildID: randomUUID(),
|
||||
templateID: name,
|
||||
tags: [],
|
||||
})
|
||||
}),
|
||||
http.post(apiUrl('/v2/templates/:templateID/builds/:buildID'), () => {
|
||||
return HttpResponse.json({})
|
||||
}),
|
||||
http.get(apiUrl('/templates/:templateID/files/:hash'), () => {
|
||||
return HttpResponse.json({ present: true })
|
||||
}),
|
||||
http.get<{ templateID: string; buildID: string }>(
|
||||
apiUrl('/templates/:templateID/builds/:buildID/status'),
|
||||
({ params }) => {
|
||||
const { templateID } = params
|
||||
return HttpResponse.json({
|
||||
status: 'error',
|
||||
reason: {
|
||||
message: 'Mocked API build error',
|
||||
step: failureMap[templateID],
|
||||
},
|
||||
logEntries: [],
|
||||
})
|
||||
}
|
||||
),
|
||||
]
|
||||
|
||||
const server = setupServer(...restHandlers)
|
||||
|
||||
beforeAll(() => server.listen({ onUnhandledRequest: 'error' }))
|
||||
|
||||
afterAll(() => server.close())
|
||||
|
||||
afterEach(() => server.resetHandlers())
|
||||
|
||||
function getStackTraceCallerMethod(
|
||||
fileContent: string,
|
||||
stackTrace: string | undefined
|
||||
) {
|
||||
if (!stackTrace) {
|
||||
return null
|
||||
}
|
||||
|
||||
const stackTraceLines = stackTrace.split('\n')
|
||||
if (stackTraceLines.length === 0) {
|
||||
return null
|
||||
}
|
||||
const callerTrace = stackTraceLines[0]
|
||||
|
||||
// Match line and column numbers at the end of the stack trace line
|
||||
// Format: ...file.ts:123:45) or ...file.ts:123:45
|
||||
// This handles Windows paths (C:\Users\...) and Unix paths
|
||||
const lineColumnMatch = callerTrace.match(/:(\d+):(\d+)\)?$/)
|
||||
if (!lineColumnMatch) {
|
||||
return null
|
||||
}
|
||||
const lineNumber = parseInt(lineColumnMatch[1])
|
||||
const columnNumber = parseInt(lineColumnMatch[2])
|
||||
|
||||
const lines = fileContent.split('\n')
|
||||
const parsedLine = lines[lineNumber - 1]
|
||||
if (!parsedLine) {
|
||||
return null
|
||||
}
|
||||
|
||||
// Extract the method name from the line
|
||||
const methodNameMatch = parsedLine
|
||||
.slice(columnNumber - 1)
|
||||
.match(/^(\w+)\s*\(/)
|
||||
if (methodNameMatch) {
|
||||
return methodNameMatch[1]
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
async function expectToThrowAndCheckTrace(
|
||||
func: (...args: any[]) => Promise<void>,
|
||||
expectedMethod: string
|
||||
) {
|
||||
try {
|
||||
await func()
|
||||
assert.fail('Expected Template.build to throw an error')
|
||||
} catch (error) {
|
||||
const callerMethod = getStackTraceCallerMethod(__fileContent, error.stack)
|
||||
if (!callerMethod) {
|
||||
throw error
|
||||
}
|
||||
assert.include(callerMethod, expectedMethod)
|
||||
}
|
||||
}
|
||||
|
||||
buildTemplateTest('traces on fromImage', async ({ buildTemplate }) => {
|
||||
const template = Template().fromImage('e2b.dev/this-image-does-not-exist')
|
||||
await expectToThrowAndCheckTrace(async () => {
|
||||
await buildTemplate(template, { name: 'fromImage', skipCache: true })
|
||||
}, 'fromImage')
|
||||
})
|
||||
|
||||
buildTemplateTest('traces on fromTemplate', async ({ buildTemplate }) => {
|
||||
const template = Template().fromTemplate('this-template-does-not-exist')
|
||||
await expectToThrowAndCheckTrace(async () => {
|
||||
await buildTemplate(template, { name: 'fromTemplate', skipCache: true })
|
||||
}, 'fromTemplate')
|
||||
})
|
||||
|
||||
buildTemplateTest('traces on fromDockerfile', async ({ buildTemplate }) => {
|
||||
const template = Template().fromDockerfile(
|
||||
'FROM ubuntu:22.04\nRUN nonexistent'
|
||||
)
|
||||
await expectToThrowAndCheckTrace(async () => {
|
||||
await buildTemplate(template, { name: 'fromDockerfile', skipCache: true })
|
||||
}, 'fromDockerfile')
|
||||
})
|
||||
|
||||
buildTemplateTest('traces on fromImage registry', async ({ buildTemplate }) => {
|
||||
const template = Template().fromImage(
|
||||
'registry.example.com/nonexistent:latest',
|
||||
{
|
||||
username: 'test',
|
||||
password: 'test',
|
||||
}
|
||||
)
|
||||
await expectToThrowAndCheckTrace(async () => {
|
||||
await buildTemplate(template, {
|
||||
name: 'fromImageRegistry',
|
||||
})
|
||||
}, 'fromImage')
|
||||
})
|
||||
|
||||
buildTemplateTest('traces on fromAWSRegistry', async ({ buildTemplate }) => {
|
||||
const template = Template().fromAWSRegistry(
|
||||
'123456789.dkr.ecr.us-east-1.amazonaws.com/nonexistent:latest',
|
||||
{
|
||||
accessKeyId: 'test',
|
||||
secretAccessKey: 'test',
|
||||
region: 'us-east-1',
|
||||
}
|
||||
)
|
||||
await expectToThrowAndCheckTrace(async () => {
|
||||
await buildTemplate(template, { name: 'fromAWSRegistry' })
|
||||
}, 'fromAWSRegistry')
|
||||
})
|
||||
|
||||
buildTemplateTest('traces on fromGCPRegistry', async ({ buildTemplate }) => {
|
||||
const template = Template().fromGCPRegistry(
|
||||
'gcr.io/nonexistent-project/nonexistent:latest',
|
||||
{
|
||||
serviceAccountJSON: { type: 'service_account' },
|
||||
}
|
||||
)
|
||||
await expectToThrowAndCheckTrace(async () => {
|
||||
await buildTemplate(template, { name: 'fromGCPRegistry' })
|
||||
}, 'fromGCPRegistry')
|
||||
})
|
||||
|
||||
buildTemplateTest('traces on fromImage credentials', async () => {
|
||||
await expectToThrowAndCheckTrace(async () => {
|
||||
// @ts-expect-error - testing runtime validation with partial credentials
|
||||
Template().fromImage('ubuntu:22.04', { username: 'user' })
|
||||
}, 'fromImage')
|
||||
})
|
||||
|
||||
buildTemplateTest('traces on copy', async ({ buildTemplate }) => {
|
||||
let template = Template().fromBaseImage()
|
||||
template = template.skipCache().copy(nonExistentPath, nonExistentPath)
|
||||
await expectToThrowAndCheckTrace(async () => {
|
||||
await buildTemplate(template, { name: 'copy' })
|
||||
}, 'copy')
|
||||
})
|
||||
|
||||
buildTemplateTest('traces on copyItems', async ({ buildTemplate }) => {
|
||||
let template = Template().fromBaseImage()
|
||||
template = template
|
||||
.skipCache()
|
||||
.copyItems([{ src: nonExistentPath, dest: nonExistentPath }])
|
||||
await expectToThrowAndCheckTrace(async () => {
|
||||
await buildTemplate(template, { name: 'copyItems' })
|
||||
}, 'copyItems')
|
||||
})
|
||||
|
||||
buildTemplateTest(
|
||||
'traces on second source of multi-source copy',
|
||||
async ({ buildTemplate }) => {
|
||||
let template = Template().fromBaseImage()
|
||||
template = template.copy(['stacktrace.test.ts', 'tags.test.ts'], '.')
|
||||
await expectToThrowAndCheckTrace(async () => {
|
||||
await buildTemplate(template, { name: 'multiSourceCopySecondSource' })
|
||||
}, 'copy')
|
||||
}
|
||||
)
|
||||
|
||||
buildTemplateTest(
|
||||
'traces on step after multi-source copy',
|
||||
async ({ buildTemplate }) => {
|
||||
let template = Template().fromBaseImage()
|
||||
template = template
|
||||
.copy(['stacktrace.test.ts', 'tags.test.ts'], '.')
|
||||
.runCmd(`./${nonExistentPath}`)
|
||||
await expectToThrowAndCheckTrace(async () => {
|
||||
await buildTemplate(template, { name: 'multiSourceCopyNextStep' })
|
||||
}, 'runCmd')
|
||||
}
|
||||
)
|
||||
|
||||
buildTemplateTest(
|
||||
'traces on second item of copyItems',
|
||||
async ({ buildTemplate }) => {
|
||||
let template = Template().fromBaseImage()
|
||||
template = template.copyItems([
|
||||
{ src: 'stacktrace.test.ts', dest: '.' },
|
||||
{ src: 'tags.test.ts', dest: '.' },
|
||||
])
|
||||
await expectToThrowAndCheckTrace(async () => {
|
||||
await buildTemplate(template, { name: 'copyItemsSecondItem' })
|
||||
}, 'copyItems')
|
||||
}
|
||||
)
|
||||
|
||||
buildTemplateTest(
|
||||
'traces on step after copyItems',
|
||||
async ({ buildTemplate }) => {
|
||||
let template = Template().fromBaseImage()
|
||||
template = template
|
||||
.copyItems([
|
||||
{ src: 'stacktrace.test.ts', dest: '.' },
|
||||
{ src: 'tags.test.ts', dest: '.' },
|
||||
])
|
||||
.runCmd(`./${nonExistentPath}`)
|
||||
await expectToThrowAndCheckTrace(async () => {
|
||||
await buildTemplate(template, { name: 'copyItemsNextStep' })
|
||||
}, 'runCmd')
|
||||
}
|
||||
)
|
||||
|
||||
buildTemplateTest('traces on copy absolute path', async () => {
|
||||
await expectToThrowAndCheckTrace(async () => {
|
||||
Template().fromBaseImage().copy('/absolute/path', '/absolute/path')
|
||||
}, 'copy')
|
||||
})
|
||||
|
||||
buildTemplateTest('traces on copyItems absolute path', async () => {
|
||||
await expectToThrowAndCheckTrace(async () => {
|
||||
Template()
|
||||
.fromBaseImage()
|
||||
.copyItems([{ src: '/absolute/path', dest: '/absolute/path' }])
|
||||
}, 'copyItems')
|
||||
})
|
||||
|
||||
buildTemplateTest('traces on remove', async ({ buildTemplate }) => {
|
||||
let template = Template().fromBaseImage()
|
||||
template = template.skipCache().remove(nonExistentPath)
|
||||
await expectToThrowAndCheckTrace(async () => {
|
||||
await buildTemplate(template, { name: 'remove' })
|
||||
}, 'remove')
|
||||
})
|
||||
|
||||
buildTemplateTest('traces on rename', async ({ buildTemplate }) => {
|
||||
let template = Template().fromBaseImage()
|
||||
template = template.skipCache().rename(nonExistentPath, '/tmp/dest.txt')
|
||||
await expectToThrowAndCheckTrace(async () => {
|
||||
await buildTemplate(template, { name: 'rename' })
|
||||
}, 'rename')
|
||||
})
|
||||
|
||||
buildTemplateTest('traces on makeDir', async ({ buildTemplate }) => {
|
||||
let template = Template().fromBaseImage()
|
||||
template = template.skipCache().makeDir('.bashrc')
|
||||
await expectToThrowAndCheckTrace(async () => {
|
||||
await buildTemplate(template, { name: 'makeDir' })
|
||||
}, 'makeDir')
|
||||
})
|
||||
|
||||
buildTemplateTest('traces on makeSymlink', async ({ buildTemplate }) => {
|
||||
let template = Template().fromBaseImage()
|
||||
template = template.skipCache().makeSymlink('.bashrc', '.bashrc')
|
||||
await expectToThrowAndCheckTrace(async () => {
|
||||
await buildTemplate(template, { name: 'makeSymlink' })
|
||||
}, 'makeSymlink')
|
||||
})
|
||||
|
||||
buildTemplateTest('traces on runCmd', async ({ buildTemplate }) => {
|
||||
let template = Template().fromBaseImage()
|
||||
template = template.skipCache().runCmd(`./${nonExistentPath}`)
|
||||
await expectToThrowAndCheckTrace(async () => {
|
||||
await buildTemplate(template, { name: 'runCmd' })
|
||||
}, 'runCmd')
|
||||
})
|
||||
|
||||
buildTemplateTest('traces on setWorkdir', async ({ buildTemplate }) => {
|
||||
let template = Template().fromBaseImage()
|
||||
template = template.skipCache().setWorkdir('/root/.bashrc')
|
||||
await expectToThrowAndCheckTrace(async () => {
|
||||
await buildTemplate(template, { name: 'setWorkdir' })
|
||||
}, 'setWorkdir')
|
||||
})
|
||||
|
||||
buildTemplateTest('traces on setUser', async ({ buildTemplate }) => {
|
||||
let template = Template().fromBaseImage()
|
||||
template = template.skipCache().setUser('; exit 1')
|
||||
await expectToThrowAndCheckTrace(async () => {
|
||||
await buildTemplate(template, { name: 'setUser' })
|
||||
}, 'setUser')
|
||||
})
|
||||
|
||||
buildTemplateTest('traces on pipInstall', async ({ buildTemplate }) => {
|
||||
let template = Template().fromBaseImage()
|
||||
template = template.skipCache().pipInstall('nonexistent-package')
|
||||
await expectToThrowAndCheckTrace(async () => {
|
||||
await buildTemplate(template, { name: 'pipInstall' })
|
||||
}, 'pipInstall')
|
||||
})
|
||||
|
||||
buildTemplateTest('traces on npmInstall', async ({ buildTemplate }) => {
|
||||
let template = Template().fromBaseImage()
|
||||
template = template.skipCache().npmInstall('nonexistent-package')
|
||||
await expectToThrowAndCheckTrace(async () => {
|
||||
await buildTemplate(template, { name: 'npmInstall' })
|
||||
}, 'npmInstall')
|
||||
})
|
||||
|
||||
buildTemplateTest('traces on aptInstall', async ({ buildTemplate }) => {
|
||||
let template = Template().fromBaseImage()
|
||||
template = template.skipCache().aptInstall('nonexistent-package')
|
||||
await expectToThrowAndCheckTrace(async () => {
|
||||
await buildTemplate(template, { name: 'aptInstall' })
|
||||
}, 'aptInstall')
|
||||
})
|
||||
|
||||
buildTemplateTest('traces on gitClone', async ({ buildTemplate }) => {
|
||||
let template = Template().fromBaseImage()
|
||||
template = template
|
||||
.skipCache()
|
||||
.gitClone('https://github.com/nonexistent/repo.git')
|
||||
await expectToThrowAndCheckTrace(async () => {
|
||||
await buildTemplate(template, { name: 'gitClone' })
|
||||
}, 'gitClone')
|
||||
})
|
||||
|
||||
buildTemplateTest('traces on setStartCmd', async ({ buildTemplate }) => {
|
||||
let template: any = Template().fromBaseImage()
|
||||
template = template.setStartCmd(
|
||||
`./${nonExistentPath}`,
|
||||
waitForTimeout(10_000)
|
||||
)
|
||||
await expectToThrowAndCheckTrace(async () => {
|
||||
await buildTemplate(template, { name: 'setStartCmd' })
|
||||
}, 'setStartCmd')
|
||||
})
|
||||
|
||||
buildTemplateTest('traces on addMcpServer', async () => {
|
||||
// needs mcp-gateway as base template, without it no mcp servers can be added
|
||||
await expectToThrowAndCheckTrace(async () => {
|
||||
Template().fromBaseImage().skipCache().addMcpServer('exa')
|
||||
}, 'addMcpServer')
|
||||
})
|
||||
|
||||
buildTemplateTest(
|
||||
'traces on betaDevContainerPrebuild',
|
||||
async ({ buildTemplate }) => {
|
||||
const template = Template()
|
||||
.fromTemplate('devcontainer')
|
||||
.skipCache()
|
||||
.betaDevContainerPrebuild(nonExistentPath)
|
||||
await expectToThrowAndCheckTrace(async () => {
|
||||
await buildTemplate(template, {
|
||||
name: 'betaDevContainerPrebuild',
|
||||
})
|
||||
}, 'betaDevContainerPrebuild')
|
||||
}
|
||||
)
|
||||
|
||||
buildTemplateTest(
|
||||
'traces on betaSetDevContainerStart',
|
||||
async ({ buildTemplate }) => {
|
||||
const template = Template()
|
||||
.fromTemplate('devcontainer')
|
||||
.betaSetDevContainerStart(nonExistentPath)
|
||||
await expectToThrowAndCheckTrace(async () => {
|
||||
await buildTemplate(template, {
|
||||
name: 'betaSetDevContainerStart',
|
||||
})
|
||||
}, 'betaSetDevContainerStart')
|
||||
}
|
||||
)
|
||||
@@ -0,0 +1,202 @@
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { afterAll, afterEach, beforeAll, describe, expect, test } from 'vitest'
|
||||
|
||||
import { http, HttpResponse } from 'msw'
|
||||
import { setupServer } from 'msw/node'
|
||||
|
||||
import { Template } from '../../src'
|
||||
import { apiUrl, buildTemplateTest, isDebug } from '../setup'
|
||||
|
||||
// Mock handlers for tag API endpoints
|
||||
const mockHandlers = [
|
||||
http.post(apiUrl('/templates/tags'), async ({ request }) => {
|
||||
const { tags } = (await request.clone().json()) as {
|
||||
tags: string[]
|
||||
}
|
||||
return HttpResponse.json({
|
||||
buildID: '00000000-0000-0000-0000-000000000000',
|
||||
tags: tags,
|
||||
})
|
||||
}),
|
||||
// Get template tags endpoint
|
||||
http.get(apiUrl('/templates/:templateID/tags'), ({ params }) => {
|
||||
const { templateID } = params
|
||||
if (templateID === 'nonexistent') {
|
||||
return HttpResponse.json(
|
||||
{ message: 'Template not found' },
|
||||
{ status: 404 }
|
||||
)
|
||||
}
|
||||
return HttpResponse.json([
|
||||
{
|
||||
tag: 'v1.0',
|
||||
buildID: '00000000-0000-0000-0000-000000000000',
|
||||
createdAt: '2024-01-15T10:30:00Z',
|
||||
},
|
||||
{
|
||||
tag: 'latest',
|
||||
buildID: '11111111-1111-1111-1111-111111111111',
|
||||
createdAt: '2024-01-16T12:00:00Z',
|
||||
},
|
||||
])
|
||||
}),
|
||||
// Bulk delete endpoint
|
||||
http.delete(apiUrl('/templates/tags'), async ({ request }) => {
|
||||
const { name } = (await request.clone().json()) as {
|
||||
name: string
|
||||
tags: string[]
|
||||
}
|
||||
if (name === 'nonexistent') {
|
||||
return HttpResponse.json(
|
||||
{ message: 'Template not found' },
|
||||
{ status: 404 }
|
||||
)
|
||||
}
|
||||
return new HttpResponse(null, { status: 204 })
|
||||
}),
|
||||
]
|
||||
|
||||
const server = setupServer(...mockHandlers)
|
||||
|
||||
// Unit tests with mock server
|
||||
describe('Template tags unit tests', () => {
|
||||
beforeAll(() => server.listen({ onUnhandledRequest: 'error' }))
|
||||
afterAll(() => server.close())
|
||||
afterEach(() => server.resetHandlers())
|
||||
|
||||
describe('Template.assignTags', () => {
|
||||
test('assigns a single tag', async () => {
|
||||
const result = await Template.assignTags('my-template:v1.0', 'production')
|
||||
expect(result.buildId).toBe('00000000-0000-0000-0000-000000000000')
|
||||
expect(result.tags).toContain('production')
|
||||
})
|
||||
|
||||
test('assigns multiple tags', async () => {
|
||||
const result = await Template.assignTags('my-template:v1.0', [
|
||||
'production',
|
||||
'stable',
|
||||
])
|
||||
expect(result.buildId).toBe('00000000-0000-0000-0000-000000000000')
|
||||
expect(result.tags).toContain('production')
|
||||
expect(result.tags).toContain('stable')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Template.removeTags', () => {
|
||||
test('deletes a single tag', async () => {
|
||||
// Should not throw
|
||||
await expect(
|
||||
Template.removeTags('my-template', 'production')
|
||||
).resolves.toBeUndefined()
|
||||
})
|
||||
|
||||
test('deletes multiple tags', async () => {
|
||||
// Should not throw
|
||||
await expect(
|
||||
Template.removeTags('my-template', ['production', 'staging'])
|
||||
).resolves.toBeUndefined()
|
||||
})
|
||||
|
||||
test('handles 404 error for nonexistent template', async () => {
|
||||
await expect(
|
||||
Template.removeTags('nonexistent', ['tag'])
|
||||
).rejects.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Template.getTags', () => {
|
||||
test('returns tags for a template', async () => {
|
||||
const tags = await Template.getTags('my-template-id')
|
||||
expect(tags).toHaveLength(2)
|
||||
expect(tags[0].tag).toBe('v1.0')
|
||||
expect(tags[0].buildId).toBe('00000000-0000-0000-0000-000000000000')
|
||||
expect(tags[0].createdAt).toBeInstanceOf(Date)
|
||||
expect(tags[1].tag).toBe('latest')
|
||||
expect(tags[1].buildId).toBe('11111111-1111-1111-1111-111111111111')
|
||||
expect(tags[1].createdAt).toBeInstanceOf(Date)
|
||||
})
|
||||
|
||||
test('handles 404 for nonexistent template', async () => {
|
||||
await expect(Template.getTags('nonexistent')).rejects.toThrow()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// Integration tests
|
||||
buildTemplateTest.skipIf(isDebug)(
|
||||
'build template with tags, assign and delete',
|
||||
{ timeout: 300_000 },
|
||||
async ({ buildTemplate }) => {
|
||||
const templateName = 'e2b-tags-test'
|
||||
const initialTag = `${templateName}:v1-${randomUUID()}`
|
||||
|
||||
// Build a template with initial tag
|
||||
const template = Template().fromBaseImage()
|
||||
const buildInfo = await buildTemplate(template, { name: initialTag })
|
||||
|
||||
expect(buildInfo.buildId).toBeTruthy()
|
||||
expect(buildInfo.templateId).toBeTruthy()
|
||||
|
||||
// Assign additional tags (just tag names, not full alias:tag format)
|
||||
const tagInfo = await Template.assignTags(initialTag, [
|
||||
'production',
|
||||
'latest',
|
||||
])
|
||||
|
||||
expect(tagInfo.buildId).toBeTruthy()
|
||||
expect(tagInfo.tags).toContain('production')
|
||||
expect(tagInfo.tags).toContain('latest')
|
||||
}
|
||||
)
|
||||
|
||||
buildTemplateTest.skipIf(isDebug)(
|
||||
'assign single tag to existing template',
|
||||
{ timeout: 300_000 },
|
||||
async ({ buildTemplate }) => {
|
||||
const templateName = 'e2b-tags-test'
|
||||
const initialTag = `${templateName}:v1-${randomUUID()}`
|
||||
|
||||
const template = Template().fromBaseImage()
|
||||
await buildTemplate(template, { name: initialTag })
|
||||
|
||||
// Assign single tag (just tag name, not full alias:tag format)
|
||||
const tagInfo = await Template.assignTags(initialTag, 'stable')
|
||||
|
||||
expect(tagInfo.buildId).toBeTruthy()
|
||||
expect(tagInfo.tags).toContain('stable')
|
||||
}
|
||||
)
|
||||
|
||||
buildTemplateTest.skipIf(isDebug)(
|
||||
'rejects invalid tag format - missing alias',
|
||||
{ timeout: 300_000 },
|
||||
async ({ buildTemplate }) => {
|
||||
const templateName = 'e2b-tags-test'
|
||||
const initialTag = `${templateName}:v1-${randomUUID()}`
|
||||
|
||||
const template = Template().fromBaseImage()
|
||||
await buildTemplate(template, { name: initialTag })
|
||||
|
||||
// Tag without alias (starts with colon) should be rejected
|
||||
await expect(
|
||||
Template.assignTags(initialTag, ':invalid-tag')
|
||||
).rejects.toThrow()
|
||||
}
|
||||
)
|
||||
|
||||
buildTemplateTest.skipIf(isDebug)(
|
||||
'rejects invalid tag format - missing tag',
|
||||
{ timeout: 300_000 },
|
||||
async ({ buildTemplate }) => {
|
||||
const templateName = 'e2b-tags-test'
|
||||
const initialTag = `${templateName}:v1-${randomUUID()}`
|
||||
|
||||
const template = Template().fromBaseImage()
|
||||
await buildTemplate(template, { name: initialTag })
|
||||
|
||||
// Tag without tag portion (ends with colon) should be rejected
|
||||
await expect(
|
||||
Template.assignTags(initialTag, `${templateName}:`)
|
||||
).rejects.toThrow()
|
||||
}
|
||||
)
|
||||
@@ -0,0 +1,70 @@
|
||||
import { describe, test, expect, beforeAll, afterAll } from 'vitest'
|
||||
import { writeFile, mkdtemp, rm } from 'fs/promises'
|
||||
import { join } from 'path'
|
||||
import { tmpdir } from 'os'
|
||||
import { createServer, type IncomingMessage, type Server } from 'http'
|
||||
import { AddressInfo } from 'net'
|
||||
import { uploadFile } from '../../src/template/buildApi'
|
||||
|
||||
// Regression test for e2b-dev/e2b#1243 — uploadFile used to pass a Node
|
||||
// Readable directly to fetch, which made undici fall back to
|
||||
// Transfer-Encoding: chunked. S3 presigned PUT URLs reject that with 501
|
||||
// NotImplemented. The fix spools the archive to a temporary file and streams
|
||||
// it from disk with an explicit Content-Length.
|
||||
describe('uploadFile transfer encoding', () => {
|
||||
let testDir: string
|
||||
let server: Server
|
||||
let baseUrl: string
|
||||
let capturedHeaders: IncomingMessage['headers'] = {}
|
||||
let capturedBodyLength = 0
|
||||
|
||||
beforeAll(async () => {
|
||||
testDir = await mkdtemp(join(tmpdir(), 'uploadFile-test-'))
|
||||
await writeFile(join(testDir, 'hello.txt'), 'hello world')
|
||||
|
||||
server = createServer((req, res) => {
|
||||
capturedHeaders = req.headers
|
||||
let bytes = 0
|
||||
req.on('data', (chunk: Buffer) => {
|
||||
bytes += chunk.length
|
||||
})
|
||||
req.on('end', () => {
|
||||
capturedBodyLength = bytes
|
||||
res.writeHead(200)
|
||||
res.end()
|
||||
})
|
||||
})
|
||||
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve))
|
||||
const { port } = server.address() as AddressInfo
|
||||
baseUrl = `http://127.0.0.1:${port}/upload`
|
||||
})
|
||||
|
||||
afterAll(async () => {
|
||||
await new Promise<void>((resolve) => server.close(() => resolve()))
|
||||
await rm(testDir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
test('sets Content-Length and does not use chunked transfer encoding', async () => {
|
||||
await uploadFile(
|
||||
{
|
||||
fileName: '*.txt',
|
||||
fileContextPath: testDir,
|
||||
url: baseUrl,
|
||||
ignorePatterns: [],
|
||||
resolveSymlinks: false,
|
||||
gzip: true,
|
||||
},
|
||||
undefined
|
||||
)
|
||||
|
||||
expect(capturedHeaders['content-length']).toBeDefined()
|
||||
const contentLength = Number(capturedHeaders['content-length'])
|
||||
expect(contentLength).toBeGreaterThan(0)
|
||||
expect(contentLength).toBe(capturedBodyLength)
|
||||
|
||||
const transferEncoding = capturedHeaders['transfer-encoding']
|
||||
if (transferEncoding !== undefined) {
|
||||
expect(transferEncoding.toLowerCase()).not.toContain('chunked')
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,328 @@
|
||||
import { expect, test, describe, beforeAll, afterAll, beforeEach } from 'vitest'
|
||||
import { writeFile, mkdir, rm } from 'fs/promises'
|
||||
import { join, basename } from 'path'
|
||||
import { getAllFilesInPath } from '../../../src/template/utils'
|
||||
|
||||
describe('getAllFilesInPath', () => {
|
||||
const testDir = join(__dirname, 'folder')
|
||||
|
||||
beforeAll(async () => {
|
||||
await rm(testDir, { recursive: true, force: true })
|
||||
await mkdir(testDir, { recursive: true })
|
||||
})
|
||||
|
||||
afterAll(async () => {
|
||||
await rm(testDir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
beforeEach(async () => {
|
||||
await rm(testDir, { recursive: true, force: true })
|
||||
await mkdir(testDir, { recursive: true })
|
||||
})
|
||||
|
||||
test('should return files matching a simple pattern', async () => {
|
||||
// Create test files
|
||||
await writeFile(join(testDir, 'file1.txt'), 'content1')
|
||||
await writeFile(join(testDir, 'file2.txt'), 'content2')
|
||||
await writeFile(join(testDir, 'file3.js'), 'content3')
|
||||
|
||||
const files = await getAllFilesInPath('*.txt', testDir, [])
|
||||
|
||||
expect(files).toHaveLength(2)
|
||||
expect(files.some((f) => f.fullpath().endsWith('file1.txt'))).toBe(true)
|
||||
expect(files.some((f) => f.fullpath().endsWith('file2.txt'))).toBe(true)
|
||||
expect(files.some((f) => f.fullpath().endsWith('file3.js'))).toBe(false)
|
||||
})
|
||||
|
||||
test('should handle directory patterns recursively', async () => {
|
||||
// Create nested directory structure
|
||||
await mkdir(join(testDir, 'src'), { recursive: true })
|
||||
await mkdir(join(testDir, 'src', 'components'), { recursive: true })
|
||||
await mkdir(join(testDir, 'src', 'utils'), { recursive: true })
|
||||
|
||||
await writeFile(join(testDir, 'src', 'index.ts'), 'index content')
|
||||
await writeFile(
|
||||
join(testDir, 'src', 'components', 'Button.tsx'),
|
||||
'button content'
|
||||
)
|
||||
await writeFile(
|
||||
join(testDir, 'src', 'utils', 'helper.ts'),
|
||||
'helper content'
|
||||
)
|
||||
await writeFile(join(testDir, 'README.md'), 'readme content')
|
||||
|
||||
const files = await getAllFilesInPath('src', testDir, [])
|
||||
|
||||
expect(files).toHaveLength(6) // 3 files + 3 directories (src, components, utils)
|
||||
expect(files.some((f) => f.fullpath().endsWith('index.ts'))).toBe(true)
|
||||
expect(files.some((f) => f.fullpath().endsWith('Button.tsx'))).toBe(true)
|
||||
expect(files.some((f) => f.fullpath().endsWith('helper.ts'))).toBe(true)
|
||||
expect(files.some((f) => f.fullpath().endsWith('README.md'))).toBe(false)
|
||||
})
|
||||
|
||||
test('should respect ignore patterns', async () => {
|
||||
// Create test files
|
||||
await writeFile(join(testDir, 'file1.txt'), 'content1')
|
||||
await writeFile(join(testDir, 'file2.txt'), 'content2')
|
||||
await writeFile(join(testDir, 'temp.txt'), 'temp content')
|
||||
await writeFile(join(testDir, 'backup.txt'), 'backup content')
|
||||
|
||||
const files = await getAllFilesInPath('*.txt', testDir, [
|
||||
'temp*',
|
||||
'backup*',
|
||||
])
|
||||
|
||||
expect(files).toHaveLength(2)
|
||||
expect(files.some((f) => f.fullpath().endsWith('file1.txt'))).toBe(true)
|
||||
expect(files.some((f) => f.fullpath().endsWith('file2.txt'))).toBe(true)
|
||||
expect(files.some((f) => f.fullpath().endsWith('temp.txt'))).toBe(false)
|
||||
expect(files.some((f) => f.fullpath().endsWith('backup.txt'))).toBe(false)
|
||||
})
|
||||
|
||||
test('should handle complex ignore patterns', async () => {
|
||||
// Create nested structure with various file types
|
||||
await mkdir(join(testDir, 'src'), { recursive: true })
|
||||
await mkdir(join(testDir, 'src', 'components'), { recursive: true })
|
||||
await mkdir(join(testDir, 'src', 'utils'), { recursive: true })
|
||||
await mkdir(join(testDir, 'tests'), { recursive: true })
|
||||
|
||||
await writeFile(join(testDir, 'src', 'index.ts'), 'index content')
|
||||
await writeFile(
|
||||
join(testDir, 'src', 'components', 'Button.tsx'),
|
||||
'button content'
|
||||
)
|
||||
await writeFile(
|
||||
join(testDir, 'src', 'utils', 'helper.ts'),
|
||||
'helper content'
|
||||
)
|
||||
await writeFile(join(testDir, 'tests', 'test.spec.ts'), 'test content')
|
||||
await writeFile(
|
||||
join(testDir, 'src', 'components', 'Button.test.tsx'),
|
||||
'test content'
|
||||
)
|
||||
await writeFile(
|
||||
join(testDir, 'src', 'utils', 'helper.spec.ts'),
|
||||
'spec content'
|
||||
)
|
||||
|
||||
const files = await getAllFilesInPath('src', testDir, [
|
||||
'**/*.test.*',
|
||||
'**/*.spec.*',
|
||||
])
|
||||
|
||||
expect(files).toHaveLength(6) // 3 files + 3 directories (src, components, utils)
|
||||
expect(files.some((f) => f.fullpath().endsWith('index.ts'))).toBe(true)
|
||||
expect(files.some((f) => f.fullpath().endsWith('Button.tsx'))).toBe(true)
|
||||
expect(files.some((f) => f.fullpath().endsWith('helper.ts'))).toBe(true)
|
||||
expect(files.some((f) => f.fullpath().endsWith('Button.test.tsx'))).toBe(
|
||||
false
|
||||
)
|
||||
expect(files.some((f) => f.fullpath().endsWith('helper.spec.ts'))).toBe(
|
||||
false
|
||||
)
|
||||
})
|
||||
|
||||
test('should handle empty directories', async () => {
|
||||
await mkdir(join(testDir, 'empty'), { recursive: true })
|
||||
await writeFile(join(testDir, 'file.txt'), 'content')
|
||||
|
||||
const files = await getAllFilesInPath('empty', testDir, [])
|
||||
|
||||
expect(files).toHaveLength(1) // The empty directory itself
|
||||
})
|
||||
|
||||
test('should handle mixed files and directories', async () => {
|
||||
// Create a mix of files and directories
|
||||
await writeFile(join(testDir, 'file1.txt'), 'content1')
|
||||
await mkdir(join(testDir, 'dir1'), { recursive: true })
|
||||
await writeFile(join(testDir, 'dir1', 'file2.txt'), 'content2')
|
||||
await writeFile(join(testDir, 'file3.txt'), 'content3')
|
||||
|
||||
const files = await getAllFilesInPath('*', testDir, [])
|
||||
|
||||
expect(files).toHaveLength(4) // 3 files + 1 directory
|
||||
expect(files.some((f) => f.fullpath().endsWith('file1.txt'))).toBe(true)
|
||||
expect(files.some((f) => f.fullpath().endsWith('file2.txt'))).toBe(true)
|
||||
expect(files.some((f) => f.fullpath().endsWith('file3.txt'))).toBe(true)
|
||||
})
|
||||
|
||||
test('should handle glob patterns with subdirectories', async () => {
|
||||
// Create nested structure
|
||||
await mkdir(join(testDir, 'src'), { recursive: true })
|
||||
await mkdir(join(testDir, 'src', 'components'), { recursive: true })
|
||||
await mkdir(join(testDir, 'src', 'utils'), { recursive: true })
|
||||
|
||||
await writeFile(join(testDir, 'src', 'index.ts'), 'index content')
|
||||
await writeFile(
|
||||
join(testDir, 'src', 'components', 'Button.tsx'),
|
||||
'button content'
|
||||
)
|
||||
await writeFile(
|
||||
join(testDir, 'src', 'utils', 'helper.ts'),
|
||||
'helper content'
|
||||
)
|
||||
await writeFile(
|
||||
join(testDir, 'src', 'components', 'Button.css'),
|
||||
'css content'
|
||||
)
|
||||
|
||||
const files = await getAllFilesInPath('src/**/*', testDir, [])
|
||||
|
||||
expect(files).toHaveLength(6) // 4 files + 2 directories (components, utils)
|
||||
expect(files.some((f) => f.fullpath().endsWith('index.ts'))).toBe(true)
|
||||
expect(files.some((f) => f.fullpath().endsWith('Button.tsx'))).toBe(true)
|
||||
expect(files.some((f) => f.fullpath().endsWith('helper.ts'))).toBe(true)
|
||||
expect(files.some((f) => f.fullpath().endsWith('Button.css'))).toBe(true)
|
||||
})
|
||||
|
||||
test('should handle specific file extensions', async () => {
|
||||
await writeFile(join(testDir, 'file1.ts'), 'ts content')
|
||||
await writeFile(join(testDir, 'file2.js'), 'js content')
|
||||
await writeFile(join(testDir, 'file3.tsx'), 'tsx content')
|
||||
await writeFile(join(testDir, 'file4.css'), 'css content')
|
||||
|
||||
const files = await getAllFilesInPath('*.ts', testDir, [])
|
||||
|
||||
expect(files).toHaveLength(1)
|
||||
expect(files.some((f) => f.fullpath().endsWith('file1.ts'))).toBe(true)
|
||||
})
|
||||
|
||||
test('should return sorted files', async () => {
|
||||
await writeFile(join(testDir, 'zebra.txt'), 'z content')
|
||||
await writeFile(join(testDir, 'apple.txt'), 'a content')
|
||||
await writeFile(join(testDir, 'banana.txt'), 'b content')
|
||||
|
||||
const files = await getAllFilesInPath('*.txt', testDir, [])
|
||||
|
||||
expect(files).toHaveLength(3)
|
||||
// Files must be returned sorted by full path so the files hash is
|
||||
// independent of filesystem traversal order
|
||||
const fileNames = files.map((f) => basename(f.fullpath()))
|
||||
expect(fileNames).toEqual(['apple.txt', 'banana.txt', 'zebra.txt'])
|
||||
})
|
||||
|
||||
test('should return nested files sorted by full path', async () => {
|
||||
await mkdir(join(testDir, 'b'), { recursive: true })
|
||||
await mkdir(join(testDir, 'a'), { recursive: true })
|
||||
await writeFile(join(testDir, 'zebra.txt'), 'z content')
|
||||
await writeFile(join(testDir, 'b', 'file.txt'), 'b content')
|
||||
await writeFile(join(testDir, 'a', 'file.txt'), 'a content')
|
||||
|
||||
const files = await getAllFilesInPath('*', testDir, [])
|
||||
|
||||
const paths = files.map((f) => f.fullpath())
|
||||
expect(paths).toEqual([...paths].sort())
|
||||
})
|
||||
|
||||
test('should handle no matching files', async () => {
|
||||
await writeFile(join(testDir, 'file.txt'), 'content')
|
||||
|
||||
const files = await getAllFilesInPath('*.js', testDir, [])
|
||||
|
||||
expect(files).toHaveLength(0)
|
||||
})
|
||||
|
||||
test('should include dotfiles', async () => {
|
||||
// Create regular and dotfiles
|
||||
await writeFile(join(testDir, 'file.txt'), 'content')
|
||||
await writeFile(join(testDir, '.env'), 'SECRET=123')
|
||||
await writeFile(join(testDir, '.gitignore'), 'node_modules')
|
||||
|
||||
const files = await getAllFilesInPath('*', testDir, [])
|
||||
|
||||
expect(files).toHaveLength(3)
|
||||
expect(files.some((f) => f.fullpath().endsWith('file.txt'))).toBe(true)
|
||||
expect(files.some((f) => f.fullpath().endsWith('.env'))).toBe(true)
|
||||
expect(files.some((f) => f.fullpath().endsWith('.gitignore'))).toBe(true)
|
||||
})
|
||||
|
||||
test('should include dotfiles in subdirectories', async () => {
|
||||
await mkdir(join(testDir, 'src'), { recursive: true })
|
||||
await writeFile(join(testDir, 'src', 'index.ts'), 'content')
|
||||
await writeFile(join(testDir, 'src', '.env.local'), 'SECRET=123')
|
||||
|
||||
const files = await getAllFilesInPath('src', testDir, [])
|
||||
|
||||
expect(files.some((f) => f.fullpath().endsWith('index.ts'))).toBe(true)
|
||||
expect(files.some((f) => f.fullpath().endsWith('.env.local'))).toBe(true)
|
||||
})
|
||||
|
||||
test('should include dotdirectories and their contents', async () => {
|
||||
await mkdir(join(testDir, '.hidden'), { recursive: true })
|
||||
await writeFile(join(testDir, '.hidden', 'config.json'), '{}')
|
||||
await writeFile(join(testDir, 'visible.txt'), 'content')
|
||||
|
||||
const files = await getAllFilesInPath('*', testDir, [])
|
||||
|
||||
expect(files.some((f) => f.fullpath().endsWith('.hidden'))).toBe(true)
|
||||
expect(files.some((f) => f.fullpath().endsWith('config.json'))).toBe(true)
|
||||
expect(files.some((f) => f.fullpath().endsWith('visible.txt'))).toBe(true)
|
||||
})
|
||||
|
||||
test('should respect ignore patterns for dotfiles', async () => {
|
||||
await writeFile(join(testDir, '.env'), 'SECRET=123')
|
||||
await writeFile(join(testDir, '.gitignore'), 'node_modules')
|
||||
await writeFile(join(testDir, 'file.txt'), 'content')
|
||||
|
||||
const files = await getAllFilesInPath('*', testDir, ['.env'])
|
||||
|
||||
expect(files).toHaveLength(2)
|
||||
expect(files.some((f) => f.fullpath().endsWith('.env'))).toBe(false)
|
||||
expect(files.some((f) => f.fullpath().endsWith('.gitignore'))).toBe(true)
|
||||
expect(files.some((f) => f.fullpath().endsWith('file.txt'))).toBe(true)
|
||||
})
|
||||
|
||||
test('should handle listing all files in current directory with dot pattern', async () => {
|
||||
// Create a small directory tree inside the test directory
|
||||
await writeFile(join(testDir, 'root.txt'), 'root')
|
||||
await mkdir(join(testDir, 'subdir'), { recursive: true })
|
||||
await writeFile(join(testDir, 'subdir', 'nested.txt'), 'nested')
|
||||
|
||||
const files = await getAllFilesInPath('.', testDir, [])
|
||||
|
||||
// We should get at least the testDir itself (.) plus its children
|
||||
expect(files.length).toBeGreaterThanOrEqual(3)
|
||||
|
||||
// All returned paths must stay within the testDir - we must not traverse the whole filesystem
|
||||
const allWithinTestDir = files.every((f) =>
|
||||
f.fullpath().startsWith(testDir)
|
||||
)
|
||||
expect(allWithinTestDir).toBe(true)
|
||||
})
|
||||
|
||||
test('should handle complex ignore patterns with directories', async () => {
|
||||
// Create a complex structure
|
||||
await mkdir(join(testDir, 'src'), { recursive: true })
|
||||
await mkdir(join(testDir, 'src', 'components'), { recursive: true })
|
||||
await mkdir(join(testDir, 'src', 'utils'), { recursive: true })
|
||||
await mkdir(join(testDir, 'src', 'tests'), { recursive: true })
|
||||
await mkdir(join(testDir, 'dist'), { recursive: true })
|
||||
|
||||
await writeFile(join(testDir, 'src', 'index.ts'), 'index content')
|
||||
await writeFile(
|
||||
join(testDir, 'src', 'components', 'Button.tsx'),
|
||||
'button content'
|
||||
)
|
||||
await writeFile(
|
||||
join(testDir, 'src', 'utils', 'helper.ts'),
|
||||
'helper content'
|
||||
)
|
||||
await writeFile(
|
||||
join(testDir, 'src', 'tests', 'test.spec.ts'),
|
||||
'test content'
|
||||
)
|
||||
await writeFile(join(testDir, 'dist', 'bundle.js'), 'bundle content')
|
||||
await writeFile(join(testDir, 'README.md'), 'readme content')
|
||||
|
||||
const files = await getAllFilesInPath('src', testDir, [
|
||||
'**/tests/**',
|
||||
'**/*.spec.*',
|
||||
])
|
||||
|
||||
expect(files).toHaveLength(6) // 3 files + 3 directories (src, components, utils)
|
||||
expect(files.some((f) => f.fullpath().endsWith('index.ts'))).toBe(true)
|
||||
expect(files.some((f) => f.fullpath().endsWith('Button.tsx'))).toBe(true)
|
||||
expect(files.some((f) => f.fullpath().endsWith('helper.ts'))).toBe(true)
|
||||
expect(files.some((f) => f.fullpath().endsWith('test.spec.ts'))).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,28 @@
|
||||
import { expect, test } from 'vitest'
|
||||
import os from 'node:os'
|
||||
import path from 'node:path'
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url'
|
||||
import { getCallerDirectory } from '../../../src/template/utils'
|
||||
|
||||
test('getCallerDirectory', () => {
|
||||
// getCallerDirectory(1) should return the directory of the current file
|
||||
// __dirname is the directory of the current file.
|
||||
// Normalize before comparing: on Windows the stack-trace file name reported
|
||||
// by vite/vitest uses forward slashes (e.g. "D:/a/...") whereas __dirname
|
||||
// uses native backslashes — the directory is the same, only the separators
|
||||
// differ.
|
||||
expect(path.normalize(getCallerDirectory(1)!)).toBe(path.normalize(__dirname))
|
||||
})
|
||||
|
||||
test('getCallerDirectory handles file:// URLs from ESM modules', () => {
|
||||
// In ESM modules, CallSite.getFileName() returns file:// URLs
|
||||
// This test verifies that the conversion works correctly
|
||||
const testDirectoryPath = path.join(os.tmpdir(), 'test', 'project', 'src')
|
||||
const testFilePath = path.join(testDirectoryPath, 'template.ts')
|
||||
const fileUrl = pathToFileURL(testFilePath)
|
||||
|
||||
// Verify that fileURLToPath correctly converts the URL
|
||||
// This is the same conversion used in getCallerDirectory
|
||||
expect(fileURLToPath(fileUrl)).toBe(testFilePath)
|
||||
expect(path.dirname(fileURLToPath(fileUrl))).toBe(testDirectoryPath)
|
||||
})
|
||||
@@ -0,0 +1,39 @@
|
||||
import { describe, expect, test } from 'vitest'
|
||||
import { normalizeBuildArguments } from '../../../src/template/utils'
|
||||
import { TemplateError } from '../../../src/errors'
|
||||
|
||||
describe('normalizeBuildArguments', () => {
|
||||
test('handles string name', () => {
|
||||
const result = normalizeBuildArguments('my-template:v1.0')
|
||||
expect(result.name).toBe('my-template:v1.0')
|
||||
})
|
||||
|
||||
test('handles string name with options', () => {
|
||||
const result = normalizeBuildArguments('my-template:v1.0', { cpuCount: 4 })
|
||||
expect(result.name).toBe('my-template:v1.0')
|
||||
expect(result.buildOptions).toEqual({ cpuCount: 4 })
|
||||
})
|
||||
|
||||
test('handles legacy options with alias', () => {
|
||||
const result = normalizeBuildArguments({ alias: 'my-template' })
|
||||
expect(result.name).toBe('my-template')
|
||||
})
|
||||
|
||||
test('removes alias from build options', () => {
|
||||
const result = normalizeBuildArguments({
|
||||
alias: 'my-template',
|
||||
cpuCount: 4,
|
||||
})
|
||||
expect(result.name).toBe('my-template')
|
||||
expect(result.buildOptions).toEqual({ cpuCount: 4 })
|
||||
expect(result.buildOptions).not.toHaveProperty('alias')
|
||||
})
|
||||
|
||||
test('throws for empty name', () => {
|
||||
expect(() => normalizeBuildArguments({ alias: '' })).toThrow(TemplateError)
|
||||
})
|
||||
|
||||
test('throws for missing name', () => {
|
||||
expect(() => normalizeBuildArguments({} as any)).toThrow(TemplateError)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,49 @@
|
||||
import { expect, test, describe } from 'vitest'
|
||||
import {
|
||||
waitForFile,
|
||||
waitForPort,
|
||||
waitForProcess,
|
||||
waitForTimeout,
|
||||
waitForURL,
|
||||
} from '../../../src/template/readycmd'
|
||||
|
||||
describe('readycmd', () => {
|
||||
test('waitForPort matches the exact listening port', () => {
|
||||
const cmd = waitForPort(80).getCmd()
|
||||
expect(cmd).toBe('[ -n "$(ss -Htuln sport = :80)" ]')
|
||||
})
|
||||
|
||||
test('waitForURL quotes the url', () => {
|
||||
const cmd = waitForURL('http://localhost:3000/health?ready=1&x=y').getCmd()
|
||||
expect(cmd).toBe(
|
||||
'curl -s -o /dev/null -w "%{http_code}" \'http://localhost:3000/health?ready=1&x=y\' | grep -q "200"'
|
||||
)
|
||||
})
|
||||
|
||||
test('waitForURL keeps simple urls unquoted', () => {
|
||||
const cmd = waitForURL('http://localhost:3000/health').getCmd()
|
||||
expect(cmd).toBe(
|
||||
'curl -s -o /dev/null -w "%{http_code}" http://localhost:3000/health | grep -q "200"'
|
||||
)
|
||||
})
|
||||
|
||||
test('waitForProcess quotes the process name', () => {
|
||||
const cmd = waitForProcess('my daemon').getCmd()
|
||||
expect(cmd).toBe("pgrep 'my daemon' > /dev/null")
|
||||
})
|
||||
|
||||
test('waitForFile quotes the filename', () => {
|
||||
const cmd = waitForFile('/tmp/ready file').getCmd()
|
||||
expect(cmd).toBe("[ -f '/tmp/ready file' ]")
|
||||
})
|
||||
|
||||
test('waitForFile keeps simple paths unquoted', () => {
|
||||
const cmd = waitForFile('/tmp/ready').getCmd()
|
||||
expect(cmd).toBe('[ -f /tmp/ready ]')
|
||||
})
|
||||
|
||||
test('waitForTimeout converts milliseconds to seconds', () => {
|
||||
expect(waitForTimeout(5000).getCmd()).toBe('sleep 5')
|
||||
expect(waitForTimeout(100).getCmd()).toBe('sleep 1')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,314 @@
|
||||
import {
|
||||
expect,
|
||||
test,
|
||||
describe,
|
||||
beforeAll,
|
||||
afterAll,
|
||||
beforeEach,
|
||||
vi,
|
||||
} from 'vitest'
|
||||
import { writeFile, mkdir, rm, symlink, readFile, access } from 'fs/promises'
|
||||
import fs from 'node:fs'
|
||||
import { Readable } from 'node:stream'
|
||||
import { pipeline } from 'node:stream/promises'
|
||||
import { join } from 'path'
|
||||
import { tmpdir } from 'os'
|
||||
import { tarFileStream } from '../../../src/template/utils'
|
||||
import * as tar from 'tar'
|
||||
import { ReadEntry } from 'tar'
|
||||
|
||||
describe('tarFileStream', () => {
|
||||
const testDir = join(__dirname, 'tar-test-folder')
|
||||
|
||||
beforeAll(async () => {
|
||||
await rm(testDir, { recursive: true, force: true })
|
||||
await mkdir(testDir, { recursive: true })
|
||||
})
|
||||
|
||||
afterAll(async () => {
|
||||
await rm(testDir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
beforeEach(async () => {
|
||||
await rm(testDir, { recursive: true, force: true })
|
||||
await mkdir(testDir, { recursive: true })
|
||||
})
|
||||
|
||||
/**
|
||||
* Wait until a path no longer exists. Cleanup runs asynchronously from the
|
||||
* stream's `close` handler, so it may not complete in the same tick.
|
||||
*/
|
||||
async function waitUntilGone(target: string): Promise<void> {
|
||||
for (let i = 0; i < 100; i++) {
|
||||
try {
|
||||
await access(target)
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 10))
|
||||
}
|
||||
throw new Error(`path was not removed: ${target}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Pipe an archive stream into tar's extractor and collect its entries.
|
||||
*/
|
||||
async function extractTarStream(
|
||||
stream: Readable
|
||||
): Promise<{ extractDir: string; members: ReadEntry[] }> {
|
||||
const extractDir = join(
|
||||
tmpdir(),
|
||||
`tar-extract-${Date.now()}-${Math.random()}`
|
||||
)
|
||||
await mkdir(extractDir, { recursive: true })
|
||||
|
||||
const members: ReadEntry[] = []
|
||||
await pipeline(
|
||||
stream,
|
||||
tar.extract({
|
||||
cwd: extractDir,
|
||||
gzip: true,
|
||||
onentry: (entry: ReadEntry) => {
|
||||
members.push(entry)
|
||||
},
|
||||
})
|
||||
)
|
||||
|
||||
return { extractDir, members }
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract archive contents into a map of path -> file contents.
|
||||
*/
|
||||
async function extractTarContents(
|
||||
stream: Readable
|
||||
): Promise<Map<string, Buffer | null>> {
|
||||
const { extractDir, members } = await extractTarStream(stream)
|
||||
const contents = new Map<string, Buffer | null>()
|
||||
|
||||
for (const member of members) {
|
||||
if (member.type === 'File') {
|
||||
try {
|
||||
contents.set(
|
||||
member.path,
|
||||
await readFile(join(extractDir, member.path))
|
||||
)
|
||||
} catch {
|
||||
// File might not exist
|
||||
}
|
||||
} else if (member.type === 'Directory') {
|
||||
contents.set(member.path, null)
|
||||
} else if (member.type === 'SymbolicLink') {
|
||||
contents.set(member.path, Buffer.from(member.linkpath || ''))
|
||||
}
|
||||
}
|
||||
|
||||
await rm(extractDir, { recursive: true, force: true })
|
||||
return contents
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract archive members into a map of path -> entry.
|
||||
*/
|
||||
async function getTarMembers(
|
||||
stream: Readable
|
||||
): Promise<Map<string, ReadEntry>> {
|
||||
const { extractDir, members } = await extractTarStream(stream)
|
||||
const map = new Map<string, ReadEntry>()
|
||||
for (const member of members) {
|
||||
map.set(member.path, member)
|
||||
}
|
||||
await rm(extractDir, { recursive: true, force: true })
|
||||
return map
|
||||
}
|
||||
|
||||
test('should create tar with simple files', async () => {
|
||||
await writeFile(join(testDir, 'file1.txt'), 'content1')
|
||||
await writeFile(join(testDir, 'file2.txt'), 'content2')
|
||||
|
||||
const { stream, size } = await tarFileStream(
|
||||
'*.txt',
|
||||
testDir,
|
||||
[],
|
||||
false,
|
||||
true
|
||||
)
|
||||
expect(size).toBeGreaterThan(0)
|
||||
|
||||
const contents = await extractTarContents(stream)
|
||||
|
||||
expect(contents.size).toBe(2)
|
||||
expect(contents.get('file1.txt')?.toString()).toBe('content1')
|
||||
expect(contents.get('file2.txt')?.toString()).toBe('content2')
|
||||
})
|
||||
|
||||
test('should create an uncompressed tar when gzip is disabled', async () => {
|
||||
await writeFile(join(testDir, 'file1.txt'), 'content1')
|
||||
await writeFile(join(testDir, 'file2.txt'), 'content2')
|
||||
|
||||
const { stream } = await tarFileStream('*.txt', testDir, [], false, false)
|
||||
|
||||
// Buffer the archive so we can both assert it is not gzipped and extract it
|
||||
const chunks: Buffer[] = []
|
||||
for await (const chunk of stream as unknown as AsyncIterable<Buffer>) {
|
||||
chunks.push(Buffer.from(chunk))
|
||||
}
|
||||
const archive = Buffer.concat(chunks)
|
||||
|
||||
// gzip streams start with the magic bytes 0x1f 0x8b — an uncompressed tar must not
|
||||
expect(archive[0]).not.toBe(0x1f)
|
||||
expect(archive.subarray(0, 2).equals(Buffer.from([0x1f, 0x8b]))).toBe(false)
|
||||
|
||||
// And it should still extract to the original files
|
||||
const tarFile = join(tmpdir(), `tar-uncompressed-${Date.now()}.tar`)
|
||||
await writeFile(tarFile, archive)
|
||||
const extractDir = join(tmpdir(), `tar-uncompressed-extract-${Date.now()}`)
|
||||
await mkdir(extractDir, { recursive: true })
|
||||
await tar.extract({ file: tarFile, cwd: extractDir })
|
||||
expect((await readFile(join(extractDir, 'file1.txt'))).toString()).toBe(
|
||||
'content1'
|
||||
)
|
||||
expect((await readFile(join(extractDir, 'file2.txt'))).toString()).toBe(
|
||||
'content2'
|
||||
)
|
||||
await rm(tarFile, { force: true })
|
||||
await rm(extractDir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
test('should respect ignore patterns', async () => {
|
||||
await writeFile(join(testDir, 'file1.txt'), 'content1')
|
||||
await writeFile(join(testDir, 'file2.txt'), 'content2')
|
||||
await writeFile(join(testDir, 'temp.txt'), 'temp content')
|
||||
await writeFile(join(testDir, 'backup.txt'), 'backup content')
|
||||
|
||||
const { stream } = await tarFileStream(
|
||||
'*.txt',
|
||||
testDir,
|
||||
['temp*', 'backup*'],
|
||||
false,
|
||||
true
|
||||
)
|
||||
|
||||
const contents = await extractTarContents(stream)
|
||||
|
||||
expect(contents.size).toBe(2)
|
||||
expect(contents.get('file1.txt')?.toString()).toBe('content1')
|
||||
expect(contents.get('file2.txt')?.toString()).toBe('content2')
|
||||
expect(contents.has('temp.txt')).toBe(false)
|
||||
expect(contents.has('backup.txt')).toBe(false)
|
||||
})
|
||||
|
||||
test('should handle nested files', async () => {
|
||||
const nestedDir = join(testDir, 'src', 'components')
|
||||
await mkdir(nestedDir, { recursive: true })
|
||||
|
||||
await writeFile(join(testDir, 'src', 'index.ts'), 'index content')
|
||||
await writeFile(join(nestedDir, 'Button.tsx'), 'button content')
|
||||
|
||||
const { stream } = await tarFileStream('src', testDir, [], false, true)
|
||||
|
||||
const contents = await extractTarContents(stream)
|
||||
const paths = Array.from(contents.keys())
|
||||
expect(paths.some((p) => p.includes('src'))).toBe(true)
|
||||
expect(paths.some((p) => p.includes('index.ts'))).toBe(true)
|
||||
expect(paths.some((p) => p.includes('Button.tsx'))).toBe(true)
|
||||
})
|
||||
|
||||
test('should resolve symlinks when enabled', async () => {
|
||||
await writeFile(join(testDir, 'original.txt'), 'original content')
|
||||
|
||||
try {
|
||||
await symlink('original.txt', join(testDir, 'link.txt'))
|
||||
} catch (error: any) {
|
||||
// Skip test if symlinks are not supported on this platform
|
||||
if (error.code === 'ENOSYS' || error.code === 'EPERM') {
|
||||
return
|
||||
}
|
||||
throw error
|
||||
}
|
||||
|
||||
// Test with resolveSymlinks=true
|
||||
const { stream } = await tarFileStream('*.txt', testDir, [], true, true)
|
||||
|
||||
const contents = await extractTarContents(stream)
|
||||
expect(contents.get('original.txt')?.toString()).toBe('original content')
|
||||
// Symlink should be resolved (contain actual content, not link)
|
||||
expect(contents.get('link.txt')?.toString()).toBe('original content')
|
||||
})
|
||||
|
||||
test('should preserve symlinks when disabled', async () => {
|
||||
await writeFile(join(testDir, 'original.txt'), 'original content')
|
||||
|
||||
try {
|
||||
await symlink('original.txt', join(testDir, 'link.txt'))
|
||||
} catch (error: any) {
|
||||
// Skip test if symlinks are not supported on this platform
|
||||
if (error.code === 'ENOSYS' || error.code === 'EPERM') {
|
||||
return
|
||||
}
|
||||
throw error
|
||||
}
|
||||
|
||||
// Test with resolveSymlinks=false
|
||||
const { stream } = await tarFileStream('*.txt', testDir, [], false, true)
|
||||
|
||||
const members = await getTarMembers(stream)
|
||||
expect(members.get('original.txt')?.type).toBe('File')
|
||||
const link = members.get('link.txt')!
|
||||
expect(link.type).toBe('SymbolicLink')
|
||||
expect(link.linkpath).toBe('original.txt')
|
||||
})
|
||||
|
||||
test('removes the spooled archive once the stream is consumed', async () => {
|
||||
await writeFile(join(testDir, 'file1.txt'), 'content1')
|
||||
|
||||
// Capture the temp dir the helper creates so we can assert it is gone.
|
||||
let tmpDir: string | undefined
|
||||
const mkdtemp = fs.promises.mkdtemp.bind(fs.promises)
|
||||
const spy = vi
|
||||
.spyOn(fs.promises, 'mkdtemp')
|
||||
.mockImplementation(async (prefix: any, ...rest: any[]) => {
|
||||
const dir = await mkdtemp(prefix, ...rest)
|
||||
tmpDir = dir
|
||||
return dir
|
||||
})
|
||||
|
||||
try {
|
||||
const { stream } = await tarFileStream('*.txt', testDir, [], false)
|
||||
expect(tmpDir).toBeDefined()
|
||||
await access(tmpDir!)
|
||||
|
||||
// Drain the stream to completion; the `close` handler then removes the
|
||||
// spooled archive.
|
||||
stream.resume()
|
||||
await waitUntilGone(tmpDir!)
|
||||
} finally {
|
||||
spy.mockRestore()
|
||||
}
|
||||
})
|
||||
|
||||
test('cleans up the spooled archive if it is never consumed', async () => {
|
||||
await writeFile(join(testDir, 'file1.txt'), 'content1')
|
||||
|
||||
let tmpDir: string | undefined
|
||||
const mkdtemp = fs.promises.mkdtemp.bind(fs.promises)
|
||||
const spy = vi
|
||||
.spyOn(fs.promises, 'mkdtemp')
|
||||
.mockImplementation(async (prefix: any, ...rest: any[]) => {
|
||||
const dir = await mkdtemp(prefix, ...rest)
|
||||
tmpDir = dir
|
||||
return dir
|
||||
})
|
||||
|
||||
try {
|
||||
const { stream } = await tarFileStream('*.txt', testDir, [], false)
|
||||
await access(tmpDir!)
|
||||
|
||||
// Destroying without reading still fires `close` and removes the file.
|
||||
stream.destroy()
|
||||
await waitUntilGone(tmpDir!)
|
||||
} finally {
|
||||
spy.mockRestore()
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,168 @@
|
||||
import { describe, expect, test } from 'vitest'
|
||||
import { validateRelativePath } from '../../../src/template/utils'
|
||||
import { TemplateError } from '../../../src/errors'
|
||||
|
||||
const isWindows = process.platform === 'win32'
|
||||
|
||||
describe('validateRelativePath', () => {
|
||||
describe('valid paths', () => {
|
||||
test('accepts simple relative path', () => {
|
||||
expect(() => validateRelativePath('foo', undefined)).not.toThrow()
|
||||
})
|
||||
|
||||
test('accepts nested relative path', () => {
|
||||
expect(() => validateRelativePath('foo/bar', undefined)).not.toThrow()
|
||||
})
|
||||
|
||||
test('accepts path with ./ prefix', () => {
|
||||
expect(() => validateRelativePath('./foo', undefined)).not.toThrow()
|
||||
})
|
||||
|
||||
test('accepts nested path with ./ prefix', () => {
|
||||
expect(() => validateRelativePath('./foo/bar', undefined)).not.toThrow()
|
||||
})
|
||||
|
||||
test('accepts path with internal parent ref that stays within context', () => {
|
||||
expect(() => validateRelativePath('foo/../bar', undefined)).not.toThrow()
|
||||
})
|
||||
|
||||
test('accepts current directory', () => {
|
||||
expect(() => validateRelativePath('.', undefined)).not.toThrow()
|
||||
})
|
||||
|
||||
test('accepts glob patterns', () => {
|
||||
expect(() => validateRelativePath('*.txt', undefined)).not.toThrow()
|
||||
expect(() => validateRelativePath('**/*.ts', undefined)).not.toThrow()
|
||||
expect(() => validateRelativePath('src/**/*', undefined)).not.toThrow()
|
||||
})
|
||||
|
||||
test('accepts hidden files and directories', () => {
|
||||
expect(() => validateRelativePath('.hidden', undefined)).not.toThrow()
|
||||
expect(() =>
|
||||
validateRelativePath('.config/settings', undefined)
|
||||
).not.toThrow()
|
||||
})
|
||||
|
||||
test('accepts filenames starting with double dots', () => {
|
||||
expect(() => validateRelativePath('..myconfig', undefined)).not.toThrow()
|
||||
expect(() => validateRelativePath('..cache', undefined)).not.toThrow()
|
||||
expect(() =>
|
||||
validateRelativePath('...something', undefined)
|
||||
).not.toThrow()
|
||||
expect(() =>
|
||||
validateRelativePath('foo/..myconfig', undefined)
|
||||
).not.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe('invalid paths - absolute', () => {
|
||||
test('rejects Unix absolute path', () => {
|
||||
expect(() => validateRelativePath('/absolute/path', undefined)).toThrow(
|
||||
TemplateError
|
||||
)
|
||||
expect(() => validateRelativePath('/absolute/path', undefined)).toThrow(
|
||||
'absolute paths are not allowed'
|
||||
)
|
||||
})
|
||||
|
||||
test('rejects root path', () => {
|
||||
expect(() => validateRelativePath('/', undefined)).toThrow(TemplateError)
|
||||
})
|
||||
|
||||
// Windows path tests - only run on Windows where path.isAbsolute detects them
|
||||
test.skipIf(!isWindows)('rejects Windows drive letter path', () => {
|
||||
expect(() =>
|
||||
validateRelativePath('C:\\Windows\\System32', undefined)
|
||||
).toThrow(TemplateError)
|
||||
expect(() =>
|
||||
validateRelativePath('C:\\Windows\\System32', undefined)
|
||||
).toThrow('absolute paths are not allowed')
|
||||
})
|
||||
|
||||
test.skipIf(!isWindows)('rejects Windows UNC path', () => {
|
||||
expect(() =>
|
||||
validateRelativePath('\\\\server\\share', undefined)
|
||||
).toThrow(TemplateError)
|
||||
})
|
||||
})
|
||||
|
||||
describe('invalid paths - parent directory escape', () => {
|
||||
test('rejects simple parent directory escape', () => {
|
||||
expect(() => validateRelativePath('../foo', undefined)).toThrow(
|
||||
TemplateError
|
||||
)
|
||||
expect(() => validateRelativePath('../foo', undefined)).toThrow(
|
||||
'path escapes the context directory'
|
||||
)
|
||||
})
|
||||
|
||||
test('rejects parent directory escape with forward slash', () => {
|
||||
expect(() => validateRelativePath('../file.txt', undefined)).toThrow(
|
||||
TemplateError
|
||||
)
|
||||
})
|
||||
|
||||
test.skipIf(!isWindows)(
|
||||
'rejects parent directory escape with backslash',
|
||||
() => {
|
||||
expect(() => validateRelativePath('..\\file.txt', undefined)).toThrow(
|
||||
TemplateError
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
test('rejects double parent directory escape', () => {
|
||||
expect(() => validateRelativePath('../../foo', undefined)).toThrow(
|
||||
TemplateError
|
||||
)
|
||||
})
|
||||
|
||||
test('rejects path that escapes via nested parent refs', () => {
|
||||
expect(() => validateRelativePath('foo/../../bar', undefined)).toThrow(
|
||||
TemplateError
|
||||
)
|
||||
})
|
||||
|
||||
test('rejects path with ./ prefix that escapes', () => {
|
||||
expect(() =>
|
||||
validateRelativePath('./foo/../../../bar', undefined)
|
||||
).toThrow(TemplateError)
|
||||
})
|
||||
|
||||
test('rejects just parent directory', () => {
|
||||
expect(() => validateRelativePath('..', undefined)).toThrow(TemplateError)
|
||||
})
|
||||
|
||||
test('rejects current directory followed by parent', () => {
|
||||
expect(() => validateRelativePath('./..', undefined)).toThrow(
|
||||
TemplateError
|
||||
)
|
||||
})
|
||||
|
||||
test('rejects deeply nested escape', () => {
|
||||
expect(() =>
|
||||
validateRelativePath('a/b/c/../../../../escape', undefined)
|
||||
).toThrow(TemplateError)
|
||||
})
|
||||
})
|
||||
|
||||
describe('error messages include path', () => {
|
||||
test('absolute path error includes the path', () => {
|
||||
try {
|
||||
validateRelativePath('/etc/passwd', undefined)
|
||||
expect.fail('Should have thrown')
|
||||
} catch (e) {
|
||||
expect(e.message).toContain('/etc/passwd')
|
||||
}
|
||||
})
|
||||
|
||||
test('escape path error includes the path', () => {
|
||||
try {
|
||||
validateRelativePath('../secret', undefined)
|
||||
expect.fail('Should have thrown')
|
||||
} catch (e) {
|
||||
expect(e.message).toContain('../secret')
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user