@webstir-io/webstir 0.1.0 → 0.1.2
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/README.md +13 -0
- package/assets/deployment/docker/.dockerignore +7 -0
- package/assets/deployment/docker/Dockerfile +17 -0
- package/assets/deployment/docker/README.md +44 -0
- package/assets/deployment/docker/example.env +3 -0
- package/assets/features/client_nav/client_nav.ts +369 -264
- package/assets/features/client_nav/document_navigation.ts +344 -0
- package/assets/features/client_nav/form_enhancement.ts +275 -0
- package/assets/templates/api/src/backend/index.ts +71 -10
- package/assets/templates/api/src/backend/tsconfig.json +6 -1
- package/assets/templates/full/src/backend/index.ts +71 -10
- package/assets/templates/full/src/backend/module.ts +515 -0
- package/assets/templates/full/src/backend/tests/progressive-enhancement.test.ts +180 -0
- package/assets/templates/full/src/backend/tsconfig.json +6 -1
- package/assets/templates/full/src/frontend/app/scripts/features/client-nav.ts +574 -0
- package/assets/templates/full/src/frontend/app/scripts/features/document-navigation.ts +344 -0
- package/assets/templates/full/src/frontend/app/scripts/features/form-enhancement.ts +275 -0
- package/assets/templates/full/src/frontend/pages/home/index.css +8 -0
- package/assets/templates/full/src/frontend/pages/home/index.html +6 -1
- package/assets/templates/full/src/frontend/pages/home/tests/home.test.ts +12 -2
- package/assets/templates/spa/src/frontend/pages/home/tests/home.test.ts +10 -2
- package/package.json +31 -13
- package/scripts/check-feature-projections.mjs +87 -0
- package/scripts/check-full-demo-sync.mjs +89 -0
- package/scripts/check-package-install.mjs +537 -0
- package/scripts/check-standalone-install.mjs +221 -0
- package/scripts/pack-standalone.mjs +52 -28
- package/scripts/publish.sh +9 -0
- package/scripts/run-tests.mjs +99 -0
- package/scripts/sync-assets.mjs +175 -17
- package/src/add-backend-compat.ts +628 -0
- package/src/add-backend.ts +155 -27
- package/src/add.ts +111 -4
- package/src/agent.ts +393 -0
- package/src/api-watch.ts +7 -4
- package/src/backend-inspect.ts +70 -2
- package/src/backend-runtime.ts +22 -14
- package/src/build.ts +1 -3
- package/src/bun-generated-frontend-watch.ts +209 -0
- package/src/bun-globals.d.ts +23 -0
- package/src/bun-spa-document.ts +310 -0
- package/src/bun-spa-routes.ts +159 -0
- package/src/bun-spa-watch.ts +29 -0
- package/src/bun-ssg-watch.ts +304 -0
- package/src/cli.ts +381 -50
- package/src/compile-tests.ts +37 -29
- package/src/dev-server.ts +214 -143
- package/src/doctor.ts +164 -0
- package/src/enable-assets.ts +18 -1
- package/src/enable.ts +133 -41
- package/src/execute.ts +28 -4
- package/src/external-workspace.ts +178 -0
- package/src/format.ts +296 -17
- package/src/frontend-inspect.ts +32 -0
- package/src/frontend-watch.ts +27 -102
- package/src/full-watch.ts +13 -18
- package/src/index.ts +7 -0
- package/src/init-assets.ts +41 -11
- package/src/init.ts +85 -71
- package/src/inspect.ts +112 -0
- package/src/mcp/run-cli-json.ts +46 -0
- package/src/mcp/server.ts +307 -0
- package/src/operations.ts +176 -0
- package/src/providers.ts +20 -18
- package/src/refresh.ts +29 -3
- package/src/repair.ts +110 -43
- package/src/runtime-filter.ts +41 -0
- package/src/runtime.ts +1 -1
- package/src/smoke.ts +48 -16
- package/src/test.ts +54 -16
- package/src/testing-runtime.ts +273 -0
- package/src/types.ts +1 -4
- package/src/watch-events.ts +46 -17
- package/src/watch.ts +5 -1
- package/src/workspace-watcher.ts +10 -6
- package/src/workspace.ts +4 -2
- package/src/watch-daemon-client.ts +0 -171
package/src/test.ts
CHANGED
|
@@ -2,18 +2,27 @@ import path from 'node:path';
|
|
|
2
2
|
import { mkdir, writeFile } from 'node:fs/promises';
|
|
3
3
|
|
|
4
4
|
import type { ModuleManifest } from '@webstir-io/module-contract';
|
|
5
|
-
import type {
|
|
5
|
+
import type {
|
|
6
|
+
TestModule,
|
|
7
|
+
RunnerSummary,
|
|
8
|
+
TestRunResult,
|
|
9
|
+
RuntimeFilter,
|
|
10
|
+
} from '@webstir-io/webstir-testing';
|
|
6
11
|
import type { BuildTargetKind, WorkspaceDescriptor } from './types.ts';
|
|
7
12
|
|
|
8
13
|
import { compileTestModules } from './compile-tests.ts';
|
|
14
|
+
import { materializeRepoLocalWorkspaceDependencies } from './external-workspace.ts';
|
|
9
15
|
import { loadProvider } from './providers.ts';
|
|
10
|
-
import { createWorkspaceRuntimeEnv } from './runtime.ts';
|
|
11
|
-
import { readWorkspaceDescriptor } from './workspace.ts';
|
|
12
16
|
import {
|
|
13
17
|
applyRuntimeFilter,
|
|
14
18
|
describeRuntimeFilter,
|
|
15
19
|
normalizeRuntimeFilter,
|
|
16
|
-
|
|
20
|
+
} from './runtime-filter.ts';
|
|
21
|
+
import { createWorkspaceRuntimeEnv } from './runtime.ts';
|
|
22
|
+
import { run as runFrontendTests } from './testing-runtime.ts';
|
|
23
|
+
import { readWorkspaceDescriptor } from './workspace.ts';
|
|
24
|
+
import {
|
|
25
|
+
createDefaultProviderRegistry as createPublishedProviderRegistry,
|
|
17
26
|
discoverTestManifest,
|
|
18
27
|
} from '@webstir-io/webstir-testing';
|
|
19
28
|
|
|
@@ -21,6 +30,7 @@ export interface RunTestOptions {
|
|
|
21
30
|
readonly workspaceRoot: string;
|
|
22
31
|
readonly rawArgs: readonly string[];
|
|
23
32
|
readonly env?: Record<string, string | undefined>;
|
|
33
|
+
readonly quietInstall?: boolean;
|
|
24
34
|
}
|
|
25
35
|
|
|
26
36
|
export interface TestCommandResult {
|
|
@@ -33,6 +43,9 @@ export interface TestCommandResult {
|
|
|
33
43
|
}
|
|
34
44
|
|
|
35
45
|
export async function runTest(options: RunTestOptions): Promise<TestCommandResult> {
|
|
46
|
+
await materializeRepoLocalWorkspaceDependencies(options.workspaceRoot, {
|
|
47
|
+
installStdio: options.quietInstall ? 'pipe' : 'inherit',
|
|
48
|
+
});
|
|
36
49
|
const runtime = parseRuntimeFlag(options.rawArgs, options.env);
|
|
37
50
|
const workspace = await readWorkspaceDescriptor(options.workspaceRoot);
|
|
38
51
|
const builtTargets = selectBuildTargets(workspace.mode, runtime);
|
|
@@ -41,7 +54,11 @@ export async function runTest(options: RunTestOptions): Promise<TestCommandResul
|
|
|
41
54
|
const provider = await loadProvider(target);
|
|
42
55
|
const result = await provider.build({
|
|
43
56
|
workspaceRoot: workspace.root,
|
|
44
|
-
env: createWorkspaceRuntimeEnv(
|
|
57
|
+
env: createWorkspaceRuntimeEnv(
|
|
58
|
+
workspace.root,
|
|
59
|
+
target === 'backend' ? 'test' : 'build',
|
|
60
|
+
options.env,
|
|
61
|
+
),
|
|
45
62
|
incremental: false,
|
|
46
63
|
});
|
|
47
64
|
|
|
@@ -52,7 +69,9 @@ export async function runTest(options: RunTestOptions): Promise<TestCommandResul
|
|
|
52
69
|
|
|
53
70
|
const manifest = await discoverTestManifest(workspace.root);
|
|
54
71
|
const filteredManifest = applyRuntimeFilter(manifest, runtime);
|
|
55
|
-
const filterMessage =
|
|
72
|
+
const filterMessage =
|
|
73
|
+
describeRuntimeFilter(runtime, manifest.modules.length, filteredManifest.modules.length) ??
|
|
74
|
+
undefined;
|
|
56
75
|
await compileTestModules(workspace.root, filteredManifest.modules);
|
|
57
76
|
const summary = await executeTestRun(filteredManifest.modules, workspace.root);
|
|
58
77
|
|
|
@@ -69,11 +88,17 @@ export async function runTest(options: RunTestOptions): Promise<TestCommandResul
|
|
|
69
88
|
export function formatFailedTests(results: readonly TestRunResult[]): string[] {
|
|
70
89
|
return results
|
|
71
90
|
.filter((result) => !result.passed)
|
|
72
|
-
.map(
|
|
91
|
+
.map(
|
|
92
|
+
(result) =>
|
|
93
|
+
`${result.file}: ${result.name}${result.message ? ` — ${firstLine(result.message)}` : ''}`,
|
|
94
|
+
);
|
|
73
95
|
}
|
|
74
96
|
|
|
75
|
-
async function executeTestRun(
|
|
76
|
-
|
|
97
|
+
async function executeTestRun(
|
|
98
|
+
modules: readonly TestModule[],
|
|
99
|
+
workspaceRoot: string,
|
|
100
|
+
): Promise<RunnerSummary> {
|
|
101
|
+
const registry = createPublishedProviderRegistry();
|
|
77
102
|
const grouped = new Map<TestModule['runtime'], TestModule[]>();
|
|
78
103
|
|
|
79
104
|
for (const module of modules) {
|
|
@@ -88,7 +113,10 @@ async function executeTestRun(modules: readonly TestModule[], workspaceRoot: str
|
|
|
88
113
|
let summary = createEmptySummary();
|
|
89
114
|
|
|
90
115
|
for (const [runtime, runtimeModules] of grouped) {
|
|
91
|
-
const provider =
|
|
116
|
+
const provider =
|
|
117
|
+
runtime === 'frontend'
|
|
118
|
+
? { id: '@webstir-io/webstir/frontend-runtime', runTests: runFrontendTests }
|
|
119
|
+
: registry.get(runtime);
|
|
92
120
|
if (!provider) {
|
|
93
121
|
continue;
|
|
94
122
|
}
|
|
@@ -96,7 +124,11 @@ async function executeTestRun(modules: readonly TestModule[], workspaceRoot: str
|
|
|
96
124
|
const files = runtimeModules
|
|
97
125
|
.map((module) => module.compiledPath)
|
|
98
126
|
.filter((compiledPath): compiledPath is string => typeof compiledPath === 'string');
|
|
99
|
-
const runtimeSummary = await withRuntimeEnv(
|
|
127
|
+
const runtimeSummary = await withRuntimeEnv(
|
|
128
|
+
runtime,
|
|
129
|
+
workspaceRoot,
|
|
130
|
+
async () => await provider.runTests(files),
|
|
131
|
+
);
|
|
100
132
|
summary = {
|
|
101
133
|
passed: summary.passed + runtimeSummary.passed,
|
|
102
134
|
failed: summary.failed + runtimeSummary.failed,
|
|
@@ -112,7 +144,7 @@ async function executeTestRun(modules: readonly TestModule[], workspaceRoot: str
|
|
|
112
144
|
async function withRuntimeEnv<T>(
|
|
113
145
|
runtime: TestModule['runtime'],
|
|
114
146
|
workspaceRoot: string,
|
|
115
|
-
callback: () => Promise<T
|
|
147
|
+
callback: () => Promise<T>,
|
|
116
148
|
): Promise<T> {
|
|
117
149
|
if (runtime !== 'backend') {
|
|
118
150
|
return await callback();
|
|
@@ -154,7 +186,10 @@ function createEmptySummary(): RunnerSummary {
|
|
|
154
186
|
};
|
|
155
187
|
}
|
|
156
188
|
|
|
157
|
-
function selectBuildTargets(
|
|
189
|
+
function selectBuildTargets(
|
|
190
|
+
mode: WorkspaceDescriptor['mode'],
|
|
191
|
+
runtime: RuntimeFilter,
|
|
192
|
+
): BuildTargetKind[] {
|
|
158
193
|
if (runtime === 'frontend') {
|
|
159
194
|
if (mode === 'spa' || mode === 'ssg' || mode === 'full') {
|
|
160
195
|
return ['frontend'];
|
|
@@ -184,7 +219,7 @@ function selectBuildTargets(mode: WorkspaceDescriptor['mode'], runtime: RuntimeF
|
|
|
184
219
|
|
|
185
220
|
function parseRuntimeFlag(
|
|
186
221
|
rawArgs: readonly string[],
|
|
187
|
-
env: Record<string, string | undefined> = process.env
|
|
222
|
+
env: Record<string, string | undefined> = process.env,
|
|
188
223
|
): RuntimeFilter {
|
|
189
224
|
for (let index = 0; index < rawArgs.length; index += 1) {
|
|
190
225
|
const arg = rawArgs[index];
|
|
@@ -200,13 +235,16 @@ function parseRuntimeFlag(
|
|
|
200
235
|
return normalizeRuntimeFilter(env.WEBSTIR_TEST_RUNTIME);
|
|
201
236
|
}
|
|
202
237
|
|
|
203
|
-
async function persistBackendManifest(
|
|
238
|
+
async function persistBackendManifest(
|
|
239
|
+
workspaceRoot: string,
|
|
240
|
+
manifest: ModuleManifest,
|
|
241
|
+
): Promise<void> {
|
|
204
242
|
const webstirDir = path.join(workspaceRoot, '.webstir');
|
|
205
243
|
await mkdir(webstirDir, { recursive: true });
|
|
206
244
|
await writeFile(
|
|
207
245
|
path.join(webstirDir, 'backend-manifest.json'),
|
|
208
246
|
`${JSON.stringify(manifest, null, 2)}\n`,
|
|
209
|
-
'utf8'
|
|
247
|
+
'utf8',
|
|
210
248
|
);
|
|
211
249
|
}
|
|
212
250
|
|
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import Module from 'node:module';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { pathToFileURL } from 'node:url';
|
|
5
|
+
import vm from 'node:vm';
|
|
6
|
+
|
|
7
|
+
import { assert } from '@webstir-io/webstir-testing';
|
|
8
|
+
import type { RunnerSummary, TestCallback, TestRunResult } from '@webstir-io/webstir-testing';
|
|
9
|
+
|
|
10
|
+
interface RegisteredTest {
|
|
11
|
+
readonly name: string;
|
|
12
|
+
readonly fn: TestCallback;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
type RequireFn = NodeJS.Require;
|
|
16
|
+
type RunnerGlobal = typeof globalThis & {
|
|
17
|
+
__currentFile?: string;
|
|
18
|
+
test?: (name: string, callback?: TestCallback) => void;
|
|
19
|
+
assert?: typeof assert;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
const registry = new Map<string, RegisteredTest[]>();
|
|
23
|
+
const moduleExports = Object.freeze({
|
|
24
|
+
get test() {
|
|
25
|
+
return test;
|
|
26
|
+
},
|
|
27
|
+
get assert() {
|
|
28
|
+
return assert;
|
|
29
|
+
},
|
|
30
|
+
});
|
|
31
|
+
const runnerGlobal = globalThis as RunnerGlobal;
|
|
32
|
+
const esmFallback = Symbol('esm-fallback');
|
|
33
|
+
|
|
34
|
+
export function test(name: string, callback?: TestCallback): void {
|
|
35
|
+
const currentFile = runnerGlobal.__currentFile;
|
|
36
|
+
if (!currentFile) {
|
|
37
|
+
throw new Error('No current file set');
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const safeCallback: TestCallback = callback ?? (async () => undefined);
|
|
41
|
+
const tests = registry.get(currentFile);
|
|
42
|
+
if (tests) {
|
|
43
|
+
tests.push({
|
|
44
|
+
name: String(name),
|
|
45
|
+
fn: safeCallback,
|
|
46
|
+
});
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
registry.set(currentFile, [
|
|
51
|
+
{
|
|
52
|
+
name: String(name),
|
|
53
|
+
fn: safeCallback,
|
|
54
|
+
},
|
|
55
|
+
]);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
runnerGlobal.test = test;
|
|
59
|
+
runnerGlobal.assert = assert;
|
|
60
|
+
export { assert };
|
|
61
|
+
|
|
62
|
+
export async function run(files: readonly string[]): Promise<RunnerSummary> {
|
|
63
|
+
const allResults: TestRunResult[] = [];
|
|
64
|
+
const start = Date.now();
|
|
65
|
+
|
|
66
|
+
for (const file of files) {
|
|
67
|
+
if (!fs.existsSync(file)) {
|
|
68
|
+
allResults.push({
|
|
69
|
+
name: '[missing compiled file]',
|
|
70
|
+
file,
|
|
71
|
+
passed: false,
|
|
72
|
+
message: 'Compiled file not found',
|
|
73
|
+
durationMs: 0,
|
|
74
|
+
});
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const evalError = await evaluateModule(file);
|
|
79
|
+
if (evalError) {
|
|
80
|
+
allResults.push({
|
|
81
|
+
name: '[module evaluation]',
|
|
82
|
+
file,
|
|
83
|
+
passed: false,
|
|
84
|
+
message: evalError,
|
|
85
|
+
durationMs: 0,
|
|
86
|
+
});
|
|
87
|
+
continue;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const tests = registry.get(file) ?? [];
|
|
91
|
+
for (const entry of tests) {
|
|
92
|
+
const outcome = await runSingleTest(entry);
|
|
93
|
+
allResults.push({
|
|
94
|
+
name: entry.name,
|
|
95
|
+
file,
|
|
96
|
+
passed: outcome.passed,
|
|
97
|
+
message: outcome.message,
|
|
98
|
+
durationMs: outcome.durationMs,
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
let passed = 0;
|
|
104
|
+
let failed = 0;
|
|
105
|
+
for (const result of allResults) {
|
|
106
|
+
if (result.passed) {
|
|
107
|
+
passed += 1;
|
|
108
|
+
} else {
|
|
109
|
+
failed += 1;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
return {
|
|
114
|
+
passed,
|
|
115
|
+
failed,
|
|
116
|
+
total: allResults.length,
|
|
117
|
+
durationMs: Date.now() - start,
|
|
118
|
+
results: allResults,
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function createRuntimeRequire(file: string): RequireFn {
|
|
123
|
+
const baseRequire = Module.createRequire(file);
|
|
124
|
+
const runtimeRequire = ((specifier: string) => {
|
|
125
|
+
if (specifier === '@webstir-io/webstir-testing') {
|
|
126
|
+
return moduleExports;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
return baseRequire(specifier);
|
|
130
|
+
}) as RequireFn;
|
|
131
|
+
|
|
132
|
+
const resolve = ((specifier: string, options?: Parameters<typeof baseRequire.resolve>[1]) => {
|
|
133
|
+
if (specifier === '@webstir-io/webstir-testing') {
|
|
134
|
+
return specifier;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
return baseRequire.resolve(specifier, options);
|
|
138
|
+
}) as NodeJS.RequireResolve;
|
|
139
|
+
|
|
140
|
+
resolve.paths = baseRequire.resolve.paths;
|
|
141
|
+
runtimeRequire.resolve = resolve;
|
|
142
|
+
runtimeRequire.cache = baseRequire.cache;
|
|
143
|
+
runtimeRequire.main = baseRequire.main;
|
|
144
|
+
runtimeRequire.extensions = baseRequire.extensions;
|
|
145
|
+
|
|
146
|
+
return runtimeRequire;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
async function evaluateModule(file: string): Promise<string | null> {
|
|
150
|
+
const code = fs.readFileSync(file, 'utf8');
|
|
151
|
+
const commonJsResult = evaluateCommonJsModule(file, code);
|
|
152
|
+
|
|
153
|
+
if (commonJsResult === null) {
|
|
154
|
+
return null;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
if (commonJsResult === esmFallback) {
|
|
158
|
+
return await evaluateEsmModule(file);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
return commonJsResult;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function evaluateCommonJsModule(file: string, code: string): string | typeof esmFallback | null {
|
|
165
|
+
const runtimeRequire = createRuntimeRequire(file);
|
|
166
|
+
const context = vm.createContext({
|
|
167
|
+
test,
|
|
168
|
+
assert,
|
|
169
|
+
globalThis,
|
|
170
|
+
console,
|
|
171
|
+
setTimeout,
|
|
172
|
+
clearTimeout,
|
|
173
|
+
require: runtimeRequire,
|
|
174
|
+
__dirname: path.dirname(file),
|
|
175
|
+
__filename: file,
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
runnerGlobal.__currentFile = file;
|
|
179
|
+
registry.set(file, []);
|
|
180
|
+
|
|
181
|
+
try {
|
|
182
|
+
const script = new vm.Script(code, { filename: file });
|
|
183
|
+
script.runInContext(context, { displayErrors: true });
|
|
184
|
+
return null;
|
|
185
|
+
} catch (error) {
|
|
186
|
+
if (isEsModuleSyntaxError(error)) {
|
|
187
|
+
return esmFallback;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
return formatError(error);
|
|
191
|
+
} finally {
|
|
192
|
+
delete runnerGlobal.__currentFile;
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
async function evaluateEsmModule(file: string): Promise<string | null> {
|
|
197
|
+
const moduleUrl = pathToFileURL(file);
|
|
198
|
+
moduleUrl.searchParams.set('ts', Date.now().toString());
|
|
199
|
+
|
|
200
|
+
try {
|
|
201
|
+
runnerGlobal.__currentFile = file;
|
|
202
|
+
registry.set(file, []);
|
|
203
|
+
await import(moduleUrl.href);
|
|
204
|
+
return null;
|
|
205
|
+
} catch (error) {
|
|
206
|
+
return formatError(error);
|
|
207
|
+
} finally {
|
|
208
|
+
delete runnerGlobal.__currentFile;
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function isEsModuleSyntaxError(error: unknown): boolean {
|
|
213
|
+
const name =
|
|
214
|
+
typeof error === 'object' && error !== null && 'name' in error
|
|
215
|
+
? String((error as { name?: unknown }).name)
|
|
216
|
+
: '';
|
|
217
|
+
const message =
|
|
218
|
+
typeof error === 'object' && error !== null && 'message' in error
|
|
219
|
+
? String((error as { message?: unknown }).message)
|
|
220
|
+
: '';
|
|
221
|
+
|
|
222
|
+
if (name !== 'SyntaxError' || message.length === 0) {
|
|
223
|
+
return false;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
return (
|
|
227
|
+
message.includes('Cannot use import statement outside a module') ||
|
|
228
|
+
message.includes('Unexpected token') ||
|
|
229
|
+
message.includes('export') ||
|
|
230
|
+
message.includes('import call expects one or two arguments')
|
|
231
|
+
);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
async function runSingleTest(
|
|
235
|
+
testCase: RegisteredTest,
|
|
236
|
+
): Promise<{ passed: boolean; message: string | null; durationMs: number }> {
|
|
237
|
+
const start = Date.now();
|
|
238
|
+
try {
|
|
239
|
+
const result = testCase.fn();
|
|
240
|
+
if (isPromiseLike(result)) {
|
|
241
|
+
await result;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
return {
|
|
245
|
+
passed: true,
|
|
246
|
+
message: null,
|
|
247
|
+
durationMs: Date.now() - start,
|
|
248
|
+
};
|
|
249
|
+
} catch (error) {
|
|
250
|
+
return {
|
|
251
|
+
passed: false,
|
|
252
|
+
message: formatError(error),
|
|
253
|
+
durationMs: Date.now() - start,
|
|
254
|
+
};
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
function isPromiseLike(value: unknown): value is Promise<unknown> {
|
|
259
|
+
return (
|
|
260
|
+
typeof value === 'object' &&
|
|
261
|
+
value !== null &&
|
|
262
|
+
'then' in value &&
|
|
263
|
+
typeof (value as { then?: unknown }).then === 'function'
|
|
264
|
+
);
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
function formatError(error: unknown): string {
|
|
268
|
+
if (error instanceof Error) {
|
|
269
|
+
return error.stack ?? error.message;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
return String(error);
|
|
273
|
+
}
|
package/src/types.ts
CHANGED
|
@@ -1,7 +1,4 @@
|
|
|
1
|
-
import type {
|
|
2
|
-
ModuleBuildResult,
|
|
3
|
-
ModuleProvider,
|
|
4
|
-
} from '@webstir-io/module-contract';
|
|
1
|
+
import type { ModuleBuildResult, ModuleProvider } from '@webstir-io/module-contract';
|
|
5
2
|
|
|
6
3
|
export const SUPPORTED_WORKSPACE_MODES = ['spa', 'ssg', 'api', 'full'] as const;
|
|
7
4
|
|
package/src/watch-events.ts
CHANGED
|
@@ -19,10 +19,16 @@ export interface HotUpdateAsset {
|
|
|
19
19
|
readonly url: string;
|
|
20
20
|
}
|
|
21
21
|
|
|
22
|
+
export interface HotUpdateTarget {
|
|
23
|
+
readonly kind: 'boundary';
|
|
24
|
+
readonly id: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
22
27
|
export interface HotUpdatePayload {
|
|
23
28
|
readonly requiresReload: boolean;
|
|
24
29
|
readonly modules: readonly HotUpdateAsset[];
|
|
25
30
|
readonly styles: readonly HotUpdateAsset[];
|
|
31
|
+
readonly target?: HotUpdateTarget;
|
|
26
32
|
readonly changedFile?: string;
|
|
27
33
|
readonly fallbackReasons?: readonly string[];
|
|
28
34
|
readonly stats?: {
|
|
@@ -74,16 +80,14 @@ export function collectWatchActions(payload: StructuredDiagnosticPayload): reado
|
|
|
74
80
|
}
|
|
75
81
|
|
|
76
82
|
const hotUpdate = readHotUpdatePayload(payload.data);
|
|
77
|
-
const changedFile =
|
|
83
|
+
const changedFile =
|
|
84
|
+
typeof hotUpdate?.changedFile === 'string' ? hotUpdate.changedFile : undefined;
|
|
78
85
|
if (!hotUpdate || !changedFile) {
|
|
79
86
|
return [{ type: 'status', status: 'success' }];
|
|
80
87
|
}
|
|
81
88
|
|
|
82
89
|
if (hotUpdate.requiresReload) {
|
|
83
|
-
return [
|
|
84
|
-
{ type: 'status', status: 'hmr-fallback' },
|
|
85
|
-
{ type: 'reload' },
|
|
86
|
-
];
|
|
90
|
+
return [{ type: 'status', status: 'hmr-fallback' }, { type: 'reload' }];
|
|
87
91
|
}
|
|
88
92
|
|
|
89
93
|
if (hotUpdate.modules.length === 0 && hotUpdate.styles.length === 0) {
|
|
@@ -97,15 +101,19 @@ export function collectWatchActions(payload: StructuredDiagnosticPayload): reado
|
|
|
97
101
|
}
|
|
98
102
|
|
|
99
103
|
function isBuildStartDiagnostic(code: string): boolean {
|
|
100
|
-
return
|
|
104
|
+
return (
|
|
105
|
+
code === 'frontend.watch.starting' ||
|
|
101
106
|
code === 'frontend.watch.reload' ||
|
|
102
|
-
code.endsWith('.build.start')
|
|
107
|
+
code.endsWith('.build.start')
|
|
108
|
+
);
|
|
103
109
|
}
|
|
104
110
|
|
|
105
111
|
function isBuildFailureDiagnostic(code: string): boolean {
|
|
106
|
-
return
|
|
112
|
+
return (
|
|
113
|
+
code === 'frontend.watch.unexpected' ||
|
|
107
114
|
code === 'frontend.watch.command.failure' ||
|
|
108
|
-
code.endsWith('.build.failure')
|
|
115
|
+
code.endsWith('.build.failure')
|
|
116
|
+
);
|
|
109
117
|
}
|
|
110
118
|
|
|
111
119
|
function readHotUpdatePayload(data: Record<string, unknown> | undefined): HotUpdatePayload | null {
|
|
@@ -127,6 +135,7 @@ function readHotUpdatePayload(data: Record<string, unknown> | undefined): HotUpd
|
|
|
127
135
|
requiresReload: payload.requiresReload,
|
|
128
136
|
modules: readAssets(payload.modules),
|
|
129
137
|
styles: readAssets(payload.styles),
|
|
138
|
+
target: readTarget(payload.target),
|
|
130
139
|
changedFile: typeof payload.changedFile === 'string' ? payload.changedFile : undefined,
|
|
131
140
|
fallbackReasons: Array.isArray(payload.fallbackReasons)
|
|
132
141
|
? payload.fallbackReasons.filter((value): value is string => typeof value === 'string')
|
|
@@ -155,12 +164,14 @@ function readAssets(value: unknown): readonly HotUpdateAsset[] {
|
|
|
155
164
|
return [];
|
|
156
165
|
}
|
|
157
166
|
|
|
158
|
-
return [
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
167
|
+
return [
|
|
168
|
+
{
|
|
169
|
+
type: asset.type,
|
|
170
|
+
path: asset.path,
|
|
171
|
+
relativePath: asset.relativePath,
|
|
172
|
+
url: asset.url,
|
|
173
|
+
},
|
|
174
|
+
];
|
|
164
175
|
});
|
|
165
176
|
}
|
|
166
177
|
|
|
@@ -180,16 +191,34 @@ function readStats(value: unknown): HotUpdatePayload['stats'] | undefined {
|
|
|
180
191
|
};
|
|
181
192
|
}
|
|
182
193
|
|
|
194
|
+
function readTarget(value: unknown): HotUpdateTarget | undefined {
|
|
195
|
+
if (typeof value !== 'object' || value === null) {
|
|
196
|
+
return undefined;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
const target = value as Record<string, unknown>;
|
|
200
|
+
if (target.kind !== 'boundary' || typeof target.id !== 'string' || target.id.trim() === '') {
|
|
201
|
+
return undefined;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
return {
|
|
205
|
+
kind: 'boundary',
|
|
206
|
+
id: target.id,
|
|
207
|
+
};
|
|
208
|
+
}
|
|
209
|
+
|
|
183
210
|
function isStructuredDiagnosticPayload(value: unknown): value is StructuredDiagnosticPayload {
|
|
184
211
|
if (typeof value !== 'object' || value === null) {
|
|
185
212
|
return false;
|
|
186
213
|
}
|
|
187
214
|
|
|
188
215
|
const payload = value as Record<string, unknown>;
|
|
189
|
-
return
|
|
216
|
+
return (
|
|
217
|
+
payload.type === 'diagnostic' &&
|
|
190
218
|
typeof payload.code === 'string' &&
|
|
191
219
|
typeof payload.kind === 'string' &&
|
|
192
220
|
typeof payload.stage === 'string' &&
|
|
193
221
|
typeof payload.severity === 'string' &&
|
|
194
|
-
typeof payload.message === 'string'
|
|
222
|
+
typeof payload.message === 'string'
|
|
223
|
+
);
|
|
195
224
|
}
|
package/src/watch.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { runApiWatch } from './api-watch.ts';
|
|
2
|
+
import { materializeRepoLocalWorkspaceDependencies } from './external-workspace.ts';
|
|
2
3
|
import { runFrontendWatch } from './frontend-watch.ts';
|
|
3
4
|
import { runFullWatch } from './full-watch.ts';
|
|
4
5
|
import type { WorkspaceDescriptor } from './types.ts';
|
|
@@ -41,10 +42,13 @@ const defaultIo: WatchIo = {
|
|
|
41
42
|
|
|
42
43
|
export async function runWatch(options: RunWatchOptions): Promise<void> {
|
|
43
44
|
const io = options.io ?? defaultIo;
|
|
45
|
+
await materializeRepoLocalWorkspaceDependencies(options.workspaceRoot);
|
|
44
46
|
const workspace = await readWorkspaceDescriptor(options.workspaceRoot);
|
|
45
47
|
|
|
46
48
|
switch (workspace.mode) {
|
|
47
49
|
case 'spa':
|
|
50
|
+
await runFrontendWatch(workspace, options, io);
|
|
51
|
+
return;
|
|
48
52
|
case 'ssg':
|
|
49
53
|
await runFrontendWatch(workspace, options, io);
|
|
50
54
|
return;
|
|
@@ -61,6 +65,6 @@ export async function runWatch(options: RunWatchOptions): Promise<void> {
|
|
|
61
65
|
|
|
62
66
|
function throwUnsupportedWatchMode(workspace: WorkspaceDescriptor): never {
|
|
63
67
|
throw new Error(
|
|
64
|
-
`Watch currently supports spa, ssg, api, and full workspaces only. "${workspace.name}" is ${workspace.mode}
|
|
68
|
+
`Watch currently supports spa, ssg, api, and full workspaces only. "${workspace.name}" is ${workspace.mode}.`,
|
|
65
69
|
);
|
|
66
70
|
}
|
package/src/workspace-watcher.ts
CHANGED
|
@@ -30,10 +30,7 @@ export class WorkspaceWatcher {
|
|
|
30
30
|
|
|
31
31
|
public constructor(options: WorkspaceWatcherOptions) {
|
|
32
32
|
this.workspaceRoot = path.resolve(options.workspaceRoot);
|
|
33
|
-
this.treeRoots = [
|
|
34
|
-
path.join(this.workspaceRoot, 'src'),
|
|
35
|
-
path.join(this.workspaceRoot, 'types'),
|
|
36
|
-
];
|
|
33
|
+
this.treeRoots = [path.join(this.workspaceRoot, 'src'), path.join(this.workspaceRoot, 'types')];
|
|
37
34
|
this.onEvent = options.onEvent;
|
|
38
35
|
this.debounceMs = options.debounceMs ?? 75;
|
|
39
36
|
}
|
|
@@ -112,7 +109,11 @@ export class WorkspaceWatcher {
|
|
|
112
109
|
}
|
|
113
110
|
|
|
114
111
|
for (const watchedDirectory of Array.from(this.treeWatchers.keys())) {
|
|
115
|
-
if (
|
|
112
|
+
if (
|
|
113
|
+
watchedDirectory !== root &&
|
|
114
|
+
watchedDirectory.startsWith(`${root}${path.sep}`) &&
|
|
115
|
+
!current.has(watchedDirectory)
|
|
116
|
+
) {
|
|
116
117
|
this.treeWatchers.get(watchedDirectory)?.close();
|
|
117
118
|
this.treeWatchers.delete(watchedDirectory);
|
|
118
119
|
}
|
|
@@ -234,7 +235,10 @@ async function collectDirectories(root: string): Promise<readonly string[]> {
|
|
|
234
235
|
const stack = [path.resolve(root)];
|
|
235
236
|
|
|
236
237
|
while (stack.length > 0) {
|
|
237
|
-
const current = stack.pop()
|
|
238
|
+
const current = stack.pop();
|
|
239
|
+
if (!current) {
|
|
240
|
+
continue;
|
|
241
|
+
}
|
|
238
242
|
directories.push(current);
|
|
239
243
|
|
|
240
244
|
const entries = await readdir(current, { withFileTypes: true });
|
package/src/workspace.ts
CHANGED
|
@@ -25,7 +25,7 @@ export function parseWorkspaceMode(value: unknown): WorkspaceMode {
|
|
|
25
25
|
}
|
|
26
26
|
|
|
27
27
|
throw new Error(
|
|
28
|
-
`Unsupported webstir.mode "${value}". Expected one of: ${SUPPORTED_WORKSPACE_MODES.join(', ')}
|
|
28
|
+
`Unsupported webstir.mode "${value}". Expected one of: ${SUPPORTED_WORKSPACE_MODES.join(', ')}.`,
|
|
29
29
|
);
|
|
30
30
|
}
|
|
31
31
|
|
|
@@ -44,7 +44,9 @@ export async function readWorkspaceDescriptor(workspacePath: string): Promise<Wo
|
|
|
44
44
|
try {
|
|
45
45
|
packageJson = JSON.parse(rawPackageJson) as WorkspacePackageJson;
|
|
46
46
|
} catch (error) {
|
|
47
|
-
throw new Error(`Workspace package.json at ${packageJsonPath} is not valid JSON.`, {
|
|
47
|
+
throw new Error(`Workspace package.json at ${packageJsonPath} is not valid JSON.`, {
|
|
48
|
+
cause: error,
|
|
49
|
+
});
|
|
48
50
|
}
|
|
49
51
|
|
|
50
52
|
return {
|