Files
dify/cli/scripts/lib/edge-manifest.test.ts
GareArc f1c32e1bc8 refactor(cli): split release scripts into pure rules and CLI shells
The release script tests could only observe behaviour through a process
exit code, because die() called process.exit from inside otherwise-pure
functions. That forced every test to spawn a subprocess, which forced
its input to come from cli/package.json, which is why editing that file
kept breaking tests — most recently #39658, and next the difyctl version
bump that cli-release.yml requires on every release.

Move the decisions into lib/release-rules.mjs and lib/edge-manifest.mjs,
which take every input as an argument and return values instead of
exiting. release-naming.mjs and release-r2-edge.mjs keep argv parsing,
manifest reading, stdout and exit codes, and nothing else.

Tests now split by what they ask. Logic is unit-tested against literal
inputs, the shells are tested for plumbing only, and release-config.test.ts
is the single place that reads the real manifest — asserting it is
internally consistent, never what it currently contains. Editing
cli/package.json no longer breaks a logic test; a malformed config still
fails. Verified by mutating each field in both directions.

Three intentional behaviour changes:

- compat-check compares the numeric A.B.C core only, ignoring prerelease
  and build suffixes, so Dify 1.16.0-rc1 now satisfies a 1.16.0 window.
  This matches the shipped runtime check in src/version/compat.ts, which
  already stripped suffixes; the release gate was the one disagreeing.
  Removes the hand-rolled prerelease ordering entirely.
- validate now rejects a missing or inverted compat window. It previously
  passed such a config while github-env emitted minDify=undefined.
- release-r2-edge reports a bad channel version under its own name rather
  than release-naming's, since it no longer borrows that script's die().

release-config.test.ts also pins the install scripts to the naming config.
They hardcode artifact names by necessity — they run standalone on a user
machine with no repo and no node — so changing tagPrefix or checksumsSuffix
now fails until all four installers are updated to match, instead of
silently publishing names no installer looks for.
2026-07-27 19:13:42 -07:00

156 lines
5.1 KiB
TypeScript

import { describe, expect, it } from 'vitest'
import {
buildIndex,
parseChecksums,
parseDirList,
renderManifest,
resolveTargets,
} from './edge-manifest.mjs'
const RELEASE = {
tagPrefix: 'testctl-v',
binName: 'testctl',
checksumsSuffix: '.sums',
targets: [
{ id: 'linux-x64', bunTarget: 'bun-linux-x64', exe: false },
{ id: 'windows-x64', bunTarget: 'bun-windows-x64', exe: true },
],
}
const VERSION = '7.7.7-edge.2fd7b82'
const SHA_LINUX = 'a'.repeat(64)
const SHA_WINDOWS = 'b'.repeat(64)
const CHECKSUMS = [
`${SHA_LINUX} testctl-v${VERSION}-linux-x64`,
`${SHA_WINDOWS} testctl-v${VERSION}-windows-x64.exe`,
].join('\n')
describe('parseChecksums', () => {
it('maps asset name to sha256', () => {
const map = parseChecksums(CHECKSUMS)
expect(map.get(`testctl-v${VERSION}-linux-x64`)).toBe(SHA_LINUX)
expect(map.get(`testctl-v${VERSION}-windows-x64.exe`)).toBe(SHA_WINDOWS)
})
it('ignores blank and malformed lines', () => {
expect(parseChecksums('\nnot a checksum line\n\n').size).toBe(0)
})
})
describe('parseDirList', () => {
it('collects non-blank trimmed lines', () => {
expect([...parseDirList(' a \n\n b\n')]).toEqual(['a', 'b'])
})
it('yields an empty set for empty input', () => {
expect(parseDirList('').size).toBe(0)
})
})
describe('resolveTargets', () => {
it('pairs every target with its sha', () => {
const { targets, missing } = resolveTargets(RELEASE, VERSION, parseChecksums(CHECKSUMS))
expect(missing).toEqual([])
expect(targets).toEqual([
{ id: 'linux-x64', asset: `testctl-v${VERSION}-linux-x64`, sha: SHA_LINUX },
{ id: 'windows-x64', asset: `testctl-v${VERSION}-windows-x64.exe`, sha: SHA_WINDOWS },
])
})
it('reports assets with no checksum', () => {
const partial = parseChecksums(`${SHA_LINUX} testctl-v${VERSION}-linux-x64`)
const { targets, missing } = resolveTargets(RELEASE, VERSION, partial)
expect(targets).toHaveLength(1)
expect(missing).toEqual([`testctl-v${VERSION}-windows-x64.exe`])
})
})
describe('renderManifest', () => {
const manifest = () =>
renderManifest({
binName: RELEASE.binName,
channel: 'edge',
version: VERSION,
commit: 'abc1234',
buildDate: '2026-06-14T12:00:00Z',
compat: { minDify: '2.0.0', maxDify: '2.5.0' },
baseUrl: 'https://example.r2.dev/testctl/edge',
targets: resolveTargets(RELEASE, VERSION, parseChecksums(CHECKSUMS)).targets,
})
it('emits the pointer fields', () => {
const json = JSON.parse(manifest())
expect(json).toMatchObject({
schema: 1,
name: 'testctl',
channel: 'edge',
version: VERSION,
commit: 'abc1234',
buildDate: '2026-06-14T12:00:00Z',
baseUrl: 'https://example.r2.dev/testctl/edge',
})
})
it('carries both compat bounds through unswapped', () => {
expect(JSON.parse(manifest()).compat).toEqual({ minDify: '2.0.0', maxDify: '2.5.0' })
})
it('lists each target with asset name and sha256', () => {
expect(JSON.parse(manifest()).targets).toEqual({
'linux-x64': { asset: `testctl-v${VERSION}-linux-x64`, sha256: SHA_LINUX },
'windows-x64': { asset: `testctl-v${VERSION}-windows-x64.exe`, sha256: SHA_WINDOWS },
})
})
it('renders each target on a single line (install-r2.sh greps it)', () => {
expect(manifest()).toMatch(/^ {4}"linux-x64": \{ "asset": ".*", "sha256": ".*" \}/m)
})
})
describe('buildIndex', () => {
const B1 = { version: '7.7.7-edge.aaaaaaa', commit: 'aaaaaaa', buildDate: '2026-06-14T09:00:00Z' }
const B2 = { version: '7.7.7-edge.bbbbbbb', commit: 'bbbbbbb', buildDate: '2026-06-14T10:00:00Z' }
const entryOf = (b: typeof B1) => ({ ...b, dir: b.version })
const build = (over = {}) =>
buildIndex({ channel: 'edge', ...B2, current: null, existingDirs: null, ...over })
it('creates a fresh ledger when there is no current index', () => {
const index = build()
expect(index).toMatchObject({ schema: 1, channel: 'edge', updated: B2.buildDate })
expect(index.builds).toEqual([entryOf(B2)])
})
it('prepends the new build, newest first', () => {
const index = build({ current: { builds: [entryOf(B1)] } })
expect(index.builds).toEqual([entryOf(B2), entryOf(B1)])
})
it('replaces an existing entry for the same version rather than duplicating', () => {
const stale = { ...entryOf(B2), commit: 'oldsha' }
const index = build({ current: { builds: [stale, entryOf(B1)] } })
expect(index.builds).toEqual([entryOf(B2), entryOf(B1)])
})
it('drops builds whose binaries no longer exist in R2', () => {
const index = build({
current: { builds: [entryOf(B1)] },
existingDirs: new Set<string>(),
})
expect(index.builds).toEqual([entryOf(B2)])
})
it('keeps builds that still exist in R2', () => {
const index = build({
current: { builds: [entryOf(B1)] },
existingDirs: new Set([B1.version]),
})
expect(index.builds).toEqual([entryOf(B2), entryOf(B1)])
})
it('reconciles nothing when the caller could not list R2', () => {
const index = build({ current: { builds: [entryOf(B1)] }, existingDirs: null })
expect(index.builds).toEqual([entryOf(B2), entryOf(B1)])
})
})