@shazhou/proman-core 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +26 -0
- package/LICENSE +18 -0
- package/dist/commands/bump.d.ts +13 -0
- package/dist/commands/bump.d.ts.map +1 -0
- package/dist/commands/bump.js +115 -0
- package/dist/commands/deploy.d.ts +9 -0
- package/dist/commands/deploy.d.ts.map +1 -0
- package/dist/commands/deploy.js +42 -0
- package/dist/commands/dev.d.ts +15 -0
- package/dist/commands/dev.d.ts.map +1 -0
- package/dist/commands/dev.js +175 -0
- package/dist/commands/index.d.ts +7 -0
- package/dist/commands/index.d.ts.map +1 -0
- package/dist/commands/index.js +7 -0
- package/dist/commands/init.d.ts +5 -0
- package/dist/commands/init.d.ts.map +1 -0
- package/dist/commands/init.js +262 -0
- package/dist/commands/link.d.ts +19 -0
- package/dist/commands/link.d.ts.map +1 -0
- package/dist/commands/link.js +155 -0
- package/dist/commands/publish.d.ts +18 -0
- package/dist/commands/publish.d.ts.map +1 -0
- package/dist/commands/publish.js +125 -0
- package/dist/config/index.d.ts +4 -0
- package/dist/config/index.d.ts.map +1 -0
- package/dist/config/index.js +2 -0
- package/dist/config/load-config.d.ts +6 -0
- package/dist/config/load-config.d.ts.map +1 -0
- package/dist/config/load-config.js +29 -0
- package/dist/config/types.d.ts +17 -0
- package/dist/config/types.d.ts.map +1 -0
- package/dist/config/types.js +1 -0
- package/dist/config/validate-config.d.ts +7 -0
- package/dist/config/validate-config.d.ts.map +1 -0
- package/dist/config/validate-config.js +72 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +6 -0
- package/dist/utils/changeset.d.ts +16 -0
- package/dist/utils/changeset.d.ts.map +1 -0
- package/dist/utils/changeset.js +80 -0
- package/dist/utils/fingerprint.d.ts +38 -0
- package/dist/utils/fingerprint.d.ts.map +1 -0
- package/dist/utils/fingerprint.js +182 -0
- package/dist/utils/git.d.ts +23 -0
- package/dist/utils/git.d.ts.map +1 -0
- package/dist/utils/git.js +105 -0
- package/dist/utils/index.d.ts +8 -0
- package/dist/utils/index.d.ts.map +1 -0
- package/dist/utils/index.js +8 -0
- package/dist/utils/npm.d.ts +30 -0
- package/dist/utils/npm.d.ts.map +1 -0
- package/dist/utils/npm.js +85 -0
- package/dist/utils/smoke-test.d.ts +7 -0
- package/dist/utils/smoke-test.d.ts.map +1 -0
- package/dist/utils/smoke-test.js +59 -0
- package/dist/utils/version.d.ts +5 -0
- package/dist/utils/version.d.ts.map +1 -0
- package/dist/utils/version.js +36 -0
- package/dist/utils/workspace.d.ts +21 -0
- package/dist/utils/workspace.d.ts.map +1 -0
- package/dist/utils/workspace.js +73 -0
- package/package.json +45 -0
- package/src/commands/bump.ts +131 -0
- package/src/commands/deploy.ts +52 -0
- package/src/commands/dev.ts +214 -0
- package/src/commands/index.ts +7 -0
- package/src/commands/init.integration.test.ts +59 -0
- package/src/commands/init.test.ts +179 -0
- package/src/commands/init.ts +290 -0
- package/src/commands/link.ts +195 -0
- package/src/commands/publish.ts +168 -0
- package/src/config/index.ts +8 -0
- package/src/config/load-config.ts +33 -0
- package/src/config/types.ts +19 -0
- package/src/config/validate-config.ts +81 -0
- package/src/index.ts +29 -0
- package/src/utils/changeset.ts +98 -0
- package/src/utils/fingerprint.ts +199 -0
- package/src/utils/git.ts +119 -0
- package/src/utils/index.ts +8 -0
- package/src/utils/npm.ts +110 -0
- package/src/utils/smoke-test.ts +79 -0
- package/src/utils/version.ts +41 -0
- package/src/utils/workspace.ts +94 -0
- package/tests/build-fingerprint-integration.test.ts +403 -0
- package/tests/bump.test.ts +261 -0
- package/tests/changeset.test.ts +147 -0
- package/tests/deploy.test.ts +98 -0
- package/tests/dev.test.ts +756 -0
- package/tests/fingerprint.test.ts +316 -0
- package/tests/fixtures/api-only/packages/api/.gitkeep +0 -0
- package/tests/fixtures/api-only/proman.yaml +4 -0
- package/tests/fixtures/bad-packages/proman.yaml +1 -0
- package/tests/fixtures/bun-project/packages/a/.gitkeep +0 -0
- package/tests/fixtures/bun-project/proman.yaml +4 -0
- package/tests/fixtures/defaults/proman.yaml +3 -0
- package/tests/fixtures/no-deployable/packages/core/.gitkeep +0 -0
- package/tests/fixtures/no-deployable/packages/mycli/.gitkeep +0 -0
- package/tests/fixtures/no-deployable/proman.yaml +7 -0
- package/tests/fixtures/node-runtime/packages/a/package.json +5 -0
- package/tests/fixtures/node-runtime/proman.yaml +3 -0
- package/tests/fixtures/pnpm-project/packages/a/package.json +1 -0
- package/tests/fixtures/pnpm-project/pnpm-lock.yaml +0 -0
- package/tests/fixtures/pnpm-project/proman.yaml +3 -0
- package/tests/fixtures/typed/packages/api/.gitkeep +0 -0
- package/tests/fixtures/typed/packages/core/.gitkeep +0 -0
- package/tests/fixtures/typed/packages/dashboard/.gitkeep +0 -0
- package/tests/fixtures/typed/packages/mycli/.gitkeep +0 -0
- package/tests/fixtures/typed/proman.yaml +13 -0
- package/tests/fixtures/valid/packages/cli/package.json +5 -0
- package/tests/fixtures/valid/packages/core/package.json +5 -0
- package/tests/fixtures/valid/packages/fs/package.json +5 -0
- package/tests/fixtures/valid/proman.yaml +13 -0
- package/tests/fixtures/webui-only/packages/dashboard/.gitkeep +0 -0
- package/tests/fixtures/webui-only/proman.yaml +4 -0
- package/tests/link.test.ts +419 -0
- package/tests/load-config.test.ts +44 -0
- package/tests/npm.test.ts +199 -0
- package/tests/publish.test.ts +599 -0
- package/tests/smoke-test.test.ts +211 -0
- package/tests/validate-config.test.ts +67 -0
- package/tests/version.test.ts +86 -0
- package/tests/workflow-schema.test.ts +72 -0
- package/tests/workspace.test.ts +160 -0
- package/tsconfig.build.json +14 -0
- package/tsconfig.json +8 -0
- package/tsconfig.tsbuildinfo +1 -0
- package/vitest.config.ts +8 -0
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { readFile, rm } from 'node:fs/promises'
|
|
2
|
+
import { tmpdir } from 'node:os'
|
|
3
|
+
import { join } from 'node:path'
|
|
4
|
+
import type { SpawnFn } from './npm.js'
|
|
5
|
+
|
|
6
|
+
type PackageJson = {
|
|
7
|
+
name: string
|
|
8
|
+
version: string
|
|
9
|
+
bin?: string | Record<string, string>
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Smoke test a package tarball by extracting it and running bin commands.
|
|
14
|
+
* Validates that the packaged artifact actually works before publishing.
|
|
15
|
+
*/
|
|
16
|
+
export async function smokeTestTarball(pkgDir: string, spawn: SpawnFn): Promise<void> {
|
|
17
|
+
// Read package.json to check for bin entries
|
|
18
|
+
const pkgJsonPath = join(pkgDir, 'package.json')
|
|
19
|
+
const pkgJsonText = await readFile(pkgJsonPath, 'utf8')
|
|
20
|
+
const pkgJson = JSON.parse(pkgJsonText) as PackageJson
|
|
21
|
+
|
|
22
|
+
// Skip if no bin entry
|
|
23
|
+
if (!pkgJson.bin) {
|
|
24
|
+
return
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// Normalize bin to Record format
|
|
28
|
+
const binEntries: Record<string, string> =
|
|
29
|
+
typeof pkgJson.bin === 'string' ? { [pkgJson.name]: pkgJson.bin } : pkgJson.bin
|
|
30
|
+
|
|
31
|
+
// Skip if bin is empty
|
|
32
|
+
if (Object.keys(binEntries).length === 0) {
|
|
33
|
+
return
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Step 1: Create tarball with pnpm pack
|
|
37
|
+
const packResult = await spawn(['pnpm', 'pack'], pkgDir)
|
|
38
|
+
if (packResult.code !== 0) {
|
|
39
|
+
throw new Error(`pnpm pack failed: ${packResult.stderr || packResult.stdout}`)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const tarballName = packResult.stdout.trim()
|
|
43
|
+
if (!tarballName) {
|
|
44
|
+
throw new Error('pnpm pack did not return tarball filename')
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Step 2: Extract tarball to temp directory
|
|
48
|
+
const { mkdtemp } = await import('node:fs/promises')
|
|
49
|
+
const testDir = await mkdtemp(join(tmpdir(), 'proman-smoke-'))
|
|
50
|
+
|
|
51
|
+
try {
|
|
52
|
+
// Extract tarball
|
|
53
|
+
const tarballPath = join(pkgDir, tarballName)
|
|
54
|
+
const extractResult = await spawn(['tar', '-xzf', tarballPath, '-C', testDir], pkgDir)
|
|
55
|
+
if (extractResult.code !== 0) {
|
|
56
|
+
throw new Error(`tar extract failed: ${extractResult.stderr}`)
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// pnpm pack creates a 'package/' directory inside the tarball
|
|
60
|
+
const extractedPkgDir = join(testDir, 'package')
|
|
61
|
+
|
|
62
|
+
// Step 3: Test each bin entry
|
|
63
|
+
for (const [binName, binPath] of Object.entries(binEntries)) {
|
|
64
|
+
const binFullPath = join(extractedPkgDir, binPath)
|
|
65
|
+
const binTestResult = await spawn(['node', binFullPath, '--version'], extractedPkgDir)
|
|
66
|
+
|
|
67
|
+
if (binTestResult.code !== 0) {
|
|
68
|
+
const errorMsg = binTestResult.stderr.trim() || binTestResult.stdout.trim()
|
|
69
|
+
throw new Error(
|
|
70
|
+
`smoke test failed for bin '${binName}': ${errorMsg || 'non-zero exit code'}`,
|
|
71
|
+
)
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
} finally {
|
|
75
|
+
// Step 4: Always clean up temp directory and tarball
|
|
76
|
+
await rm(testDir, { recursive: true, force: true })
|
|
77
|
+
await rm(join(pkgDir, tarballName), { force: true })
|
|
78
|
+
}
|
|
79
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import type { Bump, Changeset } from './changeset.js'
|
|
2
|
+
|
|
3
|
+
const VERSION_CORE_RE = /^(\d+)\.(\d+)\.(\d+)(?:-[\w.+-]+)?$/
|
|
4
|
+
|
|
5
|
+
export function bumpVersion(current: string, bump: Bump): string {
|
|
6
|
+
const m = current.match(VERSION_CORE_RE)
|
|
7
|
+
if (!m) {
|
|
8
|
+
throw new Error(`invalid version: '${current}'`)
|
|
9
|
+
}
|
|
10
|
+
const major = Number(m[1])
|
|
11
|
+
const minor = Number(m[2])
|
|
12
|
+
const patch = Number(m[3])
|
|
13
|
+
if (bump === 'major') return `${major + 1}.0.0`
|
|
14
|
+
if (bump === 'minor') return `${major}.${minor + 1}.0`
|
|
15
|
+
return `${major}.${minor}.${patch + 1}`
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const ORDER: Record<Bump, number> = { patch: 1, minor: 2, major: 3 }
|
|
19
|
+
|
|
20
|
+
export function inferBump(changesets: Changeset[]): Record<string, Bump> {
|
|
21
|
+
const result: Record<string, Bump> = {}
|
|
22
|
+
for (const c of changesets) {
|
|
23
|
+
for (const [pkg, bump] of Object.entries(c.packages)) {
|
|
24
|
+
const current = result[pkg]
|
|
25
|
+
if (current === undefined || ORDER[bump] > ORDER[current]) {
|
|
26
|
+
result[pkg] = bump
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
return result
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const TAG_RE = /^v?(\d+\.\d+\.\d+(?:-[\w.+-]+)?)$/
|
|
34
|
+
|
|
35
|
+
export function parseTagVersion(tag: string): string {
|
|
36
|
+
const m = tag.match(TAG_RE)
|
|
37
|
+
if (!m) {
|
|
38
|
+
throw new Error(`invalid tag: '${tag}'`)
|
|
39
|
+
}
|
|
40
|
+
return m[1] as string
|
|
41
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { readFile, writeFile } from 'node:fs/promises'
|
|
2
|
+
import { resolve } from 'node:path'
|
|
3
|
+
|
|
4
|
+
export type PkgManifest = {
|
|
5
|
+
name: string
|
|
6
|
+
version: string
|
|
7
|
+
dependencies?: Record<string, string>
|
|
8
|
+
devDependencies?: Record<string, string>
|
|
9
|
+
[k: string]: unknown
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
type Unresolved = { pkg: string; dep: string }
|
|
13
|
+
|
|
14
|
+
function rewriteDepsField(
|
|
15
|
+
pkgName: string,
|
|
16
|
+
field: Record<string, string> | undefined,
|
|
17
|
+
versions: Map<string, string>,
|
|
18
|
+
unresolved: Unresolved[],
|
|
19
|
+
): Record<string, string> | undefined {
|
|
20
|
+
if (!field) return undefined
|
|
21
|
+
const out: Record<string, string> = {}
|
|
22
|
+
let changed = false
|
|
23
|
+
for (const [dep, val] of Object.entries(field)) {
|
|
24
|
+
if (val === 'workspace:*') {
|
|
25
|
+
const v = versions.get(dep)
|
|
26
|
+
if (v) {
|
|
27
|
+
out[dep] = v
|
|
28
|
+
changed = true
|
|
29
|
+
} else {
|
|
30
|
+
out[dep] = val
|
|
31
|
+
unresolved.push({ pkg: pkgName, dep })
|
|
32
|
+
}
|
|
33
|
+
} else {
|
|
34
|
+
out[dep] = val
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
// Mark via reference identity if changed; caller can compare to original
|
|
38
|
+
void changed
|
|
39
|
+
return out
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function rewriteWorkspaceDeps(manifests: PkgManifest[]): {
|
|
43
|
+
rewritten: PkgManifest[]
|
|
44
|
+
unresolved: Unresolved[]
|
|
45
|
+
} {
|
|
46
|
+
const versions = new Map<string, string>()
|
|
47
|
+
for (const m of manifests) {
|
|
48
|
+
versions.set(m.name, m.version)
|
|
49
|
+
}
|
|
50
|
+
const unresolved: Unresolved[] = []
|
|
51
|
+
const rewritten = manifests.map((m) => {
|
|
52
|
+
const copy: PkgManifest = { ...m }
|
|
53
|
+
if (m.dependencies) {
|
|
54
|
+
copy.dependencies = rewriteDepsField(m.name, m.dependencies, versions, unresolved)
|
|
55
|
+
}
|
|
56
|
+
if (m.devDependencies) {
|
|
57
|
+
copy.devDependencies = rewriteDepsField(m.name, m.devDependencies, versions, unresolved)
|
|
58
|
+
}
|
|
59
|
+
return copy
|
|
60
|
+
})
|
|
61
|
+
return { rewritten, unresolved }
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export async function applyWorkspaceRewrites(
|
|
65
|
+
rootDir: string,
|
|
66
|
+
packages: { name: string; path: string }[],
|
|
67
|
+
): Promise<string[]> {
|
|
68
|
+
const paths = packages.map((p) => resolve(rootDir, p.path, 'package.json'))
|
|
69
|
+
const manifests: PkgManifest[] = []
|
|
70
|
+
for (let i = 0; i < paths.length; i++) {
|
|
71
|
+
const p = paths[i] as string
|
|
72
|
+
let text: string
|
|
73
|
+
try {
|
|
74
|
+
text = await readFile(p, 'utf8')
|
|
75
|
+
} catch (err) {
|
|
76
|
+
throw new Error(`package.json not found at ${p}: ${(err as Error).message}`)
|
|
77
|
+
}
|
|
78
|
+
manifests.push(JSON.parse(text) as PkgManifest)
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const { rewritten } = rewriteWorkspaceDeps(manifests)
|
|
82
|
+
const changed: string[] = []
|
|
83
|
+
for (let i = 0; i < paths.length; i++) {
|
|
84
|
+
const before = JSON.stringify(manifests[i])
|
|
85
|
+
const after = JSON.stringify(rewritten[i])
|
|
86
|
+
if (before !== after) {
|
|
87
|
+
const p = paths[i] as string
|
|
88
|
+
const newText = `${JSON.stringify(rewritten[i], null, 2)}\n`
|
|
89
|
+
await writeFile(p, newText)
|
|
90
|
+
changed.push(p)
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
return changed
|
|
94
|
+
}
|
|
@@ -0,0 +1,403 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Integration tests for build fingerprint caching (fixes #135).
|
|
3
|
+
*
|
|
4
|
+
* Verifies that build fingerprints stored inside `dist/` are correctly
|
|
5
|
+
* invalidated when the output directory is removed, and that incremental
|
|
6
|
+
* builds skip unchanged packages.
|
|
7
|
+
*/
|
|
8
|
+
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
|
9
|
+
import { tmpdir } from 'node:os'
|
|
10
|
+
import { join, resolve } from 'node:path'
|
|
11
|
+
import { afterEach, beforeEach, describe, expect, test } from 'vitest'
|
|
12
|
+
import { build } from '../src/commands/dev.ts'
|
|
13
|
+
|
|
14
|
+
let tmpDir: string
|
|
15
|
+
|
|
16
|
+
beforeEach(() => {
|
|
17
|
+
tmpDir = resolve(
|
|
18
|
+
tmpdir(),
|
|
19
|
+
`proman-build-test-${Date.now()}-${Math.random().toString(36).slice(2)}`,
|
|
20
|
+
)
|
|
21
|
+
mkdirSync(tmpDir, { recursive: true })
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
afterEach(() => {
|
|
25
|
+
rmSync(tmpDir, { recursive: true, force: true })
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
function setupMonorepo(): void {
|
|
29
|
+
// Create proman.yaml config
|
|
30
|
+
writeFileSync(
|
|
31
|
+
join(tmpDir, 'proman.yaml'),
|
|
32
|
+
`
|
|
33
|
+
packages:
|
|
34
|
+
- name: '@test/pkg'
|
|
35
|
+
path: packages/pkg
|
|
36
|
+
type: lib
|
|
37
|
+
`,
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
// Create package
|
|
41
|
+
mkdirSync(join(tmpDir, 'packages/pkg/src'), { recursive: true })
|
|
42
|
+
writeFileSync(join(tmpDir, 'packages/pkg/src/index.ts'), 'export const x = 1')
|
|
43
|
+
writeFileSync(
|
|
44
|
+
join(tmpDir, 'packages/pkg/package.json'),
|
|
45
|
+
JSON.stringify({ name: '@test/pkg', version: '1.0.0' }),
|
|
46
|
+
)
|
|
47
|
+
writeFileSync(join(tmpDir, 'packages/pkg/tsconfig.json'), '{}')
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function setupMultiPackageMonorepo(): void {
|
|
51
|
+
writeFileSync(
|
|
52
|
+
join(tmpDir, 'proman.yaml'),
|
|
53
|
+
`
|
|
54
|
+
packages:
|
|
55
|
+
- name: '@test/core'
|
|
56
|
+
path: packages/core
|
|
57
|
+
type: lib
|
|
58
|
+
- name: '@test/fs'
|
|
59
|
+
path: packages/fs
|
|
60
|
+
type: lib
|
|
61
|
+
- name: '@test/cli'
|
|
62
|
+
path: packages/cli
|
|
63
|
+
type: cli
|
|
64
|
+
`,
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
// core — no workspace deps
|
|
68
|
+
mkdirSync(join(tmpDir, 'packages/core/src'), { recursive: true })
|
|
69
|
+
writeFileSync(join(tmpDir, 'packages/core/src/index.ts'), 'export const x = 1')
|
|
70
|
+
writeFileSync(
|
|
71
|
+
join(tmpDir, 'packages/core/package.json'),
|
|
72
|
+
JSON.stringify({ name: '@test/core', version: '1.0.0' }),
|
|
73
|
+
)
|
|
74
|
+
writeFileSync(join(tmpDir, 'packages/core/tsconfig.json'), '{}')
|
|
75
|
+
|
|
76
|
+
// fs — depends on core
|
|
77
|
+
mkdirSync(join(tmpDir, 'packages/fs/src'), { recursive: true })
|
|
78
|
+
writeFileSync(join(tmpDir, 'packages/fs/src/index.ts'), 'export const y = 2')
|
|
79
|
+
writeFileSync(
|
|
80
|
+
join(tmpDir, 'packages/fs/package.json'),
|
|
81
|
+
JSON.stringify({
|
|
82
|
+
name: '@test/fs',
|
|
83
|
+
version: '1.0.0',
|
|
84
|
+
dependencies: { '@test/core': 'workspace:*' },
|
|
85
|
+
}),
|
|
86
|
+
)
|
|
87
|
+
writeFileSync(join(tmpDir, 'packages/fs/tsconfig.json'), '{}')
|
|
88
|
+
|
|
89
|
+
// cli — depends on fs
|
|
90
|
+
mkdirSync(join(tmpDir, 'packages/cli/src'), { recursive: true })
|
|
91
|
+
writeFileSync(join(tmpDir, 'packages/cli/src/index.ts'), 'export const z = 3')
|
|
92
|
+
writeFileSync(
|
|
93
|
+
join(tmpDir, 'packages/cli/package.json'),
|
|
94
|
+
JSON.stringify({
|
|
95
|
+
name: '@test/cli',
|
|
96
|
+
version: '1.0.0',
|
|
97
|
+
dependencies: { '@test/fs': 'workspace:*' },
|
|
98
|
+
}),
|
|
99
|
+
)
|
|
100
|
+
writeFileSync(join(tmpDir, 'packages/cli/tsconfig.json'), '{}')
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// Mock spawn function that simulates successful tsc builds
|
|
104
|
+
const mockSpawn = async (_argv: string[], cwd: string) => {
|
|
105
|
+
// Simulate tsc build by creating dist folder with output
|
|
106
|
+
const distDir = join(cwd, 'dist')
|
|
107
|
+
mkdirSync(distDir, { recursive: true })
|
|
108
|
+
|
|
109
|
+
// Create a simple index.js output
|
|
110
|
+
const srcIndexPath = join(cwd, 'src/index.ts')
|
|
111
|
+
if (existsSync(srcIndexPath)) {
|
|
112
|
+
const content = readFileSync(srcIndexPath, 'utf-8')
|
|
113
|
+
writeFileSync(join(distDir, 'index.js'), `// Compiled from TypeScript\n${content}`)
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
return { code: 0, stdout: 'Build succeeded', stderr: '' }
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
describe('Build Fingerprint Integration Tests (Issue #135)', () => {
|
|
120
|
+
test('T1: Fingerprint stored inside dist folder', async () => {
|
|
121
|
+
setupMonorepo()
|
|
122
|
+
|
|
123
|
+
await build({ cwd: tmpDir, force: false, spawn: mockSpawn })
|
|
124
|
+
|
|
125
|
+
// Verify fingerprint is inside dist folder
|
|
126
|
+
const fpPath = join(tmpDir, 'packages/pkg/dist/.build-fingerprint')
|
|
127
|
+
expect(existsSync(fpPath)).toBe(true)
|
|
128
|
+
|
|
129
|
+
// Verify old location is NOT used
|
|
130
|
+
const oldPath = join(tmpDir, '.proman/build/@test-pkg.fingerprint')
|
|
131
|
+
expect(existsSync(oldPath)).toBe(false)
|
|
132
|
+
|
|
133
|
+
// .proman directory should not be created
|
|
134
|
+
expect(existsSync(join(tmpDir, '.proman'))).toBe(false)
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
test('T2: Removing dist invalidates fingerprint and forces rebuild', async () => {
|
|
138
|
+
setupMonorepo()
|
|
139
|
+
const logs: string[] = []
|
|
140
|
+
const originalLog = console.log
|
|
141
|
+
console.log = (msg: string) => {
|
|
142
|
+
logs.push(msg)
|
|
143
|
+
originalLog(msg)
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
try {
|
|
147
|
+
// First build
|
|
148
|
+
await build({ cwd: tmpDir, force: false, spawn: mockSpawn })
|
|
149
|
+
|
|
150
|
+
const fpPath = join(tmpDir, 'packages/pkg/dist/.build-fingerprint')
|
|
151
|
+
expect(existsSync(fpPath)).toBe(true)
|
|
152
|
+
|
|
153
|
+
// Clear logs
|
|
154
|
+
logs.length = 0
|
|
155
|
+
|
|
156
|
+
// Second build without changes - should skip
|
|
157
|
+
await build({ cwd: tmpDir, force: false, spawn: mockSpawn })
|
|
158
|
+
expect(logs.some((log) => log.includes('⏭ build: @test/pkg (unchanged)'))).toBe(true)
|
|
159
|
+
|
|
160
|
+
// Remove dist folder
|
|
161
|
+
rmSync(join(tmpDir, 'packages/pkg/dist'), { recursive: true })
|
|
162
|
+
|
|
163
|
+
// Clear logs
|
|
164
|
+
logs.length = 0
|
|
165
|
+
|
|
166
|
+
// Third build - should rebuild (dist is gone)
|
|
167
|
+
await build({ cwd: tmpDir, force: false, spawn: mockSpawn })
|
|
168
|
+
|
|
169
|
+
// Should NOT skip (no skip log)
|
|
170
|
+
expect(logs.some((log) => log.includes('⏭ build: @test/pkg (unchanged)'))).toBe(false)
|
|
171
|
+
|
|
172
|
+
// New fingerprint written
|
|
173
|
+
expect(existsSync(fpPath)).toBe(true)
|
|
174
|
+
} finally {
|
|
175
|
+
console.log = originalLog
|
|
176
|
+
}
|
|
177
|
+
})
|
|
178
|
+
|
|
179
|
+
test('T3: Unchanged source with intact dist skips build', async () => {
|
|
180
|
+
setupMonorepo()
|
|
181
|
+
const logs: string[] = []
|
|
182
|
+
const originalLog = console.log
|
|
183
|
+
console.log = (msg: string) => {
|
|
184
|
+
logs.push(msg)
|
|
185
|
+
originalLog(msg)
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
try {
|
|
189
|
+
// First build
|
|
190
|
+
await build({ cwd: tmpDir, force: false, spawn: mockSpawn })
|
|
191
|
+
|
|
192
|
+
const fpPath = join(tmpDir, 'packages/pkg/dist/.build-fingerprint')
|
|
193
|
+
const fpBefore = readFileSync(fpPath, 'utf-8')
|
|
194
|
+
|
|
195
|
+
// Clear logs
|
|
196
|
+
logs.length = 0
|
|
197
|
+
|
|
198
|
+
// Second build without changes - should skip
|
|
199
|
+
await build({ cwd: tmpDir, force: false, spawn: mockSpawn })
|
|
200
|
+
expect(logs.some((log) => log.includes('⏭ build: @test/pkg (unchanged)'))).toBe(true)
|
|
201
|
+
|
|
202
|
+
// Fingerprint unchanged
|
|
203
|
+
const fpAfter = readFileSync(fpPath, 'utf-8')
|
|
204
|
+
expect(fpAfter).toBe(fpBefore)
|
|
205
|
+
} finally {
|
|
206
|
+
console.log = originalLog
|
|
207
|
+
}
|
|
208
|
+
})
|
|
209
|
+
|
|
210
|
+
test('T4: Changed source invalidates fingerprint even with intact dist', async () => {
|
|
211
|
+
setupMonorepo()
|
|
212
|
+
const logs: string[] = []
|
|
213
|
+
const originalLog = console.log
|
|
214
|
+
console.log = (msg: string) => {
|
|
215
|
+
logs.push(msg)
|
|
216
|
+
originalLog(msg)
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
try {
|
|
220
|
+
// First build
|
|
221
|
+
await build({ cwd: tmpDir, force: false, spawn: mockSpawn })
|
|
222
|
+
|
|
223
|
+
const fpPath = join(tmpDir, 'packages/pkg/dist/.build-fingerprint')
|
|
224
|
+
const fpBefore = readFileSync(fpPath, 'utf-8')
|
|
225
|
+
|
|
226
|
+
// Modify source
|
|
227
|
+
writeFileSync(join(tmpDir, 'packages/pkg/src/index.ts'), 'export const x = 999')
|
|
228
|
+
|
|
229
|
+
// Clear logs
|
|
230
|
+
logs.length = 0
|
|
231
|
+
|
|
232
|
+
// Second build with changed source - should rebuild
|
|
233
|
+
await build({ cwd: tmpDir, force: false, spawn: mockSpawn })
|
|
234
|
+
|
|
235
|
+
// Should NOT skip
|
|
236
|
+
expect(logs.some((log) => log.includes('⏭ build: @test/pkg (unchanged)'))).toBe(false)
|
|
237
|
+
|
|
238
|
+
// New fingerprint written
|
|
239
|
+
const fpAfter = readFileSync(fpPath, 'utf-8')
|
|
240
|
+
expect(fpAfter).not.toBe(fpBefore)
|
|
241
|
+
} finally {
|
|
242
|
+
console.log = originalLog
|
|
243
|
+
}
|
|
244
|
+
})
|
|
245
|
+
|
|
246
|
+
test('T5: Multiple packages each have fingerprints in their own dist', async () => {
|
|
247
|
+
setupMultiPackageMonorepo()
|
|
248
|
+
|
|
249
|
+
await build({ cwd: tmpDir, force: false, spawn: mockSpawn })
|
|
250
|
+
|
|
251
|
+
// Each package has fingerprint in its own dist
|
|
252
|
+
expect(existsSync(join(tmpDir, 'packages/core/dist/.build-fingerprint'))).toBe(true)
|
|
253
|
+
expect(existsSync(join(tmpDir, 'packages/fs/dist/.build-fingerprint'))).toBe(true)
|
|
254
|
+
expect(existsSync(join(tmpDir, 'packages/cli/dist/.build-fingerprint'))).toBe(true)
|
|
255
|
+
|
|
256
|
+
// No fingerprints in .proman/build directory
|
|
257
|
+
expect(existsSync(join(tmpDir, '.proman/build'))).toBe(false)
|
|
258
|
+
})
|
|
259
|
+
|
|
260
|
+
test('T6: Dependency propagation still works with new location', async () => {
|
|
261
|
+
setupMultiPackageMonorepo()
|
|
262
|
+
|
|
263
|
+
// First build
|
|
264
|
+
await build({ cwd: tmpDir, force: false, spawn: mockSpawn })
|
|
265
|
+
|
|
266
|
+
const coreFpBefore = readFileSync(
|
|
267
|
+
join(tmpDir, 'packages/core/dist/.build-fingerprint'),
|
|
268
|
+
'utf-8',
|
|
269
|
+
)
|
|
270
|
+
const fsFpBefore = readFileSync(join(tmpDir, 'packages/fs/dist/.build-fingerprint'), 'utf-8')
|
|
271
|
+
const cliFpBefore = readFileSync(join(tmpDir, 'packages/cli/dist/.build-fingerprint'), 'utf-8')
|
|
272
|
+
|
|
273
|
+
// Modify core's source
|
|
274
|
+
writeFileSync(join(tmpDir, 'packages/core/src/index.ts'), 'export const x = 999')
|
|
275
|
+
|
|
276
|
+
// Second build
|
|
277
|
+
await build({ cwd: tmpDir, force: false, spawn: mockSpawn })
|
|
278
|
+
|
|
279
|
+
// All packages should have new fingerprints (dependency propagation)
|
|
280
|
+
const coreFpAfter = readFileSync(join(tmpDir, 'packages/core/dist/.build-fingerprint'), 'utf-8')
|
|
281
|
+
const fsFpAfter = readFileSync(join(tmpDir, 'packages/fs/dist/.build-fingerprint'), 'utf-8')
|
|
282
|
+
const cliFpAfter = readFileSync(join(tmpDir, 'packages/cli/dist/.build-fingerprint'), 'utf-8')
|
|
283
|
+
|
|
284
|
+
expect(coreFpAfter).not.toBe(coreFpBefore)
|
|
285
|
+
expect(fsFpAfter).not.toBe(fsFpBefore)
|
|
286
|
+
expect(cliFpAfter).not.toBe(cliFpBefore)
|
|
287
|
+
})
|
|
288
|
+
|
|
289
|
+
test('T7: Force flag bypasses cache regardless of fingerprint location', async () => {
|
|
290
|
+
setupMonorepo()
|
|
291
|
+
const logs: string[] = []
|
|
292
|
+
const originalLog = console.log
|
|
293
|
+
console.log = (msg: string) => {
|
|
294
|
+
logs.push(msg)
|
|
295
|
+
originalLog(msg)
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
try {
|
|
299
|
+
// First build
|
|
300
|
+
await build({ cwd: tmpDir, force: false, spawn: mockSpawn })
|
|
301
|
+
|
|
302
|
+
const fpPath = join(tmpDir, 'packages/pkg/dist/.build-fingerprint')
|
|
303
|
+
const _fpBefore = readFileSync(fpPath, 'utf-8')
|
|
304
|
+
|
|
305
|
+
// Clear logs
|
|
306
|
+
logs.length = 0
|
|
307
|
+
|
|
308
|
+
// Second build with force - should rebuild
|
|
309
|
+
await build({ cwd: tmpDir, force: true, spawn: mockSpawn })
|
|
310
|
+
|
|
311
|
+
// Should NOT skip
|
|
312
|
+
expect(logs.some((log) => log.includes('⏭ build: @test/pkg (unchanged)'))).toBe(false)
|
|
313
|
+
|
|
314
|
+
// Fingerprint is rewritten (even if content is the same)
|
|
315
|
+
expect(existsSync(fpPath)).toBe(true)
|
|
316
|
+
} finally {
|
|
317
|
+
console.log = originalLog
|
|
318
|
+
}
|
|
319
|
+
})
|
|
320
|
+
|
|
321
|
+
test('T8: CI mode always rebuilds and writes fingerprints', async () => {
|
|
322
|
+
setupMonorepo()
|
|
323
|
+
const logs: string[] = []
|
|
324
|
+
const originalLog = console.log
|
|
325
|
+
console.log = (msg: string) => {
|
|
326
|
+
logs.push(msg)
|
|
327
|
+
originalLog(msg)
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
try {
|
|
331
|
+
// First build with force=false (enable fingerprints)
|
|
332
|
+
await build({ cwd: tmpDir, force: false, spawn: mockSpawn })
|
|
333
|
+
|
|
334
|
+
const fpPath = join(tmpDir, 'packages/pkg/dist/.build-fingerprint')
|
|
335
|
+
expect(existsSync(fpPath)).toBe(true)
|
|
336
|
+
|
|
337
|
+
// Clear logs
|
|
338
|
+
logs.length = 0
|
|
339
|
+
|
|
340
|
+
// Second build with force=true (simulates CI mode) - should rebuild
|
|
341
|
+
await build({ cwd: tmpDir, force: true, spawn: mockSpawn })
|
|
342
|
+
|
|
343
|
+
// Should NOT skip (CI always rebuilds)
|
|
344
|
+
expect(logs.some((log) => log.includes('⏭ build: @test/pkg (unchanged)'))).toBe(false)
|
|
345
|
+
|
|
346
|
+
// Fingerprint is still written (even in force mode)
|
|
347
|
+
expect(existsSync(fpPath)).toBe(true)
|
|
348
|
+
} finally {
|
|
349
|
+
console.log = originalLog
|
|
350
|
+
}
|
|
351
|
+
})
|
|
352
|
+
|
|
353
|
+
test('T9: Build failure does not write fingerprint', async () => {
|
|
354
|
+
setupMonorepo()
|
|
355
|
+
|
|
356
|
+
// Mock spawn that fails
|
|
357
|
+
const failingSpawn = async () => {
|
|
358
|
+
return { code: 1, stdout: '', stderr: 'Build failed' }
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
// Build should throw
|
|
362
|
+
await expect(build({ cwd: tmpDir, force: false, spawn: failingSpawn })).rejects.toThrow()
|
|
363
|
+
|
|
364
|
+
// No fingerprint written
|
|
365
|
+
const fpPath = join(tmpDir, 'packages/pkg/dist/.build-fingerprint')
|
|
366
|
+
expect(existsSync(fpPath)).toBe(false)
|
|
367
|
+
})
|
|
368
|
+
|
|
369
|
+
test('T10: Partial dist deletion (missing fingerprint) forces rebuild', async () => {
|
|
370
|
+
setupMonorepo()
|
|
371
|
+
const logs: string[] = []
|
|
372
|
+
const originalLog = console.log
|
|
373
|
+
console.log = (msg: string) => {
|
|
374
|
+
logs.push(msg)
|
|
375
|
+
originalLog(msg)
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
try {
|
|
379
|
+
// First build
|
|
380
|
+
await build({ cwd: tmpDir, force: false, spawn: mockSpawn })
|
|
381
|
+
|
|
382
|
+
const fpPath = join(tmpDir, 'packages/pkg/dist/.build-fingerprint')
|
|
383
|
+
expect(existsSync(fpPath)).toBe(true)
|
|
384
|
+
|
|
385
|
+
// Delete only fingerprint (keep dist folder)
|
|
386
|
+
rmSync(fpPath)
|
|
387
|
+
|
|
388
|
+
// Clear logs
|
|
389
|
+
logs.length = 0
|
|
390
|
+
|
|
391
|
+
// Second build - should rebuild (missing fingerprint)
|
|
392
|
+
await build({ cwd: tmpDir, force: false, spawn: mockSpawn })
|
|
393
|
+
|
|
394
|
+
// Should NOT skip (no skip log)
|
|
395
|
+
expect(logs.some((log) => log.includes('⏭ build: @test/pkg (unchanged)'))).toBe(false)
|
|
396
|
+
|
|
397
|
+
// New fingerprint written
|
|
398
|
+
expect(existsSync(fpPath)).toBe(true)
|
|
399
|
+
} finally {
|
|
400
|
+
console.log = originalLog
|
|
401
|
+
}
|
|
402
|
+
})
|
|
403
|
+
})
|