@harness-fe/webpack 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 +51 -0
- package/dist/index.d.ts +19 -0
- package/dist/index.js +20 -0
- package/dist/loader.d.ts +36 -0
- package/dist/loader.js +54 -0
- package/dist/plugin.d.ts +44 -0
- package/dist/plugin.js +233 -0
- package/dist/shared-state.d.ts +16 -0
- package/dist/shared-state.js +25 -0
- package/dist/transform-runner.d.ts +15 -0
- package/dist/transform-runner.js +50 -0
- package/package.json +54 -0
- package/src/index.ts +29 -0
- package/src/loader-options-serializable.test.ts +106 -0
- package/src/loader.ts +93 -0
- package/src/plugin.ts +272 -0
- package/src/shared-state.test.ts +32 -0
- package/src/shared-state.ts +30 -0
- package/src/transform-runner.test.ts +70 -0
- package/src/transform-runner.ts +80 -0
package/src/index.ts
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @harness-fe/webpack — native webpack plugin for Harness-FE.
|
|
3
|
+
*
|
|
4
|
+
* Usage:
|
|
5
|
+
* const { harnessFE } = require('@harness-fe/webpack')
|
|
6
|
+
* // or
|
|
7
|
+
* import { harnessFE } from '@harness-fe/webpack'
|
|
8
|
+
*
|
|
9
|
+
* module.exports = { plugins: [harnessFE()] }
|
|
10
|
+
*
|
|
11
|
+
* Unlike previous versions (which were thin re-exports of
|
|
12
|
+
* @harness-fe/unplugin/webpack), this package implements a native webpack
|
|
13
|
+
* plugin that is compatible with thread-loader. See plugin.ts for the why.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
export { harnessFE, HarnessFEWebpackPlugin } from './plugin.js';
|
|
17
|
+
export type { HarnessFEOptions } from '@harness-fe/unplugin';
|
|
18
|
+
|
|
19
|
+
// Re-export transform utilities for direct usage (preserves the previous
|
|
20
|
+
// public surface of @harness-fe/webpack@2.x).
|
|
21
|
+
export {
|
|
22
|
+
transformJsx,
|
|
23
|
+
type ComponentMap,
|
|
24
|
+
type ComponentLocation,
|
|
25
|
+
type TransformResult,
|
|
26
|
+
} from '@harness-fe/unplugin';
|
|
27
|
+
|
|
28
|
+
import { harnessFE } from './plugin.js';
|
|
29
|
+
export default harnessFE;
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The whole reason this package exists: loader options MUST be JSON-
|
|
3
|
+
* serializable, otherwise thread-loader crashes when dispatching jobs to
|
|
4
|
+
* its worker pool (`WorkerPool.writeJson` → JSON.stringify → circular
|
|
5
|
+
* structure on `Compiler.root`).
|
|
6
|
+
*
|
|
7
|
+
* This test pins that invariant.
|
|
8
|
+
*/
|
|
9
|
+
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
|
10
|
+
import { harnessFE, HarnessFEWebpackPlugin } from './plugin.js';
|
|
11
|
+
|
|
12
|
+
describe('HarnessFEWebpackPlugin', () => {
|
|
13
|
+
let warnSpy: ReturnType<typeof vi.spyOn>;
|
|
14
|
+
beforeEach(() => {
|
|
15
|
+
// EntryPlugin tries to tap an unstubbed hook in our minimal mock and
|
|
16
|
+
// hits our catch-and-warn path. That's fine functionally but noisy.
|
|
17
|
+
warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
18
|
+
});
|
|
19
|
+
afterEach(() => {
|
|
20
|
+
warnSpy.mockRestore();
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
function buildMockCompiler() {
|
|
24
|
+
const rules: any[] = [];
|
|
25
|
+
const hookCalls: Record<string, number> = {};
|
|
26
|
+
const tapStub = (_name: string) => {
|
|
27
|
+
hookCalls[_name] = (hookCalls[_name] ?? 0) + 1;
|
|
28
|
+
};
|
|
29
|
+
const compiler = {
|
|
30
|
+
options: {
|
|
31
|
+
context: '/fake/project/root',
|
|
32
|
+
mode: 'development',
|
|
33
|
+
module: { rules },
|
|
34
|
+
},
|
|
35
|
+
context: '/fake/project/root',
|
|
36
|
+
hooks: {
|
|
37
|
+
afterEnvironment: { tap: tapStub.bind(null, 'afterEnvironment') },
|
|
38
|
+
shutdown: { tap: tapStub.bind(null, 'shutdown') },
|
|
39
|
+
compilation: { tap: tapStub.bind(null, 'compilation') },
|
|
40
|
+
done: { tap: tapStub.bind(null, 'done') },
|
|
41
|
+
},
|
|
42
|
+
};
|
|
43
|
+
return { compiler, rules, hookCalls };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
it('registers a pre-rule whose loader options are JSON-serializable', () => {
|
|
47
|
+
const { compiler, rules } = buildMockCompiler();
|
|
48
|
+
// Force disabled=true to keep the test focused on rule injection
|
|
49
|
+
// (we don't want to actually open a websocket or inject runtime here).
|
|
50
|
+
const plugin = new HarnessFEWebpackPlugin({ projectId: 'test' });
|
|
51
|
+
|
|
52
|
+
// disabled bails out before rule injection — call apply with enabled
|
|
53
|
+
// but avoid the side-effecty hooks by stubbing webpack module loading.
|
|
54
|
+
// Easiest: enable, accept that hooks tap (they're stubbed above).
|
|
55
|
+
plugin.apply(compiler);
|
|
56
|
+
|
|
57
|
+
expect(rules.length).toBe(1);
|
|
58
|
+
const rule = rules[0];
|
|
59
|
+
expect(rule.enforce).toBe('pre');
|
|
60
|
+
expect(rule.test).toBeInstanceOf(RegExp);
|
|
61
|
+
expect(rule.use).toHaveLength(1);
|
|
62
|
+
|
|
63
|
+
const use = rule.use[0];
|
|
64
|
+
expect(use.loader).toMatch(/loader\.js$/);
|
|
65
|
+
|
|
66
|
+
// The critical assertion: options must round-trip through JSON
|
|
67
|
+
// without throwing on circular structure.
|
|
68
|
+
expect(() => JSON.stringify(use.options)).not.toThrow();
|
|
69
|
+
|
|
70
|
+
const parsed = JSON.parse(JSON.stringify(use.options));
|
|
71
|
+
expect(parsed.pluginId).toBeDefined();
|
|
72
|
+
expect(parsed.projectRoot).toBe('/fake/project/root');
|
|
73
|
+
expect(parsed.vueOptions).toEqual({ safeMode: true, dryRun: false });
|
|
74
|
+
expect(parsed.disabled).toBe(false);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it('matches the right file extensions including vue-loader virtual sub-modules', () => {
|
|
78
|
+
const { compiler, rules } = buildMockCompiler();
|
|
79
|
+
new HarnessFEWebpackPlugin().apply(compiler);
|
|
80
|
+
const re: RegExp = rules[0].test;
|
|
81
|
+
|
|
82
|
+
expect(re.test('/a/App.vue')).toBe(true);
|
|
83
|
+
expect(re.test('/a/App.vue?vue&type=template')).toBe(true);
|
|
84
|
+
expect(re.test('/a/App.vue?vue&type=script&lang=ts')).toBe(true);
|
|
85
|
+
expect(re.test('/a/Foo.tsx')).toBe(true);
|
|
86
|
+
expect(re.test('/a/Foo.jsx')).toBe(true);
|
|
87
|
+
expect(re.test('/a/Foo.ts')).toBe(false);
|
|
88
|
+
expect(re.test('/a/Foo.js')).toBe(false);
|
|
89
|
+
expect(re.test('/a/styles.css')).toBe(false);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it('skips rule injection when disabled', () => {
|
|
93
|
+
const { compiler, rules } = buildMockCompiler();
|
|
94
|
+
new HarnessFEWebpackPlugin({ disabled: true }).apply(compiler);
|
|
95
|
+
expect(rules).toHaveLength(0);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it('factory and class produce equivalent shapes', () => {
|
|
99
|
+
const { compiler: c1, rules: r1 } = buildMockCompiler();
|
|
100
|
+
const { compiler: c2, rules: r2 } = buildMockCompiler();
|
|
101
|
+
harnessFE({ projectId: 'a' }).apply(c1);
|
|
102
|
+
new HarnessFEWebpackPlugin({ projectId: 'a' }).apply(c2);
|
|
103
|
+
expect(r1[0].test.source).toBe(r2[0].test.source);
|
|
104
|
+
expect(r1[0].enforce).toBe(r2[0].enforce);
|
|
105
|
+
});
|
|
106
|
+
});
|
package/src/loader.ts
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Webpack loader entrypoint for @harness-fe/webpack.
|
|
3
|
+
*
|
|
4
|
+
* IMPORTANT: This file is loaded by webpack's loader runner (potentially
|
|
5
|
+
* inside a thread-loader worker process). The options object passed to this
|
|
6
|
+
* loader MUST be pure JSON-serializable data — no plugin instance, no
|
|
7
|
+
* compiler reference, no closures. The reason this package exists at all is
|
|
8
|
+
* that unplugin's webpack adapter passes the plugin instance in options,
|
|
9
|
+
* which closes over `compiler.root` and breaks thread-loader's JSON.stringify.
|
|
10
|
+
*
|
|
11
|
+
* The loader collects component locations into a per-call temporary
|
|
12
|
+
* componentMap, then writes them to `module.buildMeta.harnessCollected`.
|
|
13
|
+
* The main-process plugin reads buildMeta via `compilation.succeedModule`
|
|
14
|
+
* and merges the entries into the real shared componentMap.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import type { ComponentMap, ComponentLocation } from '@harness-fe/unplugin';
|
|
18
|
+
import { runTransform } from './transform-runner.js';
|
|
19
|
+
|
|
20
|
+
export interface HarnessLoaderOptions {
|
|
21
|
+
pluginId: string;
|
|
22
|
+
projectRoot: string;
|
|
23
|
+
vueOptions: {
|
|
24
|
+
safeMode: boolean;
|
|
25
|
+
dryRun: boolean;
|
|
26
|
+
};
|
|
27
|
+
disabled: boolean;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
interface CollectedLocation {
|
|
31
|
+
name: string;
|
|
32
|
+
location: ComponentLocation;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
interface LoaderContext {
|
|
36
|
+
async: () => (err: Error | null, content?: string, map?: object) => void;
|
|
37
|
+
getOptions: () => HarnessLoaderOptions;
|
|
38
|
+
resourcePath: string;
|
|
39
|
+
resourceQuery: string;
|
|
40
|
+
_module?: { buildMeta?: Record<string, unknown> };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export default function harnessLoader(this: LoaderContext, source: string): void {
|
|
44
|
+
const callback = this.async();
|
|
45
|
+
const opts = this.getOptions();
|
|
46
|
+
|
|
47
|
+
if (opts.disabled) {
|
|
48
|
+
callback(null, source);
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Fresh map per call — caller-side accumulation lives in main-process
|
|
53
|
+
// shared-state, fed via module.buildMeta.
|
|
54
|
+
const localMap: ComponentMap = new Map();
|
|
55
|
+
|
|
56
|
+
let out;
|
|
57
|
+
try {
|
|
58
|
+
out = runTransform(
|
|
59
|
+
source,
|
|
60
|
+
this.resourcePath,
|
|
61
|
+
this.resourceQuery,
|
|
62
|
+
opts.projectRoot,
|
|
63
|
+
{ safeMode: opts.vueOptions.safeMode, dryRun: opts.vueOptions.dryRun },
|
|
64
|
+
localMap,
|
|
65
|
+
);
|
|
66
|
+
} catch (err) {
|
|
67
|
+
// Never break the host build because of a transform bug.
|
|
68
|
+
callback(null, source);
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Forward collected locations to main process via buildMeta.
|
|
73
|
+
if (localMap.size > 0 && this._module) {
|
|
74
|
+
const collected: CollectedLocation[] = [];
|
|
75
|
+
for (const [name, locs] of localMap.entries()) {
|
|
76
|
+
for (const location of locs) {
|
|
77
|
+
collected.push({ name, location });
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
const buildMeta = (this._module.buildMeta ??= {});
|
|
81
|
+
const existing = (buildMeta.harnessCollected as CollectedLocation[] | undefined) ?? [];
|
|
82
|
+
buildMeta.harnessCollected = existing.concat(collected);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
if (!out) {
|
|
86
|
+
callback(null, source);
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
callback(null, out.code, out.map);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export const raw = false;
|
package/src/plugin.ts
ADDED
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @harness-fe/webpack — native webpack plugin.
|
|
3
|
+
*
|
|
4
|
+
* Why native (vs the unplugin adapter):
|
|
5
|
+
* unplugin's webpack adapter passes the plugin instance through a loader's
|
|
6
|
+
* `options` field. The plugin instance closes over `compiler` (via the
|
|
7
|
+
* `webpack(compiler)` hook), and `compiler.root` self-references the
|
|
8
|
+
* compiler — JSON.stringify chokes on the cycle. thread-loader serializes
|
|
9
|
+
* downstream loader options when dispatching to its worker pool, so any
|
|
10
|
+
* project that puts thread-loader anywhere ahead of harness in the
|
|
11
|
+
* resolved loader chain (e.g. a `.ts` rule that vue-loader inlines for
|
|
12
|
+
* `<script lang="ts">` SFC blocks) breaks the entire build.
|
|
13
|
+
*
|
|
14
|
+
* This native plugin:
|
|
15
|
+
* - Registers a separate `enforce: 'pre'` rule pointing at an independent
|
|
16
|
+
* loader file (loader.ts) whose options are pure JSON-serializable data.
|
|
17
|
+
* - Keeps all stateful work (WebSocket to MCP, log capture, runtime
|
|
18
|
+
* injection, error forwarding, HTML injection) in the main process via
|
|
19
|
+
* compiler hooks.
|
|
20
|
+
* - Aggregates componentMap entries from worker processes via
|
|
21
|
+
* `module.buildMeta.harnessCollected`.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import { createRequire } from 'node:module';
|
|
25
|
+
import { fileURLToPath } from 'node:url';
|
|
26
|
+
import { dirname, resolve as resolvePath } from 'node:path';
|
|
27
|
+
import {
|
|
28
|
+
createMcpClient,
|
|
29
|
+
createBuildIdentity,
|
|
30
|
+
installNodeLogCapture,
|
|
31
|
+
appendTokenQuery,
|
|
32
|
+
type ComponentLocation,
|
|
33
|
+
type HarnessFEOptions,
|
|
34
|
+
type McpClient,
|
|
35
|
+
type McpClientContext,
|
|
36
|
+
} from '@harness-fe/unplugin';
|
|
37
|
+
import { DEFAULT_WS_PORT } from '@harness-fe/protocol';
|
|
38
|
+
import { getOrCreateComponentMap } from './shared-state.js';
|
|
39
|
+
import type { HarnessLoaderOptions } from './loader.js';
|
|
40
|
+
|
|
41
|
+
const require = createRequire(import.meta.url);
|
|
42
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
43
|
+
const __dirname = dirname(__filename);
|
|
44
|
+
|
|
45
|
+
function newPluginId(): string {
|
|
46
|
+
const g = globalThis as { crypto?: { randomUUID?: () => string } };
|
|
47
|
+
if (g.crypto?.randomUUID) return g.crypto.randomUUID();
|
|
48
|
+
return `harness-${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
interface CollectedLocation {
|
|
52
|
+
name: string;
|
|
53
|
+
location: ComponentLocation;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export class HarnessFEWebpackPlugin {
|
|
57
|
+
private readonly pluginId = newPluginId();
|
|
58
|
+
private readonly options: HarnessFEOptions;
|
|
59
|
+
private projectRoot: string = process.cwd();
|
|
60
|
+
private projectId: string;
|
|
61
|
+
private readonly mcpUrl: string;
|
|
62
|
+
private readonly token: string | undefined;
|
|
63
|
+
private mcpClient?: McpClient;
|
|
64
|
+
private logCleanup?: () => void;
|
|
65
|
+
private identity = createBuildIdentity({
|
|
66
|
+
userBuildId: undefined,
|
|
67
|
+
userDisplayName: undefined,
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
constructor(options: HarnessFEOptions = {}) {
|
|
71
|
+
this.options = options;
|
|
72
|
+
this.projectId = options.projectId ?? 'unknown-project';
|
|
73
|
+
const baseUrl =
|
|
74
|
+
options.mcpUrl ??
|
|
75
|
+
process.env.HARNESS_FE_URL ??
|
|
76
|
+
`ws://127.0.0.1:${DEFAULT_WS_PORT}`;
|
|
77
|
+
this.token = options.token ?? process.env.HARNESS_FE_TOKEN;
|
|
78
|
+
this.mcpUrl = appendTokenQuery(baseUrl, this.token);
|
|
79
|
+
this.identity = createBuildIdentity({
|
|
80
|
+
userBuildId: options.buildId,
|
|
81
|
+
userDisplayName: options.displayName,
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
apply(compiler: any): void {
|
|
86
|
+
if (this.options.disabled) return;
|
|
87
|
+
|
|
88
|
+
this.projectRoot = compiler.options?.context ?? process.cwd();
|
|
89
|
+
|
|
90
|
+
// Resolve loader path. require.resolve gives an absolute path that
|
|
91
|
+
// webpack can hand to loader-runner regardless of host project's
|
|
92
|
+
// module resolution config.
|
|
93
|
+
const loaderPath = resolvePath(__dirname, 'loader.js');
|
|
94
|
+
|
|
95
|
+
const loaderOptions: HarnessLoaderOptions = {
|
|
96
|
+
pluginId: this.pluginId,
|
|
97
|
+
projectRoot: this.projectRoot,
|
|
98
|
+
vueOptions: {
|
|
99
|
+
safeMode: this.options.safeMode !== false,
|
|
100
|
+
dryRun: process.env.HARNESS_FE_DRY_RUN === '1',
|
|
101
|
+
},
|
|
102
|
+
disabled: false,
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
// Register the transform loader as a pre-rule.
|
|
106
|
+
compiler.options.module ??= { rules: [] };
|
|
107
|
+
compiler.options.module.rules ??= [];
|
|
108
|
+
compiler.options.module.rules.unshift({
|
|
109
|
+
test: /\.([jt]sx|vue)($|\?)/,
|
|
110
|
+
exclude: /[\\/]node_modules[\\/]/,
|
|
111
|
+
enforce: 'pre',
|
|
112
|
+
use: [
|
|
113
|
+
{
|
|
114
|
+
loader: loaderPath,
|
|
115
|
+
options: loaderOptions,
|
|
116
|
+
ident: `harness-fe-${this.pluginId}`,
|
|
117
|
+
},
|
|
118
|
+
],
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
// Skip the rest in production builds — runtime injection and MCP
|
|
122
|
+
// connection are dev-only conveniences.
|
|
123
|
+
if (compiler.options?.mode === 'production') return;
|
|
124
|
+
|
|
125
|
+
this.installRuntimeEntry(compiler);
|
|
126
|
+
this.installMcpHooks(compiler);
|
|
127
|
+
this.installComponentMapAggregator(compiler);
|
|
128
|
+
this.installHtmlInjection(compiler);
|
|
129
|
+
this.installErrorForwarding(compiler);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
private installRuntimeEntry(compiler: any): void {
|
|
133
|
+
try {
|
|
134
|
+
const webpackPkg = require('webpack');
|
|
135
|
+
const { EntryPlugin } = webpackPkg;
|
|
136
|
+
const runtimeEntry = require.resolve('@harness-fe/runtime');
|
|
137
|
+
new EntryPlugin(compiler.context ?? this.projectRoot, runtimeEntry, {
|
|
138
|
+
name: undefined,
|
|
139
|
+
}).apply(compiler);
|
|
140
|
+
} catch (err) {
|
|
141
|
+
console.warn(
|
|
142
|
+
'[harness-fe] failed to register runtime entry via webpack.EntryPlugin:',
|
|
143
|
+
err,
|
|
144
|
+
);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
private installMcpHooks(compiler: any): void {
|
|
149
|
+
const ctx: McpClientContext = {
|
|
150
|
+
get projectId() {
|
|
151
|
+
return self.projectId;
|
|
152
|
+
},
|
|
153
|
+
get mcpUrl() {
|
|
154
|
+
return self.mcpUrl;
|
|
155
|
+
},
|
|
156
|
+
get token() {
|
|
157
|
+
return self.token;
|
|
158
|
+
},
|
|
159
|
+
peerRole: 'webpack-plugin',
|
|
160
|
+
get parentProjectId() {
|
|
161
|
+
return self.options.parentProjectId;
|
|
162
|
+
},
|
|
163
|
+
get projectRoot() {
|
|
164
|
+
return self.projectRoot;
|
|
165
|
+
},
|
|
166
|
+
get componentMap() {
|
|
167
|
+
return getOrCreateComponentMap(self.pluginId);
|
|
168
|
+
},
|
|
169
|
+
getBuildId: () => this.identity.getBuildId(this.projectRoot),
|
|
170
|
+
getDisplayName: () => this.identity.getDisplayName(this.projectRoot),
|
|
171
|
+
};
|
|
172
|
+
const self = this;
|
|
173
|
+
|
|
174
|
+
compiler.hooks.afterEnvironment.tap('harness-fe', () => {
|
|
175
|
+
const client = createMcpClient(ctx);
|
|
176
|
+
this.mcpClient = client;
|
|
177
|
+
client.connect();
|
|
178
|
+
this.logCleanup = installNodeLogCapture((name, payload) => client.emitEvent(name, payload));
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
compiler.hooks.shutdown?.tap('harness-fe', () => {
|
|
182
|
+
this.logCleanup?.();
|
|
183
|
+
this.logCleanup = undefined;
|
|
184
|
+
this.mcpClient?.disconnect();
|
|
185
|
+
this.mcpClient = undefined;
|
|
186
|
+
});
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
private installComponentMapAggregator(compiler: any): void {
|
|
190
|
+
const pluginId = this.pluginId;
|
|
191
|
+
compiler.hooks.compilation.tap('harness-fe', (compilation: any) => {
|
|
192
|
+
compilation.hooks.succeedModule.tap('harness-fe', (module: any) => {
|
|
193
|
+
const collected = module.buildMeta?.harnessCollected as
|
|
194
|
+
| CollectedLocation[]
|
|
195
|
+
| undefined;
|
|
196
|
+
if (!collected?.length) return;
|
|
197
|
+
const map = getOrCreateComponentMap(pluginId);
|
|
198
|
+
for (const { name, location } of collected) {
|
|
199
|
+
const existing = map.get(name) ?? [];
|
|
200
|
+
existing.push(location);
|
|
201
|
+
map.set(name, existing);
|
|
202
|
+
}
|
|
203
|
+
});
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
private installHtmlInjection(compiler: any): void {
|
|
208
|
+
compiler.hooks.compilation.tap('harness-fe', (compilation: any) => {
|
|
209
|
+
// Prefer html-webpack-plugin hooks when available.
|
|
210
|
+
try {
|
|
211
|
+
const HtmlPlugin = require('html-webpack-plugin');
|
|
212
|
+
const hooks = HtmlPlugin.getHooks(compilation);
|
|
213
|
+
hooks.beforeEmit.tapAsync('harness-fe', (data: any, cb: any) => {
|
|
214
|
+
data.html = this.injectConfigScript(data.html);
|
|
215
|
+
cb(null, data);
|
|
216
|
+
});
|
|
217
|
+
} catch {
|
|
218
|
+
// Fallback: rewrite html assets directly.
|
|
219
|
+
const { Compilation, sources } = require('webpack');
|
|
220
|
+
compilation.hooks.processAssets.tap(
|
|
221
|
+
{
|
|
222
|
+
name: 'harness-fe',
|
|
223
|
+
stage: Compilation.PROCESS_ASSETS_STAGE_OPTIMIZE_INLINE,
|
|
224
|
+
},
|
|
225
|
+
(assets: Record<string, any>) => {
|
|
226
|
+
for (const [name, source] of Object.entries(assets)) {
|
|
227
|
+
if (!name.endsWith('.html')) continue;
|
|
228
|
+
const html = source.source();
|
|
229
|
+
if (typeof html !== 'string') continue;
|
|
230
|
+
compilation.updateAsset(
|
|
231
|
+
name,
|
|
232
|
+
new sources.RawSource(this.injectConfigScript(html)),
|
|
233
|
+
);
|
|
234
|
+
}
|
|
235
|
+
},
|
|
236
|
+
);
|
|
237
|
+
}
|
|
238
|
+
});
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
private installErrorForwarding(compiler: any): void {
|
|
242
|
+
compiler.hooks.done.tap('harness-fe', (stats: any) => {
|
|
243
|
+
if (!this.mcpClient || !stats.hasErrors()) return;
|
|
244
|
+
const errors = stats.compilation?.errors ?? [];
|
|
245
|
+
for (const err of errors) {
|
|
246
|
+
this.mcpClient.emitEvent('error', {
|
|
247
|
+
message: err.message ?? String(err),
|
|
248
|
+
file: err.module?.resource ?? undefined,
|
|
249
|
+
});
|
|
250
|
+
}
|
|
251
|
+
});
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
private injectConfigScript(html: string): string {
|
|
255
|
+
const injection = `<!-- @harness-fe injected (dev only) -->
|
|
256
|
+
<script>
|
|
257
|
+
window.__HARNESS_FE__ = ${JSON.stringify({
|
|
258
|
+
projectId: this.projectId,
|
|
259
|
+
mcpUrl: this.mcpUrl,
|
|
260
|
+
buildId: this.identity.getBuildId(this.projectRoot),
|
|
261
|
+
parentProjectId: this.options.parentProjectId,
|
|
262
|
+
displayName: this.identity.getDisplayName(this.projectRoot),
|
|
263
|
+
})};
|
|
264
|
+
</script>`;
|
|
265
|
+
return html.replace(/<\/head>/i, `${injection}\n</head>`);
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/** Factory matching the previous unplugin-based call shape. */
|
|
270
|
+
export function harnessFE(options: HarnessFEOptions = {}): HarnessFEWebpackPlugin {
|
|
271
|
+
return new HarnessFEWebpackPlugin(options);
|
|
272
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { getOrCreateComponentMap, clearComponentMap } from './shared-state.js';
|
|
3
|
+
|
|
4
|
+
describe('shared-state', () => {
|
|
5
|
+
it('returns the same map for the same pluginId across calls', () => {
|
|
6
|
+
const a = getOrCreateComponentMap('plugin-1');
|
|
7
|
+
const b = getOrCreateComponentMap('plugin-1');
|
|
8
|
+
expect(a).toBe(b);
|
|
9
|
+
a.set('Foo', [{ file: 'x.tsx', line: 1, col: 0 }]);
|
|
10
|
+
expect(b.get('Foo')?.length).toBe(1);
|
|
11
|
+
clearComponentMap('plugin-1');
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
it('isolates maps between different plugin ids', () => {
|
|
15
|
+
const a = getOrCreateComponentMap('plugin-A');
|
|
16
|
+
const b = getOrCreateComponentMap('plugin-B');
|
|
17
|
+
expect(a).not.toBe(b);
|
|
18
|
+
a.set('Foo', [{ file: 'a.tsx', line: 1, col: 0 }]);
|
|
19
|
+
expect(b.has('Foo')).toBe(false);
|
|
20
|
+
clearComponentMap('plugin-A');
|
|
21
|
+
clearComponentMap('plugin-B');
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it('clearComponentMap forgets the entry', () => {
|
|
25
|
+
const a = getOrCreateComponentMap('plugin-X');
|
|
26
|
+
a.set('Foo', [{ file: 'x.tsx', line: 1, col: 0 }]);
|
|
27
|
+
clearComponentMap('plugin-X');
|
|
28
|
+
const b = getOrCreateComponentMap('plugin-X');
|
|
29
|
+
expect(b.has('Foo')).toBe(false);
|
|
30
|
+
clearComponentMap('plugin-X');
|
|
31
|
+
});
|
|
32
|
+
});
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Module-level componentMap registry keyed by plugin instance id.
|
|
3
|
+
*
|
|
4
|
+
* Used by the main webpack process to accumulate component locations forwarded
|
|
5
|
+
* from worker processes via `module.buildMeta.harnessCollected`. The MCP
|
|
6
|
+
* client (also main-process only) reads from this map to answer
|
|
7
|
+
* `project.where_is` / `project.module_graph` queries.
|
|
8
|
+
*
|
|
9
|
+
* thread-loader workers fork separate Node processes, so they get their own
|
|
10
|
+
* empty `maps` instance — but the loader in workers doesn't persist anything
|
|
11
|
+
* here. It writes collected locations to `module.buildMeta` and the main
|
|
12
|
+
* process aggregates them via the `succeedModule` hook.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import type { ComponentMap } from '@harness-fe/unplugin';
|
|
16
|
+
|
|
17
|
+
const maps = new Map<string, ComponentMap>();
|
|
18
|
+
|
|
19
|
+
export function getOrCreateComponentMap(pluginId: string): ComponentMap {
|
|
20
|
+
let m = maps.get(pluginId);
|
|
21
|
+
if (!m) {
|
|
22
|
+
m = new Map();
|
|
23
|
+
maps.set(pluginId, m);
|
|
24
|
+
}
|
|
25
|
+
return m;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function clearComponentMap(pluginId: string): void {
|
|
29
|
+
maps.delete(pluginId);
|
|
30
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* runTransform dispatcher tests — verifies the right transform function is
|
|
3
|
+
* picked for each id variant (.vue, .vue?type=template, .vue?type=script,
|
|
4
|
+
* .tsx, .jsx) and that componentMap is populated for the cases that own
|
|
5
|
+
* component name resolution.
|
|
6
|
+
*/
|
|
7
|
+
import { describe, it, expect } from 'vitest';
|
|
8
|
+
import type { ComponentMap } from '@harness-fe/unplugin';
|
|
9
|
+
import { runTransform } from './transform-runner.js';
|
|
10
|
+
|
|
11
|
+
const VUE_OPTIONS = { safeMode: true, dryRun: false } as const;
|
|
12
|
+
|
|
13
|
+
describe('runTransform', () => {
|
|
14
|
+
it('transforms a .tsx file and populates componentMap', () => {
|
|
15
|
+
const map: ComponentMap = new Map();
|
|
16
|
+
const src = `
|
|
17
|
+
export function App() {
|
|
18
|
+
return <div className="root"><span>hi</span></div>;
|
|
19
|
+
}
|
|
20
|
+
`;
|
|
21
|
+
const out = runTransform(src, '/project/App.tsx', '', '/project', VUE_OPTIONS, map);
|
|
22
|
+
expect(out).not.toBeNull();
|
|
23
|
+
expect(out!.code).toContain('data-morphix-loc=');
|
|
24
|
+
expect(out!.code).toContain('data-morphix-comp="App"');
|
|
25
|
+
const locs = map.get('App');
|
|
26
|
+
expect(locs && locs.length).toBeGreaterThan(0);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it('returns null for non-template .vue sub-modules (script/style)', () => {
|
|
30
|
+
const map: ComponentMap = new Map();
|
|
31
|
+
const out = runTransform(
|
|
32
|
+
'export default {}',
|
|
33
|
+
'/project/App.vue',
|
|
34
|
+
'?vue&type=script&lang=ts',
|
|
35
|
+
'/project',
|
|
36
|
+
VUE_OPTIONS,
|
|
37
|
+
map,
|
|
38
|
+
);
|
|
39
|
+
expect(out).toBeNull();
|
|
40
|
+
expect(map.size).toBe(0);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('returns null for a non-JSX .tsx file (transformJsx returns null when nothing tagged)', () => {
|
|
44
|
+
const map: ComponentMap = new Map();
|
|
45
|
+
const out = runTransform(
|
|
46
|
+
'export const x: number = 1;',
|
|
47
|
+
'/project/util.tsx',
|
|
48
|
+
'',
|
|
49
|
+
'/project',
|
|
50
|
+
VUE_OPTIONS,
|
|
51
|
+
map,
|
|
52
|
+
);
|
|
53
|
+
expect(out).toBeNull();
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('returns null when transform encounters malformed source (errorRecovery falls back to no tags)', () => {
|
|
57
|
+
const map: ComponentMap = new Map();
|
|
58
|
+
const out = runTransform(
|
|
59
|
+
'this is not valid javascript at all <<<>>>',
|
|
60
|
+
'/project/broken.tsx',
|
|
61
|
+
'',
|
|
62
|
+
'/project',
|
|
63
|
+
VUE_OPTIONS,
|
|
64
|
+
map,
|
|
65
|
+
);
|
|
66
|
+
// Either returns null (no JSX found) or returns a result with no
|
|
67
|
+
// taggedCount — both are acceptable, neither should throw.
|
|
68
|
+
expect(out === null || typeof out.code === 'string').toBe(true);
|
|
69
|
+
});
|
|
70
|
+
});
|