@akanjs/devkit 3.0.0-beta.9 → 3.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/DEV_RUNTIME_KNOBS.md +44 -26
- package/README.ko.md +21 -7
- package/README.md +17 -8
- package/abstractDoc.test.ts +6 -15
- package/abstractDoc.ts +6 -8
- package/agentsIndex.test.ts +4 -7
- package/agentsIndex.ts +6 -22
- package/akanApp/BackendImportGraph.test.ts +8 -22
- package/akanApp/BackendImportGraph.ts +1 -20
- package/akanApp/akanApp.host.test.ts +56 -3
- package/akanApp/akanApp.host.ts +218 -278
- package/akanApp/devBootLatch.test.ts +39 -0
- package/akanApp/devBootLatch.ts +39 -0
- package/akanApp/devHostPolicy.test.ts +81 -35
- package/akanApp/devHostPolicy.ts +41 -102
- package/akanConfig/akanBin.test.ts +62 -0
- package/akanConfig/akanBin.ts +78 -0
- package/akanConfig/akanConfig.test.ts +439 -141
- package/akanConfig/akanConfig.ts +359 -275
- package/akanConfig/index.ts +1 -0
- package/akanConfig/types.ts +22 -8
- package/akanContext.ts +110 -160
- package/akanMcpContract.ts +28 -73
- package/appSelectionMemory.ts +1 -4
- package/applicationBuildReporter.ts +37 -5
- package/applicationBuildRunner.test.ts +41 -5
- package/applicationBuildRunner.ts +71 -63
- package/applicationTestPreload.ts +3 -12
- package/artifact/implicitRootLayout.test.ts +32 -30
- package/artifact/implicitRootLayout.ts +58 -106
- package/artifact/routeSeedIndex.test.ts +7 -6
- package/artifact/routeSeedIndex.ts +11 -44
- package/biome.base.json +35 -2
- package/biomeBase.ts +2 -5
- package/cloud/cloudApi.test.ts +202 -0
- package/cloud/cloudApi.ts +77 -39
- package/cloud/configLock.test.ts +64 -0
- package/cloud/configLock.ts +96 -0
- package/cloud/constants.ts +26 -5
- package/cloud/globalConfig.ts +71 -32
- package/codeAgent/agent/AkanCodeServices.ts +97 -0
- package/codeAgent/agent/AkanContextFiles.ts +94 -0
- package/codeAgent/agent/CodeAgent.ts +551 -0
- package/codeAgent/agent/CodeAgentAsks.ts +66 -0
- package/codeAgent/agent/CodeAgentEventMapper.ts +225 -0
- package/codeAgent/agent/CodeAgentGate.ts +93 -0
- package/codeAgent/agent/CodeAgentProxy.ts +82 -0
- package/codeAgent/agent/CodeAgentRpcHost.ts +103 -0
- package/codeAgent/agent/CodeAgentRpcListener.ts +109 -0
- package/codeAgent/agent/CodeAgentStreamPrinter.ts +86 -0
- package/codeAgent/agent/CodeAgentSuspended.ts +85 -0
- package/codeAgent/agent/CodeAgentUi.ts +47 -0
- package/codeAgent/agent/CodeMailbox.ts +148 -0
- package/codeAgent/agent/CodeSessionFork.ts +56 -0
- package/codeAgent/agent/CodeSessionIndex.ts +78 -0
- package/codeAgent/agent/SubagentPool.ts +214 -0
- package/codeAgent/agent/akanCodeModel.ts +47 -0
- package/codeAgent/agent/akanCodePaths.ts +31 -0
- package/codeAgent/agent/akanContextFiles.test.ts +114 -0
- package/codeAgent/agent/akanSystemPrompt.ts +46 -0
- package/codeAgent/agent/codeAgent.test.ts +178 -0
- package/codeAgent/agent/codeAgentContract.test.ts +523 -0
- package/codeAgent/agent/codeAgentProxy.test.ts +74 -0
- package/codeAgent/agent/codeAgentRpcListener.test.ts +113 -0
- package/codeAgent/agent/codeAgentSuspended.test.ts +47 -0
- package/codeAgent/agent/codeMailbox.test.ts +76 -0
- package/codeAgent/agent/codeSessionFork.test.ts +58 -0
- package/codeAgent/agent/codeSessionIndex.test.ts +77 -0
- package/codeAgent/agent/consoleToStderr.ts +8 -0
- package/codeAgent/agent/jsonLines.ts +29 -0
- package/codeAgent/agent/stringArg.ts +5 -0
- package/codeAgent/feedback/DevLogFeedback.ts +82 -0
- package/codeAgent/feedback/PreviewChrome.ts +82 -0
- package/codeAgent/feedback/PreviewFeedback.ts +46 -0
- package/codeAgent/feedback/PreviewView.ts +95 -0
- package/codeAgent/feedback/TurnFeedback.ts +98 -0
- package/codeAgent/feedback/VerifyFeedback.ts +40 -0
- package/codeAgent/feedback/previewChrome.test.ts +73 -0
- package/codeAgent/index.ts +34 -0
- package/codeAgent/skills/akan-code/SKILL.md +130 -0
- package/codeAgent/skills/akan-debug/SKILL.md +42 -0
- package/codeAgent/skills/akan-deps/SKILL.md +50 -0
- package/codeAgent/skills/akan-err/SKILL.md +39 -0
- package/codeAgent/skills/akan-module/SKILL.md +45 -0
- package/codeAgent/skills/akan-scaffold/SKILL.md +76 -0
- package/codeAgent/skills/akan-scalar/SKILL.md +52 -0
- package/codeAgent/skills/akan-service/SKILL.md +34 -0
- package/codeAgent/skills/akan-store/SKILL.md +67 -0
- package/codeAgent/skills/akan-ui/SKILL.md +63 -0
- package/codeAgent/skills/akan-validate/SKILL.md +44 -0
- package/codeAgent/tools/AkanCli.ts +50 -0
- package/codeAgent/tools/AkanCodePlugins.ts +136 -0
- package/codeAgent/tools/AkanEditScope.ts +48 -0
- package/codeAgent/tools/AkanEnvKeys.ts +50 -0
- package/codeAgent/tools/AkanToolPack.ts +92 -0
- package/codeAgent/tools/AkanVerifier.ts +48 -0
- package/codeAgent/tools/AskToolPack.ts +99 -0
- package/codeAgent/tools/MailToolPack.ts +76 -0
- package/codeAgent/tools/McpClient.ts +187 -0
- package/codeAgent/tools/McpOAuth.ts +233 -0
- package/codeAgent/tools/McpOAuthLoopback.ts +64 -0
- package/codeAgent/tools/McpServerConfig.ts +129 -0
- package/codeAgent/tools/McpSignIn.ts +158 -0
- package/codeAgent/tools/McpTokenStore.ts +81 -0
- package/codeAgent/tools/McpToolPack.ts +199 -0
- package/codeAgent/tools/SessionToolPack.ts +91 -0
- package/codeAgent/tools/WebToolPack.ts +147 -0
- package/codeAgent/tools/akanCodePlugins.test.ts +677 -0
- package/codeAgent/tools/askToolPack.test.ts +116 -0
- package/codeAgent/tools/mcpOAuth.test.ts +612 -0
- package/codeAgent/tools/mcpServerConfig.test.ts +212 -0
- package/codegenLock.test.ts +4 -13
- package/codegenLock.ts +3 -17
- package/commandDecorators/argMeta.ts +1 -5
- package/commandDecorators/camelToKebabCase.ts +1 -0
- package/commandDecorators/command.ts +46 -102
- package/commandDecorators/commandBuilder.ts +30 -61
- package/commandDecorators/commandDecorators.test.ts +2 -10
- package/commandDecorators/dependencyBuilder.ts +10 -1
- package/commandDecorators/helpFormatter.ts +68 -145
- package/commandDecorators/index.ts +0 -2
- package/commandDecorators/targetMeta.ts +9 -7
- package/createTunnel.ts +7 -14
- package/csrE2e/csrE2eHarness.fixture.ts +505 -0
- package/csrE2e/csrFastRefresh.e2e.test.ts +233 -0
- package/csrE2e/csrPageStack.e2e.test.ts +252 -0
- package/csrE2e/csrPatchFirst.e2e.test.ts +87 -0
- package/csrE2e/csrRootLayout.e2e.test.ts +134 -0
- package/csrE2e/devHmrLatency.e2e.test.ts +391 -0
- package/csrE2e/registryVendor.e2e.test.ts +221 -0
- package/csrE2e/ssrRegistry.e2e.test.ts +644 -0
- package/dependencyScanner.test.ts +4 -16
- package/dependencyScanner.ts +31 -80
- package/devkitUtils.test.ts +48 -20
- package/executors.linkFallback.test.ts +103 -0
- package/executors.test.ts +176 -106
- package/executors.ts +212 -498
- package/fileEditor.ts +9 -37
- package/{getDirname.test.ts → fileSys.test.ts} +5 -3
- package/fileSys.ts +22 -9
- package/formSetterScanner.ts +3 -16
- package/frontendBuild/allRoutesBuilder.ts +7 -21
- package/frontendBuild/autoImportSync.test.ts +27 -38
- package/frontendBuild/autoImportSync.ts +31 -65
- package/frontendBuild/buildRouteClient.test.ts +123 -37
- package/frontendBuild/bundleDefine.test.ts +12 -0
- package/frontendBuild/bundleDefine.ts +16 -0
- package/frontendBuild/clientBuildTypes.ts +5 -36
- package/frontendBuild/clientEntriesBundler.ts +9 -40
- package/frontendBuild/clientEntryDiscovery.test.ts +112 -0
- package/frontendBuild/clientEntryDiscovery.ts +122 -110
- package/frontendBuild/csrArtifactBuilder.ts +9 -31
- package/frontendBuild/csrDevArtifactWriter.test.ts +151 -0
- package/frontendBuild/csrDevArtifactWriter.ts +307 -0
- package/frontendBuild/csrDevBundler.test.ts +66 -0
- package/frontendBuild/csrDevBundler.ts +95 -0
- package/frontendBuild/csrDevFactoryCheck.test.ts +33 -0
- package/frontendBuild/csrDevFactoryCheck.ts +39 -0
- package/frontendBuild/csrDevModuleCompiler.test.ts +293 -0
- package/frontendBuild/csrDevModuleCompiler.ts +422 -0
- package/frontendBuild/csrDevPatcher.test.ts +572 -0
- package/frontendBuild/csrDevPatcher.ts +548 -0
- package/frontendBuild/csrDevPaths.ts +145 -0
- package/frontendBuild/csrDevResolver.test.ts +239 -0
- package/frontendBuild/csrDevResolver.ts +363 -0
- package/frontendBuild/csrDevTypes.ts +61 -0
- package/frontendBuild/csrEntryFiles.ts +24 -0
- package/frontendBuild/cssCandidateCache.test.ts +0 -6
- package/frontendBuild/cssCandidateCache.ts +6 -41
- package/frontendBuild/cssCompiler.ts +31 -82
- package/frontendBuild/cssImportResolver.ts +6 -19
- package/frontendBuild/devChangePlanner.ts +7 -10
- package/frontendBuild/devGeneratedIndexSync.test.ts +12 -27
- package/frontendBuild/devGeneratedIndexSync.ts +1 -4
- package/frontendBuild/devRegistryBundler.ts +192 -0
- package/frontendBuild/fontOptimizer.test.ts +6 -14
- package/frontendBuild/fontOptimizer.ts +15 -72
- package/frontendBuild/fontPruner.test.ts +5 -10
- package/frontendBuild/fontPruner.ts +5 -20
- package/frontendBuild/frontendBuild.test.ts +57 -70
- package/frontendBuild/hmrChangeClassifier.ts +2 -2
- package/frontendBuild/hmrWatcher.test.ts +59 -32
- package/frontendBuild/hmrWatcher.ts +31 -74
- package/frontendBuild/index.ts +4 -0
- package/frontendBuild/pagesBundleBuilder.ts +28 -30
- package/frontendBuild/pagesEntrySourceGenerator.ts +56 -6
- package/frontendBuild/precompressArtifacts.ts +5 -18
- package/frontendBuild/registryMetadataFingerprint.test.ts +63 -0
- package/frontendBuild/registryMetadataFingerprint.ts +28 -0
- package/frontendBuild/routeClientBuilder.ts +100 -62
- package/frontendBuild/routesManifestArtifactSerializer.ts +5 -36
- package/frontendBuild/serverGraphFile.test.ts +77 -0
- package/frontendBuild/serverGraphFile.ts +93 -0
- package/frontendBuild/sourceMtimeIndex.test.ts +41 -97
- package/frontendBuild/sourceMtimeIndex.ts +19 -130
- package/frontendBuild/ssrBaseArtifactBuilder.ts +19 -13
- package/frontendBuild/ssrDevBundler.ts +85 -0
- package/frontendBuild/styleContract.ts +1 -8
- package/frontendBuild/themeValidator.ts +4 -26
- package/frontendBuild/watchRootResolver.test.ts +23 -42
- package/frontendBuild/watchRootResolver.ts +5 -27
- package/getRelatedCnsts.ts +12 -92
- package/incrementalBuilder/buildBatch.proc.ts +147 -63
- package/incrementalBuilder/buildBatchProtocol.ts +13 -38
- package/incrementalBuilder/buildBatchRunner.test.ts +65 -0
- package/incrementalBuilder/buildBatchRunner.ts +26 -38
- package/incrementalBuilder/builderChannel.test.ts +24 -49
- package/incrementalBuilder/builderChannel.ts +6 -37
- package/incrementalBuilder/builderRequestRouter.test.ts +0 -8
- package/incrementalBuilder/builderRequestRouter.ts +5 -24
- package/incrementalBuilder/builderWorkQueue.test.ts +94 -0
- package/incrementalBuilder/builderWorkQueue.ts +126 -0
- package/incrementalBuilder/devWatchBatch.test.ts +21 -24
- package/incrementalBuilder/incrementalBuilder.host.test.ts +53 -56
- package/incrementalBuilder/incrementalBuilder.host.ts +68 -81
- package/incrementalBuilder/incrementalBuilder.proc.test.ts +270 -0
- package/incrementalBuilder/incrementalBuilder.proc.ts +596 -208
- package/index.ts +5 -19
- package/integration/devResourceProbe.ts +50 -56
- package/integration/devStability.integration.test.ts +105 -199
- package/integration/devStabilityHarness.test.ts +13 -28
- package/integration/devStabilityHarness.ts +65 -330
- package/integration/ssrMemoryProbe.ts +41 -167
- package/libSource.test.ts +4 -15
- package/libSource.ts +19 -31
- package/lint/__fixtures__/no-document-cookie/bad.tsx +4 -0
- package/lint/__fixtures__/no-document-cookie/good.tsx +7 -0
- package/lint/__fixtures__/no-web-only-api-outside-webkit/bad.tsx +7 -0
- package/lint/__fixtures__/no-web-only-api-outside-webkit/good.tsx +11 -0
- package/lint/__fixtures__/no-web-storage/bad.tsx +6 -0
- package/lint/__fixtures__/no-web-storage/good.tsx +7 -0
- package/lint/gritRules.test.ts +8 -24
- package/lint/no-deep-internal-import.grit +2 -2
- package/lint/no-document-cookie.grit +29 -0
- package/lint/no-redeclare-predefined-endpoint.grit +2 -1
- package/lint/no-web-only-api-outside-webkit.grit +44 -0
- package/lint/no-web-storage.grit +30 -0
- package/linter.test.ts +1 -2
- package/linter.ts +26 -116
- package/mobile/desktopBin.test.ts +182 -0
- package/mobile/desktopBin.ts +144 -0
- package/mobile/desktopServerStage.test.ts +252 -0
- package/mobile/desktopServerStage.ts +178 -0
- package/mobile/index.ts +9 -0
- package/mobile/mobileTarget.test.ts +57 -8
- package/mobile/mobileTarget.ts +45 -26
- package/mobile/nativeApi.test.ts +30 -0
- package/mobile/nativeApi.ts +40 -0
- package/mobile/nativeApp.test.ts +184 -0
- package/mobile/nativeApp.ts +454 -0
- package/mobile/nativeAppLine.test.ts +69 -0
- package/mobile/nativeAppLine.ts +71 -0
- package/mobile/nativeConfig.test.ts +438 -0
- package/mobile/nativeConfig.ts +294 -0
- package/mobile/nativePluginFolders.test.ts +81 -0
- package/mobile/nativePluginFolders.ts +60 -0
- package/mobile/nativeWebDir.test.ts +80 -0
- package/mobile/nativeWebDir.ts +66 -0
- package/mobile/placeholderAppId.test.ts +13 -0
- package/mobile/placeholderAppId.ts +16 -0
- package/mobile/usageDescriptions.test.ts +27 -0
- package/mobile/usageDescriptions.ts +24 -0
- package/package.json +11 -11
- package/packageExports.test.ts +3 -18
- package/packageExportsMap.ts +3 -19
- package/piDependencyPins.test.ts +46 -0
- package/platformTest/BunTestReport.test.ts +60 -0
- package/platformTest/BunTestReport.ts +65 -0
- package/platformTest/IdleWatchedProcess.test.ts +56 -0
- package/platformTest/IdleWatchedProcess.ts +73 -0
- package/platformTest/LinuxDockerTarget.ts +105 -0
- package/platformTest/PlatformTestRun.test.ts +64 -0
- package/platformTest/PlatformTestRun.ts +141 -0
- package/platformTest/PlatformTestTarget.ts +142 -0
- package/platformTest/TestSnapshot.test.ts +72 -0
- package/platformTest/TestSnapshot.ts +103 -0
- package/platformTest/WindowsSshTarget.ts +151 -0
- package/prompter.ts +3 -13
- package/qualityScanner.test.ts +9 -26
- package/qualityScanner.ts +200 -286
- package/recipeScanner.test.ts +7 -12
- package/recipeScanner.ts +9 -38
- package/repoIdentity.ts +2 -9
- package/routeSourceValidator.test.ts +0 -1
- package/routeSourceValidator.ts +7 -42
- package/scanInfo.ts +91 -225
- package/selectModel.ts +1 -2
- package/semver.ts +3 -5
- package/slicePlanner.test.ts +6 -24
- package/slicePlanner.ts +11 -32
- package/spinner.test.ts +3 -8
- package/spinner.ts +4 -16
- package/sshTunnel.ts +1 -6
- package/ssrScanner.test.ts +1 -6
- package/ssrScanner.ts +103 -113
- package/stripAnsi.ts +3 -0
- package/subspace.test.ts +9 -25
- package/subspace.ts +48 -115
- package/subspaceConfig.ts +3 -11
- package/testHelpers.ts +194 -0
- package/transforms/asyncDefaultExportDetector.ts +3 -11
- package/transforms/barrelAnalyzer.ts +43 -135
- package/transforms/barrelImportsPlugin.ts +38 -175
- package/transforms/externalizeFrameworkPlugin.test.ts +39 -118
- package/transforms/externalizeFrameworkPlugin.ts +13 -87
- package/transforms/moduleSyntax.ts +32 -0
- package/transforms/rscUseClientTransform.ts +42 -20
- package/transforms/serverEnvPlugin.test.ts +103 -0
- package/transforms/serverEnvPlugin.ts +41 -0
- package/transforms/transforms.test.ts +166 -44
- package/transforms/tsconfigPackageResolver.test.ts +6 -24
- package/transforms/useClientBundlePlugin.ts +6 -23
- package/typeChecker.ts +7 -63
- package/types.ts +2 -0
- package/uploadRelease.ts +10 -25
- package/workflow/artifacts.ts +18 -0
- package/workflow/executor.test.ts +1 -12
- package/workflow/executor.ts +101 -110
- package/workflow/index.ts +0 -1
- package/workflow/moduleIndex.test.ts +4 -15
- package/workflow/moduleIndex.ts +9 -49
- package/workflow/plan.ts +18 -25
- package/workflow/render.ts +80 -96
- package/workflow/rolloutGate.test.ts +1 -1
- package/workflow/source.ts +52 -69
- package/workflow/sourceAst.ts +50 -0
- package/workspaceLayout.test.ts +18 -3
- package/workspaceLayout.ts +22 -23
- package/abstractCompactor.ts +0 -112
- package/aiEditor.test.ts +0 -60
- package/aiEditor.ts +0 -387
- package/applicationReleasePackager.ts +0 -209
- package/capacitorApp.test.ts +0 -372
- package/capacitorApp.ts +0 -1648
- package/commandDecorators/commandMeta.ts +0 -7
- package/commandDecorators/types.ts +0 -10
- package/getDirname.ts +0 -4
- package/guideline.ts +0 -19
- package/local/optimize-resource/ipcprobe/child.ts +0 -1
- package/local/optimize-resource/ipcprobe/parent.ts +0 -11
- package/workflow/primitive.ts +0 -59
|
@@ -0,0 +1,505 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import { mkdir } from "node:fs/promises";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
|
|
5
|
+
export interface CsrE2eOptions {
|
|
6
|
+
app: string;
|
|
7
|
+
/** An `akan start` that is already running; without it the harness starts one on `port`. */
|
|
8
|
+
url?: string;
|
|
9
|
+
port?: number;
|
|
10
|
+
/** Extra env for the `akan start` it spawns, e.g. `{ AKAN_DEV_CSR: "registry" }`. */
|
|
11
|
+
env?: Record<string, string>;
|
|
12
|
+
lang?: string;
|
|
13
|
+
mobileTarget?: string;
|
|
14
|
+
workspaceRoot?: string;
|
|
15
|
+
startTimeoutMs?: number;
|
|
16
|
+
viewport?: { width: number; height: number };
|
|
17
|
+
/** `webkit` is macOS only; elsewhere, or with `AKAN_CSR_E2E_BACKEND=chrome`, an installed Chrome is driven. */
|
|
18
|
+
backend?: "webkit" | "chrome";
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface CsrE2ePageContainer {
|
|
22
|
+
key: string;
|
|
23
|
+
path: string;
|
|
24
|
+
hidden: boolean;
|
|
25
|
+
inert: boolean;
|
|
26
|
+
ariaHidden: boolean;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface CsrE2eProbe {
|
|
30
|
+
renders: number;
|
|
31
|
+
ticks: number;
|
|
32
|
+
mountId: string;
|
|
33
|
+
mounted: boolean;
|
|
34
|
+
activity: string;
|
|
35
|
+
focused: boolean;
|
|
36
|
+
focusCount: number;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
interface CsrE2eServer {
|
|
40
|
+
proc: ReturnType<typeof Bun.spawn>;
|
|
41
|
+
logPath: string;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const DEFAULT_PORT = 8391;
|
|
45
|
+
const RELOAD_MARKER = "__akanE2eBootMarker";
|
|
46
|
+
//? Longer than the frame's navigation lock (360ms after a transition commits, `webkit/useCsrValues.ts`).
|
|
47
|
+
const SETTLE_MS = 450;
|
|
48
|
+
|
|
49
|
+
/** Drives a page of a running `akan start` in a headless `Bun.WebView`: CSR (`?csr=true` + a mobile target) or SSR. */
|
|
50
|
+
export class CsrE2eHarness {
|
|
51
|
+
readonly origin: string;
|
|
52
|
+
readonly #view: Bun.WebView;
|
|
53
|
+
readonly #server: CsrE2eServer | null;
|
|
54
|
+
readonly #lang: string;
|
|
55
|
+
readonly #mobileTarget: string;
|
|
56
|
+
readonly #edited = new Map<string, string>();
|
|
57
|
+
readonly #console: string[];
|
|
58
|
+
#marker = "";
|
|
59
|
+
#csr: boolean = true;
|
|
60
|
+
|
|
61
|
+
private constructor(options: {
|
|
62
|
+
origin: string;
|
|
63
|
+
view: Bun.WebView;
|
|
64
|
+
server: CsrE2eServer | null;
|
|
65
|
+
lang: string;
|
|
66
|
+
mobileTarget: string;
|
|
67
|
+
console: string[];
|
|
68
|
+
}) {
|
|
69
|
+
this.origin = options.origin;
|
|
70
|
+
this.#view = options.view;
|
|
71
|
+
this.#console = options.console;
|
|
72
|
+
this.#server = options.server;
|
|
73
|
+
this.#lang = options.lang;
|
|
74
|
+
this.#mobileTarget = options.mobileTarget;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** What keeps this host from driving a page, or null when a `Bun.WebView` opens one and answers. */
|
|
78
|
+
static async preflight(backend = CsrE2eHarness.#defaultBackend()): Promise<string | null> {
|
|
79
|
+
if (typeof (Bun as { WebView?: unknown }).WebView !== "function") return `Bun ${Bun.version} has no Bun.WebView`;
|
|
80
|
+
let view: Bun.WebView | null = null;
|
|
81
|
+
try {
|
|
82
|
+
const opened = new Bun.WebView({
|
|
83
|
+
width: 320,
|
|
84
|
+
height: 240,
|
|
85
|
+
backend: backend === "chrome" ? { type: "chrome", url: false } : backend,
|
|
86
|
+
});
|
|
87
|
+
view = opened;
|
|
88
|
+
const answer = await Promise.race([
|
|
89
|
+
opened.navigate("about:blank").then(async () => await opened.evaluate<number>("1 + 1")),
|
|
90
|
+
Bun.sleep(30_000).then(() => null),
|
|
91
|
+
]);
|
|
92
|
+
return answer === 2 ? null : `a ${backend} Bun.WebView did not answer within 30s`;
|
|
93
|
+
} catch (error) {
|
|
94
|
+
return `a ${backend} Bun.WebView cannot start: ${error instanceof Error ? error.message : String(error)}`;
|
|
95
|
+
} finally {
|
|
96
|
+
view?.close();
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** E2E suites run only with `AKAN_CSR_E2E=1`: they boot a dev server and a browser. */
|
|
101
|
+
static get enabled() {
|
|
102
|
+
return process.env.AKAN_CSR_E2E === "1";
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
static async start(options: CsrE2eOptions): Promise<CsrE2eHarness> {
|
|
106
|
+
const workspaceRoot = options.workspaceRoot ?? CsrE2eHarness.#findWorkspaceRoot();
|
|
107
|
+
const url = options.url ?? process.env.AKAN_CSR_E2E_URL;
|
|
108
|
+
//? A running server took its env when it started: a suite's hooks would be dropped and its checks pass for nothing.
|
|
109
|
+
if (url && Object.keys(options.env ?? {}).length > 0)
|
|
110
|
+
throw new Error(
|
|
111
|
+
`[csr-e2e] this suite starts its dev server with ${Object.keys(options.env ?? {}).join(", ")}, which the server at ${url} does not have; unset AKAN_CSR_E2E_URL`,
|
|
112
|
+
);
|
|
113
|
+
const port = options.port ?? Number(process.env.AKAN_CSR_E2E_PORT ?? DEFAULT_PORT);
|
|
114
|
+
const server = url ? null : await CsrE2eHarness.#startServer({ ...options, workspaceRoot, port });
|
|
115
|
+
const origin = new URL(url ?? `http://localhost:${port}`).origin;
|
|
116
|
+
try {
|
|
117
|
+
await CsrE2eHarness.#waitForHealth(origin, server, options.startTimeoutMs ?? 180_000);
|
|
118
|
+
const { width, height } = { width: 390, height: 844, ...options.viewport };
|
|
119
|
+
const backend = options.backend ?? CsrE2eHarness.#defaultBackend();
|
|
120
|
+
const messages: string[] = [];
|
|
121
|
+
const print = process.env.AKAN_CSR_E2E_CONSOLE === "1";
|
|
122
|
+
//? `url: false` spawns a fresh Chrome instead of attaching to one the developer runs with remote debugging on.
|
|
123
|
+
const view = new Bun.WebView({
|
|
124
|
+
width,
|
|
125
|
+
height,
|
|
126
|
+
backend: backend === "chrome" ? { type: "chrome", url: false } : backend,
|
|
127
|
+
//? Kept for `consoleMessages()`; AKAN_CSR_E2E_CONSOLE=1 also prints it, the only view of a hydration warning or
|
|
128
|
+
//? a failed patch. An uncaught error is not a console call and never arrives here.
|
|
129
|
+
console: (type: string, ...args: unknown[]) => {
|
|
130
|
+
messages.push(`[${type}] ${args.map(CsrE2eHarness.#formatArg).join(" ")}`);
|
|
131
|
+
if (print) globalThis.console.info(`[page ${type}]`, ...args);
|
|
132
|
+
},
|
|
133
|
+
});
|
|
134
|
+
return new CsrE2eHarness({
|
|
135
|
+
origin,
|
|
136
|
+
view,
|
|
137
|
+
server,
|
|
138
|
+
lang: options.lang ?? "en",
|
|
139
|
+
mobileTarget: options.mobileTarget ?? "e2e",
|
|
140
|
+
console: messages,
|
|
141
|
+
});
|
|
142
|
+
} catch (error) {
|
|
143
|
+
await CsrE2eHarness.#stopServer(server);
|
|
144
|
+
throw error;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** Loads `path` (without the locale), as CSR unless `csr: false`, and plants the marker `reloaded()` checks. */
|
|
149
|
+
async open(path: string, { csr = true }: { csr?: boolean } = {}) {
|
|
150
|
+
const url = new URL(`/${this.#lang}${path === "/" ? "" : path}`, this.origin);
|
|
151
|
+
if (csr) {
|
|
152
|
+
url.searchParams.set("csr", "true");
|
|
153
|
+
url.searchParams.set("akanMobileTarget", this.#mobileTarget);
|
|
154
|
+
}
|
|
155
|
+
this.#csr = csr;
|
|
156
|
+
this.#console.length = 0;
|
|
157
|
+
await this.#view.navigate(url.toString());
|
|
158
|
+
await this.#markBoot();
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Reloads the page where it is, as a WebView does after its content process died, and marks the new boot. Returns
|
|
163
|
+
* the `performance.timeOrigin` of the first document seen after it: one that differs later was reloaded again.
|
|
164
|
+
*/
|
|
165
|
+
async reload(): Promise<number> {
|
|
166
|
+
await this.#view.reload();
|
|
167
|
+
//? On the WebKit backend `reload()` resolves before the old document is gone; its marker going is the reload.
|
|
168
|
+
await this.waitFor(
|
|
169
|
+
(key: string, previous: string) => (window as unknown as Record<string, string | undefined>)[key] !== previous,
|
|
170
|
+
{ args: [RELOAD_MARKER, this.#marker], timeout: 30_000 },
|
|
171
|
+
);
|
|
172
|
+
const timeOrigin = await this.evaluate(() => performance.timeOrigin);
|
|
173
|
+
await this.#markBoot();
|
|
174
|
+
return timeOrigin;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
//? An SSR page has no page stack; `rscClient` installs its refresh hook in the root's layout effect, once hydrated.
|
|
178
|
+
async #markBoot() {
|
|
179
|
+
if (this.#csr)
|
|
180
|
+
await this.waitFor(() => document.querySelector('[id^="pageContainer-"]') !== null, { timeout: 30_000 });
|
|
181
|
+
else
|
|
182
|
+
await this.waitFor(
|
|
183
|
+
() => typeof (globalThis as unknown as { __AKAN_RSC_REFRESH__?: unknown }).__AKAN_RSC_REFRESH__ === "function",
|
|
184
|
+
{ timeout: 30_000 },
|
|
185
|
+
);
|
|
186
|
+
await Bun.sleep(SETTLE_MS);
|
|
187
|
+
this.#marker = Math.random().toString(36).slice(2);
|
|
188
|
+
await this.evaluate(
|
|
189
|
+
(key: string, value: string) => {
|
|
190
|
+
(window as unknown as Record<string, string>)[key] = value;
|
|
191
|
+
},
|
|
192
|
+
RELOAD_MARKER,
|
|
193
|
+
this.#marker,
|
|
194
|
+
);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/** Reports the document as `state` and tells the page, as an app moving to or from the background does. */
|
|
198
|
+
async setVisibility(state: "hidden" | "visible") {
|
|
199
|
+
await this.evaluate((next: string) => {
|
|
200
|
+
Object.defineProperty(document, "visibilityState", { value: next, configurable: true });
|
|
201
|
+
document.dispatchEvent(new Event("visibilitychange"));
|
|
202
|
+
}, state);
|
|
203
|
+
await Bun.sleep(50);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
//? The CSR frame's own dev sync-navigation event: it calls the app router's push/replace, as a click would.
|
|
207
|
+
//? The frame drops a navigation that starts while a transition runs, so each one waits the transition out.
|
|
208
|
+
async navigate(href: string, kind: "push" | "replace" = "push", { settleMs = SETTLE_MS } = {}) {
|
|
209
|
+
const target = new URL(href, this.origin);
|
|
210
|
+
await this.evaluate(
|
|
211
|
+
(next: string, how: string) => {
|
|
212
|
+
window.dispatchEvent(new CustomEvent("akan:sync-navigation", { detail: { href: next, kind: how } }));
|
|
213
|
+
},
|
|
214
|
+
href,
|
|
215
|
+
kind,
|
|
216
|
+
);
|
|
217
|
+
await this.waitFor(
|
|
218
|
+
(pathname: string, search: string) =>
|
|
219
|
+
location.pathname.endsWith(pathname) &&
|
|
220
|
+
[...new URLSearchParams(search)].every(
|
|
221
|
+
([key, value]) => new URLSearchParams(location.search).get(key) === value,
|
|
222
|
+
),
|
|
223
|
+
{ args: [target.pathname, target.search], timeout: 10_000 },
|
|
224
|
+
);
|
|
225
|
+
await Bun.sleep(settleMs);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/** A history back through popstate, the path a native back button or a browser back takes. */
|
|
229
|
+
async back({ settleMs = SETTLE_MS } = {}) {
|
|
230
|
+
await this.go(-1, { settleMs });
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
async go(delta: number, { settleMs = SETTLE_MS } = {}) {
|
|
234
|
+
const before = await this.currentPath();
|
|
235
|
+
await this.evaluate((steps: number) => {
|
|
236
|
+
window.history.go(steps);
|
|
237
|
+
}, delta);
|
|
238
|
+
await this.waitFor((path: string) => window.location.pathname + window.location.search !== path, {
|
|
239
|
+
args: [before],
|
|
240
|
+
timeout: 10_000,
|
|
241
|
+
});
|
|
242
|
+
await Bun.sleep(settleMs);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* Runs `fn` in the page. Only its source crosses, so it closes over nothing; `args` and the result travel as JSON.
|
|
247
|
+
*/
|
|
248
|
+
async evaluate<Args extends unknown[], Result>(fn: (...args: Args) => Result, ...args: Args) {
|
|
249
|
+
return await this.#view.evaluate<Awaited<Result>>(`(${fn.toString()})(...${JSON.stringify(args)})`);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
//? A poll that lands while the page reloads rejects; it is retried, so a wait spans a reload.
|
|
253
|
+
async waitFor<Args extends unknown[]>(
|
|
254
|
+
predicate: (...args: Args) => unknown,
|
|
255
|
+
{ timeout = 5_000, args = [] as unknown as Args }: { timeout?: number; args?: Args } = {},
|
|
256
|
+
) {
|
|
257
|
+
const deadline = Date.now() + timeout;
|
|
258
|
+
let lastError: unknown = null;
|
|
259
|
+
for (;;) {
|
|
260
|
+
try {
|
|
261
|
+
if (await this.evaluate(predicate, ...args)) return;
|
|
262
|
+
lastError = null;
|
|
263
|
+
} catch (error) {
|
|
264
|
+
lastError = error;
|
|
265
|
+
}
|
|
266
|
+
if (Date.now() >= deadline) break;
|
|
267
|
+
await Bun.sleep(50);
|
|
268
|
+
}
|
|
269
|
+
throw new Error(`[csr-e2e] waitFor timed out after ${timeout}ms: ${predicate.toString()}`, { cause: lastError });
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
async currentPath() {
|
|
273
|
+
return await this.evaluate(() => window.location.pathname + window.location.search);
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
async containers(): Promise<CsrE2ePageContainer[]> {
|
|
277
|
+
return await this.evaluate(() =>
|
|
278
|
+
[...document.querySelectorAll<HTMLElement>('[id^="pageContainer-"]')].map((element) => ({
|
|
279
|
+
key: element.id.slice("pageContainer-".length),
|
|
280
|
+
path: element.dataset.path ?? element.id.slice("pageContainer-".length),
|
|
281
|
+
hidden: getComputedStyle(element).display === "none",
|
|
282
|
+
inert: element.inert || element.closest("[inert]") !== null,
|
|
283
|
+
ariaHidden: element.closest('[aria-hidden="true"]') !== null,
|
|
284
|
+
})),
|
|
285
|
+
);
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
async probe(name: string): Promise<CsrE2eProbe | null> {
|
|
289
|
+
return await this.evaluate(
|
|
290
|
+
(probeName: string) =>
|
|
291
|
+
(window as unknown as { __akanE2eProbes?: Record<string, CsrE2eProbe> }).__akanE2eProbes?.[probeName] ?? null,
|
|
292
|
+
name,
|
|
293
|
+
);
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/** Ticks a probe gained over `ms` — 0 while its page's effects are paused. */
|
|
297
|
+
async ticksOver(name: string, ms = 400) {
|
|
298
|
+
const before = (await this.probe(name))?.ticks ?? 0;
|
|
299
|
+
await Bun.sleep(ms);
|
|
300
|
+
return ((await this.probe(name))?.ticks ?? 0) - before;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/** The tool names the in-page agent is offered right now, built-ins included. */
|
|
304
|
+
async agentTools(): Promise<string[]> {
|
|
305
|
+
return await this.evaluate(() => {
|
|
306
|
+
const holder = globalThis as unknown as {
|
|
307
|
+
[key: symbol]: { snapshot: () => { tools: { name: string }[] } } | undefined;
|
|
308
|
+
};
|
|
309
|
+
return holder[Symbol.for("useAgentic.sharedSurface")]?.snapshot().tools.map((tool) => tool.name) ?? [];
|
|
310
|
+
});
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/** Text of every rendered match; `{ mounted: true }` reads hidden ones too, which is how a parked page is seen. */
|
|
314
|
+
async text(selector: string, { mounted = false }: { mounted?: boolean } = {}) {
|
|
315
|
+
return await this.evaluate(
|
|
316
|
+
(target: string, all: boolean) =>
|
|
317
|
+
[...document.querySelectorAll<HTMLElement>(target)]
|
|
318
|
+
.filter((element) => all || element.checkVisibility())
|
|
319
|
+
.map((element) => element.textContent ?? ""),
|
|
320
|
+
selector,
|
|
321
|
+
mounted,
|
|
322
|
+
);
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/** Waits until the rendered matches read `expected`, for a step whose cost is a mount rather than a transition. */
|
|
326
|
+
async waitForText(selector: string, expected: string[], { timeout = 5_000 } = {}) {
|
|
327
|
+
await this.waitFor(
|
|
328
|
+
(target: string, want: string[]) =>
|
|
329
|
+
JSON.stringify(
|
|
330
|
+
[...document.querySelectorAll<HTMLElement>(target)]
|
|
331
|
+
.filter((element) => element.checkVisibility())
|
|
332
|
+
.map((element) => element.textContent ?? ""),
|
|
333
|
+
) === JSON.stringify(want),
|
|
334
|
+
{ args: [selector, expected], timeout },
|
|
335
|
+
);
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
async values(selector: string) {
|
|
339
|
+
return await this.evaluate(
|
|
340
|
+
(target: string) => [...document.querySelectorAll<HTMLInputElement>(target)].map((element) => element.value),
|
|
341
|
+
selector,
|
|
342
|
+
);
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
//? `Bun.WebView.type` inserts into the focused element as a paste does: `input` fires, `keydown` does not.
|
|
346
|
+
async type(selector: string, text: string) {
|
|
347
|
+
const focused = await this.evaluate((target: string) => {
|
|
348
|
+
const element = document.querySelector<HTMLElement>(target);
|
|
349
|
+
element?.focus();
|
|
350
|
+
return element !== null && document.activeElement === element;
|
|
351
|
+
}, selector);
|
|
352
|
+
if (!focused) throw new Error(`[csr-e2e] type: ${selector} is not a focusable element on the page`);
|
|
353
|
+
await this.#view.type(text);
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
/** The process group of the `akan start` this harness spawned; null when it drives a running one. */
|
|
357
|
+
get serverGroup(): number | null {
|
|
358
|
+
return this.#server?.proc.pid ?? null;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
/** Where the spawned `akan start`'s stdout and stderr end now, for `serverLogSince`. */
|
|
362
|
+
async serverLogMark(): Promise<{ out: number; err: number }> {
|
|
363
|
+
const [out, err] = await this.#serverLogs();
|
|
364
|
+
return { out: out.length, err: err.length };
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
/** What the spawned `akan start` wrote to stdout and stderr since `mark`; empty when it drives a running one. */
|
|
368
|
+
async serverLogSince(mark: { out: number; err: number } = { out: 0, err: 0 }): Promise<string> {
|
|
369
|
+
const [out, err] = await this.#serverLogs();
|
|
370
|
+
return `${out.slice(mark.out)}${err.slice(mark.err)}`;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
async #serverLogs(): Promise<[string, string]> {
|
|
374
|
+
if (!this.#server) return ["", ""];
|
|
375
|
+
const read = async (file: string) =>
|
|
376
|
+
await Bun.file(file)
|
|
377
|
+
.text()
|
|
378
|
+
.catch(() => "");
|
|
379
|
+
const { logPath } = this.#server;
|
|
380
|
+
return [await read(logPath), await read(logPath.replace(/\.log$/, ".err.log"))];
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
/** The page's console calls since `open()`, as `[type] text`. */
|
|
384
|
+
consoleMessages(): string[] {
|
|
385
|
+
return [...this.#console];
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
/** Whether the page reloaded since `open()`: the boot marker lives only in the page `open()` loaded. */
|
|
389
|
+
async reloaded() {
|
|
390
|
+
const marker = await this.evaluate(
|
|
391
|
+
(key: string) => (window as unknown as Record<string, string | undefined>)[key] ?? "",
|
|
392
|
+
RELOAD_MARKER,
|
|
393
|
+
).catch(() => "");
|
|
394
|
+
return marker !== this.#marker;
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
/** Rewrites a workspace file for the duration of `run`, and always puts the original back. */
|
|
398
|
+
async editSource<T>(file: string, transform: (source: string) => string, run: () => Promise<T>): Promise<T> {
|
|
399
|
+
const absolute = path.resolve(file);
|
|
400
|
+
const original = this.#edited.get(absolute) ?? (await Bun.file(absolute).text());
|
|
401
|
+
const next = transform(original);
|
|
402
|
+
if (next === original) throw new Error(`[csr-e2e] editSource left ${file} unchanged`);
|
|
403
|
+
this.#edited.set(absolute, original);
|
|
404
|
+
await Bun.write(absolute, next);
|
|
405
|
+
try {
|
|
406
|
+
return await run();
|
|
407
|
+
} finally {
|
|
408
|
+
await Bun.write(absolute, original);
|
|
409
|
+
this.#edited.delete(absolute);
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
async close() {
|
|
414
|
+
for (const [file, original] of this.#edited) await Bun.write(file, original);
|
|
415
|
+
this.#edited.clear();
|
|
416
|
+
this.#view.close();
|
|
417
|
+
await CsrE2eHarness.#stopServer(this.#server);
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
static #formatArg(arg: unknown): string {
|
|
421
|
+
if (typeof arg === "string") return arg;
|
|
422
|
+
try {
|
|
423
|
+
return JSON.stringify(arg) ?? String(arg);
|
|
424
|
+
} catch {
|
|
425
|
+
// A circular descriptor still names itself.
|
|
426
|
+
return String(arg);
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
static #defaultBackend(): "webkit" | "chrome" {
|
|
431
|
+
const requested = process.env.AKAN_CSR_E2E_BACKEND;
|
|
432
|
+
if (requested === "webkit" || requested === "chrome") return requested;
|
|
433
|
+
return process.platform === "darwin" ? "webkit" : "chrome";
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
static #findWorkspaceRoot() {
|
|
437
|
+
let dir = import.meta.dir;
|
|
438
|
+
for (;;) {
|
|
439
|
+
if (existsSync(path.join(dir, "pkgs/@akanjs/cli/build.ts"))) return dir;
|
|
440
|
+
const parent = path.dirname(dir);
|
|
441
|
+
if (parent === dir) throw new Error("[csr-e2e] workspace root not found above the harness");
|
|
442
|
+
dir = parent;
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
static async #startServer({
|
|
447
|
+
app,
|
|
448
|
+
port,
|
|
449
|
+
env = {},
|
|
450
|
+
workspaceRoot,
|
|
451
|
+
}: CsrE2eOptions & { port: number; workspaceRoot: string }): Promise<CsrE2eServer> {
|
|
452
|
+
//? The dev host runs from the CLI's dist bundle while the builder runs from source: a stale dist tests an old host.
|
|
453
|
+
//? The build is a no-op when its stamp matches the sources.
|
|
454
|
+
const built = Bun.spawnSync(["bun", path.join(workspaceRoot, "pkgs/@akanjs/cli/build.ts")], { cwd: workspaceRoot });
|
|
455
|
+
if (built.exitCode !== 0) throw new Error(`[csr-e2e] building the CLI failed: ${built.stderr.toString()}`);
|
|
456
|
+
const cli = path.join(workspaceRoot, "dist/pkgs/@akanjs/cli/index.js");
|
|
457
|
+
const logDir = path.join(workspaceRoot, "local", "csr-e2e");
|
|
458
|
+
await mkdir(logDir, { recursive: true });
|
|
459
|
+
const logPath = path.join(logDir, `${app}-${port}.log`);
|
|
460
|
+
const errPath = logPath.replace(/\.log$/, ".err.log");
|
|
461
|
+
//? A `Bun.file` sink writes from offset 0 without truncating, so the last run's tail would outlive this one's start.
|
|
462
|
+
await Promise.all([Bun.write(logPath, ""), Bun.write(errPath, "")]);
|
|
463
|
+
//? Its own process group, so stopping it reaches the gateway, the builder and every replica it spawned. That group
|
|
464
|
+
//? outlives a run killed before it could stop it, so `--kill` takes the suite's own port back from it first.
|
|
465
|
+
const proc = Bun.spawn(["bun", cli, "start", app, "--plain", "--kill"], {
|
|
466
|
+
cwd: workspaceRoot,
|
|
467
|
+
env: { ...process.env, AKAN_DEV_PORT: String(port), ...env },
|
|
468
|
+
stdout: Bun.file(logPath),
|
|
469
|
+
stderr: Bun.file(errPath),
|
|
470
|
+
detached: true,
|
|
471
|
+
});
|
|
472
|
+
return { proc, logPath };
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
static async #waitForHealth(origin: string, server: CsrE2eServer | null, timeoutMs: number) {
|
|
476
|
+
const deadline = Date.now() + timeoutMs;
|
|
477
|
+
while (Date.now() < deadline) {
|
|
478
|
+
if (server && server.proc.exitCode !== null)
|
|
479
|
+
throw new Error(`[csr-e2e] akan start exited with ${server.proc.exitCode}; see ${server.logPath}`);
|
|
480
|
+
const ok = await fetch(`${origin}/_akan/app/health`)
|
|
481
|
+
.then((res) => res.ok)
|
|
482
|
+
.catch(() => false);
|
|
483
|
+
if (ok) return;
|
|
484
|
+
await Bun.sleep(500);
|
|
485
|
+
}
|
|
486
|
+
throw new Error(
|
|
487
|
+
`[csr-e2e] ${origin} did not become healthy in ${timeoutMs}ms${server ? `; see ${server.logPath}` : ""}`,
|
|
488
|
+
);
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
static async #stopServer(server: CsrE2eServer | null) {
|
|
492
|
+
if (!server || server.proc.exitCode !== null) return;
|
|
493
|
+
const group = -server.proc.pid;
|
|
494
|
+
const signal = (name: NodeJS.Signals) => {
|
|
495
|
+
try {
|
|
496
|
+
process.kill(group, name);
|
|
497
|
+
} catch {
|
|
498
|
+
// The group is already gone.
|
|
499
|
+
}
|
|
500
|
+
};
|
|
501
|
+
signal("SIGINT");
|
|
502
|
+
const exited = await Promise.race([server.proc.exited.then(() => true), Bun.sleep(15_000).then(() => false)]);
|
|
503
|
+
if (!exited) signal("SIGKILL");
|
|
504
|
+
}
|
|
505
|
+
}
|