@mandujs/core 0.53.3 → 0.54.1
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 +654 -654
- package/package.json +1 -1
- package/src/a11y/__tests__/run-audit.test.ts +333 -333
- package/src/a11y/fix-hints.ts +76 -76
- package/src/a11y/index.ts +18 -18
- package/src/a11y/run-audit.ts +394 -394
- package/src/a11y/types.ts +125 -125
- package/src/auth/__tests__/login.test.ts +1 -1
- package/src/auth/__tests__/password.test.ts +122 -122
- package/src/auth/__tests__/tokens.test.ts +274 -274
- package/src/auth/__tests__/verification.test.ts +274 -274
- package/src/auth/index.ts +76 -76
- package/src/auth/login.ts +225 -225
- package/src/auth/password.ts +120 -120
- package/src/auth/reset.ts +243 -243
- package/src/auth/tokens.ts +612 -612
- package/src/auth/verification.ts +253 -253
- package/src/brain/__tests__/redactor.test.ts +94 -94
- package/src/brain/adapters/__tests__/_helpers.ts +83 -83
- package/src/brain/adapters/__tests__/anthropic-oauth.test.ts +196 -196
- package/src/brain/adapters/__tests__/chatgpt-auth.test.ts +193 -193
- package/src/brain/adapters/__tests__/openai-oauth.test.ts +209 -209
- package/src/brain/adapters/__tests__/resolver.test.ts +143 -143
- package/src/brain/adapters/anthropic-oauth.ts +1 -1
- package/src/brain/adapters/chatgpt-auth.ts +300 -300
- package/src/brain/adapters/index.ts +319 -319
- package/src/brain/adapters/oauth-flow.ts +439 -439
- package/src/brain/consent.ts +240 -240
- package/src/brain/credentials.ts +396 -396
- package/src/bundler/__tests__/build-runner.ts +113 -113
- package/src/bundler/__tests__/dev-reliability.test.ts +619 -619
- package/src/bundler/__tests__/extended-watch.test.ts +711 -711
- package/src/bundler/__tests__/fast-refresh.test.ts +10 -10
- package/src/bundler/__tests__/hdr.test.ts +353 -353
- package/src/bundler/__tests__/hmr-client.test.ts +532 -532
- package/src/bundler/__tests__/manifest-schema.test.ts +266 -266
- package/src/bundler/__tests__/prod-smoke.test.ts +138 -138
- package/src/bundler/__tests__/reverse-import-graph.test.ts +519 -519
- package/src/bundler/__tests__/slot-dispatch.test.ts +573 -573
- package/src/bundler/__tests__/url-cap-and-slot-regex.test.ts +286 -286
- package/src/bundler/__tests__/vendor-cache.test.ts +455 -455
- package/src/bundler/budget.ts +404 -404
- package/src/bundler/build.test.ts +179 -179
- package/src/bundler/build.ts +55 -55
- package/src/bundler/dev.ts +42 -42
- package/src/bundler/manifest-schema.ts +301 -301
- package/src/bundler/plugins/__tests__/block-generated-imports.test.ts +263 -263
- package/src/bundler/plugins/__tests__/react-compiler-config.test.ts +83 -83
- package/src/bundler/plugins/__tests__/react-compiler-lint.test.ts +110 -110
- package/src/bundler/plugins/block-generated-imports.ts +155 -155
- package/src/bundler/plugins/index.ts +83 -83
- package/src/bundler/plugins/react-compiler-config.ts +108 -108
- package/src/bundler/plugins/react-compiler-lint.ts +253 -253
- package/src/bundler/plugins/react-compiler.ts +162 -162
- package/src/bundler/reverse-import-graph.ts +339 -339
- package/src/bundler/safe-build.test.ts +201 -201
- package/src/bundler/safe-build.ts +103 -103
- package/src/bundler/scenario-matrix.ts +229 -229
- package/src/bundler/types.ts +10 -10
- package/src/bundler/vendor-cache-types.ts +130 -130
- package/src/bundler/vendor-cache.ts +526 -526
- package/src/change/snapshot.ts +18 -18
- package/src/client/Form.tsx +105 -105
- package/src/client/Link.tsx +9 -9
- package/src/client/__tests__/use-sse.test.ts +153 -153
- package/src/client/globals.ts +1 -1
- package/src/client/hooks.ts +362 -362
- package/src/client/hydrate.ts +340 -340
- package/src/client/prefetch-helper.ts +55 -55
- package/src/client/router.ts +11 -11
- package/src/client/runtime.ts +47 -47
- package/src/client/serialize.ts +404 -404
- package/src/client/use-fetch.ts +6 -6
- package/src/client/use-head.ts +197 -197
- package/src/client/use-sse.ts +378 -378
- package/src/client/window-state.ts +101 -101
- package/src/components/Image.tsx +162 -162
- package/src/config/validate.ts +3 -3
- package/src/config/watcher.ts +311 -311
- package/src/constants.ts +40 -40
- package/src/content/collection.ts +8 -8
- package/src/content/content-layer.ts +7 -7
- package/src/content/data-store.ts +245 -245
- package/src/content/frontmatter.ts +189 -189
- package/src/content/loader-context.ts +171 -171
- package/src/content/loaders/api.ts +216 -216
- package/src/content/loaders/file.ts +172 -172
- package/src/content/loaders/glob.ts +253 -253
- package/src/content/loaders/index.ts +34 -34
- package/src/content/loaders/types.ts +137 -137
- package/src/content/meta-store.ts +209 -209
- package/src/content/prebuild.test.ts +571 -571
- package/src/content/prebuild.ts +636 -636
- package/src/content/schema.ts +20 -20
- package/src/content/sidebar.ts +630 -630
- package/src/content/slug.ts +110 -110
- package/src/content/types.ts +282 -282
- package/src/content/watcher.ts +135 -135
- package/src/contract/client-safe.test.ts +42 -42
- package/src/contract/client-safe.ts +114 -114
- package/src/contract/define.ts +11 -11
- package/src/contract/index.ts +1 -1
- package/src/contract/normalize.test.ts +276 -276
- package/src/contract/normalize.ts +410 -410
- package/src/contract/registry.test.ts +206 -206
- package/src/contract/route-helpers.ts +1 -1
- package/src/contract/rpc.ts +443 -443
- package/src/contract/schema.ts +48 -48
- package/src/contract/types.ts +58 -58
- package/src/db/__tests__/db.test.ts +482 -482
- package/src/db/index.ts +138 -138
- package/src/db/migrations/history-table.ts +345 -345
- package/src/db/migrations/index.ts +3 -3
- package/src/db/migrations/lock.ts +324 -324
- package/src/db/migrations/runner.ts +650 -650
- package/src/deploy/cache.ts +140 -140
- package/src/deploy/compile/vercel.ts +344 -344
- package/src/deploy/index.ts +87 -87
- package/src/deploy/inference/brain.ts +268 -268
- package/src/deploy/inference/context.ts +182 -182
- package/src/deploy/inference/filling-extract.ts +245 -245
- package/src/deploy/inference/heuristic.ts +182 -182
- package/src/deploy/intent.ts +173 -173
- package/src/deploy/plan.ts +178 -178
- package/src/design/__tests__/agents-link.test.ts +109 -109
- package/src/design/__tests__/extract-patch-diff.test.ts +265 -265
- package/src/design/__tests__/lint.test.ts +110 -110
- package/src/design/__tests__/parser.test.ts +195 -195
- package/src/design/__tests__/tailwind-theme.test.ts +229 -229
- package/src/design/agents-link.ts +165 -165
- package/src/design/diff.ts +138 -138
- package/src/design/extract.ts +285 -285
- package/src/design/index.ts +102 -102
- package/src/design/lint.ts +209 -209
- package/src/design/parser.ts +555 -555
- package/src/design/patch.ts +241 -241
- package/src/design/scaffold.ts +147 -147
- package/src/design/tailwind-theme.ts +441 -441
- package/src/design/types.ts +210 -210
- package/src/desktop/__tests__/webview-fallback.test.ts +254 -254
- package/src/desktop/__tests__/window.test.ts +248 -248
- package/src/desktop/__tests__/worker.test.ts +266 -266
- package/src/desktop/index.ts +43 -43
- package/src/desktop/types.ts +158 -158
- package/src/desktop/worker.ts +180 -180
- package/src/dev-error-overlay/__tests__/overlay-injector.test.ts +241 -241
- package/src/dev-error-overlay/index.ts +30 -30
- package/src/dev-error-overlay/overlay-injector.ts +243 -243
- package/src/dev-error-overlay/overlay-styles.ts +52 -52
- package/src/dev-error-overlay/types.ts +66 -66
- package/src/devtools/ai/context-builder.ts +375 -375
- package/src/devtools/ai/index.ts +25 -25
- package/src/devtools/ai/mcp-connector.ts +25 -25
- package/src/devtools/client/catchers/error-catcher.ts +344 -344
- package/src/devtools/client/catchers/index.ts +18 -18
- package/src/devtools/client/components/index.ts +39 -39
- package/src/devtools/client/components/mandu-character.tsx +331 -331
- package/src/devtools/client/components/overlay.tsx +368 -368
- package/src/devtools/client/components/panel/errors-panel.tsx +259 -259
- package/src/devtools/client/components/panel/guard-panel.tsx +30 -30
- package/src/devtools/client/components/panel/islands-panel.tsx +320 -320
- package/src/devtools/client/components/panel/network-panel.tsx +291 -291
- package/src/devtools/client/components/panel/panel-container.tsx +500 -500
- package/src/devtools/client/components/panel/preview-panel.tsx +46 -46
- package/src/devtools/client/filters/context-filters.ts +282 -282
- package/src/devtools/client/filters/index.ts +16 -16
- package/src/devtools/client/index.ts +63 -63
- package/src/devtools/client/persistence.ts +335 -335
- package/src/devtools/hook/create-hook.ts +207 -207
- package/src/devtools/hook/index.ts +13 -13
- package/src/devtools/index.ts +439 -439
- package/src/devtools/init.ts +265 -265
- package/src/devtools/protocol.ts +237 -237
- package/src/devtools/server/index.ts +17 -17
- package/src/devtools/server/source-context.ts +450 -450
- package/src/devtools/types.ts +35 -35
- package/src/devtools/worker/index.ts +25 -25
- package/src/devtools/worker/redaction-worker.ts +233 -233
- package/src/devtools/worker/worker-manager.ts +410 -410
- package/src/diagnose/__tests__/checks.test.ts +451 -451
- package/src/diagnose/checks.ts +832 -832
- package/src/diagnose/index.ts +17 -17
- package/src/diagnose/run.ts +93 -93
- package/src/diagnose/types.ts +53 -53
- package/src/email/__tests__/email.test.ts +355 -355
- package/src/email/index.ts +282 -282
- package/src/email/smtp.ts +64 -64
- package/src/error/domains.ts +265 -265
- package/src/error/result.ts +60 -60
- package/src/error/types.ts +6 -6
- package/src/errors/extractor.ts +409 -409
- package/src/errors/index.ts +19 -19
- package/src/filling/__tests__/session-sqlite.test.ts +454 -454
- package/src/filling/auth.ts +308 -308
- package/src/filling/cookie-codec.ts +299 -299
- package/src/filling/deps.ts +265 -265
- package/src/filling/session-sqlite.ts +617 -617
- package/src/filling/sse.ts +5 -5
- package/src/filling/ws.ts +78 -78
- package/src/generator/index.ts +3 -3
- package/src/guard/__tests__/design-inline-class.test.ts +219 -219
- package/src/guard/__tests__/tsgolint-bridge.test.ts +347 -347
- package/src/guard/analyzer.ts +360 -360
- package/src/guard/auto-correct.ts +1 -1
- package/src/guard/contract-guard.ts +9 -9
- package/src/guard/define-rule.ts +243 -243
- package/src/guard/design-inline-class.ts +393 -393
- package/src/guard/file-type.test.ts +24 -24
- package/src/guard/graph.ts +1 -1
- package/src/guard/healing.ts +36 -36
- package/src/guard/presets/atomic.ts +70 -70
- package/src/guard/presets/clean.ts +77 -77
- package/src/guard/presets/fsd.ts +79 -79
- package/src/guard/presets/hexagonal.ts +68 -68
- package/src/guard/reporter.ts +442 -442
- package/src/guard/rule-presets.ts +379 -379
- package/src/guard/semantic-slots.ts +1 -1
- package/src/guard/suggestions.ts +358 -358
- package/src/guard/tsgolint-bridge.ts +512 -512
- package/src/guard/types.ts +348 -348
- package/src/guard/watcher.ts +405 -405
- package/src/i18n/define.ts +126 -126
- package/src/i18n/index.ts +52 -52
- package/src/i18n/message-registry.ts +173 -173
- package/src/i18n/types.ts +112 -112
- package/src/id/index.ts +105 -105
- package/src/index.ts +2 -2
- package/src/kitchen/api/agent-devtools-api.ts +779 -544
- package/src/kitchen/api/errors-grouping.ts +126 -0
- package/src/kitchen/kitchen-handler.ts +192 -63
- package/src/kitchen/kitchen-ui.ts +842 -464
- package/src/logging/index.ts +22 -22
- package/src/logging/transports.ts +365 -365
- package/src/middleware/bridge.ts +147 -147
- package/src/middleware/compose.ts +134 -134
- package/src/middleware/compress.ts +62 -62
- package/src/middleware/cors.ts +47 -47
- package/src/middleware/csrf.ts +328 -328
- package/src/middleware/define.ts +132 -132
- package/src/middleware/jwt.ts +134 -134
- package/src/middleware/logger.ts +58 -58
- package/src/middleware/oauth/__tests__/oauth.test.ts +1 -1
- package/src/middleware/oauth/index.ts +505 -505
- package/src/middleware/oauth/providers.ts +115 -115
- package/src/middleware/rate-limit/index.ts +522 -522
- package/src/middleware/rate-limit/sqlite-store.ts +382 -382
- package/src/middleware/scheduler-cron.ts +96 -96
- package/src/middleware/secure/__tests__/secure.test.ts +360 -360
- package/src/middleware/secure/csp.ts +193 -193
- package/src/middleware/session.ts +174 -174
- package/src/middleware/timeout.ts +55 -55
- package/src/observability/logger-adapter.ts +36 -36
- package/src/observability/sqlite-store.ts +254 -254
- package/src/openapi/generator.ts +1 -1
- package/src/openapi/openapi.test.ts +43 -43
- package/src/perf/__tests__/user-marks.test.ts +354 -354
- package/src/perf/index.ts +133 -133
- package/src/plugins/__tests__/lifecycle-integration.test.ts +272 -272
- package/src/plugins/__tests__/runner.test.ts +409 -409
- package/src/plugins/define.ts +124 -124
- package/src/plugins/examples/dep-check-plugin.ts +80 -80
- package/src/plugins/examples/prerender-cache-plugin.ts +111 -111
- package/src/plugins/examples/sitemap-plugin.ts +65 -65
- package/src/plugins/runner.ts +361 -361
- package/src/plugins/types.ts +368 -368
- package/src/report/index.ts +1 -1
- package/src/resource/__tests__/generator.test.ts +32 -32
- package/src/resource/ddl/__tests__/diff.test.ts +639 -639
- package/src/resource/ddl/__tests__/emit.test.ts +823 -823
- package/src/resource/ddl/__tests__/snapshot.test.ts +499 -499
- package/src/resource/ddl/emit.ts +559 -559
- package/src/resource/ddl/persistence-types.ts +218 -218
- package/src/resource/ddl/type-map.ts +223 -223
- package/src/resource/ddl/types.ts +232 -232
- package/src/resource/generator-repo.ts +630 -630
- package/src/resource/schema.ts +1 -1
- package/src/router/fs-patterns.test.ts +96 -96
- package/src/router/fs-routes.ts +6 -6
- package/src/routes/index.ts +74 -74
- package/src/routes/metadata-routes.ts +427 -427
- package/src/routes/types.ts +341 -341
- package/src/runtime/__tests__/error-boundary-redaction.test.ts +141 -141
- package/src/runtime/__tests__/hdr-client.test.ts +223 -223
- package/src/runtime/__tests__/http-errors.test.ts +117 -117
- package/src/runtime/__tests__/not-found.test.ts +152 -152
- package/src/runtime/adapter.ts +47 -47
- package/src/runtime/boundary.tsx +252 -252
- package/src/runtime/cache.ts +494 -494
- package/src/runtime/compose.ts +222 -222
- package/src/runtime/fast-refresh-runtime.ts +322 -322
- package/src/runtime/handler.ts +65 -65
- package/src/runtime/handlers.ts +300 -300
- package/src/runtime/http-errors.ts +113 -113
- package/src/runtime/image-handler.ts +1 -1
- package/src/runtime/lifecycle.ts +381 -381
- package/src/runtime/logger.test.ts +345 -345
- package/src/runtime/middleware.ts +264 -264
- package/src/runtime/not-found.ts +93 -93
- package/src/runtime/openapi-endpoint.ts +236 -236
- package/src/runtime/ppr.ts +74 -74
- package/src/runtime/registry.ts +171 -171
- package/src/runtime/router.ts +105 -105
- package/src/runtime/server.ts +67 -67
- package/src/runtime/shims.ts +48 -48
- package/src/runtime/trace.ts +144 -144
- package/src/scheduler/validate.ts +169 -169
- package/src/seo/index.ts +219 -219
- package/src/seo/integration/ssr.ts +306 -306
- package/src/seo/render/basic.ts +435 -435
- package/src/seo/render/index.ts +143 -143
- package/src/seo/render/jsonld.ts +539 -539
- package/src/seo/render/opengraph.ts +197 -197
- package/src/seo/render/robots.ts +116 -116
- package/src/seo/render/sitemap.ts +137 -137
- package/src/seo/render/twitter.ts +127 -127
- package/src/seo/resolve/opengraph.ts +143 -143
- package/src/seo/resolve/robots.ts +73 -73
- package/src/seo/resolve/title.ts +94 -94
- package/src/seo/resolve/twitter.ts +73 -73
- package/src/seo/resolve/url.ts +104 -104
- package/src/seo/routes/index.ts +290 -290
- package/src/seo/types.ts +588 -588
- package/src/slot/validator.ts +39 -39
- package/src/storage/s3/__tests__/s3.test.ts +479 -479
- package/src/storage/s3/index.ts +412 -412
- package/src/testing/__tests__/assertions.test.ts +632 -632
- package/src/testing/__tests__/reporter.test.ts +454 -454
- package/src/testing/assertions.ts +986 -986
- package/src/testing/db.ts +157 -157
- package/src/testing/mocks.ts +203 -203
- package/src/testing/session.ts +190 -190
- package/src/types/branded.ts +56 -56
- package/src/types/index.ts +1 -1
- package/src/utils/safe-io.ts +188 -188
- package/src/utils/string-safe.ts +298 -298
- package/src/watcher/watcher.ts +18 -18
|
@@ -1,519 +1,519 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* #189 — Unit coverage for the reverse import-graph invalidation.
|
|
3
|
-
*
|
|
4
|
-
* Three classes of tests:
|
|
5
|
-
* 1. Pure in-memory graph (BFS closure, cycles, diamond, depth cap).
|
|
6
|
-
* 2. Import scanner (regex over ESM import patterns, dynamic imports,
|
|
7
|
-
* barrel re-exports).
|
|
8
|
-
* 3. Integration — `startDevBundler` dispatches an unknown-file change
|
|
9
|
-
* through the transitive-importer path so a deep leaf edit shows up
|
|
10
|
-
* as an `onSSRChange` call for its ancestor root.
|
|
11
|
-
*
|
|
12
|
-
* Category 1 is pure and runs unconditionally. Category 3 shares the
|
|
13
|
-
* bundler-startup gate with `dev-reliability.test.ts` to avoid the
|
|
14
|
-
* `Bun.build` cross-worker race pattern.
|
|
15
|
-
*/
|
|
16
|
-
import { describe, it, expect, beforeEach, afterEach } from "bun:test";
|
|
17
|
-
import { mkdtempSync, writeFileSync, mkdirSync, rmSync } from "fs";
|
|
18
|
-
import { tmpdir } from "os";
|
|
19
|
-
import path from "path";
|
|
20
|
-
import {
|
|
21
|
-
ReverseImportGraph,
|
|
22
|
-
scanFileImports,
|
|
23
|
-
DEFAULT_MAX_CLOSURE_DEPTH,
|
|
24
|
-
extractImportSpecifiers,
|
|
25
|
-
resolveRelativeImport,
|
|
26
|
-
} from "../reverse-import-graph";
|
|
27
|
-
import { startDevBundler, SSR_CHANGE_WILDCARD } from "../dev";
|
|
28
|
-
import type { RoutesManifest } from "../../spec/schema";
|
|
29
|
-
|
|
30
|
-
// -----------------------------------------------------------------------------
|
|
31
|
-
// Pure graph coverage — no fs, no bundler startup
|
|
32
|
-
// -----------------------------------------------------------------------------
|
|
33
|
-
|
|
34
|
-
describe("ReverseImportGraph — pure graph operations", () => {
|
|
35
|
-
// Reproduce the exact normalization `ReverseImportGraph` applies so
|
|
36
|
-
// tests can assert against the stored key shape (forward slashes,
|
|
37
|
-
// lowercased on win32).
|
|
38
|
-
const normalizedKey = (p: string): string => {
|
|
39
|
-
const abs = path.resolve(p).replace(/\\/g, "/");
|
|
40
|
-
return process.platform === "win32" ? abs.toLowerCase() : abs;
|
|
41
|
-
};
|
|
42
|
-
|
|
43
|
-
it("records a single edge and exposes it via directImporters", () => {
|
|
44
|
-
const g = new ReverseImportGraph();
|
|
45
|
-
const barrel = path.resolve("/abs/barrel.ts");
|
|
46
|
-
const leaf = path.resolve("/abs/leaf.ts");
|
|
47
|
-
g.update(barrel, [leaf]);
|
|
48
|
-
|
|
49
|
-
const direct = g.directImporters(leaf);
|
|
50
|
-
expect(direct.has(normalizedKey(barrel))).toBe(true);
|
|
51
|
-
});
|
|
52
|
-
|
|
53
|
-
it("transitiveImporters walks a diamond without duplicates", () => {
|
|
54
|
-
const g = new ReverseImportGraph();
|
|
55
|
-
const leaf = path.resolve("/abs/leaf.ts");
|
|
56
|
-
const left = path.resolve("/abs/left.ts");
|
|
57
|
-
const right = path.resolve("/abs/right.ts");
|
|
58
|
-
const top = path.resolve("/abs/top.ts");
|
|
59
|
-
|
|
60
|
-
// top -> left -> leaf
|
|
61
|
-
// top -> right -> leaf
|
|
62
|
-
g.update(left, [leaf]);
|
|
63
|
-
g.update(right, [leaf]);
|
|
64
|
-
g.update(top, [left, right]);
|
|
65
|
-
|
|
66
|
-
const closure = g.transitiveImporters(leaf);
|
|
67
|
-
expect(closure.has(normalizedKey(left))).toBe(true);
|
|
68
|
-
expect(closure.has(normalizedKey(right))).toBe(true);
|
|
69
|
-
expect(closure.has(normalizedKey(top))).toBe(true);
|
|
70
|
-
// The target itself is never in its own closure.
|
|
71
|
-
expect(closure.has(normalizedKey(leaf))).toBe(false);
|
|
72
|
-
expect(closure.size).toBe(3);
|
|
73
|
-
});
|
|
74
|
-
|
|
75
|
-
it("transitiveImporters handles simple cycles without looping", () => {
|
|
76
|
-
const g = new ReverseImportGraph();
|
|
77
|
-
const a = path.resolve("/abs/a.ts");
|
|
78
|
-
const b = path.resolve("/abs/b.ts");
|
|
79
|
-
const c = path.resolve("/abs/c.ts");
|
|
80
|
-
|
|
81
|
-
// a -> b -> c -> a (cycle). Ask: who imports c?
|
|
82
|
-
g.update(a, [b]);
|
|
83
|
-
g.update(b, [c]);
|
|
84
|
-
g.update(c, [a]);
|
|
85
|
-
|
|
86
|
-
const closure = g.transitiveImporters(c);
|
|
87
|
-
// Every node except c itself reaches c transitively.
|
|
88
|
-
expect(closure.size).toBe(2);
|
|
89
|
-
});
|
|
90
|
-
|
|
91
|
-
it("honors maxDepth cap — depth 1 returns only direct importers", () => {
|
|
92
|
-
const g = new ReverseImportGraph();
|
|
93
|
-
const leaf = path.resolve("/abs/leaf.ts");
|
|
94
|
-
const lvl1 = path.resolve("/abs/lvl1.ts");
|
|
95
|
-
const lvl2 = path.resolve("/abs/lvl2.ts");
|
|
96
|
-
const lvl3 = path.resolve("/abs/lvl3.ts");
|
|
97
|
-
g.update(lvl1, [leaf]);
|
|
98
|
-
g.update(lvl2, [lvl1]);
|
|
99
|
-
g.update(lvl3, [lvl2]);
|
|
100
|
-
|
|
101
|
-
const direct = g.transitiveImporters(leaf, 1);
|
|
102
|
-
expect(direct.size).toBe(1);
|
|
103
|
-
|
|
104
|
-
const twoHop = g.transitiveImporters(leaf, 2);
|
|
105
|
-
expect(twoHop.size).toBe(2);
|
|
106
|
-
|
|
107
|
-
// Default depth suffices for 3 hops.
|
|
108
|
-
const full = g.transitiveImporters(leaf, DEFAULT_MAX_CLOSURE_DEPTH);
|
|
109
|
-
expect(full.size).toBe(3);
|
|
110
|
-
});
|
|
111
|
-
|
|
112
|
-
it("treats depth 0 / negative as a no-op", () => {
|
|
113
|
-
const g = new ReverseImportGraph();
|
|
114
|
-
const leaf = path.resolve("/abs/leaf.ts");
|
|
115
|
-
const top = path.resolve("/abs/top.ts");
|
|
116
|
-
g.update(top, [leaf]);
|
|
117
|
-
expect(g.transitiveImporters(leaf, 0).size).toBe(0);
|
|
118
|
-
expect(g.transitiveImporters(leaf, -1).size).toBe(0);
|
|
119
|
-
});
|
|
120
|
-
|
|
121
|
-
it("update replaces stale outgoing edges without leaking", () => {
|
|
122
|
-
const g = new ReverseImportGraph();
|
|
123
|
-
const importer = path.resolve("/abs/i.ts");
|
|
124
|
-
const oldDep = path.resolve("/abs/old.ts");
|
|
125
|
-
const newDep = path.resolve("/abs/new.ts");
|
|
126
|
-
|
|
127
|
-
g.update(importer, [oldDep]);
|
|
128
|
-
g.update(importer, [newDep]);
|
|
129
|
-
|
|
130
|
-
// Stale entry must not resurface in the reverse index.
|
|
131
|
-
expect(g.directImporters(oldDep).size).toBe(0);
|
|
132
|
-
expect(g.directImporters(newDep).size).toBe(1);
|
|
133
|
-
});
|
|
134
|
-
|
|
135
|
-
it("remove drops a single importer and its reverse edges", () => {
|
|
136
|
-
const g = new ReverseImportGraph();
|
|
137
|
-
const leaf = path.resolve("/abs/leaf.ts");
|
|
138
|
-
const a = path.resolve("/abs/a.ts");
|
|
139
|
-
const b = path.resolve("/abs/b.ts");
|
|
140
|
-
g.update(a, [leaf]);
|
|
141
|
-
g.update(b, [leaf]);
|
|
142
|
-
|
|
143
|
-
expect(g.directImporters(leaf).size).toBe(2);
|
|
144
|
-
g.remove(a);
|
|
145
|
-
expect(g.directImporters(leaf).size).toBe(1);
|
|
146
|
-
});
|
|
147
|
-
|
|
148
|
-
it("drops a self-import without creating a trivial cycle", () => {
|
|
149
|
-
const g = new ReverseImportGraph();
|
|
150
|
-
const self = path.resolve("/abs/self.ts");
|
|
151
|
-
g.update(self, [self]);
|
|
152
|
-
// Self-edge rejected at insert time → no importer recorded.
|
|
153
|
-
expect(g.directImporters(self).size).toBe(0);
|
|
154
|
-
expect(g.transitiveImporters(self).size).toBe(0);
|
|
155
|
-
});
|
|
156
|
-
|
|
157
|
-
it("clear() drops every tracked edge", () => {
|
|
158
|
-
const g = new ReverseImportGraph();
|
|
159
|
-
g.update(path.resolve("/a.ts"), [path.resolve("/b.ts")]);
|
|
160
|
-
g.update(path.resolve("/c.ts"), [path.resolve("/d.ts")]);
|
|
161
|
-
expect(g.size).toBe(2);
|
|
162
|
-
g.clear();
|
|
163
|
-
expect(g.size).toBe(0);
|
|
164
|
-
});
|
|
165
|
-
});
|
|
166
|
-
|
|
167
|
-
// -----------------------------------------------------------------------------
|
|
168
|
-
// Import scanner coverage
|
|
169
|
-
// -----------------------------------------------------------------------------
|
|
170
|
-
|
|
171
|
-
describe("extractImportSpecifiers — regex scanner", () => {
|
|
172
|
-
it("matches plain `import from` statements", () => {
|
|
173
|
-
const src = `import foo from "./a";\nimport { b } from "./b";\n`;
|
|
174
|
-
const specs = extractImportSpecifiers(src);
|
|
175
|
-
expect(specs).toContain("./a");
|
|
176
|
-
expect(specs).toContain("./b");
|
|
177
|
-
});
|
|
178
|
-
|
|
179
|
-
it("matches side-effect-only imports", () => {
|
|
180
|
-
const src = `import "./side-effects";\n`;
|
|
181
|
-
const specs = extractImportSpecifiers(src);
|
|
182
|
-
expect(specs).toContain("./side-effects");
|
|
183
|
-
});
|
|
184
|
-
|
|
185
|
-
it("matches type-only imports", () => {
|
|
186
|
-
const src = `import type { Foo } from "./types";\n`;
|
|
187
|
-
const specs = extractImportSpecifiers(src);
|
|
188
|
-
expect(specs).toContain("./types");
|
|
189
|
-
});
|
|
190
|
-
|
|
191
|
-
it("matches `export from` re-exports (barrel pattern)", () => {
|
|
192
|
-
const src = `export * from "./en";\nexport { greet } from "./ko";\n`;
|
|
193
|
-
const specs = extractImportSpecifiers(src);
|
|
194
|
-
expect(specs).toContain("./en");
|
|
195
|
-
expect(specs).toContain("./ko");
|
|
196
|
-
});
|
|
197
|
-
|
|
198
|
-
it("matches dynamic import calls with string literals", () => {
|
|
199
|
-
const src = `const m = await import("./lazy");\n`;
|
|
200
|
-
const specs = extractImportSpecifiers(src);
|
|
201
|
-
expect(specs).toContain("./lazy");
|
|
202
|
-
});
|
|
203
|
-
|
|
204
|
-
it("deduplicates repeated specifiers in a single file", () => {
|
|
205
|
-
const src = `import a from "./dup";\nimport b from "./dup";\n`;
|
|
206
|
-
const specs = extractImportSpecifiers(src);
|
|
207
|
-
expect(specs.filter((s) => s === "./dup").length).toBe(1);
|
|
208
|
-
});
|
|
209
|
-
|
|
210
|
-
it("ignores bare specifiers at extraction time (filtered at resolve)", () => {
|
|
211
|
-
// extractImportSpecifiers returns raw specifiers. Filtering of
|
|
212
|
-
// bare packages happens inside resolveRelativeImport.
|
|
213
|
-
const src = `import React from "react";\nimport { Core } from "@mandujs/core";\n`;
|
|
214
|
-
const specs = extractImportSpecifiers(src);
|
|
215
|
-
// We expect them present in raw form; the scanner does not judge
|
|
216
|
-
// first-party vs bare.
|
|
217
|
-
expect(specs).toContain("react");
|
|
218
|
-
});
|
|
219
|
-
});
|
|
220
|
-
|
|
221
|
-
describe("resolveRelativeImport — first-party filter + extension search", () => {
|
|
222
|
-
let tmpRoot: string;
|
|
223
|
-
|
|
224
|
-
beforeEach(() => {
|
|
225
|
-
tmpRoot = mkdtempSync(path.join(tmpdir(), "mandu-resolve-"));
|
|
226
|
-
});
|
|
227
|
-
|
|
228
|
-
afterEach(() => {
|
|
229
|
-
try {
|
|
230
|
-
rmSync(tmpRoot, { recursive: true, force: true });
|
|
231
|
-
} catch {
|
|
232
|
-
// Windows may hold the handle.
|
|
233
|
-
}
|
|
234
|
-
});
|
|
235
|
-
|
|
236
|
-
it("rejects bare module specifiers", () => {
|
|
237
|
-
const from = path.join(tmpRoot, "a.ts");
|
|
238
|
-
writeFileSync(from, "");
|
|
239
|
-
expect(resolveRelativeImport(from, "react")).toBeNull();
|
|
240
|
-
expect(resolveRelativeImport(from, "@mandujs/core")).toBeNull();
|
|
241
|
-
});
|
|
242
|
-
|
|
243
|
-
it("resolves a sibling .ts file", () => {
|
|
244
|
-
const from = path.join(tmpRoot, "a.ts");
|
|
245
|
-
const sib = path.join(tmpRoot, "b.ts");
|
|
246
|
-
writeFileSync(from, "");
|
|
247
|
-
writeFileSync(sib, "");
|
|
248
|
-
const resolved = resolveRelativeImport(from, "./b");
|
|
249
|
-
expect(resolved).not.toBeNull();
|
|
250
|
-
expect(path.resolve(resolved!)).toBe(path.resolve(sib));
|
|
251
|
-
});
|
|
252
|
-
|
|
253
|
-
it("resolves a sibling .tsx file when .ts does not exist", () => {
|
|
254
|
-
const from = path.join(tmpRoot, "a.ts");
|
|
255
|
-
const sib = path.join(tmpRoot, "b.tsx");
|
|
256
|
-
writeFileSync(from, "");
|
|
257
|
-
writeFileSync(sib, "");
|
|
258
|
-
const resolved = resolveRelativeImport(from, "./b");
|
|
259
|
-
expect(resolved).not.toBeNull();
|
|
260
|
-
expect(path.resolve(resolved!)).toBe(path.resolve(sib));
|
|
261
|
-
});
|
|
262
|
-
|
|
263
|
-
it("resolves a barrel directory via index.ts", () => {
|
|
264
|
-
const from = path.join(tmpRoot, "a.ts");
|
|
265
|
-
mkdirSync(path.join(tmpRoot, "pkg"));
|
|
266
|
-
const index = path.join(tmpRoot, "pkg/index.ts");
|
|
267
|
-
writeFileSync(from, "");
|
|
268
|
-
writeFileSync(index, "");
|
|
269
|
-
const resolved = resolveRelativeImport(from, "./pkg");
|
|
270
|
-
expect(resolved).not.toBeNull();
|
|
271
|
-
expect(path.resolve(resolved!)).toBe(path.resolve(index));
|
|
272
|
-
});
|
|
273
|
-
|
|
274
|
-
it("returns null when the target file does not exist", () => {
|
|
275
|
-
const from = path.join(tmpRoot, "a.ts");
|
|
276
|
-
writeFileSync(from, "");
|
|
277
|
-
expect(resolveRelativeImport(from, "./nonexistent")).toBeNull();
|
|
278
|
-
});
|
|
279
|
-
});
|
|
280
|
-
|
|
281
|
-
describe("scanFileImports — end-to-end", () => {
|
|
282
|
-
let tmpRoot: string;
|
|
283
|
-
|
|
284
|
-
beforeEach(() => {
|
|
285
|
-
tmpRoot = mkdtempSync(path.join(tmpdir(), "mandu-scan-"));
|
|
286
|
-
});
|
|
287
|
-
|
|
288
|
-
afterEach(() => {
|
|
289
|
-
try {
|
|
290
|
-
rmSync(tmpRoot, { recursive: true, force: true });
|
|
291
|
-
} catch {
|
|
292
|
-
// Windows may hold the handle.
|
|
293
|
-
}
|
|
294
|
-
});
|
|
295
|
-
|
|
296
|
-
it("returns absolute paths for resolvable first-party imports only", async () => {
|
|
297
|
-
const a = path.join(tmpRoot, "a.ts");
|
|
298
|
-
const b = path.join(tmpRoot, "b.ts");
|
|
299
|
-
writeFileSync(
|
|
300
|
-
a,
|
|
301
|
-
`import React from "react";\nimport { x } from "./b";\nimport "./missing";\n`,
|
|
302
|
-
);
|
|
303
|
-
writeFileSync(b, "export const x = 1;\n");
|
|
304
|
-
|
|
305
|
-
const imports = await scanFileImports(a);
|
|
306
|
-
expect(imports.length).toBe(1);
|
|
307
|
-
expect(path.resolve(imports[0])).toBe(path.resolve(b));
|
|
308
|
-
});
|
|
309
|
-
|
|
310
|
-
it("returns [] for an unreadable / missing file", async () => {
|
|
311
|
-
const missing = path.join(tmpRoot, "does-not-exist.ts");
|
|
312
|
-
const imports = await scanFileImports(missing);
|
|
313
|
-
expect(imports).toEqual([]);
|
|
314
|
-
});
|
|
315
|
-
|
|
316
|
-
it("captures a 3-deep chain (barrel -> module -> leaf)", async () => {
|
|
317
|
-
const leaf = path.join(tmpRoot, "leaf.ts");
|
|
318
|
-
const middle = path.join(tmpRoot, "middle.ts");
|
|
319
|
-
const barrel = path.join(tmpRoot, "barrel.ts");
|
|
320
|
-
writeFileSync(leaf, "export const v = 1;\n");
|
|
321
|
-
writeFileSync(middle, `export { v } from "./leaf";\n`);
|
|
322
|
-
writeFileSync(barrel, `import { v } from "./middle";\nexport { v };\n`);
|
|
323
|
-
|
|
324
|
-
// Each hop should scan to exactly one outgoing edge.
|
|
325
|
-
const barrelImports = await scanFileImports(barrel);
|
|
326
|
-
expect(barrelImports.length).toBe(1);
|
|
327
|
-
expect(path.resolve(barrelImports[0])).toBe(path.resolve(middle));
|
|
328
|
-
|
|
329
|
-
const middleImports = await scanFileImports(middle);
|
|
330
|
-
expect(middleImports.length).toBe(1);
|
|
331
|
-
expect(path.resolve(middleImports[0])).toBe(path.resolve(leaf));
|
|
332
|
-
});
|
|
333
|
-
});
|
|
334
|
-
|
|
335
|
-
// -----------------------------------------------------------------------------
|
|
336
|
-
// Integration — transitive dispatch through startDevBundler
|
|
337
|
-
// -----------------------------------------------------------------------------
|
|
338
|
-
|
|
339
|
-
/**
|
|
340
|
-
* Minimum time (ms) to wait after a writeFile for fs.watch + debounce
|
|
341
|
-
* to flush. Mirrors `dev-reliability.test.ts`'s WATCH_SETTLE_MS so
|
|
342
|
-
* Windows polling latency is tolerated.
|
|
343
|
-
*/
|
|
344
|
-
const WATCH_SETTLE_MS = 400;
|
|
345
|
-
|
|
346
|
-
function sleep(ms: number): Promise<void> {
|
|
347
|
-
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
348
|
-
}
|
|
349
|
-
|
|
350
|
-
function createTempProject(): string {
|
|
351
|
-
const root = mkdtempSync(path.join(tmpdir(), "mandu-189-"));
|
|
352
|
-
mkdirSync(path.join(root, ".mandu"), { recursive: true });
|
|
353
|
-
mkdirSync(path.join(root, ".mandu/client"), { recursive: true });
|
|
354
|
-
writeFileSync(
|
|
355
|
-
path.join(root, ".mandu/manifest.json"),
|
|
356
|
-
JSON.stringify(
|
|
357
|
-
{
|
|
358
|
-
version: 1,
|
|
359
|
-
buildTime: new Date().toISOString(),
|
|
360
|
-
env: "development",
|
|
361
|
-
bundles: {},
|
|
362
|
-
shared: {
|
|
363
|
-
runtime: "/.mandu/client/runtime.js",
|
|
364
|
-
vendor: "/.mandu/client/vendor.js",
|
|
365
|
-
},
|
|
366
|
-
},
|
|
367
|
-
null,
|
|
368
|
-
2,
|
|
369
|
-
),
|
|
370
|
-
);
|
|
371
|
-
mkdirSync(path.join(root, "app"), { recursive: true });
|
|
372
|
-
// Barrel + leaf pattern — the canonical #189 scenario. The leaf lives
|
|
373
|
-
// OUTSIDE any default common-dir so the pre-#189 watcher would not
|
|
374
|
-
// have dispatched it.
|
|
375
|
-
mkdirSync(path.join(root, "app/_utils/translations"), { recursive: true });
|
|
376
|
-
writeFileSync(
|
|
377
|
-
path.join(root, "app/_utils/translations/ko.ts"),
|
|
378
|
-
'export const greeting = "안녕";\n',
|
|
379
|
-
);
|
|
380
|
-
writeFileSync(
|
|
381
|
-
path.join(root, "app/_utils/translations/en.ts"),
|
|
382
|
-
'export const greeting = "Hello";\n',
|
|
383
|
-
);
|
|
384
|
-
writeFileSync(
|
|
385
|
-
path.join(root, "app/_utils/translations/index.ts"),
|
|
386
|
-
`import { greeting as ko } from "./ko";\nimport { greeting as en } from "./en";\nexport const translations = { ko, en };\n`,
|
|
387
|
-
);
|
|
388
|
-
writeFileSync(
|
|
389
|
-
path.join(root, "app/page.tsx"),
|
|
390
|
-
`import { translations } from "./_utils/translations";\nexport default function Page() { return null; }\n`,
|
|
391
|
-
);
|
|
392
|
-
return root;
|
|
393
|
-
}
|
|
394
|
-
|
|
395
|
-
describe.skipIf(process.env.MANDU_SKIP_BUNDLER_TESTS === "1")(
|
|
396
|
-
"#189 — reverse import-graph invalidation (integration)",
|
|
397
|
-
() => {
|
|
398
|
-
let rootDir: string;
|
|
399
|
-
let close: (() => void) | null = null;
|
|
400
|
-
|
|
401
|
-
beforeEach(() => {
|
|
402
|
-
rootDir = createTempProject();
|
|
403
|
-
});
|
|
404
|
-
|
|
405
|
-
afterEach(() => {
|
|
406
|
-
close?.();
|
|
407
|
-
close = null;
|
|
408
|
-
try {
|
|
409
|
-
rmSync(rootDir, { recursive: true, force: true });
|
|
410
|
-
} catch {
|
|
411
|
-
// Windows may hold handles.
|
|
412
|
-
}
|
|
413
|
-
});
|
|
414
|
-
|
|
415
|
-
it("leaf edit outside common-dir still invalidates the SSR root (barrel + static map)", async () => {
|
|
416
|
-
const ssrCalls: string[] = [];
|
|
417
|
-
|
|
418
|
-
const manifest: RoutesManifest = {
|
|
419
|
-
version: 1,
|
|
420
|
-
routes: [
|
|
421
|
-
{
|
|
422
|
-
id: "page",
|
|
423
|
-
kind: "page",
|
|
424
|
-
pattern: "/",
|
|
425
|
-
module: "app/page.tsx",
|
|
426
|
-
componentModule: "app/page.tsx",
|
|
427
|
-
},
|
|
428
|
-
],
|
|
429
|
-
} as unknown as RoutesManifest;
|
|
430
|
-
|
|
431
|
-
const bundler = await startDevBundler({
|
|
432
|
-
rootDir,
|
|
433
|
-
manifest,
|
|
434
|
-
onSSRChange: (filePath) => {
|
|
435
|
-
ssrCalls.push(filePath);
|
|
436
|
-
},
|
|
437
|
-
});
|
|
438
|
-
close = bundler.close;
|
|
439
|
-
|
|
440
|
-
// Give the seedReverseGraph fire-and-forget a chance to finish.
|
|
441
|
-
// The seed is O(|routes| * fs.readFile) which on a 2-route
|
|
442
|
-
// manifest completes in <50 ms on any dev machine.
|
|
443
|
-
await sleep(100);
|
|
444
|
-
|
|
445
|
-
// Deep leaf edit — NOT in any known root set, NOT in a default
|
|
446
|
-
// common dir (`app/` isn't a common dir by default).
|
|
447
|
-
writeFileSync(
|
|
448
|
-
path.join(rootDir, "app/_utils/translations/ko.ts"),
|
|
449
|
-
'export const greeting = "안녕하세요";\n',
|
|
450
|
-
);
|
|
451
|
-
|
|
452
|
-
await sleep(WATCH_SETTLE_MS);
|
|
453
|
-
|
|
454
|
-
// The leaf change should reach onSSRChange via the
|
|
455
|
-
// barrel -> page.tsx transitive chain. The callback is
|
|
456
|
-
// invoked with the normalized absolute path of page.tsx
|
|
457
|
-
// (the SSR root that imports the barrel).
|
|
458
|
-
const normalizedPage =
|
|
459
|
-
process.platform === "win32"
|
|
460
|
-
? path
|
|
461
|
-
.resolve(rootDir, "app/page.tsx")
|
|
462
|
-
.replace(/\\/g, "/")
|
|
463
|
-
.toLowerCase()
|
|
464
|
-
: path.resolve(rootDir, "app/page.tsx").replace(/\\/g, "/");
|
|
465
|
-
|
|
466
|
-
expect(ssrCalls.length).toBeGreaterThanOrEqual(1);
|
|
467
|
-
// Exact path match — defensive so a future refactor that loses
|
|
468
|
-
// the normalization surfaces here, not in production.
|
|
469
|
-
expect(ssrCalls).toContain(normalizedPage);
|
|
470
|
-
});
|
|
471
|
-
|
|
472
|
-
it("emits no transitive dispatch for a genuinely unrelated file", async () => {
|
|
473
|
-
const ssrCalls: string[] = [];
|
|
474
|
-
|
|
475
|
-
const manifest: RoutesManifest = {
|
|
476
|
-
version: 1,
|
|
477
|
-
routes: [
|
|
478
|
-
{
|
|
479
|
-
id: "page",
|
|
480
|
-
kind: "page",
|
|
481
|
-
pattern: "/",
|
|
482
|
-
module: "app/page.tsx",
|
|
483
|
-
componentModule: "app/page.tsx",
|
|
484
|
-
},
|
|
485
|
-
],
|
|
486
|
-
} as unknown as RoutesManifest;
|
|
487
|
-
|
|
488
|
-
const bundler = await startDevBundler({
|
|
489
|
-
rootDir,
|
|
490
|
-
manifest,
|
|
491
|
-
onSSRChange: (filePath) => {
|
|
492
|
-
// Wildcard common-dir fires too — we only care about the
|
|
493
|
-
// specific-path dispatch here.
|
|
494
|
-
if (filePath !== SSR_CHANGE_WILDCARD) ssrCalls.push(filePath);
|
|
495
|
-
},
|
|
496
|
-
});
|
|
497
|
-
close = bundler.close;
|
|
498
|
-
await sleep(100);
|
|
499
|
-
|
|
500
|
-
// Write a brand-new file that nothing imports. Has to live
|
|
501
|
-
// UNDER a watched directory for the watcher to see it at all;
|
|
502
|
-
// `app/` is covered because `page.tsx` itself is there.
|
|
503
|
-
const orphan = path.join(rootDir, "app/orphan.ts");
|
|
504
|
-
writeFileSync(orphan, "export const NOTHING = 1;\n");
|
|
505
|
-
|
|
506
|
-
await sleep(WATCH_SETTLE_MS);
|
|
507
|
-
|
|
508
|
-
// No known importer → legacy silent-drop → zero SSR calls with
|
|
509
|
-
// the orphan's path. (Some platforms may emit spurious
|
|
510
|
-
// wildcard fires for unrelated file-system noise; we already
|
|
511
|
-
// filtered those.)
|
|
512
|
-
const normalizedOrphan =
|
|
513
|
-
process.platform === "win32"
|
|
514
|
-
? orphan.replace(/\\/g, "/").toLowerCase()
|
|
515
|
-
: orphan.replace(/\\/g, "/");
|
|
516
|
-
expect(ssrCalls).not.toContain(normalizedOrphan);
|
|
517
|
-
});
|
|
518
|
-
},
|
|
519
|
-
);
|
|
1
|
+
/**
|
|
2
|
+
* #189 — Unit coverage for the reverse import-graph invalidation.
|
|
3
|
+
*
|
|
4
|
+
* Three classes of tests:
|
|
5
|
+
* 1. Pure in-memory graph (BFS closure, cycles, diamond, depth cap).
|
|
6
|
+
* 2. Import scanner (regex over ESM import patterns, dynamic imports,
|
|
7
|
+
* barrel re-exports).
|
|
8
|
+
* 3. Integration — `startDevBundler` dispatches an unknown-file change
|
|
9
|
+
* through the transitive-importer path so a deep leaf edit shows up
|
|
10
|
+
* as an `onSSRChange` call for its ancestor root.
|
|
11
|
+
*
|
|
12
|
+
* Category 1 is pure and runs unconditionally. Category 3 shares the
|
|
13
|
+
* bundler-startup gate with `dev-reliability.test.ts` to avoid the
|
|
14
|
+
* `Bun.build` cross-worker race pattern.
|
|
15
|
+
*/
|
|
16
|
+
import { describe, it, expect, beforeEach, afterEach } from "bun:test";
|
|
17
|
+
import { mkdtempSync, writeFileSync, mkdirSync, rmSync } from "fs";
|
|
18
|
+
import { tmpdir } from "os";
|
|
19
|
+
import path from "path";
|
|
20
|
+
import {
|
|
21
|
+
ReverseImportGraph,
|
|
22
|
+
scanFileImports,
|
|
23
|
+
DEFAULT_MAX_CLOSURE_DEPTH,
|
|
24
|
+
extractImportSpecifiers,
|
|
25
|
+
resolveRelativeImport,
|
|
26
|
+
} from "../reverse-import-graph";
|
|
27
|
+
import { startDevBundler, SSR_CHANGE_WILDCARD } from "../dev";
|
|
28
|
+
import type { RoutesManifest } from "../../spec/schema";
|
|
29
|
+
|
|
30
|
+
// -----------------------------------------------------------------------------
|
|
31
|
+
// Pure graph coverage — no fs, no bundler startup
|
|
32
|
+
// -----------------------------------------------------------------------------
|
|
33
|
+
|
|
34
|
+
describe("ReverseImportGraph — pure graph operations", () => {
|
|
35
|
+
// Reproduce the exact normalization `ReverseImportGraph` applies so
|
|
36
|
+
// tests can assert against the stored key shape (forward slashes,
|
|
37
|
+
// lowercased on win32).
|
|
38
|
+
const normalizedKey = (p: string): string => {
|
|
39
|
+
const abs = path.resolve(p).replace(/\\/g, "/");
|
|
40
|
+
return process.platform === "win32" ? abs.toLowerCase() : abs;
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
it("records a single edge and exposes it via directImporters", () => {
|
|
44
|
+
const g = new ReverseImportGraph();
|
|
45
|
+
const barrel = path.resolve("/abs/barrel.ts");
|
|
46
|
+
const leaf = path.resolve("/abs/leaf.ts");
|
|
47
|
+
g.update(barrel, [leaf]);
|
|
48
|
+
|
|
49
|
+
const direct = g.directImporters(leaf);
|
|
50
|
+
expect(direct.has(normalizedKey(barrel))).toBe(true);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("transitiveImporters walks a diamond without duplicates", () => {
|
|
54
|
+
const g = new ReverseImportGraph();
|
|
55
|
+
const leaf = path.resolve("/abs/leaf.ts");
|
|
56
|
+
const left = path.resolve("/abs/left.ts");
|
|
57
|
+
const right = path.resolve("/abs/right.ts");
|
|
58
|
+
const top = path.resolve("/abs/top.ts");
|
|
59
|
+
|
|
60
|
+
// top -> left -> leaf
|
|
61
|
+
// top -> right -> leaf
|
|
62
|
+
g.update(left, [leaf]);
|
|
63
|
+
g.update(right, [leaf]);
|
|
64
|
+
g.update(top, [left, right]);
|
|
65
|
+
|
|
66
|
+
const closure = g.transitiveImporters(leaf);
|
|
67
|
+
expect(closure.has(normalizedKey(left))).toBe(true);
|
|
68
|
+
expect(closure.has(normalizedKey(right))).toBe(true);
|
|
69
|
+
expect(closure.has(normalizedKey(top))).toBe(true);
|
|
70
|
+
// The target itself is never in its own closure.
|
|
71
|
+
expect(closure.has(normalizedKey(leaf))).toBe(false);
|
|
72
|
+
expect(closure.size).toBe(3);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it("transitiveImporters handles simple cycles without looping", () => {
|
|
76
|
+
const g = new ReverseImportGraph();
|
|
77
|
+
const a = path.resolve("/abs/a.ts");
|
|
78
|
+
const b = path.resolve("/abs/b.ts");
|
|
79
|
+
const c = path.resolve("/abs/c.ts");
|
|
80
|
+
|
|
81
|
+
// a -> b -> c -> a (cycle). Ask: who imports c?
|
|
82
|
+
g.update(a, [b]);
|
|
83
|
+
g.update(b, [c]);
|
|
84
|
+
g.update(c, [a]);
|
|
85
|
+
|
|
86
|
+
const closure = g.transitiveImporters(c);
|
|
87
|
+
// Every node except c itself reaches c transitively.
|
|
88
|
+
expect(closure.size).toBe(2);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it("honors maxDepth cap — depth 1 returns only direct importers", () => {
|
|
92
|
+
const g = new ReverseImportGraph();
|
|
93
|
+
const leaf = path.resolve("/abs/leaf.ts");
|
|
94
|
+
const lvl1 = path.resolve("/abs/lvl1.ts");
|
|
95
|
+
const lvl2 = path.resolve("/abs/lvl2.ts");
|
|
96
|
+
const lvl3 = path.resolve("/abs/lvl3.ts");
|
|
97
|
+
g.update(lvl1, [leaf]);
|
|
98
|
+
g.update(lvl2, [lvl1]);
|
|
99
|
+
g.update(lvl3, [lvl2]);
|
|
100
|
+
|
|
101
|
+
const direct = g.transitiveImporters(leaf, 1);
|
|
102
|
+
expect(direct.size).toBe(1);
|
|
103
|
+
|
|
104
|
+
const twoHop = g.transitiveImporters(leaf, 2);
|
|
105
|
+
expect(twoHop.size).toBe(2);
|
|
106
|
+
|
|
107
|
+
// Default depth suffices for 3 hops.
|
|
108
|
+
const full = g.transitiveImporters(leaf, DEFAULT_MAX_CLOSURE_DEPTH);
|
|
109
|
+
expect(full.size).toBe(3);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it("treats depth 0 / negative as a no-op", () => {
|
|
113
|
+
const g = new ReverseImportGraph();
|
|
114
|
+
const leaf = path.resolve("/abs/leaf.ts");
|
|
115
|
+
const top = path.resolve("/abs/top.ts");
|
|
116
|
+
g.update(top, [leaf]);
|
|
117
|
+
expect(g.transitiveImporters(leaf, 0).size).toBe(0);
|
|
118
|
+
expect(g.transitiveImporters(leaf, -1).size).toBe(0);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it("update replaces stale outgoing edges without leaking", () => {
|
|
122
|
+
const g = new ReverseImportGraph();
|
|
123
|
+
const importer = path.resolve("/abs/i.ts");
|
|
124
|
+
const oldDep = path.resolve("/abs/old.ts");
|
|
125
|
+
const newDep = path.resolve("/abs/new.ts");
|
|
126
|
+
|
|
127
|
+
g.update(importer, [oldDep]);
|
|
128
|
+
g.update(importer, [newDep]);
|
|
129
|
+
|
|
130
|
+
// Stale entry must not resurface in the reverse index.
|
|
131
|
+
expect(g.directImporters(oldDep).size).toBe(0);
|
|
132
|
+
expect(g.directImporters(newDep).size).toBe(1);
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it("remove drops a single importer and its reverse edges", () => {
|
|
136
|
+
const g = new ReverseImportGraph();
|
|
137
|
+
const leaf = path.resolve("/abs/leaf.ts");
|
|
138
|
+
const a = path.resolve("/abs/a.ts");
|
|
139
|
+
const b = path.resolve("/abs/b.ts");
|
|
140
|
+
g.update(a, [leaf]);
|
|
141
|
+
g.update(b, [leaf]);
|
|
142
|
+
|
|
143
|
+
expect(g.directImporters(leaf).size).toBe(2);
|
|
144
|
+
g.remove(a);
|
|
145
|
+
expect(g.directImporters(leaf).size).toBe(1);
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it("drops a self-import without creating a trivial cycle", () => {
|
|
149
|
+
const g = new ReverseImportGraph();
|
|
150
|
+
const self = path.resolve("/abs/self.ts");
|
|
151
|
+
g.update(self, [self]);
|
|
152
|
+
// Self-edge rejected at insert time → no importer recorded.
|
|
153
|
+
expect(g.directImporters(self).size).toBe(0);
|
|
154
|
+
expect(g.transitiveImporters(self).size).toBe(0);
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it("clear() drops every tracked edge", () => {
|
|
158
|
+
const g = new ReverseImportGraph();
|
|
159
|
+
g.update(path.resolve("/a.ts"), [path.resolve("/b.ts")]);
|
|
160
|
+
g.update(path.resolve("/c.ts"), [path.resolve("/d.ts")]);
|
|
161
|
+
expect(g.size).toBe(2);
|
|
162
|
+
g.clear();
|
|
163
|
+
expect(g.size).toBe(0);
|
|
164
|
+
});
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
// -----------------------------------------------------------------------------
|
|
168
|
+
// Import scanner coverage
|
|
169
|
+
// -----------------------------------------------------------------------------
|
|
170
|
+
|
|
171
|
+
describe("extractImportSpecifiers — regex scanner", () => {
|
|
172
|
+
it("matches plain `import from` statements", () => {
|
|
173
|
+
const src = `import foo from "./a";\nimport { b } from "./b";\n`;
|
|
174
|
+
const specs = extractImportSpecifiers(src);
|
|
175
|
+
expect(specs).toContain("./a");
|
|
176
|
+
expect(specs).toContain("./b");
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
it("matches side-effect-only imports", () => {
|
|
180
|
+
const src = `import "./side-effects";\n`;
|
|
181
|
+
const specs = extractImportSpecifiers(src);
|
|
182
|
+
expect(specs).toContain("./side-effects");
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
it("matches type-only imports", () => {
|
|
186
|
+
const src = `import type { Foo } from "./types";\n`;
|
|
187
|
+
const specs = extractImportSpecifiers(src);
|
|
188
|
+
expect(specs).toContain("./types");
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
it("matches `export from` re-exports (barrel pattern)", () => {
|
|
192
|
+
const src = `export * from "./en";\nexport { greet } from "./ko";\n`;
|
|
193
|
+
const specs = extractImportSpecifiers(src);
|
|
194
|
+
expect(specs).toContain("./en");
|
|
195
|
+
expect(specs).toContain("./ko");
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
it("matches dynamic import calls with string literals", () => {
|
|
199
|
+
const src = `const m = await import("./lazy");\n`;
|
|
200
|
+
const specs = extractImportSpecifiers(src);
|
|
201
|
+
expect(specs).toContain("./lazy");
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
it("deduplicates repeated specifiers in a single file", () => {
|
|
205
|
+
const src = `import a from "./dup";\nimport b from "./dup";\n`;
|
|
206
|
+
const specs = extractImportSpecifiers(src);
|
|
207
|
+
expect(specs.filter((s) => s === "./dup").length).toBe(1);
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
it("ignores bare specifiers at extraction time (filtered at resolve)", () => {
|
|
211
|
+
// extractImportSpecifiers returns raw specifiers. Filtering of
|
|
212
|
+
// bare packages happens inside resolveRelativeImport.
|
|
213
|
+
const src = `import React from "react";\nimport { Core } from "@mandujs/core";\n`;
|
|
214
|
+
const specs = extractImportSpecifiers(src);
|
|
215
|
+
// We expect them present in raw form; the scanner does not judge
|
|
216
|
+
// first-party vs bare.
|
|
217
|
+
expect(specs).toContain("react");
|
|
218
|
+
});
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
describe("resolveRelativeImport — first-party filter + extension search", () => {
|
|
222
|
+
let tmpRoot: string;
|
|
223
|
+
|
|
224
|
+
beforeEach(() => {
|
|
225
|
+
tmpRoot = mkdtempSync(path.join(tmpdir(), "mandu-resolve-"));
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
afterEach(() => {
|
|
229
|
+
try {
|
|
230
|
+
rmSync(tmpRoot, { recursive: true, force: true });
|
|
231
|
+
} catch {
|
|
232
|
+
// Windows may hold the handle.
|
|
233
|
+
}
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
it("rejects bare module specifiers", () => {
|
|
237
|
+
const from = path.join(tmpRoot, "a.ts");
|
|
238
|
+
writeFileSync(from, "");
|
|
239
|
+
expect(resolveRelativeImport(from, "react")).toBeNull();
|
|
240
|
+
expect(resolveRelativeImport(from, "@mandujs/core")).toBeNull();
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
it("resolves a sibling .ts file", () => {
|
|
244
|
+
const from = path.join(tmpRoot, "a.ts");
|
|
245
|
+
const sib = path.join(tmpRoot, "b.ts");
|
|
246
|
+
writeFileSync(from, "");
|
|
247
|
+
writeFileSync(sib, "");
|
|
248
|
+
const resolved = resolveRelativeImport(from, "./b");
|
|
249
|
+
expect(resolved).not.toBeNull();
|
|
250
|
+
expect(path.resolve(resolved!)).toBe(path.resolve(sib));
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
it("resolves a sibling .tsx file when .ts does not exist", () => {
|
|
254
|
+
const from = path.join(tmpRoot, "a.ts");
|
|
255
|
+
const sib = path.join(tmpRoot, "b.tsx");
|
|
256
|
+
writeFileSync(from, "");
|
|
257
|
+
writeFileSync(sib, "");
|
|
258
|
+
const resolved = resolveRelativeImport(from, "./b");
|
|
259
|
+
expect(resolved).not.toBeNull();
|
|
260
|
+
expect(path.resolve(resolved!)).toBe(path.resolve(sib));
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
it("resolves a barrel directory via index.ts", () => {
|
|
264
|
+
const from = path.join(tmpRoot, "a.ts");
|
|
265
|
+
mkdirSync(path.join(tmpRoot, "pkg"));
|
|
266
|
+
const index = path.join(tmpRoot, "pkg/index.ts");
|
|
267
|
+
writeFileSync(from, "");
|
|
268
|
+
writeFileSync(index, "");
|
|
269
|
+
const resolved = resolveRelativeImport(from, "./pkg");
|
|
270
|
+
expect(resolved).not.toBeNull();
|
|
271
|
+
expect(path.resolve(resolved!)).toBe(path.resolve(index));
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
it("returns null when the target file does not exist", () => {
|
|
275
|
+
const from = path.join(tmpRoot, "a.ts");
|
|
276
|
+
writeFileSync(from, "");
|
|
277
|
+
expect(resolveRelativeImport(from, "./nonexistent")).toBeNull();
|
|
278
|
+
});
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
describe("scanFileImports — end-to-end", () => {
|
|
282
|
+
let tmpRoot: string;
|
|
283
|
+
|
|
284
|
+
beforeEach(() => {
|
|
285
|
+
tmpRoot = mkdtempSync(path.join(tmpdir(), "mandu-scan-"));
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
afterEach(() => {
|
|
289
|
+
try {
|
|
290
|
+
rmSync(tmpRoot, { recursive: true, force: true });
|
|
291
|
+
} catch {
|
|
292
|
+
// Windows may hold the handle.
|
|
293
|
+
}
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
it("returns absolute paths for resolvable first-party imports only", async () => {
|
|
297
|
+
const a = path.join(tmpRoot, "a.ts");
|
|
298
|
+
const b = path.join(tmpRoot, "b.ts");
|
|
299
|
+
writeFileSync(
|
|
300
|
+
a,
|
|
301
|
+
`import React from "react";\nimport { x } from "./b";\nimport "./missing";\n`,
|
|
302
|
+
);
|
|
303
|
+
writeFileSync(b, "export const x = 1;\n");
|
|
304
|
+
|
|
305
|
+
const imports = await scanFileImports(a);
|
|
306
|
+
expect(imports.length).toBe(1);
|
|
307
|
+
expect(path.resolve(imports[0])).toBe(path.resolve(b));
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
it("returns [] for an unreadable / missing file", async () => {
|
|
311
|
+
const missing = path.join(tmpRoot, "does-not-exist.ts");
|
|
312
|
+
const imports = await scanFileImports(missing);
|
|
313
|
+
expect(imports).toEqual([]);
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
it("captures a 3-deep chain (barrel -> module -> leaf)", async () => {
|
|
317
|
+
const leaf = path.join(tmpRoot, "leaf.ts");
|
|
318
|
+
const middle = path.join(tmpRoot, "middle.ts");
|
|
319
|
+
const barrel = path.join(tmpRoot, "barrel.ts");
|
|
320
|
+
writeFileSync(leaf, "export const v = 1;\n");
|
|
321
|
+
writeFileSync(middle, `export { v } from "./leaf";\n`);
|
|
322
|
+
writeFileSync(barrel, `import { v } from "./middle";\nexport { v };\n`);
|
|
323
|
+
|
|
324
|
+
// Each hop should scan to exactly one outgoing edge.
|
|
325
|
+
const barrelImports = await scanFileImports(barrel);
|
|
326
|
+
expect(barrelImports.length).toBe(1);
|
|
327
|
+
expect(path.resolve(barrelImports[0])).toBe(path.resolve(middle));
|
|
328
|
+
|
|
329
|
+
const middleImports = await scanFileImports(middle);
|
|
330
|
+
expect(middleImports.length).toBe(1);
|
|
331
|
+
expect(path.resolve(middleImports[0])).toBe(path.resolve(leaf));
|
|
332
|
+
});
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
// -----------------------------------------------------------------------------
|
|
336
|
+
// Integration — transitive dispatch through startDevBundler
|
|
337
|
+
// -----------------------------------------------------------------------------
|
|
338
|
+
|
|
339
|
+
/**
|
|
340
|
+
* Minimum time (ms) to wait after a writeFile for fs.watch + debounce
|
|
341
|
+
* to flush. Mirrors `dev-reliability.test.ts`'s WATCH_SETTLE_MS so
|
|
342
|
+
* Windows polling latency is tolerated.
|
|
343
|
+
*/
|
|
344
|
+
const WATCH_SETTLE_MS = 400;
|
|
345
|
+
|
|
346
|
+
function sleep(ms: number): Promise<void> {
|
|
347
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
function createTempProject(): string {
|
|
351
|
+
const root = mkdtempSync(path.join(tmpdir(), "mandu-189-"));
|
|
352
|
+
mkdirSync(path.join(root, ".mandu"), { recursive: true });
|
|
353
|
+
mkdirSync(path.join(root, ".mandu/client"), { recursive: true });
|
|
354
|
+
writeFileSync(
|
|
355
|
+
path.join(root, ".mandu/manifest.json"),
|
|
356
|
+
JSON.stringify(
|
|
357
|
+
{
|
|
358
|
+
version: 1,
|
|
359
|
+
buildTime: new Date().toISOString(),
|
|
360
|
+
env: "development",
|
|
361
|
+
bundles: {},
|
|
362
|
+
shared: {
|
|
363
|
+
runtime: "/.mandu/client/runtime.js",
|
|
364
|
+
vendor: "/.mandu/client/vendor.js",
|
|
365
|
+
},
|
|
366
|
+
},
|
|
367
|
+
null,
|
|
368
|
+
2,
|
|
369
|
+
),
|
|
370
|
+
);
|
|
371
|
+
mkdirSync(path.join(root, "app"), { recursive: true });
|
|
372
|
+
// Barrel + leaf pattern — the canonical #189 scenario. The leaf lives
|
|
373
|
+
// OUTSIDE any default common-dir so the pre-#189 watcher would not
|
|
374
|
+
// have dispatched it.
|
|
375
|
+
mkdirSync(path.join(root, "app/_utils/translations"), { recursive: true });
|
|
376
|
+
writeFileSync(
|
|
377
|
+
path.join(root, "app/_utils/translations/ko.ts"),
|
|
378
|
+
'export const greeting = "안녕";\n',
|
|
379
|
+
);
|
|
380
|
+
writeFileSync(
|
|
381
|
+
path.join(root, "app/_utils/translations/en.ts"),
|
|
382
|
+
'export const greeting = "Hello";\n',
|
|
383
|
+
);
|
|
384
|
+
writeFileSync(
|
|
385
|
+
path.join(root, "app/_utils/translations/index.ts"),
|
|
386
|
+
`import { greeting as ko } from "./ko";\nimport { greeting as en } from "./en";\nexport const translations = { ko, en };\n`,
|
|
387
|
+
);
|
|
388
|
+
writeFileSync(
|
|
389
|
+
path.join(root, "app/page.tsx"),
|
|
390
|
+
`import { translations } from "./_utils/translations";\nexport default function Page() { return null; }\n`,
|
|
391
|
+
);
|
|
392
|
+
return root;
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
describe.skipIf(process.env.MANDU_SKIP_BUNDLER_TESTS === "1")(
|
|
396
|
+
"#189 — reverse import-graph invalidation (integration)",
|
|
397
|
+
() => {
|
|
398
|
+
let rootDir: string;
|
|
399
|
+
let close: (() => void) | null = null;
|
|
400
|
+
|
|
401
|
+
beforeEach(() => {
|
|
402
|
+
rootDir = createTempProject();
|
|
403
|
+
});
|
|
404
|
+
|
|
405
|
+
afterEach(() => {
|
|
406
|
+
close?.();
|
|
407
|
+
close = null;
|
|
408
|
+
try {
|
|
409
|
+
rmSync(rootDir, { recursive: true, force: true });
|
|
410
|
+
} catch {
|
|
411
|
+
// Windows may hold handles.
|
|
412
|
+
}
|
|
413
|
+
});
|
|
414
|
+
|
|
415
|
+
it("leaf edit outside common-dir still invalidates the SSR root (barrel + static map)", async () => {
|
|
416
|
+
const ssrCalls: string[] = [];
|
|
417
|
+
|
|
418
|
+
const manifest: RoutesManifest = {
|
|
419
|
+
version: 1,
|
|
420
|
+
routes: [
|
|
421
|
+
{
|
|
422
|
+
id: "page",
|
|
423
|
+
kind: "page",
|
|
424
|
+
pattern: "/",
|
|
425
|
+
module: "app/page.tsx",
|
|
426
|
+
componentModule: "app/page.tsx",
|
|
427
|
+
},
|
|
428
|
+
],
|
|
429
|
+
} as unknown as RoutesManifest;
|
|
430
|
+
|
|
431
|
+
const bundler = await startDevBundler({
|
|
432
|
+
rootDir,
|
|
433
|
+
manifest,
|
|
434
|
+
onSSRChange: (filePath) => {
|
|
435
|
+
ssrCalls.push(filePath);
|
|
436
|
+
},
|
|
437
|
+
});
|
|
438
|
+
close = bundler.close;
|
|
439
|
+
|
|
440
|
+
// Give the seedReverseGraph fire-and-forget a chance to finish.
|
|
441
|
+
// The seed is O(|routes| * fs.readFile) which on a 2-route
|
|
442
|
+
// manifest completes in <50 ms on any dev machine.
|
|
443
|
+
await sleep(100);
|
|
444
|
+
|
|
445
|
+
// Deep leaf edit — NOT in any known root set, NOT in a default
|
|
446
|
+
// common dir (`app/` isn't a common dir by default).
|
|
447
|
+
writeFileSync(
|
|
448
|
+
path.join(rootDir, "app/_utils/translations/ko.ts"),
|
|
449
|
+
'export const greeting = "안녕하세요";\n',
|
|
450
|
+
);
|
|
451
|
+
|
|
452
|
+
await sleep(WATCH_SETTLE_MS);
|
|
453
|
+
|
|
454
|
+
// The leaf change should reach onSSRChange via the
|
|
455
|
+
// barrel -> page.tsx transitive chain. The callback is
|
|
456
|
+
// invoked with the normalized absolute path of page.tsx
|
|
457
|
+
// (the SSR root that imports the barrel).
|
|
458
|
+
const normalizedPage =
|
|
459
|
+
process.platform === "win32"
|
|
460
|
+
? path
|
|
461
|
+
.resolve(rootDir, "app/page.tsx")
|
|
462
|
+
.replace(/\\/g, "/")
|
|
463
|
+
.toLowerCase()
|
|
464
|
+
: path.resolve(rootDir, "app/page.tsx").replace(/\\/g, "/");
|
|
465
|
+
|
|
466
|
+
expect(ssrCalls.length).toBeGreaterThanOrEqual(1);
|
|
467
|
+
// Exact path match — defensive so a future refactor that loses
|
|
468
|
+
// the normalization surfaces here, not in production.
|
|
469
|
+
expect(ssrCalls).toContain(normalizedPage);
|
|
470
|
+
});
|
|
471
|
+
|
|
472
|
+
it("emits no transitive dispatch for a genuinely unrelated file", async () => {
|
|
473
|
+
const ssrCalls: string[] = [];
|
|
474
|
+
|
|
475
|
+
const manifest: RoutesManifest = {
|
|
476
|
+
version: 1,
|
|
477
|
+
routes: [
|
|
478
|
+
{
|
|
479
|
+
id: "page",
|
|
480
|
+
kind: "page",
|
|
481
|
+
pattern: "/",
|
|
482
|
+
module: "app/page.tsx",
|
|
483
|
+
componentModule: "app/page.tsx",
|
|
484
|
+
},
|
|
485
|
+
],
|
|
486
|
+
} as unknown as RoutesManifest;
|
|
487
|
+
|
|
488
|
+
const bundler = await startDevBundler({
|
|
489
|
+
rootDir,
|
|
490
|
+
manifest,
|
|
491
|
+
onSSRChange: (filePath) => {
|
|
492
|
+
// Wildcard common-dir fires too — we only care about the
|
|
493
|
+
// specific-path dispatch here.
|
|
494
|
+
if (filePath !== SSR_CHANGE_WILDCARD) ssrCalls.push(filePath);
|
|
495
|
+
},
|
|
496
|
+
});
|
|
497
|
+
close = bundler.close;
|
|
498
|
+
await sleep(100);
|
|
499
|
+
|
|
500
|
+
// Write a brand-new file that nothing imports. Has to live
|
|
501
|
+
// UNDER a watched directory for the watcher to see it at all;
|
|
502
|
+
// `app/` is covered because `page.tsx` itself is there.
|
|
503
|
+
const orphan = path.join(rootDir, "app/orphan.ts");
|
|
504
|
+
writeFileSync(orphan, "export const NOTHING = 1;\n");
|
|
505
|
+
|
|
506
|
+
await sleep(WATCH_SETTLE_MS);
|
|
507
|
+
|
|
508
|
+
// No known importer → legacy silent-drop → zero SSR calls with
|
|
509
|
+
// the orphan's path. (Some platforms may emit spurious
|
|
510
|
+
// wildcard fires for unrelated file-system noise; we already
|
|
511
|
+
// filtered those.)
|
|
512
|
+
const normalizedOrphan =
|
|
513
|
+
process.platform === "win32"
|
|
514
|
+
? orphan.replace(/\\/g, "/").toLowerCase()
|
|
515
|
+
: orphan.replace(/\\/g, "/");
|
|
516
|
+
expect(ssrCalls).not.toContain(normalizedOrphan);
|
|
517
|
+
});
|
|
518
|
+
},
|
|
519
|
+
);
|