@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
|
@@ -0,0 +1,398 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import {
|
|
3
|
+
transformVueSFC,
|
|
4
|
+
transformVueTemplate,
|
|
5
|
+
resolveVueComponentName,
|
|
6
|
+
getTemplateLineOffset,
|
|
7
|
+
type VueTransformResult,
|
|
8
|
+
} from './vue-transform.js';
|
|
9
|
+
import type { ComponentMap } from './transform.js';
|
|
10
|
+
|
|
11
|
+
function makeMap(): ComponentMap {
|
|
12
|
+
return new Map();
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
describe('transformVueSFC', () => {
|
|
16
|
+
it('injects data-morphix-loc and data-morphix-comp on template elements', () => {
|
|
17
|
+
const source = `<template>
|
|
18
|
+
<div class="app">
|
|
19
|
+
<h1>Hello</h1>
|
|
20
|
+
</div>
|
|
21
|
+
</template>
|
|
22
|
+
|
|
23
|
+
<script setup lang="ts">
|
|
24
|
+
defineOptions({ name: 'MyApp' });
|
|
25
|
+
</script>
|
|
26
|
+
`;
|
|
27
|
+
const map = makeMap();
|
|
28
|
+
const result = transformVueSFC(source, 'src/App.vue', map);
|
|
29
|
+
|
|
30
|
+
expect(result).not.toBeNull();
|
|
31
|
+
expect(result!.taggedCount).toBeGreaterThan(0);
|
|
32
|
+
expect(result!.code).toContain('data-morphix-loc="src/App.vue:');
|
|
33
|
+
expect(result!.code).toContain('data-morphix-comp="MyApp"');
|
|
34
|
+
expect(result!.componentName).toBe('MyApp');
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('resolves component name from defineOptions in <script setup>', () => {
|
|
38
|
+
const source = `<template>
|
|
39
|
+
<div>test</div>
|
|
40
|
+
</template>
|
|
41
|
+
|
|
42
|
+
<script setup>
|
|
43
|
+
defineOptions({ name: 'CustomName' });
|
|
44
|
+
</script>
|
|
45
|
+
`;
|
|
46
|
+
const map = makeMap();
|
|
47
|
+
const result = transformVueSFC(source, 'src/Whatever.vue', map);
|
|
48
|
+
|
|
49
|
+
expect(result).not.toBeNull();
|
|
50
|
+
expect(result!.componentName).toBe('CustomName');
|
|
51
|
+
expect(result!.code).toContain('data-morphix-comp="CustomName"');
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('resolves component name from export default { name } in <script>', () => {
|
|
55
|
+
const source = `<template>
|
|
56
|
+
<div>test</div>
|
|
57
|
+
</template>
|
|
58
|
+
|
|
59
|
+
<script>
|
|
60
|
+
export default {
|
|
61
|
+
name: 'OptionsName',
|
|
62
|
+
data() { return {}; }
|
|
63
|
+
}
|
|
64
|
+
</script>
|
|
65
|
+
`;
|
|
66
|
+
const map = makeMap();
|
|
67
|
+
const result = transformVueSFC(source, 'src/Whatever.vue', map);
|
|
68
|
+
|
|
69
|
+
expect(result).not.toBeNull();
|
|
70
|
+
expect(result!.componentName).toBe('OptionsName');
|
|
71
|
+
expect(result!.code).toContain('data-morphix-comp="OptionsName"');
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('resolves component name from filename', () => {
|
|
75
|
+
const source = `<template>
|
|
76
|
+
<div>test</div>
|
|
77
|
+
</template>
|
|
78
|
+
|
|
79
|
+
<script setup>
|
|
80
|
+
const x = 1;
|
|
81
|
+
</script>
|
|
82
|
+
`;
|
|
83
|
+
const map = makeMap();
|
|
84
|
+
const result = transformVueSFC(source, 'src/MyComponent.vue', map);
|
|
85
|
+
|
|
86
|
+
expect(result).not.toBeNull();
|
|
87
|
+
expect(result!.componentName).toBe('MyComponent');
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it('uses parent directory name for index.vue', () => {
|
|
91
|
+
const source = `<template>
|
|
92
|
+
<div>test</div>
|
|
93
|
+
</template>
|
|
94
|
+
|
|
95
|
+
<script setup>
|
|
96
|
+
const x = 1;
|
|
97
|
+
</script>
|
|
98
|
+
`;
|
|
99
|
+
const map = makeMap();
|
|
100
|
+
const result = transformVueSFC(source, 'src/user-profile/index.vue', map);
|
|
101
|
+
|
|
102
|
+
expect(result).not.toBeNull();
|
|
103
|
+
expect(result!.componentName).toBe('UserProfile');
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it('preserves Vue directives (v-if, v-for, v-bind)', () => {
|
|
107
|
+
const source = `<template>
|
|
108
|
+
<div v-if="show" :class="cls" @click="handler">
|
|
109
|
+
<span v-for="item in items" :key="item.id">{{ item.name }}</span>
|
|
110
|
+
</div>
|
|
111
|
+
</template>
|
|
112
|
+
|
|
113
|
+
<script setup>
|
|
114
|
+
defineOptions({ name: 'DirectiveTest' });
|
|
115
|
+
const show = true;
|
|
116
|
+
const cls = 'active';
|
|
117
|
+
const items = [];
|
|
118
|
+
const handler = () => {};
|
|
119
|
+
</script>
|
|
120
|
+
`;
|
|
121
|
+
const map = makeMap();
|
|
122
|
+
const result = transformVueSFC(source, 'src/Test.vue', map);
|
|
123
|
+
|
|
124
|
+
expect(result).not.toBeNull();
|
|
125
|
+
// Original directives must still be present
|
|
126
|
+
expect(result!.code).toContain('v-if="show"');
|
|
127
|
+
expect(result!.code).toContain(':class="cls"');
|
|
128
|
+
expect(result!.code).toContain('@click="handler"');
|
|
129
|
+
expect(result!.code).toContain('v-for="item in items"');
|
|
130
|
+
expect(result!.code).toContain(':key="item.id"');
|
|
131
|
+
// And our attributes are injected
|
|
132
|
+
expect(result!.code).toContain('data-morphix-loc=');
|
|
133
|
+
expect(result!.code).toContain('data-morphix-comp="DirectiveTest"');
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it('returns null for unparseable files', () => {
|
|
137
|
+
const source = `this is not a valid vue file at all {{{{`;
|
|
138
|
+
const map = makeMap();
|
|
139
|
+
const result = transformVueSFC(source, 'src/Bad.vue', map);
|
|
140
|
+
|
|
141
|
+
// Should return null (no template block found)
|
|
142
|
+
expect(result).toBeNull();
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it('registers component in ComponentMap', () => {
|
|
146
|
+
const source = `<template>
|
|
147
|
+
<div>
|
|
148
|
+
<span>hello</span>
|
|
149
|
+
</div>
|
|
150
|
+
</template>
|
|
151
|
+
|
|
152
|
+
<script setup>
|
|
153
|
+
defineOptions({ name: 'RegisteredComp' });
|
|
154
|
+
</script>
|
|
155
|
+
`;
|
|
156
|
+
const map = makeMap();
|
|
157
|
+
transformVueSFC(source, 'src/Registered.vue', map);
|
|
158
|
+
|
|
159
|
+
expect(map.has('RegisteredComp')).toBe(true);
|
|
160
|
+
const entries = map.get('RegisteredComp')!;
|
|
161
|
+
expect(entries.length).toBeGreaterThan(0);
|
|
162
|
+
expect(entries[0].file).toBe('src/Registered.vue');
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it('returns null for SFC without template', () => {
|
|
166
|
+
const source = `<script setup>
|
|
167
|
+
const x = 1;
|
|
168
|
+
</script>
|
|
169
|
+
`;
|
|
170
|
+
const map = makeMap();
|
|
171
|
+
const result = transformVueSFC(source, 'src/NoTemplate.vue', map);
|
|
172
|
+
|
|
173
|
+
expect(result).toBeNull();
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
it('handles kebab-case filename conversion to PascalCase', () => {
|
|
177
|
+
const source = `<template>
|
|
178
|
+
<div>test</div>
|
|
179
|
+
</template>
|
|
180
|
+
`;
|
|
181
|
+
const map = makeMap();
|
|
182
|
+
const result = transformVueSFC(source, 'src/my-fancy-button.vue', map);
|
|
183
|
+
|
|
184
|
+
expect(result).not.toBeNull();
|
|
185
|
+
expect(result!.componentName).toBe('MyFancyButton');
|
|
186
|
+
});
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
// Webpack + vue-loader: vue-loader splits the SFC into virtual sub-modules and
|
|
190
|
+
// re-reads from disk for each request. Our transform must tag template
|
|
191
|
+
// fragments separately on those sub-module hits.
|
|
192
|
+
describe('transformVueTemplate (webpack vue-loader sub-module path)', () => {
|
|
193
|
+
it('injects data-morphix-* on every element in a bare template fragment', () => {
|
|
194
|
+
const templateFragment = `<div class="container">
|
|
195
|
+
<h1>Title</h1>
|
|
196
|
+
<p>Body</p>
|
|
197
|
+
</div>`;
|
|
198
|
+
const map: ComponentMap = new Map();
|
|
199
|
+
const result = transformVueTemplate(templateFragment, 'src/App.vue', 'App', map, 0);
|
|
200
|
+
|
|
201
|
+
expect(result).not.toBeNull();
|
|
202
|
+
expect(result!.taggedCount).toBe(3);
|
|
203
|
+
expect(result!.code).toContain('data-morphix-loc="src/App.vue:1:');
|
|
204
|
+
expect(result!.code).toContain('data-morphix-comp="App"');
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
it('applies lineOffset so locations are file-relative, not fragment-relative', () => {
|
|
208
|
+
const templateFragment = `<div>x</div>`;
|
|
209
|
+
const map: ComponentMap = new Map();
|
|
210
|
+
const result = transformVueTemplate(templateFragment, 'src/Foo.vue', 'Foo', map, 7);
|
|
211
|
+
|
|
212
|
+
expect(result).not.toBeNull();
|
|
213
|
+
// Element at fragment line 1 + offset 7 = file line 8
|
|
214
|
+
expect(result!.code).toMatch(/data-morphix-loc="src\/Foo\.vue:8:/);
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
it('returns null when the fragment has no elements (e.g. text only)', () => {
|
|
218
|
+
const map: ComponentMap = new Map();
|
|
219
|
+
expect(transformVueTemplate('plain text', 'src/Foo.vue', 'Foo', map)).toBeNull();
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
it('preserves existing data-morphix-* attributes (idempotent)', () => {
|
|
223
|
+
const templateFragment = `<div data-morphix-loc="src/Foo.vue:99:99" data-morphix-comp="Foo">x</div>`;
|
|
224
|
+
const map: ComponentMap = new Map();
|
|
225
|
+
const result = transformVueTemplate(templateFragment, 'src/Foo.vue', 'Foo', map, 0);
|
|
226
|
+
// No tagging happens — existing attrs already cover both
|
|
227
|
+
expect(result).toBeNull();
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
it('populates componentMap with file-relative line numbers', () => {
|
|
231
|
+
const templateFragment = `<button>click</button>`;
|
|
232
|
+
const map: ComponentMap = new Map();
|
|
233
|
+
transformVueTemplate(templateFragment, 'src/Counter.vue', 'Counter', map, 5);
|
|
234
|
+
|
|
235
|
+
const entries = map.get('Counter');
|
|
236
|
+
expect(entries).toBeDefined();
|
|
237
|
+
expect(entries![0]).toMatchObject({ file: 'src/Counter.vue', line: 6, col: 1 });
|
|
238
|
+
});
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
describe('resolveVueComponentName + getTemplateLineOffset (SFC helpers)', () => {
|
|
242
|
+
it('resolveVueComponentName picks up defineOptions name', () => {
|
|
243
|
+
const sfc = `<script setup>defineOptions({ name: 'CustomName' });</script>
|
|
244
|
+
<template><div>x</div></template>`;
|
|
245
|
+
expect(resolveVueComponentName(sfc, 'src/Anything.vue')).toBe('CustomName');
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
it('resolveVueComponentName falls back to filename PascalCase', () => {
|
|
249
|
+
const sfc = `<template><div>x</div></template>`;
|
|
250
|
+
expect(resolveVueComponentName(sfc, 'src/my-widget.vue')).toBe('MyWidget');
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
it('getTemplateLineOffset returns line of first char inside <template>', () => {
|
|
254
|
+
// template tag is on line 3, content starts on the same line (after the >)
|
|
255
|
+
const sfc = `<script>
|
|
256
|
+
export default {};
|
|
257
|
+
</script><template><div>x</div></template>`;
|
|
258
|
+
// descriptor.template.loc.start.line is the line of the first content
|
|
259
|
+
// char (after the closing >). We subtract 1 so fragment line 1 maps
|
|
260
|
+
// to this source line.
|
|
261
|
+
const offset = getTemplateLineOffset(sfc, 'src/Foo.vue');
|
|
262
|
+
expect(offset).toBeGreaterThanOrEqual(0);
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
it('getTemplateLineOffset returns 0 when SFC has no template', () => {
|
|
266
|
+
const sfc = `<script>export default {};</script>`;
|
|
267
|
+
expect(getTemplateLineOffset(sfc, 'src/Foo.vue')).toBe(0);
|
|
268
|
+
});
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
// ─── Vue 2 hardening — pathological inputs must not break the build ────────
|
|
272
|
+
|
|
273
|
+
describe('Vue 2 legacy syntax — must never throw, must never corrupt output', () => {
|
|
274
|
+
it('Vue 2 filter syntax {{ x | foo }} is handled without throwing', () => {
|
|
275
|
+
// @vue/compiler-dom in Vue 3 either errors or treats `|` as bitwise.
|
|
276
|
+
// Either way: never throw, never emit a broken template.
|
|
277
|
+
const source = `<template>
|
|
278
|
+
<div>{{ message | uppercase }}</div>
|
|
279
|
+
</template>
|
|
280
|
+
<script>export default { name: 'LegacyFilter' };</script>
|
|
281
|
+
`;
|
|
282
|
+
const map = makeMap();
|
|
283
|
+
expect(() => transformVueSFC(source, 'src/LegacyFilter.vue', map)).not.toThrow();
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
it('<template functional> functional component does not throw', () => {
|
|
287
|
+
const source = `<template functional>
|
|
288
|
+
<div>{{ props.value }}</div>
|
|
289
|
+
</template>
|
|
290
|
+
`;
|
|
291
|
+
const map = makeMap();
|
|
292
|
+
expect(() => transformVueSFC(source, 'src/Func.vue', map)).not.toThrow();
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
it('v-bind.sync attribute parses through (Vue 2 modifier kept as attribute)', () => {
|
|
296
|
+
const source = `<template>
|
|
297
|
+
<input :value.sync="model" />
|
|
298
|
+
</template>
|
|
299
|
+
<script>export default { name: 'SyncInput' };</script>
|
|
300
|
+
`;
|
|
301
|
+
const map = makeMap();
|
|
302
|
+
const result = transformVueSFC(source, 'src/SyncInput.vue', map);
|
|
303
|
+
// .sync is no longer a real Vue 3 modifier but it's a valid attribute
|
|
304
|
+
// string from the parser's perspective. The element still gets tagged.
|
|
305
|
+
expect(result).not.toBeNull();
|
|
306
|
+
expect(result!.code).toContain('data-morphix-loc=');
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
it('slot="x" / slot-scope still allow tagging the host element', () => {
|
|
310
|
+
const source = `<template>
|
|
311
|
+
<div>
|
|
312
|
+
<child>
|
|
313
|
+
<template slot="header" slot-scope="props">
|
|
314
|
+
<span>{{ props.title }}</span>
|
|
315
|
+
</template>
|
|
316
|
+
</child>
|
|
317
|
+
</div>
|
|
318
|
+
</template>
|
|
319
|
+
<script>export default { name: 'SlotHost' };</script>
|
|
320
|
+
`;
|
|
321
|
+
const map = makeMap();
|
|
322
|
+
expect(() => transformVueSFC(source, 'src/SlotHost.vue', map)).not.toThrow();
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
it('safeMode (default) returns null on synthesised malformed SFC', () => {
|
|
326
|
+
// Real-world miss: SFC with unbalanced template that compiler-sfc may
|
|
327
|
+
// partially accept. Guarded by safeMode self-check.
|
|
328
|
+
const source = `<template>
|
|
329
|
+
<div><span></div></span>
|
|
330
|
+
</template>
|
|
331
|
+
`;
|
|
332
|
+
const map = makeMap();
|
|
333
|
+
const result = transformVueSFC(source, 'src/Bad.vue', map);
|
|
334
|
+
// Either returned null OR returned a result whose code re-parses
|
|
335
|
+
// cleanly. The contract: never throw, never hand back broken output.
|
|
336
|
+
if (result) {
|
|
337
|
+
expect(() => {
|
|
338
|
+
// Cheap sanity check: there should be the same number of
|
|
339
|
+
// injected attrs as opening tags.
|
|
340
|
+
const count = (result.code.match(/data-morphix-loc=/g) ?? []).length;
|
|
341
|
+
expect(count).toBeGreaterThanOrEqual(0);
|
|
342
|
+
}).not.toThrow();
|
|
343
|
+
}
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
it('updates stats counters for skipped files', () => {
|
|
347
|
+
const stats = {
|
|
348
|
+
filesAttempted: 0,
|
|
349
|
+
filesInjected: 0,
|
|
350
|
+
elementsTagged: 0,
|
|
351
|
+
skippedSfcError: 0,
|
|
352
|
+
skippedTemplateError: 0,
|
|
353
|
+
skippedWalkError: 0,
|
|
354
|
+
skippedSelfCheck: 0,
|
|
355
|
+
skippedPaths: [] as string[],
|
|
356
|
+
};
|
|
357
|
+
const source = `<template>
|
|
358
|
+
<div>{{ x | filter }}</div>
|
|
359
|
+
</template>
|
|
360
|
+
`;
|
|
361
|
+
const map = makeMap();
|
|
362
|
+
// safeMode on by default — filter syntax should NOT throw.
|
|
363
|
+
transformVueSFC(source, 'src/Filter.vue', map, { stats });
|
|
364
|
+
expect(stats.filesAttempted).toBe(1);
|
|
365
|
+
// We don't assert which counter incremented — different compiler
|
|
366
|
+
// versions classify filters differently — only that at most one
|
|
367
|
+
// skip counter went up (or it injected cleanly).
|
|
368
|
+
const totalSkips =
|
|
369
|
+
stats.skippedSfcError + stats.skippedTemplateError +
|
|
370
|
+
stats.skippedWalkError + stats.skippedSelfCheck;
|
|
371
|
+
expect(totalSkips + stats.filesInjected).toBe(1);
|
|
372
|
+
});
|
|
373
|
+
|
|
374
|
+
it('dryRun=true populates componentMap but returns null', () => {
|
|
375
|
+
const source = `<template>
|
|
376
|
+
<div><span>hi</span></div>
|
|
377
|
+
</template>
|
|
378
|
+
<script>export default { name: 'DryRunVue' };</script>
|
|
379
|
+
`;
|
|
380
|
+
const map = makeMap();
|
|
381
|
+
const result = transformVueSFC(source, 'src/DryRun.vue', map, { dryRun: true });
|
|
382
|
+
expect(result).toBeNull();
|
|
383
|
+
// Component map still populated so source-aware tools work in dry-run.
|
|
384
|
+
expect(map.has('DryRunVue')).toBe(true);
|
|
385
|
+
});
|
|
386
|
+
|
|
387
|
+
it('safeMode=false skips the self-check', () => {
|
|
388
|
+
// Smoke test: same input passes through both modes without throwing.
|
|
389
|
+
const source = `<template><div>x</div></template>
|
|
390
|
+
<script>export default { name: 'NoCheck' };</script>
|
|
391
|
+
`;
|
|
392
|
+
const map = makeMap();
|
|
393
|
+
const safe = transformVueSFC(source, 'src/NoCheck.vue', map);
|
|
394
|
+
const unsafe = transformVueSFC(source, 'src/NoCheck.vue', makeMap(), { safeMode: false });
|
|
395
|
+
expect(safe).not.toBeNull();
|
|
396
|
+
expect(unsafe).not.toBeNull();
|
|
397
|
+
});
|
|
398
|
+
});
|