Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 55 additions & 0 deletions .github/workflows/test-e2e-deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -129,3 +129,58 @@ jobs:
NEXT_TEST_JOB: 1
NEXT_TELEMETRY_DISABLED: 1
run: node run-tests.js --timings -g ${{ matrix.group }} -c 2 --type e2e

- name: Upload adapter test result artifacts
if: ${{ always() && github.ref == 'refs/heads/main' && (inputs.nextjsRef || 'canary') == 'canary' }}
uses: actions/upload-artifact@v4
with:
name: adapter-test-reports-${{ replace(matrix.group, '/', '-') }}
path: next.js/test/**/*.results.json
if-no-files-found: ignore
retention-days: 1

upload-adapter-test-results:
name: Upload adapter test results
needs: [test]
if: ${{ always() && github.ref == 'refs/heads/main' && (inputs.nextjsRef || 'canary') == 'canary' }}
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4

- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: "20"

- name: Download adapter test result artifacts
continue-on-error: true
uses: actions/download-artifact@v4
with:
pattern: adapter-test-reports-*
path: adapter-test-results
merge-multiple: true

- name: Upload adapter test results
run: |
if [ -z "${ADAPTER_TEST_RESULTS_SECRET:-}" ]; then
echo "ADAPTER_TEST_RESULTS_SECRET is not configured, skipping upload"
exit 0
fi

if [ ! -d adapter-test-results ]; then
echo "No adapter test artifact directory found, skipping upload"
exit 0
fi

if ! find adapter-test-results -type f -name '*.results.json' | grep -q .; then
echo "No adapter .results.json files found, skipping upload"
exit 0
fi

node scripts/upload-adapter-test-results.mjs \
--results-root adapter-test-results \
--provider bun \
--commit-sha "${{ github.sha }}"
env:
ADAPTER_TEST_RESULTS_SECRET: ${{ secrets.ADAPTER_TEST_RESULTS_SECRET }}
326 changes: 326 additions & 0 deletions scripts/upload-adapter-test-results.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,326 @@
import fs from 'node:fs/promises'
import os from 'node:os'
import path from 'node:path'
import { spawn } from 'node:child_process'

const RESULT_FILE_SUFFIX = '.results.json'
const DEFAULT_ENDPOINT = 'https://nextjs.org/api/adapter-test-results'
const COMMIT_SHA_PATTERN = /^[0-9a-f]{7,40}$/i
const BOOLEAN_ARGS = new Set(['help', 'dry-run'])
const IGNORED_DIRECTORIES = new Set([
'.git',
'.hg',
'.svn',
'.next',
'.turbo',
'node_modules',
'dist',
'out',
'coverage',
])

function usage() {
console.log(
[
'Collect Jest *.results.json files, zip them, and upload to the adapter test results endpoint.',
'',
'Usage:',
' node scripts/upload-adapter-test-results.mjs \\',
' --results-root ~/dev/adapter-bun \\',
' --provider bun \\',
' --secret "$ADAPTER_TEST_RESULTS_SECRET" \\',
' --commit-sha "$(git -C ~/dev/adapter-bun rev-parse HEAD)"',
'',
'Required:',
' --provider <name> or ADAPTER_TEST_RESULTS_PROVIDER',
' --secret <secret> or ADAPTER_TEST_RESULTS_SECRET',
' --commit-sha <sha> or ADAPTER_TEST_RESULTS_COMMIT_SHA or GITHUB_SHA',
'',
'Optional:',
` --results-root <path> default: ${process.cwd()}`,
` --endpoint <url> default: ${DEFAULT_ENDPOINT}`,
' --zip-out <path> write zip to disk before upload',
' --dry-run only collect + zip; skip upload',
' --help show this message',
].join('\n')
)
}

function parseArgs(argv) {
/** @type {Record<string, string | boolean>} */
const args = {}

for (let index = 2; index < argv.length; index += 1) {
const arg = argv[index]

if (arg === '--') {
continue
}

if (!arg.startsWith('--')) {
throw new Error(`Unexpected argument: ${arg}`)
}

const separatorIndex = arg.indexOf('=')
if (separatorIndex > -1) {
const key = arg.slice(2, separatorIndex)
const value = arg.slice(separatorIndex + 1)
args[key] = value
continue
}

const key = arg.slice(2)
if (BOOLEAN_ARGS.has(key)) {
args[key] = true
continue
}

const value = argv[index + 1]
if (!value || value.startsWith('--')) {
throw new Error(`Missing value for --${key}`)
}

args[key] = value
index += 1
}

return args
}

function resolvePathWithHome(inputPath) {
if (inputPath === '~') {
return os.homedir()
}

if (inputPath.startsWith('~/')) {
return path.join(os.homedir(), inputPath.slice(2))
}

return inputPath
}

function toZipEntryPath(filePath) {
return filePath.split(path.sep).join('/')
}

async function walkResultFiles(rootDir, currentDir, files) {
const entries = await fs.readdir(currentDir, { withFileTypes: true })

for (const entry of entries) {
const entryPath = path.join(currentDir, entry.name)

if (entry.isDirectory()) {
if (IGNORED_DIRECTORIES.has(entry.name)) {
continue
}

await walkResultFiles(rootDir, entryPath, files)
continue
}

if (!entry.isFile() || !entry.name.endsWith(RESULT_FILE_SUFFIX)) {
continue
}

const relativePath = path.relative(rootDir, entryPath)
files.push({
absolutePath: entryPath,
relativePath: toZipEntryPath(relativePath),
})
}
}

async function collectResultFiles(resultsRoot) {
/** @type {{ absolutePath: string; relativePath: string }[]} */
const files = []
await walkResultFiles(resultsRoot, resultsRoot, files)
files.sort((a, b) => a.relativePath.localeCompare(b.relativePath))
return files
}

function formatBytes(value) {
if (value < 1024) {
return `${value} B`
}

if (value < 1024 * 1024) {
return `${(value / 1024).toFixed(1)} KiB`
}

return `${(value / (1024 * 1024)).toFixed(2)} MiB`
}

async function runZipCommand(resultsRoot, files, outputZipPath) {
const relativePaths = files.map((file) => file.relativePath)

await new Promise((resolve, reject) => {
const child = spawn('zip', ['-q', '-9', outputZipPath, '-@'], {
cwd: resultsRoot,
stdio: ['pipe', 'ignore', 'pipe'],
})

let stderr = ''

child.on('error', (error) => {
reject(new Error(`Failed to run zip: ${error.message}`))
})

child.stderr.on('data', (chunk) => {
stderr += String(chunk)
})

child.on('close', (code, signal) => {
if (code === 0) {
resolve(undefined)
return
}

reject(
new Error(
[
`zip failed with code ${code}${signal ? ` and signal ${signal}` : ''}.`,
stderr.trim(),
]
.filter(Boolean)
.join(' ')
)
)
})

child.stdin.write(`${relativePaths.join('\n')}\n`)
child.stdin.end()
})
}

async function createArchive(resultsRoot, files) {
const tempDir = await fs.mkdtemp(
path.join(os.tmpdir(), 'adapter-test-results-')
)
const zipPath = path.join(tempDir, 'results.zip')

try {
await runZipCommand(resultsRoot, files, zipPath)
return new Uint8Array(await fs.readFile(zipPath))
} finally {
await fs.rm(tempDir, { recursive: true, force: true }).catch(() => {})
}
}

async function main() {
const args = parseArgs(process.argv)

if (args.help) {
usage()
return
}

const provider =
args.provider || process.env.ADAPTER_TEST_RESULTS_PROVIDER || ''
const secret = args.secret || process.env.ADAPTER_TEST_RESULTS_SECRET || ''
const commitSha =
args['commit-sha'] ||
process.env.ADAPTER_TEST_RESULTS_COMMIT_SHA ||
process.env.GITHUB_SHA ||
''
const endpoint =
args.endpoint ||
process.env.ADAPTER_TEST_RESULTS_ENDPOINT ||
DEFAULT_ENDPOINT
const inputRoot = args['results-root'] || process.cwd()
const resultsRoot = path.resolve(resolvePathWithHome(String(inputRoot)))
const zipOutArg = args['zip-out']
const zipOutPath = zipOutArg
? path.resolve(resolvePathWithHome(String(zipOutArg)))
: null
const dryRun = Boolean(args['dry-run'])

if (!provider) {
throw new Error(
'Missing provider. Pass --provider or ADAPTER_TEST_RESULTS_PROVIDER.'
)
}

if (!secret) {
throw new Error(
'Missing secret. Pass --secret or ADAPTER_TEST_RESULTS_SECRET.'
)
}

if (!commitSha || !COMMIT_SHA_PATTERN.test(String(commitSha))) {
throw new Error(
'Invalid commit SHA. Pass --commit-sha (7-40 hex chars), ADAPTER_TEST_RESULTS_COMMIT_SHA, or GITHUB_SHA.'
)
}

const stat = await fs.stat(resultsRoot).catch(() => null)
if (!stat || !stat.isDirectory()) {
throw new Error(
`results-root does not exist or is not a directory: ${resultsRoot}`
)
}

console.log(`Collecting ${RESULT_FILE_SUFFIX} files from: ${resultsRoot}`)
const resultFiles = await collectResultFiles(resultsRoot)

if (resultFiles.length === 0) {
throw new Error(`No ${RESULT_FILE_SUFFIX} files found under ${resultsRoot}`)
}

console.log(`Found ${resultFiles.length} result files`)
const archive = await createArchive(resultsRoot, resultFiles)
console.log(`Created archive (${formatBytes(archive.byteLength)})`)

if (zipOutPath) {
await fs.mkdir(path.dirname(zipOutPath), { recursive: true })
await fs.writeFile(zipOutPath, archive)
console.log(`Wrote archive to: ${zipOutPath}`)
}

if (dryRun) {
console.log('Dry run complete (skipping upload).')
return
}

console.log(`Uploading to: ${endpoint}`)
const response = await fetch(endpoint, {
method: 'POST',
headers: {
'content-type': 'application/zip',
'x-adapter-test-results-provider': String(provider),
'x-adapter-test-results-secret': String(secret),
'x-adapter-test-results-commit-sha': String(commitSha),
},
body: archive,
})

const responseText = await response.text()
let parsedResponse

try {
parsedResponse = JSON.parse(responseText)
} catch {
parsedResponse = responseText
}

if (!response.ok) {
throw new Error(
`Upload failed (${response.status} ${response.statusText}): ${
typeof parsedResponse === 'string'
? parsedResponse
: JSON.stringify(parsedResponse)
}`
)
}

console.log('Upload succeeded')
if (typeof parsedResponse === 'string') {
console.log(parsedResponse)
} else {
console.log(JSON.stringify(parsedResponse, null, 2))
}
}

main().catch((error) => {
console.error(error instanceof Error ? error.message : String(error))
process.exit(1)
})