@harness-fe/unplugin 3.0.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/LICENSE +21 -0
- package/README.md +46 -0
- package/dist/core.d.ts +19 -0
- package/dist/core.js +211 -0
- package/dist/esbuild.d.ts +9 -0
- package/dist/esbuild.js +9 -0
- package/dist/index.d.ts +20 -0
- package/dist/index.js +20 -0
- package/dist/internal/buildIdentity.d.ts +19 -0
- package/dist/internal/buildIdentity.js +49 -0
- package/dist/internal/log-capture.d.ts +7 -0
- package/dist/internal/log-capture.js +22 -0
- package/dist/internal/mcp-client.d.ts +11 -0
- package/dist/internal/mcp-client.js +165 -0
- package/dist/internal/types.d.ts +61 -0
- package/dist/internal/types.js +4 -0
- package/dist/resolveBuildId.d.ts +32 -0
- package/dist/resolveBuildId.js +88 -0
- package/dist/resolveProjectId.d.ts +9 -0
- package/dist/resolveProjectId.js +44 -0
- package/dist/rollup.d.ts +9 -0
- package/dist/rollup.js +9 -0
- package/dist/rspack.d.ts +9 -0
- package/dist/rspack.js +9 -0
- package/dist/transform.d.ts +27 -0
- package/dist/transform.js +150 -0
- package/dist/vite.d.ts +10 -0
- package/dist/vite.js +10 -0
- package/dist/vue-transform.d.ts +90 -0
- package/dist/vue-transform.js +350 -0
- package/package.json +75 -0
- package/src/core.ts +230 -0
- package/src/esbuild.ts +12 -0
- package/src/index.ts +34 -0
- package/src/internal/buildIdentity.ts +66 -0
- package/src/internal/log-capture.ts +26 -0
- package/src/internal/mcp-client.ts +181 -0
- package/src/internal/types.ts +66 -0
- package/src/resolveBuildId.test.ts +63 -0
- package/src/resolveBuildId.ts +125 -0
- package/src/resolveProjectId.test.ts +99 -0
- package/src/resolveProjectId.ts +48 -0
- package/src/rollup.ts +12 -0
- package/src/rspack.ts +12 -0
- package/src/transform.test.ts +89 -0
- package/src/transform.ts +188 -0
- package/src/vite.ts +13 -0
- package/src/vue-transform.test.ts +398 -0
- package/src/vue-transform.ts +455 -0
package/src/index.ts
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @harness-fe/unplugin — unified build plugin.
|
|
3
|
+
*
|
|
4
|
+
* Usage:
|
|
5
|
+
* import { harnessFE } from '@harness-fe/unplugin/vite'
|
|
6
|
+
* import { harnessFE } from '@harness-fe/unplugin/rspack'
|
|
7
|
+
*
|
|
8
|
+
* Webpack: use @harness-fe/webpack (a native plugin — see that package).
|
|
9
|
+
*
|
|
10
|
+
* Or import the raw unplugin for custom integrations:
|
|
11
|
+
* import { unplugin, unpluginFactory } from '@harness-fe/unplugin'
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
export { unplugin, unpluginFactory, type HarnessFEOptions } from './core.js';
|
|
15
|
+
export { transformJsx, type ComponentMap, type ComponentLocation, type TransformResult } from './transform.js';
|
|
16
|
+
export {
|
|
17
|
+
transformVueSFC,
|
|
18
|
+
transformVueTemplate,
|
|
19
|
+
resolveVueComponentName,
|
|
20
|
+
getTemplateLineOffset,
|
|
21
|
+
createVueTransformStats,
|
|
22
|
+
formatVueTransformReport,
|
|
23
|
+
type VueTransformOptions,
|
|
24
|
+
type VueTransformResult,
|
|
25
|
+
type VueTransformStats,
|
|
26
|
+
} from './vue-transform.js';
|
|
27
|
+
|
|
28
|
+
// Internal building blocks — re-exported for downstream native plugins (e.g.
|
|
29
|
+
// @harness-fe/webpack) that cannot use the unplugin adapter directly.
|
|
30
|
+
export { createMcpClient } from './internal/mcp-client.js';
|
|
31
|
+
export { installNodeLogCapture } from './internal/log-capture.js';
|
|
32
|
+
export { createBuildIdentity, appendTokenQuery } from './internal/buildIdentity.js';
|
|
33
|
+
export type { McpClient, McpClientContext, PeerRole } from './internal/types.js';
|
|
34
|
+
export type { BuildIdentity, BuildIdentityOptions } from './internal/buildIdentity.js';
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Lazy resolvers for build-time identity metadata (buildId, displayName).
|
|
3
|
+
*
|
|
4
|
+
* Both are deferred until first read because the host bundler may not have
|
|
5
|
+
* resolved `projectRoot` at plugin instantiation time.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { createRequire } from 'node:module';
|
|
9
|
+
import { resolveBuildId } from '../resolveBuildId.js';
|
|
10
|
+
|
|
11
|
+
const require = createRequire(import.meta.url);
|
|
12
|
+
|
|
13
|
+
export interface BuildIdentityOptions {
|
|
14
|
+
userBuildId?: string;
|
|
15
|
+
userDisplayName?: string;
|
|
16
|
+
/** Captured once so dev-mode fallback ids stay stable across re-reads. */
|
|
17
|
+
startTs?: number;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface BuildIdentity {
|
|
21
|
+
getBuildId(root: string): string;
|
|
22
|
+
getDisplayName(root: string): string | undefined;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function createBuildIdentity(opts: BuildIdentityOptions = {}): BuildIdentity {
|
|
26
|
+
const startTs = opts.startTs ?? Date.now();
|
|
27
|
+
let resolvedBuild: ReturnType<typeof resolveBuildId> | undefined;
|
|
28
|
+
let resolvedDisplayName: string | undefined = opts.userDisplayName;
|
|
29
|
+
let displayNameResolved = opts.userDisplayName !== undefined;
|
|
30
|
+
|
|
31
|
+
return {
|
|
32
|
+
getBuildId(root: string): string {
|
|
33
|
+
if (resolvedBuild) return resolvedBuild.buildId;
|
|
34
|
+
resolvedBuild = resolveBuildId({
|
|
35
|
+
userConfig: opts.userBuildId,
|
|
36
|
+
root,
|
|
37
|
+
startTs,
|
|
38
|
+
});
|
|
39
|
+
return resolvedBuild.buildId;
|
|
40
|
+
},
|
|
41
|
+
getDisplayName(root: string): string | undefined {
|
|
42
|
+
if (displayNameResolved) return resolvedDisplayName;
|
|
43
|
+
displayNameResolved = true;
|
|
44
|
+
try {
|
|
45
|
+
const pkg = JSON.parse(
|
|
46
|
+
require('node:fs').readFileSync(
|
|
47
|
+
require('node:path').join(root, 'package.json'),
|
|
48
|
+
'utf-8',
|
|
49
|
+
),
|
|
50
|
+
) as { name?: string };
|
|
51
|
+
resolvedDisplayName = pkg.name;
|
|
52
|
+
} catch {
|
|
53
|
+
resolvedDisplayName = undefined;
|
|
54
|
+
}
|
|
55
|
+
return resolvedDisplayName;
|
|
56
|
+
},
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Append `?token=…` (or `&token=…`) onto a URL, idempotent on empty token. */
|
|
61
|
+
export function appendTokenQuery(url: string, token: string | undefined): string {
|
|
62
|
+
if (!token) return url;
|
|
63
|
+
if (/[?&]token=/.test(url)) return url;
|
|
64
|
+
const sep = url.includes('?') ? '&' : '?';
|
|
65
|
+
return `${url}${sep}token=${encodeURIComponent(token)}`;
|
|
66
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Intercepts `process.stdout.write` and `process.stderr.write` to emit
|
|
3
|
+
* `'node:log'` / `'node:err'` events to the MCP server.
|
|
4
|
+
*
|
|
5
|
+
* Returns a cleanup function that restores the original write methods.
|
|
6
|
+
*/
|
|
7
|
+
export function installNodeLogCapture(
|
|
8
|
+
emitEvent: (name: string, payload: unknown) => void,
|
|
9
|
+
): () => void {
|
|
10
|
+
const origOut = process.stdout.write.bind(process.stdout);
|
|
11
|
+
const origErr = process.stderr.write.bind(process.stderr);
|
|
12
|
+
|
|
13
|
+
(process.stdout as any).write = (chunk: any, ...args: any[]) => {
|
|
14
|
+
emitEvent('node:log', { text: String(chunk) });
|
|
15
|
+
return origOut(chunk, ...args);
|
|
16
|
+
};
|
|
17
|
+
(process.stderr as any).write = (chunk: any, ...args: any[]) => {
|
|
18
|
+
emitEvent('node:err', { text: String(chunk) });
|
|
19
|
+
return origErr(chunk, ...args);
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
return () => {
|
|
23
|
+
(process.stdout as any).write = origOut;
|
|
24
|
+
(process.stderr as any).write = origErr;
|
|
25
|
+
};
|
|
26
|
+
}
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MCP WebSocket client + command dispatch loop, factored out of the unplugin
|
|
3
|
+
* core so the native webpack plugin can reuse it without depending on the
|
|
4
|
+
* unplugin webpack adapter (which carries circular references via the plugin
|
|
5
|
+
* instance and breaks thread-loader serialization).
|
|
6
|
+
*
|
|
7
|
+
* The componentMap is supplied by the caller — vite/rspack pass their shared
|
|
8
|
+
* map; the webpack plugin passes its shared-state map keyed by pluginId.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { readFileSync } from 'node:fs';
|
|
12
|
+
import { resolve } from 'node:path';
|
|
13
|
+
import { WebSocket } from 'ws';
|
|
14
|
+
import {
|
|
15
|
+
COMMAND,
|
|
16
|
+
type CommandFrame,
|
|
17
|
+
type EventFrame,
|
|
18
|
+
type Frame,
|
|
19
|
+
type HelloFrame,
|
|
20
|
+
type ResponseFrame,
|
|
21
|
+
frameSchema,
|
|
22
|
+
} from '@harness-fe/protocol';
|
|
23
|
+
import type { McpClient, McpClientContext } from './types.js';
|
|
24
|
+
|
|
25
|
+
function newId(): string {
|
|
26
|
+
const g = globalThis as { crypto?: { randomUUID?: () => string } };
|
|
27
|
+
return g.crypto?.randomUUID ? g.crypto.randomUUID() : `${Date.now()}-${Math.random()}`;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function createMcpClient(ctx: McpClientContext): McpClient {
|
|
31
|
+
let ws: WebSocket | undefined;
|
|
32
|
+
let isActive = false;
|
|
33
|
+
|
|
34
|
+
function send(frame: EventFrame | HelloFrame | ResponseFrame): void {
|
|
35
|
+
if (!ws || ws.readyState !== WebSocket.OPEN) return;
|
|
36
|
+
try {
|
|
37
|
+
ws.send(JSON.stringify(frame));
|
|
38
|
+
} catch {
|
|
39
|
+
/* swallow */
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
async function runCommand(command: string, args: unknown): Promise<unknown> {
|
|
44
|
+
switch (command) {
|
|
45
|
+
case COMMAND.PROJECT_SOURCE: {
|
|
46
|
+
const a = args as { file?: string; component?: string };
|
|
47
|
+
let file = a.file;
|
|
48
|
+
if (!file && a.component) {
|
|
49
|
+
const locs = ctx.componentMap.get(a.component);
|
|
50
|
+
if (!locs?.length) {
|
|
51
|
+
throw new Error(
|
|
52
|
+
`project.source: component "${a.component}" not found in the scan`,
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
file = locs[0].file;
|
|
56
|
+
}
|
|
57
|
+
if (!file) {
|
|
58
|
+
throw new Error('project.source: pass either `file` or `component`');
|
|
59
|
+
}
|
|
60
|
+
const abs = resolve(ctx.projectRoot, file);
|
|
61
|
+
if (!abs.startsWith(ctx.projectRoot)) {
|
|
62
|
+
throw new Error(
|
|
63
|
+
`project.source: refusing to read outside project root: ${file}`,
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
const content = readFileSync(abs, 'utf-8');
|
|
67
|
+
return { file, content };
|
|
68
|
+
}
|
|
69
|
+
case COMMAND.PROJECT_WHERE_IS: {
|
|
70
|
+
const a = args as { component: string };
|
|
71
|
+
const locs = ctx.componentMap.get(a.component);
|
|
72
|
+
if (!locs?.length) {
|
|
73
|
+
throw new Error(`project.where_is: component "${a.component}" not found`);
|
|
74
|
+
}
|
|
75
|
+
return { component: a.component, locations: locs };
|
|
76
|
+
}
|
|
77
|
+
case COMMAND.PROJECT_MODULE_GRAPH: {
|
|
78
|
+
const components: Record<string, Array<{ file: string; line: number; col: number }>> = {};
|
|
79
|
+
for (const [name, locs] of ctx.componentMap.entries()) {
|
|
80
|
+
components[name] = locs;
|
|
81
|
+
}
|
|
82
|
+
return {
|
|
83
|
+
components,
|
|
84
|
+
totalFiles: new Set(
|
|
85
|
+
[...ctx.componentMap.values()].flat().map((l) => l.file),
|
|
86
|
+
).size,
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
default:
|
|
90
|
+
throw new Error(`harness-fe: unhandled command "${command}"`);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
async function handleCommand(frame: CommandFrame): Promise<void> {
|
|
95
|
+
let response: ResponseFrame;
|
|
96
|
+
try {
|
|
97
|
+
const result = await runCommand(frame.command, frame.args);
|
|
98
|
+
response = { type: 'response', id: frame.id, ok: true, result };
|
|
99
|
+
} catch (err) {
|
|
100
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
101
|
+
response = { type: 'response', id: frame.id, ok: false, error: { message } };
|
|
102
|
+
}
|
|
103
|
+
send(response);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function connect(): void {
|
|
107
|
+
if (isActive) return;
|
|
108
|
+
isActive = true;
|
|
109
|
+
connectInternal();
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function connectInternal(): void {
|
|
113
|
+
try {
|
|
114
|
+
const headers: Record<string, string> = {};
|
|
115
|
+
if (ctx.token) headers.authorization = `Bearer ${ctx.token}`;
|
|
116
|
+
ws = new WebSocket(ctx.mcpUrl, { headers });
|
|
117
|
+
ws.on('open', () => {
|
|
118
|
+
const hello: HelloFrame = {
|
|
119
|
+
type: 'hello',
|
|
120
|
+
id: newId(),
|
|
121
|
+
role: ctx.peerRole,
|
|
122
|
+
projectId: ctx.projectId,
|
|
123
|
+
parentProjectId: ctx.parentProjectId,
|
|
124
|
+
displayName: ctx.getDisplayName(),
|
|
125
|
+
buildId: ctx.getBuildId(),
|
|
126
|
+
};
|
|
127
|
+
send(hello);
|
|
128
|
+
});
|
|
129
|
+
ws.on('message', (raw) => {
|
|
130
|
+
let parsed: unknown;
|
|
131
|
+
try {
|
|
132
|
+
parsed = JSON.parse(raw.toString());
|
|
133
|
+
} catch {
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
const result = frameSchema.safeParse(parsed);
|
|
137
|
+
if (!result.success) return;
|
|
138
|
+
const frame = result.data as Frame;
|
|
139
|
+
if (frame.type === 'command') void handleCommand(frame);
|
|
140
|
+
});
|
|
141
|
+
ws.on('error', () => {
|
|
142
|
+
// Server may not be running — best-effort metadata only.
|
|
143
|
+
});
|
|
144
|
+
ws.on('close', () => {
|
|
145
|
+
setTimeout(() => {
|
|
146
|
+
if (isActive) connectInternal();
|
|
147
|
+
}, 2000);
|
|
148
|
+
});
|
|
149
|
+
} catch {
|
|
150
|
+
/* swallow */
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function disconnect(): void {
|
|
155
|
+
isActive = false;
|
|
156
|
+
ws?.close();
|
|
157
|
+
ws = undefined;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function emitEvent(name: string, payload: unknown): void {
|
|
161
|
+
const event: EventFrame = {
|
|
162
|
+
type: 'event',
|
|
163
|
+
id: newId(),
|
|
164
|
+
projectId: ctx.projectId,
|
|
165
|
+
buildId: ctx.getBuildId(),
|
|
166
|
+
name,
|
|
167
|
+
ts: Date.now(),
|
|
168
|
+
payload,
|
|
169
|
+
};
|
|
170
|
+
send(event);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
return {
|
|
174
|
+
connect,
|
|
175
|
+
disconnect,
|
|
176
|
+
emitEvent,
|
|
177
|
+
get isActive() {
|
|
178
|
+
return isActive;
|
|
179
|
+
},
|
|
180
|
+
};
|
|
181
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared types used by both the unplugin core and the native webpack plugin.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import type { ComponentMap } from '../transform.js';
|
|
6
|
+
|
|
7
|
+
export type PeerRole = 'vite-plugin' | 'webpack-plugin';
|
|
8
|
+
|
|
9
|
+
export interface HarnessFEOptions {
|
|
10
|
+
/** Override projectId (defaults to package.json `name`). */
|
|
11
|
+
projectId?: string;
|
|
12
|
+
/** MCP server WebSocket URL (default: ws://127.0.0.1:47729). */
|
|
13
|
+
mcpUrl?: string;
|
|
14
|
+
/** Disable injection entirely. */
|
|
15
|
+
disabled?: boolean;
|
|
16
|
+
/**
|
|
17
|
+
* Parent project's id, used to build the project tree on the daemon.
|
|
18
|
+
* Set this on the iframe child app's plugin config when you can declare
|
|
19
|
+
* the relationship at build time. Otherwise the runtime client will
|
|
20
|
+
* auto-detect it via same-origin parent inspection.
|
|
21
|
+
*/
|
|
22
|
+
parentProjectId?: string;
|
|
23
|
+
/** Human-readable name; defaults to package.json `name`. */
|
|
24
|
+
displayName?: string;
|
|
25
|
+
/**
|
|
26
|
+
* Override buildId. When omitted, the plugin resolves it from git sha
|
|
27
|
+
* (or CI env vars) and falls back to a dev-stable hash of config files.
|
|
28
|
+
*/
|
|
29
|
+
buildId?: string;
|
|
30
|
+
/**
|
|
31
|
+
* Token to authenticate against the daemon when it's bound to a non-
|
|
32
|
+
* loopback host. Appended as `?token=…` to the WS URL and propagated
|
|
33
|
+
* to the runtime client via `__HARNESS_FE__`. Read from
|
|
34
|
+
* `HARNESS_FE_TOKEN` when omitted.
|
|
35
|
+
*/
|
|
36
|
+
token?: string;
|
|
37
|
+
/**
|
|
38
|
+
* Vue SFC transform safety: when true (default), the plugin re-parses
|
|
39
|
+
* its own output to catch any mis-aligned attribute injection.
|
|
40
|
+
*/
|
|
41
|
+
safeMode?: boolean;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Context handed to the MCP client. Getters keep the values fresh as the
|
|
46
|
+
* host (vite/webpack) resolves them lazily (e.g. projectRoot is only known
|
|
47
|
+
* after configResolved / afterEnvironment).
|
|
48
|
+
*/
|
|
49
|
+
export interface McpClientContext {
|
|
50
|
+
readonly projectId: string;
|
|
51
|
+
readonly mcpUrl: string;
|
|
52
|
+
readonly token: string | undefined;
|
|
53
|
+
readonly peerRole: PeerRole;
|
|
54
|
+
readonly parentProjectId: string | undefined;
|
|
55
|
+
readonly projectRoot: string;
|
|
56
|
+
readonly componentMap: ComponentMap;
|
|
57
|
+
getBuildId(): string;
|
|
58
|
+
getDisplayName(): string | undefined;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export interface McpClient {
|
|
62
|
+
connect(): void;
|
|
63
|
+
disconnect(): void;
|
|
64
|
+
emitEvent(name: string, payload: unknown): void;
|
|
65
|
+
readonly isActive: boolean;
|
|
66
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
2
|
+
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
|
3
|
+
import { tmpdir } from 'node:os';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
import { resolveBuildId } from './resolveBuildId.js';
|
|
6
|
+
|
|
7
|
+
describe('resolveBuildId', () => {
|
|
8
|
+
let root: string;
|
|
9
|
+
const origEnv = { ...process.env };
|
|
10
|
+
|
|
11
|
+
beforeEach(() => {
|
|
12
|
+
root = mkdtempSync(join(tmpdir(), 'rbid-'));
|
|
13
|
+
// Strip CI env vars so tests aren't influenced by the host env.
|
|
14
|
+
delete process.env.GITHUB_SHA;
|
|
15
|
+
delete process.env.GIT_COMMIT;
|
|
16
|
+
delete process.env.CI_COMMIT_SHA;
|
|
17
|
+
delete process.env.BUILDKITE_COMMIT;
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
afterEach(() => {
|
|
21
|
+
rmSync(root, { recursive: true, force: true });
|
|
22
|
+
process.env = { ...origEnv };
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it('userConfig wins over all auto-detection', () => {
|
|
26
|
+
process.env.GITHUB_SHA = 'aaaaaaaaaa';
|
|
27
|
+
const out = resolveBuildId({ root, userConfig: 'explicit-id-99' });
|
|
28
|
+
expect(out.buildId).toBe('explicit-id-99');
|
|
29
|
+
expect(out.gitSha).toBeUndefined();
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it('falls back to dev hash + startTs when neither git nor CI is available', () => {
|
|
33
|
+
// No package.json, no git → pure fallback path
|
|
34
|
+
const out = resolveBuildId({ root, startTs: 1700000000000 });
|
|
35
|
+
// dev-<8-char-hash>-<base36-ts>
|
|
36
|
+
expect(out.buildId).toMatch(/^dev-[a-f0-9]{8}-[a-z0-9]+$/);
|
|
37
|
+
expect(out.sourceDigest).toBeDefined();
|
|
38
|
+
expect(out.gitSha).toBeUndefined();
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it('two consecutive resolves with the same root + startTs produce the same buildId', () => {
|
|
42
|
+
writeFileSync(join(root, 'package.json'), JSON.stringify({ name: 'fixture' }));
|
|
43
|
+
const a = resolveBuildId({ root, startTs: 42 });
|
|
44
|
+
const b = resolveBuildId({ root, startTs: 42 });
|
|
45
|
+
expect(a.buildId).toBe(b.buildId);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it('changing package.json changes the dev hash component', () => {
|
|
49
|
+
writeFileSync(join(root, 'package.json'), JSON.stringify({ name: 'a' }));
|
|
50
|
+
const a = resolveBuildId({ root, startTs: 1 });
|
|
51
|
+
writeFileSync(join(root, 'package.json'), JSON.stringify({ name: 'b' }));
|
|
52
|
+
const b = resolveBuildId({ root, startTs: 1 });
|
|
53
|
+
expect(a.buildId).not.toBe(b.buildId);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('reads GITHUB_SHA when present', () => {
|
|
57
|
+
process.env.GITHUB_SHA = '0123456789abcdef0123';
|
|
58
|
+
const out = resolveBuildId({ root });
|
|
59
|
+
expect(out.buildId).toBe('0123456789ab-ci');
|
|
60
|
+
expect(out.gitSha).toBe('0123456789abcdef0123');
|
|
61
|
+
expect(out.gitDirty).toBe(false);
|
|
62
|
+
});
|
|
63
|
+
});
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import { spawnSync } from 'node:child_process';
|
|
2
|
+
import { createHash } from 'node:crypto';
|
|
3
|
+
import { readFileSync } from 'node:fs';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Resolves the buildId for one harness-fe build (vite dev server start /
|
|
8
|
+
* webpack build / prod build). One buildId = one source-code snapshot.
|
|
9
|
+
*
|
|
10
|
+
* Stability rules:
|
|
11
|
+
* - HMR / file edits within a dev server run → same buildId
|
|
12
|
+
* - dev server restart → new buildId
|
|
13
|
+
* - prod build → buildId matches git sha (or dirty-marked)
|
|
14
|
+
*
|
|
15
|
+
* Priority:
|
|
16
|
+
* 1. `userConfig` — caller-supplied via `harnessFE({ buildId })`
|
|
17
|
+
* 2. CI env vars (GITHUB_SHA / GIT_COMMIT) when present
|
|
18
|
+
* 3. `git rev-parse HEAD` + dirty marker when in a git repo
|
|
19
|
+
* 4. Fallback: `dev-<short-source-hash>-<startTs>` derived from package.json,
|
|
20
|
+
* lockfile, and bundler config — stable for the lifetime of this process.
|
|
21
|
+
*/
|
|
22
|
+
export interface ResolveBuildIdOptions {
|
|
23
|
+
/** Caller override; wins over all auto-detection. */
|
|
24
|
+
userConfig?: string;
|
|
25
|
+
/** Project root (where package.json lives). */
|
|
26
|
+
root: string;
|
|
27
|
+
/** Stable timestamp to embed in the dev fallback id. Pass `Date.now()`
|
|
28
|
+
* once at plugin init so this id doesn't change across resolve() calls. */
|
|
29
|
+
startTs?: number;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface ResolvedBuildId {
|
|
33
|
+
buildId: string;
|
|
34
|
+
gitSha?: string;
|
|
35
|
+
gitDirty?: boolean;
|
|
36
|
+
sourceDigest?: string;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function resolveBuildId(opts: ResolveBuildIdOptions): ResolvedBuildId {
|
|
40
|
+
if (opts.userConfig && opts.userConfig.length > 0) {
|
|
41
|
+
return { buildId: opts.userConfig };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// CI env vars take precedence over git so docker/CI builds with shallow
|
|
45
|
+
// checkouts still get a meaningful id even when git isn't available.
|
|
46
|
+
const ciSha =
|
|
47
|
+
process.env.GITHUB_SHA ||
|
|
48
|
+
process.env.GIT_COMMIT ||
|
|
49
|
+
process.env.CI_COMMIT_SHA ||
|
|
50
|
+
process.env.BUILDKITE_COMMIT ||
|
|
51
|
+
undefined;
|
|
52
|
+
if (ciSha) {
|
|
53
|
+
return {
|
|
54
|
+
buildId: `${ciSha.slice(0, 12)}-ci`,
|
|
55
|
+
gitSha: ciSha,
|
|
56
|
+
gitDirty: false,
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Try git locally.
|
|
61
|
+
const gitSha = runGit(['rev-parse', 'HEAD'], opts.root);
|
|
62
|
+
if (gitSha) {
|
|
63
|
+
const dirty = runGit(['status', '--porcelain'], opts.root);
|
|
64
|
+
const gitDirty = dirty !== undefined && dirty.length > 0;
|
|
65
|
+
return {
|
|
66
|
+
buildId: `${gitSha.slice(0, 12)}${gitDirty ? '-dirty' : ''}`,
|
|
67
|
+
gitSha,
|
|
68
|
+
gitDirty,
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// No git, no CI: derive a stable digest from the project's config files
|
|
73
|
+
// and stamp it with the dev-server start timestamp.
|
|
74
|
+
const startTs = opts.startTs ?? Date.now();
|
|
75
|
+
const sourceDigest = hashConfigFiles(opts.root);
|
|
76
|
+
return {
|
|
77
|
+
buildId: `dev-${sourceDigest.slice(0, 8)}-${startTs.toString(36)}`,
|
|
78
|
+
sourceDigest,
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function runGit(args: string[], cwd: string): string | undefined {
|
|
83
|
+
try {
|
|
84
|
+
const out = spawnSync('git', args, {
|
|
85
|
+
cwd,
|
|
86
|
+
encoding: 'utf-8',
|
|
87
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
88
|
+
timeout: 1500,
|
|
89
|
+
});
|
|
90
|
+
if (out.status !== 0) return undefined;
|
|
91
|
+
return out.stdout.trim();
|
|
92
|
+
} catch {
|
|
93
|
+
return undefined;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Hash the contents of the few config files that, when changed, mean a fresh
|
|
99
|
+
* build artifact. Avoids reading every source file — that work belongs to the
|
|
100
|
+
* bundler's own incremental cache.
|
|
101
|
+
*/
|
|
102
|
+
function hashConfigFiles(root: string): string {
|
|
103
|
+
const candidates = [
|
|
104
|
+
'package.json',
|
|
105
|
+
'pnpm-lock.yaml',
|
|
106
|
+
'package-lock.json',
|
|
107
|
+
'yarn.lock',
|
|
108
|
+
'vite.config.ts',
|
|
109
|
+
'vite.config.js',
|
|
110
|
+
'webpack.config.ts',
|
|
111
|
+
'webpack.config.js',
|
|
112
|
+
'webpack.config.cjs',
|
|
113
|
+
'tsconfig.json',
|
|
114
|
+
];
|
|
115
|
+
const h = createHash('sha256');
|
|
116
|
+
for (const name of candidates) {
|
|
117
|
+
try {
|
|
118
|
+
h.update(name);
|
|
119
|
+
h.update(readFileSync(join(root, name)));
|
|
120
|
+
} catch {
|
|
121
|
+
// missing file → contribute the name only (still affects hash)
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
return h.digest('hex');
|
|
125
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { describe, it, expect, afterEach } from 'vitest';
|
|
2
|
+
import * as fc from 'fast-check';
|
|
3
|
+
import { mkdtemp, writeFile, readFile, rm } from 'node:fs/promises';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
import { tmpdir } from 'node:os';
|
|
6
|
+
import { resolveProjectId } from './resolveProjectId.js';
|
|
7
|
+
|
|
8
|
+
// Cleanup temp dirs after each test
|
|
9
|
+
const tempDirs: string[] = [];
|
|
10
|
+
|
|
11
|
+
afterEach(async () => {
|
|
12
|
+
for (const dir of tempDirs.splice(0)) {
|
|
13
|
+
await rm(dir, { recursive: true, force: true });
|
|
14
|
+
}
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
async function makeTempDir(): Promise<string> {
|
|
18
|
+
const dir = await mkdtemp(join(tmpdir(), 'harness-test-'));
|
|
19
|
+
tempDirs.push(dir);
|
|
20
|
+
return dir;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// Feature: persistence, Property 1: Project ID resolution priority
|
|
24
|
+
describe('Property 1: Project ID resolution priority', () => {
|
|
25
|
+
it('resolveProjectId respects priority: userConfig > file > generated UUID', async () => {
|
|
26
|
+
// Validates: Requirements 1.1, 1.2, 1.3
|
|
27
|
+
await fc.assert(
|
|
28
|
+
fc.asyncProperty(
|
|
29
|
+
fc.tuple(fc.option(fc.uuidV(4)), fc.boolean()),
|
|
30
|
+
async ([userConfig, filePresent]) => {
|
|
31
|
+
const root = await makeTempDir();
|
|
32
|
+
const idFilePath = join(root, '.harness-id');
|
|
33
|
+
const fileUuid = 'aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee';
|
|
34
|
+
|
|
35
|
+
if (filePresent) {
|
|
36
|
+
await writeFile(idFilePath, fileUuid, 'utf-8');
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const result = await resolveProjectId(root, userConfig ?? undefined);
|
|
40
|
+
|
|
41
|
+
if (userConfig !== null) {
|
|
42
|
+
// Priority 1: userConfig wins regardless of file presence
|
|
43
|
+
expect(result).toBe(userConfig);
|
|
44
|
+
} else if (filePresent) {
|
|
45
|
+
// Priority 2: file content used when userConfig absent
|
|
46
|
+
expect(result).toBe(fileUuid);
|
|
47
|
+
} else {
|
|
48
|
+
// Priority 3: freshly generated UUID — must be a valid UUID v4
|
|
49
|
+
expect(result).toMatch(
|
|
50
|
+
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i
|
|
51
|
+
);
|
|
52
|
+
// And it must have been written to the file
|
|
53
|
+
const written = await readFile(idFilePath, 'utf-8');
|
|
54
|
+
expect(written.trim()).toBe(result);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
),
|
|
58
|
+
{ numRuns: 100 }
|
|
59
|
+
);
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
// Feature: persistence, Property 2: .harness-id file format round-trip
|
|
64
|
+
describe('Property 2: .harness-id file format round-trip', () => {
|
|
65
|
+
it('UUID written to .harness-id round-trips exactly with no extra characters', async () => {
|
|
66
|
+
// Validates: Requirements 1.5
|
|
67
|
+
await fc.assert(
|
|
68
|
+
fc.asyncProperty(
|
|
69
|
+
fc.uuidV(4),
|
|
70
|
+
async (_uuid) => {
|
|
71
|
+
// We call resolveProjectId with no userConfig and no existing file,
|
|
72
|
+
// so it generates a fresh UUID and writes it to .harness-id.
|
|
73
|
+
// We then verify the file content matches the returned UUID exactly.
|
|
74
|
+
const root = await makeTempDir();
|
|
75
|
+
const idFilePath = join(root, '.harness-id');
|
|
76
|
+
|
|
77
|
+
const returned = await resolveProjectId(root, undefined);
|
|
78
|
+
|
|
79
|
+
const fileContent = await readFile(idFilePath, 'utf-8');
|
|
80
|
+
|
|
81
|
+
// Trimming whitespace must produce the exact same UUID
|
|
82
|
+
expect(fileContent.trim()).toBe(returned);
|
|
83
|
+
|
|
84
|
+
// No BOM (UTF-8 BOM is \uFEFF)
|
|
85
|
+
expect(fileContent.charCodeAt(0)).not.toBe(0xfeff);
|
|
86
|
+
|
|
87
|
+
// No trailing newline or extra whitespace — file contains only the UUID
|
|
88
|
+
expect(fileContent).toBe(returned);
|
|
89
|
+
|
|
90
|
+
// Must be a valid UUID v4
|
|
91
|
+
expect(returned).toMatch(
|
|
92
|
+
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
),
|
|
96
|
+
{ numRuns: 100 }
|
|
97
|
+
);
|
|
98
|
+
});
|
|
99
|
+
});
|