@akanjs/devkit 3.0.0 → 3.0.2-rc.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/CHANGELOG.md +1832 -0
- package/akanConfig/akanConfig.test.ts +3 -0
- package/akanConfig/akanConfig.ts +3 -1
- package/akanContext.ts +18 -1
- package/artifact/implicitRootLayout.test.ts +2 -1
- package/artifact/implicitRootLayout.ts +2 -1
- package/artifact/routeSeedIndex.test.ts +0 -14
- package/biome.base.json +12 -1
- package/commandDecorators/command.ts +3 -2
- package/commandDecorators/commandDecorators.test.ts +7 -0
- package/csrE2e/csrE2eHarness.fixture.ts +3 -2
- package/csrE2e/csrPageStack.e2e.test.ts +25 -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/frontendBuild/hmrWatcher.ts +6 -0
- package/integration/devStability.integration.test.ts +8 -5
- package/integration/devStabilityHarness.ts +2 -0
- 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-static-in-object-light-model/bad.ts +43 -0
- package/lint/__fixtures__/no-static-in-object-light-model/fixture.json +1 -0
- package/lint/__fixtures__/no-static-in-object-light-model/good.ts +42 -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-static-in-object-light-model.grit +27 -0
- 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/subspace.test.ts +10 -1
- package/testHelpers.ts +17 -0
- package/transforms/transforms.test.ts +13 -0
- package/tsconfig.json +4 -15
- package/workspaceLayout.test.ts +24 -1
- package/workspaceLayout.ts +19 -2
|
@@ -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", () => {
|
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);
|
|
@@ -69,7 +69,8 @@ describe("resolveSsrPageEntries", () => {
|
|
|
69
69
|
|
|
70
70
|
const generatedSource = await Bun.file(groupedRoot?.moduleAbsPath ?? "").text();
|
|
71
71
|
expect(generatedSource).toContain("<System.Provider");
|
|
72
|
-
expect(generatedSource).toContain("theme=
|
|
72
|
+
expect(generatedSource).toContain("export const theme = userLayout.theme ?? inheritedLayout.theme;");
|
|
73
|
+
expect(generatedSource).toContain("theme={theme}");
|
|
73
74
|
expect(generatedSource).toContain('import { allDictionary } from "../dict/useDict.ts";');
|
|
74
75
|
expect(generatedSource).toContain(
|
|
75
76
|
'allDictionary={process.env.AKAN_PUBLIC_RENDER_ENV === "ssr" ? allDictionary : undefined}',
|
|
@@ -215,6 +215,7 @@ const defaultFonts = userFonts.filter((font) => font.default);
|
|
|
215
215
|
if (defaultFonts.length > 1) throw new Error("[route-convention] only one default font is allowed per root layout");
|
|
216
216
|
const defaultFont = defaultFonts[0];
|
|
217
217
|
const defaultFontClassName = defaultFont ? (defaultFont.className ?? \`font-\${defaultFont.name}\`) : undefined;
|
|
218
|
+
export const theme = userLayout.theme ?? inheritedLayout.theme;
|
|
218
219
|
|
|
219
220
|
${layoutExports}(
|
|
220
221
|
<System.Provider
|
|
@@ -223,7 +224,7 @@ ${layoutExports}(
|
|
|
223
224
|
${prefix ? `prefix=${JSON.stringify(prefix)}\n ` : ""}params={params}
|
|
224
225
|
manifest={userLayout.manifest ?? inheritedLayout.manifest}
|
|
225
226
|
env={env}
|
|
226
|
-
theme={
|
|
227
|
+
theme={theme}
|
|
227
228
|
fonts={loadFonts(userFonts)}
|
|
228
229
|
className={defaultFontClassName}
|
|
229
230
|
layoutStyle={userLayout.layoutStyle ?? inheritedLayout.layoutStyle}
|
|
@@ -33,20 +33,6 @@ describe("computeRouteSeedIndex", () => {
|
|
|
33
33
|
).toThrow("route conflict");
|
|
34
34
|
});
|
|
35
35
|
|
|
36
|
-
test("keeps special route seeds outside implicit locale", () => {
|
|
37
|
-
const index = computeRouteSeedIndex([
|
|
38
|
-
{
|
|
39
|
-
key: "./__root_layout.tsx",
|
|
40
|
-
moduleAbsPath: "/app/.akan/generated/root-layouts/__root_layout.tsx",
|
|
41
|
-
seedAbsPaths: ["/app/page/_layout.tsx"],
|
|
42
|
-
},
|
|
43
|
-
{ key: "./robots.txt.tsx", moduleAbsPath: "/app/page/robots.txt.tsx" },
|
|
44
|
-
]);
|
|
45
|
-
const robots = index.entries.find((entry) => entry.routeId === "/robots.txt");
|
|
46
|
-
expect(robots?.seeds).not.toContain(path.resolve("/app/.akan/generated/root-layouts/__root_layout.tsx"));
|
|
47
|
-
expect(robots?.seeds).not.toContain(path.resolve("/app/page/_layout.tsx"));
|
|
48
|
-
});
|
|
49
|
-
|
|
50
36
|
test("serializes seed paths relative to the artifact directory", () => {
|
|
51
37
|
const artifactDir = "/repo/dist/apps/akan/.akan/artifact";
|
|
52
38
|
const serialized = serializeRouteSeedIndexForArtifact(
|
package/biome.base.json
CHANGED
|
@@ -176,13 +176,24 @@
|
|
|
176
176
|
"includes": ["{apps,libs}/**/*.store.ts"],
|
|
177
177
|
"plugins": ["./node_modules/@akanjs/devkit/lint/no-return-in-store-action.grit"]
|
|
178
178
|
},
|
|
179
|
+
{
|
|
180
|
+
"includes": ["{apps,libs}/**/*.constant.ts"],
|
|
181
|
+
"plugins": ["./node_modules/@akanjs/devkit/lint/no-static-in-object-light-model.grit"]
|
|
182
|
+
},
|
|
179
183
|
{
|
|
180
184
|
"includes": ["{apps,libs}/**/*.ts", "{apps,libs}/**/*.tsx"],
|
|
181
185
|
"plugins": ["./node_modules/@akanjs/devkit/lint/no-deprecated-log-level.grit"]
|
|
182
186
|
},
|
|
183
187
|
{
|
|
184
188
|
"includes": ["{apps,libs}/**/*.signal.ts"],
|
|
185
|
-
"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"]
|
|
186
197
|
},
|
|
187
198
|
{
|
|
188
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
|
});
|
|
@@ -203,9 +203,10 @@ export class CsrE2eHarness {
|
|
|
203
203
|
await Bun.sleep(50);
|
|
204
204
|
}
|
|
205
205
|
|
|
206
|
-
//? The CSR frame's own dev sync-navigation event: it calls the app router's push/replace, as a click would
|
|
206
|
+
//? The CSR frame's own dev sync-navigation event: it calls the app router's push/replace, as a click would, and a
|
|
207
|
+
//? `back` onto the entry under the page goes back, as another screen's relayed back does.
|
|
207
208
|
//? 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
|
+
async navigate(href: string, kind: "push" | "replace" | "back" = "push", { settleMs = SETTLE_MS } = {}) {
|
|
209
210
|
const target = new URL(href, this.origin);
|
|
210
211
|
await this.evaluate(
|
|
211
212
|
(next: string, how: string) => {
|
|
@@ -63,6 +63,19 @@ describe.skipIf(!CsrE2eHarness.enabled)("CSR page stack (minimal, /e2e/stack)",
|
|
|
63
63
|
expect(await csr.reloaded()).toBe(false);
|
|
64
64
|
}, 90_000);
|
|
65
65
|
|
|
66
|
+
test("a back relayed from another screen pops onto the entry under the page, keeping its state", async () => {
|
|
67
|
+
await csr.open(TAB_A);
|
|
68
|
+
await csr.navigate(item("31"));
|
|
69
|
+
await csr.type(currentInput, "thirty-one");
|
|
70
|
+
await csr.navigate(item("32"));
|
|
71
|
+
await csr.navigate(item("31"), "back");
|
|
72
|
+
expect(await itemIdsIn(CURRENT)).toEqual(["31"]);
|
|
73
|
+
expect(await csr.values(currentInput)).toEqual(["thirty-one"]);
|
|
74
|
+
await csr.back();
|
|
75
|
+
expect((await csr.currentPath()).split("?")[0]).toEndWith(TAB_A);
|
|
76
|
+
expect(await csr.reloaded()).toBe(false);
|
|
77
|
+
}, 60_000);
|
|
78
|
+
|
|
66
79
|
test("a replace within one route updates the page in place", async () => {
|
|
67
80
|
await csr.open(item("21"));
|
|
68
81
|
await csr.type(currentInput, "kept");
|
|
@@ -196,13 +209,22 @@ describe.skipIf(!CsrE2eHarness.enabled)("CSR page stack (minimal, /e2e/stack)",
|
|
|
196
209
|
expect(await csr.reloaded()).toBe(false);
|
|
197
210
|
}, 60_000);
|
|
198
211
|
|
|
199
|
-
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 () => {
|
|
200
213
|
await csr.open("/e2e/stack/broken");
|
|
201
|
-
await csr.waitFor(() =>
|
|
202
|
-
await Bun.sleep(500);
|
|
214
|
+
await csr.waitFor(() => !!document.querySelector("[role=alert] button"), { timeout: 10_000 });
|
|
203
215
|
expect(
|
|
204
216
|
csr.consoleMessages().some((line) => /render of page \d+ of \S*\/e2e\/stack\/broken failed/.test(line)),
|
|
205
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 });
|
|
206
228
|
}, 60_000);
|
|
207
229
|
|
|
208
230
|
test("the memory frame trace records navigation without writing it to the console", async () => {
|
package/executors.test.ts
CHANGED
|
@@ -1,15 +1,33 @@
|
|
|
1
|
-
import { describe, expect, test } from "bun:test";
|
|
1
|
+
import { describe, expect, spyOn, test } from "bun:test";
|
|
2
2
|
import { existsSync } from "node:fs";
|
|
3
3
|
import { lstat, mkdir, readFile, readlink, rm, stat, writeFile } from "node:fs/promises";
|
|
4
4
|
import path from "node:path";
|
|
5
5
|
import { AkanAppConfig } from "./akanConfig";
|
|
6
|
-
import {
|
|
6
|
+
import {
|
|
7
|
+
AppExecutor,
|
|
8
|
+
CommandExecutionError,
|
|
9
|
+
Executor,
|
|
10
|
+
type LibExecutor,
|
|
11
|
+
PkgExecutor,
|
|
12
|
+
WorkspaceExecutor,
|
|
13
|
+
} from "./executors";
|
|
14
|
+
import { DevGeneratedIndexSync } from "./frontendBuild/devGeneratedIndexSync";
|
|
7
15
|
import { AppInfo } from "./scanInfo";
|
|
8
|
-
import {
|
|
16
|
+
import {
|
|
17
|
+
createTempLib,
|
|
18
|
+
formatWithBiome,
|
|
19
|
+
hasBiome,
|
|
20
|
+
isolateEnv,
|
|
21
|
+
tempDirs,
|
|
22
|
+
tempRoots,
|
|
23
|
+
writeJson,
|
|
24
|
+
writeText,
|
|
25
|
+
} from "./testHelpers";
|
|
9
26
|
import type { PackageJson } from "./types";
|
|
10
27
|
|
|
11
28
|
isolateEnv();
|
|
12
29
|
const makeTempRoot = tempDirs("akan-devkit-");
|
|
30
|
+
const trackRoot = tempRoots();
|
|
13
31
|
|
|
14
32
|
const PAGE_SOURCE = "export default function Page() {\n return null;\n}\n";
|
|
15
33
|
|
|
@@ -309,11 +327,16 @@ describe("Workspace and app executor environment contracts", () => {
|
|
|
309
327
|
expect(env.EXTRA).toBe("ok");
|
|
310
328
|
|
|
311
329
|
const shellOperationMode = process.env.AKAN_PUBLIC_OPERATION_MODE;
|
|
330
|
+
const shellServerPort = process.env.AKAN_PUBLIC_SERVER_PORT;
|
|
312
331
|
process.env.AKAN_PUBLIC_OPERATION_MODE = "local";
|
|
332
|
+
process.env.AKAN_PUBLIC_SERVER_PORT = "8282";
|
|
313
333
|
const prepared = await app.prepareCommand("build");
|
|
314
334
|
const bakedOperationMode = process.env.AKAN_PUBLIC_OPERATION_MODE;
|
|
335
|
+
const bakedServerPort = process.env.AKAN_PUBLIC_SERVER_PORT;
|
|
315
336
|
if (shellOperationMode === undefined) delete process.env.AKAN_PUBLIC_OPERATION_MODE;
|
|
316
337
|
else process.env.AKAN_PUBLIC_OPERATION_MODE = shellOperationMode;
|
|
338
|
+
if (shellServerPort !== undefined) process.env.AKAN_PUBLIC_SERVER_PORT = shellServerPort;
|
|
339
|
+
expect(bakedServerPort).toBeUndefined();
|
|
317
340
|
expect(bakedOperationMode).toBeUndefined();
|
|
318
341
|
expect(prepared.env.AKAN_COMMAND_TYPE).toBe("build");
|
|
319
342
|
expect(prepared.env.AKAN_PUBLIC_BASE_PATHS).toBe("admin");
|
|
@@ -327,7 +350,7 @@ describe("Workspace and app executor environment contracts", () => {
|
|
|
327
350
|
expect((await stat(path.join(root, "dist/apps/demo/public"))).isDirectory()).toBe(true);
|
|
328
351
|
});
|
|
329
352
|
|
|
330
|
-
test("akan start clears the dev output and keeps
|
|
353
|
+
test("akan start clears the dev output and keeps the bin downloads and a native folder from before dist/native", async () => {
|
|
331
354
|
const root = await makeTempRoot();
|
|
332
355
|
process.env.AKAN_PUBLIC_REPO_NAME = "repo";
|
|
333
356
|
process.env.AKAN_PUBLIC_SERVE_DOMAIN = "example.com";
|
|
@@ -1055,3 +1078,305 @@ describe("SysExecutor module listing", () => {
|
|
|
1055
1078
|
]);
|
|
1056
1079
|
});
|
|
1057
1080
|
});
|
|
1081
|
+
|
|
1082
|
+
interface ScannableLibOptions {
|
|
1083
|
+
root?: Pick<PackageJson, "dependencies" | "devDependencies">;
|
|
1084
|
+
manifest?: Partial<PackageJson>;
|
|
1085
|
+
config?: string;
|
|
1086
|
+
files?: Record<string, string>;
|
|
1087
|
+
}
|
|
1088
|
+
|
|
1089
|
+
//* `LibInfo.libInfos` caches a scan by lib name for the whole file, so every test passes a name of its own.
|
|
1090
|
+
const createScannableLib = async (
|
|
1091
|
+
libName: string,
|
|
1092
|
+
{ root = {}, manifest = {}, config = "export default {};\n", files = {} }: ScannableLibOptions = {},
|
|
1093
|
+
) => {
|
|
1094
|
+
const temp = trackRoot(await createTempLib(libName));
|
|
1095
|
+
const libDir = path.join(temp.root, "libs", libName);
|
|
1096
|
+
await writeJson(path.join(temp.root, "package.json"), {
|
|
1097
|
+
name: "repo",
|
|
1098
|
+
version: "1.0.0",
|
|
1099
|
+
description: "repo",
|
|
1100
|
+
...root,
|
|
1101
|
+
});
|
|
1102
|
+
await writeJson(path.join(libDir, "package.json"), {
|
|
1103
|
+
type: "module",
|
|
1104
|
+
name: `@${libName}`,
|
|
1105
|
+
version: "0.0.1",
|
|
1106
|
+
...manifest,
|
|
1107
|
+
});
|
|
1108
|
+
await writeJson(path.join(libDir, "tsconfig.json"), { compilerOptions: { target: "ESNext", paths: {} } });
|
|
1109
|
+
await writeText(path.join(libDir, "akan.config.ts"), config);
|
|
1110
|
+
await mkdir(path.join(libDir, "lib", "__scalar"), { recursive: true });
|
|
1111
|
+
for (const [file, content] of Object.entries(files)) await writeText(path.join(libDir, file), content);
|
|
1112
|
+
return { ...temp, libDir };
|
|
1113
|
+
};
|
|
1114
|
+
|
|
1115
|
+
describe("SysExecutor scan", () => {
|
|
1116
|
+
test("gives a facet folder with nothing to export an empty barrel, so a barrel a deleted file left heals", async () => {
|
|
1117
|
+
const { lib } = await createScannableLib("barrelheal", {
|
|
1118
|
+
files: {
|
|
1119
|
+
"common/index.ts": 'export * from "./commonLogic";\n',
|
|
1120
|
+
"webkit/thing.helper.ts": "export const thing = 1;\n",
|
|
1121
|
+
},
|
|
1122
|
+
});
|
|
1123
|
+
|
|
1124
|
+
await lib.scan();
|
|
1125
|
+
|
|
1126
|
+
expect(await lib.readFile("common/index.ts")).toBe("export {};\n");
|
|
1127
|
+
expect(await lib.readFile("webkit/index.ts")).toBe("export {};\n");
|
|
1128
|
+
expect(await lib.exists("ui")).toBe(false);
|
|
1129
|
+
});
|
|
1130
|
+
|
|
1131
|
+
test("imports into the generated lib/dict.ts and lib/srv.ts only the helpers their code calls", async () => {
|
|
1132
|
+
const { lib: bare } = await createScannableLib("nomodulelib");
|
|
1133
|
+
await bare.scan();
|
|
1134
|
+
expect(await bare.readFile("lib/dict.ts")).toContain(
|
|
1135
|
+
'import { makeDictionary, makeTrans, dictionary as base } from "akanjs/dictionary";',
|
|
1136
|
+
);
|
|
1137
|
+
const bareSrv = await bare.readFile("lib/srv.ts");
|
|
1138
|
+
for (const unused of ["ServiceModel", '"./cnst"', '"./db"']) expect(bareSrv).not.toContain(unused);
|
|
1139
|
+
|
|
1140
|
+
const { lib: full } = await createScannableLib("allmodulelib", {
|
|
1141
|
+
files: {
|
|
1142
|
+
"lib/post/post.constant.ts": "export class Post {}\n",
|
|
1143
|
+
"lib/post/post.service.ts": "export class PostService {}\n",
|
|
1144
|
+
"lib/_mail/mail.service.ts": "export class MailService {}\n",
|
|
1145
|
+
"lib/__scalar/money/money.constant.ts": "export class Money {}\n",
|
|
1146
|
+
},
|
|
1147
|
+
});
|
|
1148
|
+
await full.scan();
|
|
1149
|
+
expect(await full.readFile("lib/dict.ts")).toContain(
|
|
1150
|
+
"import { makeDictionary, makeTrans, registerScalarTrans, registerServiceTrans, registerModelTrans, dictionary as base }",
|
|
1151
|
+
);
|
|
1152
|
+
const fullSrv = await full.readFile("lib/srv.ts");
|
|
1153
|
+
expect(fullSrv).toContain('import { ServiceModel } from "akanjs/service";');
|
|
1154
|
+
expect(fullSrv).toContain('import * as cnst from "./cnst";\nimport * as db from "./db";');
|
|
1155
|
+
});
|
|
1156
|
+
|
|
1157
|
+
test.skipIf(!hasBiome)("writes barrels, module indexes and akan.lib.json the way Biome prints them", async () => {
|
|
1158
|
+
const component = "export const C = () => null;\n";
|
|
1159
|
+
const uiFiles = ["QRCode", "Qa", "SSOButton", "Select", "Icon10", "Icon2"].map((name) => [
|
|
1160
|
+
`ui/${name}.tsx`,
|
|
1161
|
+
component,
|
|
1162
|
+
]);
|
|
1163
|
+
const srvkitFiles = ["aB", "aa", "a10", "a9"].map((name) => [`srvkit/${name}.ts`, "export const x = 1;\n"]);
|
|
1164
|
+
const moduleFiles = ["Template", "Unit", "View"].map((role) => [`lib/post/Post.${role}.tsx`, component]);
|
|
1165
|
+
const { root, lib, libDir } = await createScannableLib("biomestable", {
|
|
1166
|
+
files: Object.fromEntries([
|
|
1167
|
+
...uiFiles,
|
|
1168
|
+
...srvkitFiles,
|
|
1169
|
+
...moduleFiles,
|
|
1170
|
+
["lib/post/post.constant.ts", "export class Post {}\n"],
|
|
1171
|
+
]),
|
|
1172
|
+
});
|
|
1173
|
+
expect(await formatWithBiome('export * from "./b";\nexport * from "./a";\n', "libs/x/srvkit/index.ts")).toBe(
|
|
1174
|
+
'export * from "./a";\nexport * from "./b";\n',
|
|
1175
|
+
);
|
|
1176
|
+
|
|
1177
|
+
await lib.scan();
|
|
1178
|
+
|
|
1179
|
+
expect(await lib.readFile("ui/index.ts")).toBe(
|
|
1180
|
+
["Icon2", "Icon10", "Qa", "QRCode", "Select", "SSOButton"].map((name) => `export * from "./${name}";\n`).join(""),
|
|
1181
|
+
);
|
|
1182
|
+
expect(await lib.readFile("srvkit/index.ts")).toBe(
|
|
1183
|
+
["a9", "a10", "aa", "aB"].map((name) => `export * from "./${name}";\n`).join(""),
|
|
1184
|
+
);
|
|
1185
|
+
for (const file of ["ui/index.ts", "srvkit/index.ts", "lib/post/index.ts", "akan.lib.json"]) {
|
|
1186
|
+
const generated = await lib.readFile(file);
|
|
1187
|
+
expect(await formatWithBiome(generated, `libs/biomestable/${file}`)).toBe(generated);
|
|
1188
|
+
}
|
|
1189
|
+
const devSync = new DevGeneratedIndexSync({ workspaceRoot: root });
|
|
1190
|
+
const events = ["ui/QRCode.tsx", "srvkit/a9.ts", "lib/post"].map((file) => path.join(libDir, file));
|
|
1191
|
+
expect(await devSync.syncForBatch(events)).toEqual({ changedFiles: [], errors: [] });
|
|
1192
|
+
});
|
|
1193
|
+
|
|
1194
|
+
test("refuses a hand-written ui/index.tsx beside the generated barrel, naming the file and the fix", async () => {
|
|
1195
|
+
const { lib } = await createScannableLib("shadowedbarrel", {
|
|
1196
|
+
files: {
|
|
1197
|
+
"ui/Chat.tsx": "export const Chat = () => null;\n",
|
|
1198
|
+
"ui/index.tsx": 'export { Chat } from "./Chat";\n',
|
|
1199
|
+
"ui/Page/index.tsx": 'export { Inner } from "./Inner";\n',
|
|
1200
|
+
"ui/Page/index_.tsx": '"use client";\nexport { Inner } from "./Inner";\n',
|
|
1201
|
+
},
|
|
1202
|
+
});
|
|
1203
|
+
|
|
1204
|
+
const error = await lib.scan().catch((caught: unknown) => caught);
|
|
1205
|
+
|
|
1206
|
+
expect(error).toBeInstanceOf(Error);
|
|
1207
|
+
const { message } = error as Error;
|
|
1208
|
+
expect(message).toContain("libs/shadowedbarrel/ui/index.tsx: shadows the generated ui/index.ts");
|
|
1209
|
+
expect(message).toContain("Delete it");
|
|
1210
|
+
expect(message).not.toContain("ui/Page/");
|
|
1211
|
+
});
|
|
1212
|
+
});
|
|
1213
|
+
|
|
1214
|
+
describe("SysExecutor dependency sync", () => {
|
|
1215
|
+
const root: Pick<PackageJson, "dependencies" | "devDependencies"> = {
|
|
1216
|
+
dependencies: {
|
|
1217
|
+
chalk: "^5.6.2",
|
|
1218
|
+
esbuild: "^0.25.0",
|
|
1219
|
+
lodash: "^4.17.21",
|
|
1220
|
+
react: "19.3.0",
|
|
1221
|
+
"react-dom": "19.3.0",
|
|
1222
|
+
sharp: "^0.34.0",
|
|
1223
|
+
zod: "3.25.76",
|
|
1224
|
+
},
|
|
1225
|
+
devDependencies: { dayjs: "^1.11.20", "happy-dom": "20.11.2", typescript: "^6.0.3" },
|
|
1226
|
+
};
|
|
1227
|
+
const readManifest = async (libDir: string) =>
|
|
1228
|
+
(await Bun.file(path.join(libDir, "package.json")).json()) as PackageJson;
|
|
1229
|
+
const silenced = (lib: LibExecutor) => ({
|
|
1230
|
+
info: spyOn(lib.logger, "info").mockImplementation(() => undefined),
|
|
1231
|
+
warn: spyOn(lib.logger, "warn").mockImplementation(() => undefined),
|
|
1232
|
+
});
|
|
1233
|
+
|
|
1234
|
+
test("prunes what the root declares and nothing imports, and keeps and warns once about what it does not", async () => {
|
|
1235
|
+
const { lib, libDir } = await createScannableLib("prunedeps", {
|
|
1236
|
+
root,
|
|
1237
|
+
manifest: {
|
|
1238
|
+
dependencies: { lodash: "^4.0.0", "react-dom": "19.2.6", "left-pad": "^1.3.0", "is-odd": "^3.0.1" },
|
|
1239
|
+
devDependencies: {},
|
|
1240
|
+
},
|
|
1241
|
+
files: { "srvkit/pad.ts": 'import padStart from "lodash/padStart";\nexport const pad = padStart;\n' },
|
|
1242
|
+
});
|
|
1243
|
+
const { info, warn } = silenced(lib);
|
|
1244
|
+
|
|
1245
|
+
await lib.scan();
|
|
1246
|
+
|
|
1247
|
+
expect((await readManifest(libDir)).dependencies).toEqual({
|
|
1248
|
+
lodash: "^4.17.21",
|
|
1249
|
+
"left-pad": "^1.3.0",
|
|
1250
|
+
"is-odd": "^3.0.1",
|
|
1251
|
+
});
|
|
1252
|
+
expect(info).toHaveBeenCalledTimes(1);
|
|
1253
|
+
expect(info.mock.calls[0]?.[0]).toContain(": react-dom (");
|
|
1254
|
+
expect(warn).toHaveBeenCalledTimes(1);
|
|
1255
|
+
expect(warn.mock.calls[0]?.[0]).toContain("libs/prunedeps/package.json lists left-pad, is-odd");
|
|
1256
|
+
});
|
|
1257
|
+
|
|
1258
|
+
test("keeps what externalLibs, trustedDependencies or akan.keepDependencies names", async () => {
|
|
1259
|
+
const akan = { keepDependencies: ["chalk"] };
|
|
1260
|
+
const { lib, libDir } = await createScannableLib("keptdeps", {
|
|
1261
|
+
root,
|
|
1262
|
+
config: 'export default { externalLibs: ["sharp"], trustedDependencies: ["esbuild"] };\n',
|
|
1263
|
+
manifest: {
|
|
1264
|
+
dependencies: { sharp: "^0.33.0", esbuild: "^0.24.0", chalk: "^5.0.0", "react-dom": "19.2.6" },
|
|
1265
|
+
devDependencies: {},
|
|
1266
|
+
akan,
|
|
1267
|
+
},
|
|
1268
|
+
});
|
|
1269
|
+
silenced(lib);
|
|
1270
|
+
|
|
1271
|
+
await lib.scan();
|
|
1272
|
+
|
|
1273
|
+
const manifest = await readManifest(libDir);
|
|
1274
|
+
expect(manifest.dependencies).toEqual({ sharp: "^0.34.0", esbuild: "^0.25.0", chalk: "^5.6.2" });
|
|
1275
|
+
expect(manifest.akan).toEqual(akan);
|
|
1276
|
+
});
|
|
1277
|
+
|
|
1278
|
+
test("gives every surviving entry the root version, leaving peer, optional and the akan source alone", async () => {
|
|
1279
|
+
const untouched = {
|
|
1280
|
+
peerDependencies: { react: "^19.0.0" },
|
|
1281
|
+
optionalDependencies: { fsevents: "^2.3.3" },
|
|
1282
|
+
akan: { source: { origin: "akanjs", sha: "3.0.0", hash: "0f0f", syncedAt: "2026-01-01T00:00:00.000Z" } },
|
|
1283
|
+
};
|
|
1284
|
+
const { lib, libDir } = await createScannableLib("realigneddeps", {
|
|
1285
|
+
root,
|
|
1286
|
+
manifest: {
|
|
1287
|
+
dependencies: { react: "19.2.7", lodash: "^4.0.0" },
|
|
1288
|
+
devDependencies: { dayjs: "^1.11.13" },
|
|
1289
|
+
...untouched,
|
|
1290
|
+
},
|
|
1291
|
+
files: {
|
|
1292
|
+
"ui/Thing.tsx": 'import { useState } from "react";\nexport const Thing = () => useState(0);\n',
|
|
1293
|
+
"srvkit/pick.ts": 'import pick from "lodash/pick";\nexport const pickOf = pick;\n',
|
|
1294
|
+
},
|
|
1295
|
+
});
|
|
1296
|
+
silenced(lib);
|
|
1297
|
+
|
|
1298
|
+
await lib.scan();
|
|
1299
|
+
|
|
1300
|
+
const manifest = await readManifest(libDir);
|
|
1301
|
+
expect(manifest.dependencies).toEqual({ react: "19.3.0", lodash: "^4.17.21" });
|
|
1302
|
+
expect(manifest.devDependencies).toEqual({ dayjs: "^1.11.20" });
|
|
1303
|
+
expect(manifest).toMatchObject(untouched);
|
|
1304
|
+
});
|
|
1305
|
+
|
|
1306
|
+
test("keeps the existing order, appends new names sorted, and a second scan rewrites nothing", async () => {
|
|
1307
|
+
const { lib, libDir } = await createScannableLib("ordereddeps", {
|
|
1308
|
+
root,
|
|
1309
|
+
manifest: { dependencies: { zod: "3.25.76", lodash: "^4.17.21" }, devDependencies: {} },
|
|
1310
|
+
files: {
|
|
1311
|
+
"srvkit/uses.ts": [
|
|
1312
|
+
'import { z } from "zod";',
|
|
1313
|
+
'import chunk from "lodash/chunk";',
|
|
1314
|
+
'import { createElement } from "react";',
|
|
1315
|
+
'import chalk from "chalk";',
|
|
1316
|
+
"export const uses = [z, chunk, createElement, chalk];",
|
|
1317
|
+
"",
|
|
1318
|
+
].join("\n"),
|
|
1319
|
+
},
|
|
1320
|
+
});
|
|
1321
|
+
silenced(lib);
|
|
1322
|
+
|
|
1323
|
+
await lib.scan();
|
|
1324
|
+
const manifestFile = path.join(libDir, "package.json");
|
|
1325
|
+
const [first, { mtimeMs }] = await Promise.all([readFile(manifestFile, "utf8"), stat(manifestFile)]);
|
|
1326
|
+
expect(Object.keys((JSON.parse(first) as PackageJson).dependencies ?? {})).toEqual([
|
|
1327
|
+
"zod",
|
|
1328
|
+
"lodash",
|
|
1329
|
+
"chalk",
|
|
1330
|
+
"react",
|
|
1331
|
+
]);
|
|
1332
|
+
|
|
1333
|
+
await lib.scan({ refresh: true });
|
|
1334
|
+
expect(await readFile(manifestFile, "utf8")).toBe(first);
|
|
1335
|
+
expect((await stat(manifestFile)).mtimeMs).toBe(mtimeMs);
|
|
1336
|
+
});
|
|
1337
|
+
|
|
1338
|
+
test("moves a type-only import to devDependencies", async () => {
|
|
1339
|
+
const { lib, libDir } = await createScannableLib("typeonlydeps", {
|
|
1340
|
+
root,
|
|
1341
|
+
manifest: { dependencies: { zod: "3.25.76" }, devDependencies: {} },
|
|
1342
|
+
files: { "common/schema.ts": 'import type { ZodType } from "zod";\nexport type Schema = ZodType;\n' },
|
|
1343
|
+
});
|
|
1344
|
+
silenced(lib);
|
|
1345
|
+
|
|
1346
|
+
await lib.scan();
|
|
1347
|
+
|
|
1348
|
+
const manifest = await readManifest(libDir);
|
|
1349
|
+
expect(manifest.dependencies).toEqual({});
|
|
1350
|
+
expect(manifest.devDependencies).toEqual({ zod: "3.25.76" });
|
|
1351
|
+
});
|
|
1352
|
+
|
|
1353
|
+
test("leaves a runtime import the root lists only in devDependencies in devDependencies", async () => {
|
|
1354
|
+
const { lib, libDir } = await createScannableLib("devonlydeps", {
|
|
1355
|
+
root,
|
|
1356
|
+
manifest: { dependencies: {}, devDependencies: { "happy-dom": "20.0.0" } },
|
|
1357
|
+
files: {
|
|
1358
|
+
"srvkit/dom.ts": 'import { Window } from "happy-dom";\nexport const createWindow = () => new Window();\n',
|
|
1359
|
+
},
|
|
1360
|
+
});
|
|
1361
|
+
silenced(lib);
|
|
1362
|
+
|
|
1363
|
+
await lib.scan();
|
|
1364
|
+
|
|
1365
|
+
const manifest = await readManifest(libDir);
|
|
1366
|
+
expect(manifest.dependencies).toEqual({});
|
|
1367
|
+
expect(manifest.devDependencies).toEqual({ "happy-dom": "20.11.2" });
|
|
1368
|
+
});
|
|
1369
|
+
|
|
1370
|
+
test("has written the manifest by the time scan returns", async () => {
|
|
1371
|
+
const { lib, libDir } = await createScannableLib("settleddeps", {
|
|
1372
|
+
root,
|
|
1373
|
+
manifest: { dependencies: { "react-dom": "19.2.6" }, devDependencies: {} },
|
|
1374
|
+
files: { "srvkit/pick.ts": 'import pick from "lodash/pick";\nexport const pickOf = pick;\n' },
|
|
1375
|
+
});
|
|
1376
|
+
silenced(lib);
|
|
1377
|
+
|
|
1378
|
+
await lib.scan({ writeLib: false });
|
|
1379
|
+
|
|
1380
|
+
expect((await readManifest(libDir)).dependencies).toEqual({ lodash: "^4.17.21" });
|
|
1381
|
+
});
|
|
1382
|
+
});
|