@akanjs/devkit 3.0.1 → 3.0.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/CHANGELOG.md +15 -2
- package/akanApp/akanApp.host.test.ts +62 -0
- package/akanApp/akanApp.host.ts +13 -2
- package/akanApp/devHostPolicy.test.ts +14 -0
- package/akanApp/devHostPolicy.ts +11 -0
- package/akanConfig/akanConfig.test.ts +4 -1
- package/akanConfig/akanConfig.ts +3 -1
- package/akanContext.ts +18 -1
- package/biome.base.json +8 -1
- package/commandDecorators/command.ts +3 -2
- package/commandDecorators/commandDecorators.test.ts +7 -0
- package/createTunnel.test.ts +51 -0
- package/createTunnel.ts +32 -14
- package/csrE2e/csrPageStack.e2e.test.ts +12 -3
- package/executors.test.ts +329 -4
- package/executors.ts +49 -47
- package/frontendBuild/csrArtifactBuilder.test.ts +34 -0
- package/frontendBuild/csrArtifactBuilder.ts +14 -8
- package/frontendBuild/csrDevModuleCompiler.ts +0 -1
- package/frontendBuild/csrDevResolver.ts +0 -2
- package/frontendBuild/devGeneratedIndexSync.test.ts +13 -1
- package/frontendBuild/devGeneratedIndexSync.ts +21 -4
- package/frontendBuild/frontendBuild.test.ts +1 -1
- package/jsonFormatter.test.ts +64 -0
- package/jsonFormatter.ts +78 -0
- package/lint/__fixtures__/README.md +12 -3
- package/lint/__fixtures__/no-double-brace-placeholder/bad.ts +22 -0
- package/lint/__fixtures__/no-double-brace-placeholder/fixture.json +1 -0
- package/lint/__fixtures__/no-double-brace-placeholder/good.ts +15 -0
- package/lint/__fixtures__/no-inline-color/bad.tsx +17 -0
- package/lint/__fixtures__/no-inline-color/good.tsx +13 -0
- package/lint/__fixtures__/no-throw-raw-error/bad.tsx +8 -0
- package/lint/__fixtures__/no-throw-raw-error/good.tsx +8 -1
- package/lint/__fixtures__/no-unguarded-endpoint/bad.ts +20 -0
- package/lint/__fixtures__/no-unguarded-endpoint/fixture.json +1 -0
- package/lint/__fixtures__/no-unguarded-endpoint/good.ts +28 -0
- package/lint/__fixtures__/no-web-only-api-outside-webkit/bad.tsx +7 -7
- package/lint/gritRules.test.ts +23 -15
- package/lint/no-double-brace-placeholder.grit +15 -0
- package/lint/no-inline-color.grit +21 -5
- package/lint/no-throw-raw-error.grit +18 -33
- package/lint/no-unguarded-endpoint.grit +73 -0
- package/manifestDependencies.ts +65 -0
- package/mobile/desktopBin.test.ts +1 -1
- package/mobile/desktopServerStage.test.ts +7 -4
- package/mobile/nativeApp.test.ts +57 -8
- package/mobile/nativeApp.ts +27 -9
- package/mobile/nativeConfig.test.ts +26 -4
- package/mobile/nativeConfig.ts +19 -1
- package/package.json +2 -2
- package/platformTest/LinuxDockerTarget.ts +1 -1
- package/platformTest/PlatformTestTarget.ts +3 -1
- package/platformTest/WindowsSshTarget.ts +1 -1
- package/qualityScanner.test.ts +51 -0
- package/qualityScanner.ts +22 -1
- package/scanInfo.ts +14 -2
- package/testHelpers.ts +17 -0
- package/transforms/transforms.test.ts +13 -0
- package/tsconfig.json +4 -15
- package/workspaceLayout.test.ts +78 -1
- package/workspaceLayout.ts +19 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,18 @@
|
|
|
1
1
|
# @akanjs/devkit
|
|
2
2
|
|
|
3
|
+
## 3.0.1
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- 4871ae5: New lint rule `no-static-in-object-light-model` for `*.constant.ts`: a `static` on an `XObject` or `LightX` class is an error. The full model `via(XObject, LightX, …)` mixes in their prototypes only, so such a static never reaches `X`; declare it on `XInput` (or a scalar), `X`, or `XInsight` instead.
|
|
8
|
+
|
|
9
|
+
### Patch Changes
|
|
10
|
+
|
|
11
|
+
- Updated dependencies [4871ae5]
|
|
12
|
+
- Updated dependencies [4871ae5]
|
|
13
|
+
- Updated dependencies [4871ae5]
|
|
14
|
+
- akanjs@3.1.0
|
|
15
|
+
|
|
3
16
|
## 3.0.0
|
|
4
17
|
|
|
5
18
|
### Minor Changes
|
|
@@ -542,11 +555,11 @@
|
|
|
542
555
|
|
|
543
556
|
**Lint config now extends a config shipped in the package.** `@akanjs/devkit/biome.base.json` carries the
|
|
544
557
|
formatter, the rule set, and every grit plugin registration; a workspace `biome.json` is `extends` plus its own
|
|
545
|
-
`files.includes`. Biome resolves the specifier through
|
|
558
|
+
`files.includes`. Biome resolves the specifier through node*modules (it does not consult the package `exports`
|
|
546
559
|
map), and `plugins` paths inside an extended config resolve from the entry config's directory, so the
|
|
547
560
|
`./node_modules/@akanjs/devkit/lint/*.grit` form the template already used is correct. Rule changes now reach a
|
|
548
561
|
workspace on `bun update` with no command to run. Two merge behaviours decided the split: `overrides` concatenate,
|
|
549
|
-
so the generated-file opt-out moved there from `files.includes`, which
|
|
562
|
+
so the generated-file opt-out moved there from `files.includes`, which \_replaces* and would silently drop the base
|
|
550
563
|
list the moment a workspace added one exclusion of its own. Because Biome moves rules between groups across minors
|
|
551
564
|
(`noUnnecessaryConditions` is `nursery` at 2.4 and `suspicious` at 2.5, and the old position is a hard error), the
|
|
552
565
|
base config and the Biome version travel together: `biomeBase.ts` pins the version `createWorkspace` installs
|
|
@@ -19,6 +19,68 @@ describe("readProcessRssBytes", () => {
|
|
|
19
19
|
});
|
|
20
20
|
});
|
|
21
21
|
|
|
22
|
+
describe("Redis tunnels during dev startup", () => {
|
|
23
|
+
isolateEnv({ AKAN_PUBLIC_REPO_NAME: "repo", AKAN_PUBLIC_SERVE_DOMAIN: "localhost", AKAN_PUBLIC_ENV: "testing" });
|
|
24
|
+
const track = tempRoots();
|
|
25
|
+
|
|
26
|
+
test.each(["single", "multiple", "cluster", undefined, "local"] as const)(
|
|
27
|
+
"%s mode preserves its Redis startup requirement",
|
|
28
|
+
async (mode) => {
|
|
29
|
+
const { root, app } = track(await createTempApp("demo"));
|
|
30
|
+
await writeText(
|
|
31
|
+
path.join(root, "apps/demo/main.ts"),
|
|
32
|
+
'process.send?.({ type: "backend-ready", pid: process.pid });\nsetInterval(() => undefined, 1_000);\n',
|
|
33
|
+
);
|
|
34
|
+
const listener = Bun.listen({ hostname: "127.0.0.1", port: 0, socket: { data() {} } });
|
|
35
|
+
const port = listener.port;
|
|
36
|
+
listener.stop(true);
|
|
37
|
+
process.env.SSH_TUNNEL_PORT = String(port);
|
|
38
|
+
if (mode === "local") process.env.AKAN_PUBLIC_ENV = "local";
|
|
39
|
+
const create = IncrementalBuilderHost.create;
|
|
40
|
+
IncrementalBuilderHost.create = async () =>
|
|
41
|
+
({
|
|
42
|
+
status: "ready",
|
|
43
|
+
patcherOff: false,
|
|
44
|
+
start: ({ onReady }: { onReady?: () => void }) => onReady?.(),
|
|
45
|
+
send: () => true,
|
|
46
|
+
stop: () => undefined,
|
|
47
|
+
}) as unknown as IncrementalBuilderHost;
|
|
48
|
+
const ready = Promise.withResolvers<void>();
|
|
49
|
+
const env = { ...process.env } as Record<string, string>;
|
|
50
|
+
delete env.AKAN_DATABASE_MODE;
|
|
51
|
+
if (mode !== undefined && mode !== "local") env.AKAN_DATABASE_MODE = mode;
|
|
52
|
+
const host = new AkanAppHost(app, {
|
|
53
|
+
env,
|
|
54
|
+
onDevEvent: (event) => {
|
|
55
|
+
if ("state" in event && event.state === "ready") ready.resolve();
|
|
56
|
+
},
|
|
57
|
+
});
|
|
58
|
+
let deadline: ReturnType<typeof setTimeout> | undefined;
|
|
59
|
+
try {
|
|
60
|
+
if (mode === "single" || mode === "local") {
|
|
61
|
+
await host.start();
|
|
62
|
+
await Promise.race([
|
|
63
|
+
ready.promise,
|
|
64
|
+
new Promise<never>((_, reject) => {
|
|
65
|
+
deadline = setTimeout(() => reject(new Error("backend-ready IPC timed out")), 5_000);
|
|
66
|
+
}),
|
|
67
|
+
]);
|
|
68
|
+
expect(host.env.REDIS_HOST).toBe(mode === "local" ? "localhost" : "redis-tunnel-unavailable.invalid");
|
|
69
|
+
if (mode === "single") expect(host.env.REDIS_HOST).not.toBe("localhost");
|
|
70
|
+
} else {
|
|
71
|
+
await expect(host.start()).rejects.toThrow(
|
|
72
|
+
new RegExp(`redis.*demo-testing\\.localhost:${port}.*ECONNREFUSED`),
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
} finally {
|
|
76
|
+
clearTimeout(deadline);
|
|
77
|
+
await host.stop();
|
|
78
|
+
IncrementalBuilderHost.create = create;
|
|
79
|
+
}
|
|
80
|
+
},
|
|
81
|
+
);
|
|
82
|
+
});
|
|
83
|
+
|
|
22
84
|
describe("a gateway whose replica crash-loops", () => {
|
|
23
85
|
isolateEnv({ AKAN_PUBLIC_REPO_NAME: "repo", AKAN_PUBLIC_SERVE_DOMAIN: "localhost", AKAN_PUBLIC_ENV: "local" });
|
|
24
86
|
const track = tempRoots();
|
package/akanApp/akanApp.host.ts
CHANGED
|
@@ -32,6 +32,7 @@ import {
|
|
|
32
32
|
mergeBackendRestartReasons,
|
|
33
33
|
mergeInvalidateMessages,
|
|
34
34
|
normalizeBackendReportedGeneration,
|
|
35
|
+
redisTunnelPolicy,
|
|
35
36
|
resolveIdleSuspendMs,
|
|
36
37
|
type SourceFingerprints,
|
|
37
38
|
shouldAbandonBackendRecovery,
|
|
@@ -46,6 +47,7 @@ import {
|
|
|
46
47
|
shouldRestartBuilderByDevPlan,
|
|
47
48
|
shouldRestartDevHostByDevPlan,
|
|
48
49
|
shouldWarnBuilderRssCeilingTight,
|
|
50
|
+
UNAVAILABLE_REDIS_HOST,
|
|
49
51
|
} from "./devHostPolicy";
|
|
50
52
|
|
|
51
53
|
const backendMsgTypeSet = new Set<BuilderMessage["type"]>(["build-route", "build-csr"]);
|
|
@@ -203,8 +205,17 @@ export class AkanAppHost {
|
|
|
203
205
|
|
|
204
206
|
async #prepareDatabase(type: "redis") {
|
|
205
207
|
const environment = WorkspaceExecutor.getBaseDevEnv().env;
|
|
206
|
-
|
|
207
|
-
|
|
208
|
+
const policy = redisTunnelPolicy({ environment, databaseMode: this.env.AKAN_DATABASE_MODE });
|
|
209
|
+
if (policy === "none") return "localhost";
|
|
210
|
+
if (policy === "required") return await createTunnel(type, { app: this.app, environment });
|
|
211
|
+
try {
|
|
212
|
+
return await createTunnel(type, { app: this.app, environment });
|
|
213
|
+
} catch (error) {
|
|
214
|
+
this.logger.warn(
|
|
215
|
+
`${error instanceof Error ? error.message : String(error)} - continuing without Redis: the single database mode does not use it unless an app adaptor does`,
|
|
216
|
+
);
|
|
217
|
+
return UNAVAILABLE_REDIS_HOST;
|
|
218
|
+
}
|
|
208
219
|
}
|
|
209
220
|
#startBackend(startStatus: { generation?: number; files: string[] } | null = null) {
|
|
210
221
|
if (this.#stopping) return;
|
|
@@ -16,6 +16,7 @@ import {
|
|
|
16
16
|
mergeBackendRestartReasons,
|
|
17
17
|
mergeInvalidateMessages,
|
|
18
18
|
normalizeBackendReportedGeneration,
|
|
19
|
+
redisTunnelPolicy,
|
|
19
20
|
resolveIdleSuspendMs,
|
|
20
21
|
shouldAbandonBackendRecovery,
|
|
21
22
|
shouldHoldForReturningBuilder,
|
|
@@ -31,6 +32,19 @@ import {
|
|
|
31
32
|
shouldWarnBuilderRssCeilingTight,
|
|
32
33
|
} from "./devHostPolicy";
|
|
33
34
|
|
|
35
|
+
describe("redisTunnelPolicy", () => {
|
|
36
|
+
test.each([
|
|
37
|
+
["local", "single", "none"],
|
|
38
|
+
["local", "multiple", "none"],
|
|
39
|
+
["testing", "single", "optional"],
|
|
40
|
+
["testing", "multiple", "required"],
|
|
41
|
+
["testing", "cluster", "required"],
|
|
42
|
+
["testing", undefined, "required"],
|
|
43
|
+
] as const)("%s with %s Redis is %s", (environment, databaseMode, expected) => {
|
|
44
|
+
expect(redisTunnelPolicy({ environment, databaseMode })).toBe(expected);
|
|
45
|
+
});
|
|
46
|
+
});
|
|
47
|
+
|
|
34
48
|
const status = (phase: BuildPhase, generation: number, ok: boolean): DevBuildStatus => ({
|
|
35
49
|
generation,
|
|
36
50
|
phase,
|
package/akanApp/devHostPolicy.ts
CHANGED
|
@@ -9,6 +9,17 @@ import type {
|
|
|
9
9
|
} from "akanjs/server";
|
|
10
10
|
import type { IncrementalBuilderStatus } from "../incrementalBuilder";
|
|
11
11
|
|
|
12
|
+
export const UNAVAILABLE_REDIS_HOST = "redis-tunnel-unavailable.invalid";
|
|
13
|
+
|
|
14
|
+
export const redisTunnelPolicy = ({
|
|
15
|
+
environment,
|
|
16
|
+
databaseMode,
|
|
17
|
+
}: {
|
|
18
|
+
environment: string;
|
|
19
|
+
databaseMode?: string;
|
|
20
|
+
}): "none" | "optional" | "required" =>
|
|
21
|
+
environment === "local" ? "none" : databaseMode === "single" ? "optional" : "required";
|
|
22
|
+
|
|
12
23
|
const BACKEND_RECOVERY_MAX_ATTEMPTS = 5;
|
|
13
24
|
|
|
14
25
|
// A page load asks for several routes, but a builder that never returns must not grow the hold queue unbounded.
|
|
@@ -678,6 +678,9 @@ describe("AkanAppConfig", () => {
|
|
|
678
678
|
expect(make({ native: { server: { url: "http://x" } } })).toThrow(
|
|
679
679
|
"native.server in apps/portal/akan.config.ts is not a native setting",
|
|
680
680
|
);
|
|
681
|
+
expect(make({ native: { desktop: { dmg: { backgroundImage: "bg.png" } } } })).toThrow(
|
|
682
|
+
"native.desktop.dmg.backgroundImage in apps/portal/akan.config.ts is not a native setting",
|
|
683
|
+
);
|
|
681
684
|
});
|
|
682
685
|
|
|
683
686
|
test("derives a repo-scoped default appId and records an explicit native section", () => {
|
|
@@ -693,7 +696,7 @@ describe("AkanAppConfig", () => {
|
|
|
693
696
|
|
|
694
697
|
describe("deriveDefaultAppId", () => {
|
|
695
698
|
test("sanitizes org/app names into a valid reverse-DNS bundle id", () => {
|
|
696
|
-
expect(deriveDefaultAppId("
|
|
699
|
+
expect(deriveDefaultAppId("my-org", "myapp")).toBe("com.myorg.myapp");
|
|
697
700
|
expect(deriveDefaultAppId("Acme Corp", "Store")).toBe("com.acmecorp.store");
|
|
698
701
|
// Empty org and digit-leading segments stay valid package identifiers.
|
|
699
702
|
expect(deriveDefaultAppId("", "app")).toBe("com.app.app");
|
package/akanConfig/akanConfig.ts
CHANGED
|
@@ -401,8 +401,9 @@ export class AkanAppConfig implements AppConfigResult {
|
|
|
401
401
|
"activity",
|
|
402
402
|
"files",
|
|
403
403
|
],
|
|
404
|
-
desktop: ["indexPath", "server", "recovery", "window", "screenCapture", "entitlements"],
|
|
404
|
+
desktop: ["indexPath", "server", "recovery", "window", "screenCapture", "entitlements", "dmg"],
|
|
405
405
|
desktopServer: ["omit"],
|
|
406
|
+
dmg: ["background", "background2x", "window", "iconSize", "textSize", "app", "applications"],
|
|
406
407
|
deepLinks: ["schemes", "domains"],
|
|
407
408
|
} as const;
|
|
408
409
|
//* Settings that moved when `mobile` became `native` are named with where they went, instead of "unknown".
|
|
@@ -440,6 +441,7 @@ export class AkanAppConfig implements AppConfigResult {
|
|
|
440
441
|
`${where}.desktop.server`,
|
|
441
442
|
"desktopServer",
|
|
442
443
|
);
|
|
444
|
+
check(settings.desktop?.dmg, AkanAppConfig.#nativeKeys.dmg, `${where}.desktop.dmg`, "dmg");
|
|
443
445
|
check(settings.deepLinks, AkanAppConfig.#nativeKeys.deepLinks, `${where}.deepLinks`, "deepLinks");
|
|
444
446
|
}
|
|
445
447
|
static readonly #androidFilePattern = /^(?:res\/[^/]+\/[^/]+|assets\/.+)$/;
|
package/akanContext.ts
CHANGED
|
@@ -16,7 +16,14 @@ import {
|
|
|
16
16
|
workflowRunArtifactPath,
|
|
17
17
|
workflowSyncDir,
|
|
18
18
|
} from "./workflow";
|
|
19
|
-
import {
|
|
19
|
+
import {
|
|
20
|
+
barrelFacetDirs,
|
|
21
|
+
facetIndexShadowOf,
|
|
22
|
+
isScannedRootEntry,
|
|
23
|
+
rootAllowedDirs,
|
|
24
|
+
rootAllowedFiles,
|
|
25
|
+
rootEntryHintOf,
|
|
26
|
+
} from "./workspaceLayout";
|
|
20
27
|
|
|
21
28
|
export type AkanContextFormat = "json" | "markdown";
|
|
22
29
|
export type AkanModuleKind = "domain" | "service" | "scalar";
|
|
@@ -676,6 +683,16 @@ export class AkanContextAnalyzer {
|
|
|
676
683
|
),
|
|
677
684
|
);
|
|
678
685
|
}
|
|
686
|
+
for (const facet of barrelFacetDirs)
|
|
687
|
+
for (const entry of await safeReadDir(path.join(sysPath, facet))) {
|
|
688
|
+
const shadow = entry.isDirectory() ? null : facetIndexShadowOf(`${facet}/${entry.name}`);
|
|
689
|
+
if (!shadow) continue;
|
|
690
|
+
const entryPath = `${sys.path}/${facet}/${entry.name}`;
|
|
691
|
+
report(
|
|
692
|
+
{ severity: "error", code: "facet-index-shadowed", path: entryPath, message: `${entryPath} ${shadow}` },
|
|
693
|
+
repairAction("generated", `akan sync ${sys.name}`, `Delete ${entryPath} by hand, then sync.`, false),
|
|
694
|
+
);
|
|
695
|
+
}
|
|
679
696
|
}
|
|
680
697
|
|
|
681
698
|
const agentDrift = await AkanContextAnalyzer.#agentGuideDrift(workspace);
|
package/biome.base.json
CHANGED
|
@@ -186,7 +186,14 @@
|
|
|
186
186
|
},
|
|
187
187
|
{
|
|
188
188
|
"includes": ["{apps,libs}/**/*.signal.ts"],
|
|
189
|
-
"plugins": [
|
|
189
|
+
"plugins": [
|
|
190
|
+
"./node_modules/@akanjs/devkit/lint/no-redeclare-predefined-endpoint.grit",
|
|
191
|
+
"./node_modules/@akanjs/devkit/lint/no-unguarded-endpoint.grit"
|
|
192
|
+
]
|
|
193
|
+
},
|
|
194
|
+
{
|
|
195
|
+
"includes": ["{apps,libs}/**/*.dictionary.ts"],
|
|
196
|
+
"plugins": ["./node_modules/@akanjs/devkit/lint/no-double-brace-placeholder.grit"]
|
|
190
197
|
},
|
|
191
198
|
{
|
|
192
199
|
"includes": ["{apps,libs}/**/*.Util.tsx", "{apps,libs}/**/*.Zone.tsx"],
|
|
@@ -128,6 +128,8 @@ export const getOptionValue = async (argMeta: ArgMeta, opt: Record<string, unkno
|
|
|
128
128
|
assertEnumChoice(argMeta, opt[argMeta.name]);
|
|
129
129
|
return convertArgValue(opt[argMeta.name] as string, type ?? "string");
|
|
130
130
|
} else if (defaultValue !== undefined) return defaultValue;
|
|
131
|
+
//? Before the choices: a nullable option left out means something (`--arch`: this computer's), so it is not asked.
|
|
132
|
+
else if (nullable) return null;
|
|
131
133
|
|
|
132
134
|
if (enumChoices) {
|
|
133
135
|
const choices = normalizeEnumChoices(
|
|
@@ -135,8 +137,7 @@ export const getOptionValue = async (argMeta: ArgMeta, opt: Record<string, unkno
|
|
|
135
137
|
);
|
|
136
138
|
if (choices.length === 1) return choices[0]?.value;
|
|
137
139
|
return await select({ message: ask ?? desc ?? `Select the ${name} value`, choices });
|
|
138
|
-
} else if (
|
|
139
|
-
else if (type === "boolean") {
|
|
140
|
+
} else if (type === "boolean") {
|
|
140
141
|
const message = ask ?? desc ?? `Do you want to set ${name}? ${desc ? ` (${desc})` : ""}: `;
|
|
141
142
|
return await confirm({ message });
|
|
142
143
|
} else return convertArgValue(await input({ message: inputMessageOf(argMeta) }), type ?? "string");
|
|
@@ -277,6 +277,13 @@ describe("enum arg choices", () => {
|
|
|
277
277
|
).toBe("anything");
|
|
278
278
|
});
|
|
279
279
|
|
|
280
|
+
test("a nullable option left out is null, not a prompt for one of its choices", async () => {
|
|
281
|
+
const arch = { ...optionMeta(["arm64", "x64"]), name: "arch" };
|
|
282
|
+
expect(await getOptionValue({ ...arch, argsOption: { ...arch.argsOption, nullable: true } }, {}, context)).toBe(
|
|
283
|
+
null,
|
|
284
|
+
);
|
|
285
|
+
});
|
|
286
|
+
|
|
280
287
|
test("an option with no enum is unaffected", async () => {
|
|
281
288
|
expect(await getOptionValue(optionMeta(undefined), { format: "whatever" }, context)).toBe("whatever");
|
|
282
289
|
});
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { describe, expect, spyOn, test } from "bun:test";
|
|
2
|
+
import { Client } from "ssh2";
|
|
3
|
+
import { createTunnel } from "./createTunnel";
|
|
4
|
+
import { createTempApp, isolateEnv, tempRoots } from "./testHelpers";
|
|
5
|
+
|
|
6
|
+
describe("createTunnel", () => {
|
|
7
|
+
isolateEnv({ AKAN_PUBLIC_REPO_NAME: "repo", AKAN_PUBLIC_SERVE_DOMAIN: "localhost", AKAN_PUBLIC_ENV: "testing" });
|
|
8
|
+
const track = tempRoots();
|
|
9
|
+
|
|
10
|
+
test("describes a refused Redis SSH connection and preserves its cause", async () => {
|
|
11
|
+
const { app } = track(await createTempApp("demo"));
|
|
12
|
+
const listener = Bun.listen({ hostname: "127.0.0.1", port: 0, socket: { data() {} } });
|
|
13
|
+
const port = listener.port;
|
|
14
|
+
listener.stop(true);
|
|
15
|
+
process.env.SSH_TUNNEL_PORT = String(port);
|
|
16
|
+
|
|
17
|
+
let originalError: Error | undefined;
|
|
18
|
+
const connect = Client.prototype.connect;
|
|
19
|
+
const observeConnect = spyOn(Client.prototype, "connect").mockImplementation(function (this: Client, options) {
|
|
20
|
+
this.once("error", (error: Error) => {
|
|
21
|
+
originalError = error;
|
|
22
|
+
});
|
|
23
|
+
return connect.call(this, options);
|
|
24
|
+
});
|
|
25
|
+
let failure: unknown;
|
|
26
|
+
try {
|
|
27
|
+
await createTunnel("redis", { app, environment: "testing" });
|
|
28
|
+
} catch (error) {
|
|
29
|
+
failure = error;
|
|
30
|
+
} finally {
|
|
31
|
+
observeConnect.mockRestore();
|
|
32
|
+
}
|
|
33
|
+
expect(failure).toBeInstanceOf(Error);
|
|
34
|
+
if (!(failure instanceof Error)) throw new Error("expected tunnel failure");
|
|
35
|
+
expect(failure.message).toContain("redis");
|
|
36
|
+
expect(failure.message).toContain(`demo-testing.localhost:${port}`);
|
|
37
|
+
expect(failure.message).toContain("redis-0.redis-svc.demo-testing.svc.cluster.local:6379");
|
|
38
|
+
expect(failure.message).toContain("ECONNREFUSED");
|
|
39
|
+
expect(failure.cause).toBeInstanceOf(Error);
|
|
40
|
+
expect(failure.cause).toBe(originalError);
|
|
41
|
+
expect(failure.cause).not.toBe(failure);
|
|
42
|
+
if (!(failure.cause instanceof Error)) throw new Error("expected original SSH error");
|
|
43
|
+
const reasons = failure.cause instanceof AggregateError ? failure.cause.errors : [failure.cause];
|
|
44
|
+
expect(reasons.length).toBeGreaterThan(0);
|
|
45
|
+
for (const reason of reasons) {
|
|
46
|
+
expect(reason).toBeInstanceOf(Error);
|
|
47
|
+
expect(reason.message).toContain("ECONNREFUSED");
|
|
48
|
+
expect(failure.message).toContain(reason.message);
|
|
49
|
+
}
|
|
50
|
+
});
|
|
51
|
+
});
|
package/createTunnel.ts
CHANGED
|
@@ -11,19 +11,37 @@ export const createTunnel = async (
|
|
|
11
11
|
{ app, environment, port = service === "postgres" ? 5432 : 6379 }: TunnelOption,
|
|
12
12
|
) => {
|
|
13
13
|
const { serveDomain, repoName } = WorkspaceExecutor.getBaseDevEnv();
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
14
|
+
const host = `${app.name}-${environment}.${serveDomain}`;
|
|
15
|
+
const sshPort = process.env.SSH_TUNNEL_PORT ? parseInt(process.env.SSH_TUNNEL_PORT) : 32767;
|
|
16
|
+
const dstHost = `${service}-0.${service}-svc.${app.name}-${environment}.svc.cluster.local`;
|
|
17
|
+
const dstPort = service === "postgres" ? 5432 : 6379;
|
|
18
|
+
try {
|
|
19
|
+
await createSshTunnel({
|
|
20
|
+
localHost: "0.0.0.0",
|
|
21
|
+
localPort: port,
|
|
22
|
+
srcHost: "0.0.0.0",
|
|
23
|
+
srcPort: port,
|
|
24
|
+
dstHost,
|
|
25
|
+
dstPort,
|
|
26
|
+
sshOptions: {
|
|
27
|
+
//? RFC 6761 makes *.localhost loopback, but only macOS resolves it; glibc without systemd-resolved and Windows answer ENOTFOUND
|
|
28
|
+
host: host.endsWith(".localhost") ? "localhost" : host,
|
|
29
|
+
port: sshPort,
|
|
30
|
+
username: process.env.SSH_TUNNEL_USERNAME ?? "root",
|
|
31
|
+
password: process.env.SSH_TUNNEL_PASSWORD ?? repoName,
|
|
32
|
+
},
|
|
33
|
+
});
|
|
34
|
+
} catch (error) {
|
|
35
|
+
const reasons =
|
|
36
|
+
error instanceof AggregateError
|
|
37
|
+
? error.errors.map((reason) => (reason instanceof Error ? reason.message : String(reason))).join("; ")
|
|
38
|
+
: error instanceof Error
|
|
39
|
+
? error.message
|
|
40
|
+
: String(error);
|
|
41
|
+
throw new Error(
|
|
42
|
+
`Could not open the ${service} tunnel for ${app.name}-${environment}: SSH ${host}:${sshPort} -> ${dstHost}:${dstPort} failed (${reasons})`,
|
|
43
|
+
{ cause: error },
|
|
44
|
+
);
|
|
45
|
+
}
|
|
28
46
|
return `localhost:${port}`;
|
|
29
47
|
};
|
|
@@ -209,13 +209,22 @@ describe.skipIf(!CsrE2eHarness.enabled)("CSR page stack (minimal, /e2e/stack)",
|
|
|
209
209
|
expect(await csr.reloaded()).toBe(false);
|
|
210
210
|
}, 60_000);
|
|
211
211
|
|
|
212
|
-
test("an async render that throws is logged with its route instead of
|
|
212
|
+
test("an async render that throws is logged with its route and shows a retry instead of a blank page", async () => {
|
|
213
213
|
await csr.open("/e2e/stack/broken");
|
|
214
|
-
await csr.waitFor(() =>
|
|
215
|
-
await Bun.sleep(500);
|
|
214
|
+
await csr.waitFor(() => !!document.querySelector("[role=alert] button"), { timeout: 10_000 });
|
|
216
215
|
expect(
|
|
217
216
|
csr.consoleMessages().some((line) => /render of page \d+ of \S*\/e2e\/stack\/broken failed/.test(line)),
|
|
218
217
|
).toBe(true);
|
|
218
|
+
const failure = await csr.evaluate(() => {
|
|
219
|
+
const alert = document.querySelector("[role=alert]");
|
|
220
|
+
const button = alert?.querySelector("button");
|
|
221
|
+
return {
|
|
222
|
+
message: alert?.querySelector("p")?.textContent ?? "",
|
|
223
|
+
retry: button?.textContent ?? "",
|
|
224
|
+
painted: button ? getComputedStyle(button).backgroundColor !== "rgba(0, 0, 0, 0)" : false,
|
|
225
|
+
};
|
|
226
|
+
});
|
|
227
|
+
expect(failure).toEqual({ message: "This page could not be loaded.", retry: "Try again", painted: true });
|
|
219
228
|
}, 60_000);
|
|
220
229
|
|
|
221
230
|
test("the memory frame trace records navigation without writing it to the console", async () => {
|