@akanjs/devkit 3.1.1 → 3.1.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/akanConfig/akanConfig.test.ts +75 -0
- package/akanConfig/akanConfig.ts +11 -1
- package/akanMcpContract.test.ts +28 -1
- package/akanMcpContract.ts +51 -3
- package/applicationBuildRunner.test.ts +36 -0
- package/applicationBuildRunner.ts +4 -3
- package/artifact/implicitRootLayout.ts +4 -2
- package/cloud/globalConfig.test.ts +34 -0
- package/cloud/globalConfig.ts +2 -2
- package/codeAgent/agent/AkanCodeEnv.ts +26 -0
- package/codeAgent/agent/AkanCodeServices.ts +5 -2
- package/codeAgent/agent/CodeAgent.ts +7 -0
- package/codeAgent/agent/CodeAgentGate.ts +156 -10
- package/codeAgent/agent/CodeAgentProxy.ts +5 -4
- package/codeAgent/agent/CodeAgentRpcHost.ts +3 -0
- package/codeAgent/agent/CodeAgentRpcListener.ts +102 -16
- package/codeAgent/agent/WorkspaceTrust.ts +200 -0
- package/codeAgent/agent/akanCodePaths.ts +8 -1
- package/codeAgent/agent/akanSystemPrompt.ts +4 -0
- package/codeAgent/agent/codeAgentContract.test.ts +119 -0
- package/codeAgent/agent/codeAgentHosts.ts +30 -0
- package/codeAgent/agent/codeAgentProxy.test.ts +53 -1
- package/codeAgent/agent/codeAgentRpcListener.test.ts +115 -13
- package/codeAgent/agent/stringArg.ts +1 -1
- package/codeAgent/skills/akan-agent/SKILL.md +8 -1
- package/codeAgent/skills/akan-code/SKILL.md +37 -11
- package/codeAgent/skills/akan-debug/SKILL.md +2 -1
- package/codeAgent/skills/akan-err/SKILL.md +2 -1
- package/codeAgent/skills/akan-query/SKILL.md +3 -2
- package/codeAgent/skills/akan-runtime/SKILL.md +3 -0
- package/codeAgent/tools/McpServerConfig.ts +6 -4
- package/codeAgent/tools/McpSignIn.ts +7 -7
- package/codeAgent/tools/McpTokenStore.ts +63 -16
- package/codeAgent/tools/McpToolPack.ts +8 -1
- package/codeAgent/tools/WebToolPack.ts +50 -6
- package/codeAgent/tools/akanCodePlugins.test.ts +70 -2
- package/codeAgent/tools/mcpOAuth.test.ts +50 -14
- package/codeAgent/tools/mcpServerConfig.test.ts +130 -1
- package/commandDecorators/command.ts +4 -1
- package/createTunnel.test.ts +200 -2
- package/createTunnel.ts +80 -6
- package/envSeed.test.ts +90 -0
- package/envSeed.ts +68 -0
- package/executors.test.ts +19 -1
- package/executors.ts +41 -2
- package/fileSys.test.ts +30 -2
- package/fileSys.ts +14 -0
- package/formSetterScanner.test.ts +44 -1
- package/formSetterScanner.ts +53 -8
- package/frontendBuild/csrArtifactBuilder.ts +7 -0
- package/frontendBuild/mcpAppViewBuilder.test.ts +63 -0
- package/frontendBuild/mcpAppViewBuilder.ts +7 -1
- package/frontendBuild/optionalPeers.test.ts +132 -0
- package/integration/ssrMemoryProbe.ts +6 -0
- package/lint/no-unguarded-endpoint.grit +5 -5
- package/maintainabilityReport.ts +1 -1
- package/mobile/desktopServerStage.test.ts +1 -0
- package/mobile/desktopServerStage.ts +3 -1
- package/mobile/nativeApp.test.ts +20 -5
- package/mobile/nativeApp.ts +4 -1
- package/mobile/nativeConfig.test.ts +209 -5
- package/mobile/nativeConfig.ts +127 -6
- package/mobile/nativePublicEnv.test.ts +2 -0
- package/mobile/nativePublicEnv.ts +13 -0
- package/package.json +3 -2
- package/shellOnlyEnv.test.ts +65 -0
- package/shellOnlyEnv.ts +54 -0
- package/signalMutationIndex.ts +22 -81
- package/sshHostKeys.ts +59 -0
- package/storeSource.ts +9 -8
- package/subspace.test.ts +10 -0
- package/subspaceClone.ts +3 -1
- package/subspaceConfig.ts +6 -0
- package/transforms/barrelImportsPlugin.ts +10 -4
- package/transforms/mcpAppPageTrimmer.test.ts +8 -2
- package/transforms/mcpAppPageTrimmer.ts +5 -20
- package/transforms/pureLazyMarker.ts +1 -1
- package/workflow/mutationScaffold.ts +35 -21
- package/workflow/plan.ts +28 -0
- package/workflow/sourceAst.ts +10 -48
package/executors.test.ts
CHANGED
|
@@ -376,6 +376,23 @@ describe("Workspace and app executor environment contracts", () => {
|
|
|
376
376
|
});
|
|
377
377
|
});
|
|
378
378
|
|
|
379
|
+
test("a workspace with no .env, as a fresh clone has, runs local unless the shell names an environment", async () => {
|
|
380
|
+
const root = await makeTempRoot();
|
|
381
|
+
delete process.env.AKAN_PUBLIC_ENV;
|
|
382
|
+
|
|
383
|
+
WorkspaceExecutor.defaultCloneToLocalEnv(root);
|
|
384
|
+
expect(WorkspaceExecutor.getBaseDevEnv(path.join(root, ".env")).env).toBe("local");
|
|
385
|
+
|
|
386
|
+
process.env.AKAN_PUBLIC_ENV = "main";
|
|
387
|
+
WorkspaceExecutor.defaultCloneToLocalEnv(root);
|
|
388
|
+
expect(process.env.AKAN_PUBLIC_ENV).toBe("main");
|
|
389
|
+
|
|
390
|
+
delete process.env.AKAN_PUBLIC_ENV;
|
|
391
|
+
await writeFile(path.join(root, ".env"), "AKAN_PUBLIC_REPO_NAME=repo\n");
|
|
392
|
+
WorkspaceExecutor.defaultCloneToLocalEnv(root);
|
|
393
|
+
expect(WorkspaceExecutor.getBaseDevEnv(path.join(root, ".env")).env).toBe("debug");
|
|
394
|
+
});
|
|
395
|
+
|
|
379
396
|
test("a build or tunnel for a real environment refuses a defaulted value, and a local one takes it", () => {
|
|
380
397
|
const baseDevEnv = {
|
|
381
398
|
repoName: "fixture-repo",
|
|
@@ -1305,7 +1322,8 @@ const createScannableLib = async (
|
|
|
1305
1322
|
await writeJson(path.join(libDir, "tsconfig.json"), { compilerOptions: { target: "ESNext", paths: {} } });
|
|
1306
1323
|
await writeText(path.join(libDir, "akan.config.ts"), config);
|
|
1307
1324
|
await mkdir(path.join(libDir, "lib", "__scalar"), { recursive: true });
|
|
1308
|
-
|
|
1325
|
+
const libFiles = { "env/env.server.testing.ts": "export const env = {};\n", ...files };
|
|
1326
|
+
for (const [file, content] of Object.entries(libFiles)) await writeText(path.join(libDir, file), content);
|
|
1309
1327
|
return { ...temp, libDir };
|
|
1310
1328
|
};
|
|
1311
1329
|
|
package/executors.ts
CHANGED
|
@@ -44,6 +44,7 @@ import {
|
|
|
44
44
|
import { AkanAppConfig, AkanLibConfig, AkanWorkspaceConfig, decreaseBuildNum, increaseBuildNum } from "./akanConfig";
|
|
45
45
|
import { getRootBoundarySegments, isRootBoundarySegments } from "./artifact/implicitRootLayout";
|
|
46
46
|
import { CodegenLock } from "./codegenLock";
|
|
47
|
+
import { EnvSeed } from "./envSeed";
|
|
47
48
|
import { FileSys, getDirname } from "./fileSys";
|
|
48
49
|
import { JsonFormatter } from "./jsonFormatter";
|
|
49
50
|
import { Linter } from "./linter";
|
|
@@ -429,11 +430,13 @@ export class Executor {
|
|
|
429
430
|
targetPath,
|
|
430
431
|
scanInfo,
|
|
431
432
|
overwrite = true,
|
|
433
|
+
dryRun = false,
|
|
432
434
|
}: {
|
|
433
435
|
templatePath: string;
|
|
434
436
|
targetPath: string;
|
|
435
437
|
scanInfo?: AppInfo | LibInfo | null;
|
|
436
438
|
overwrite?: boolean;
|
|
439
|
+
dryRun?: boolean;
|
|
437
440
|
},
|
|
438
441
|
dict: { [key: string]: string } = {},
|
|
439
442
|
options: { [key: string]: unknown } = {},
|
|
@@ -456,6 +459,7 @@ export class Executor {
|
|
|
456
459
|
const filename = typeof result === "object" ? result.filename : path.basename(targetPath).replace(".js", ".ts");
|
|
457
460
|
const content = typeof result === "object" ? result.content : result;
|
|
458
461
|
const convertedTargetPath = convertPath(`${path.dirname(targetPath)}/${filename}`);
|
|
462
|
+
if (dryRun) return { filePath: this.getPath(convertedTargetPath), content, written: false };
|
|
459
463
|
this.logger.verbose(`Apply template ${templatePath} to ${convertedTargetPath}`);
|
|
460
464
|
return this.writeFile(convertedTargetPath, content, { overwrite });
|
|
461
465
|
} else if (targetPath.endsWith(".template")) {
|
|
@@ -465,11 +469,13 @@ export class Executor {
|
|
|
465
469
|
(data, [key, value]) => data.replace(new RegExp(`<%= ${key} %>`, "g"), value),
|
|
466
470
|
content,
|
|
467
471
|
);
|
|
472
|
+
if (dryRun) return { filePath: this.getPath(convertedTargetPath), content: convertedContent, written: false };
|
|
468
473
|
this.logger.verbose(`Apply template ${templatePath} to ${convertedTargetPath}`);
|
|
469
474
|
return this.writeFile(convertedTargetPath, convertedContent, { overwrite });
|
|
470
475
|
} else if (staticTemplateFileExtensions.has(path.extname(targetPath).toLowerCase())) {
|
|
471
476
|
const convertedTargetPath = convertPath(targetPath);
|
|
472
477
|
const writePath = this.getPath(convertedTargetPath);
|
|
478
|
+
if (dryRun) return { filePath: writePath, content: "", written: false };
|
|
473
479
|
if (!overwrite && (await FileSys.fileExists(writePath)))
|
|
474
480
|
return { filePath: writePath, content: "", written: false };
|
|
475
481
|
const dirname = path.dirname(writePath);
|
|
@@ -486,6 +492,7 @@ export class Executor {
|
|
|
486
492
|
dict = {},
|
|
487
493
|
options = {},
|
|
488
494
|
overwrite = true,
|
|
495
|
+
dryRun = false,
|
|
489
496
|
}: {
|
|
490
497
|
basePath: string;
|
|
491
498
|
template: string;
|
|
@@ -493,6 +500,7 @@ export class Executor {
|
|
|
493
500
|
dict?: { [key: string]: string };
|
|
494
501
|
options?: { [key: string]: unknown };
|
|
495
502
|
overwrite?: boolean;
|
|
503
|
+
dryRun?: boolean;
|
|
496
504
|
}): Promise<WrittenFileContent[]> {
|
|
497
505
|
const templateRoot = await this.#resolveTemplateRoot();
|
|
498
506
|
const templatePath = `${templateRoot}${template ? `/${template}` : ""}`;
|
|
@@ -503,6 +511,7 @@ export class Executor {
|
|
|
503
511
|
targetPath: path.join(basePath, path.basename(templatePath)),
|
|
504
512
|
scanInfo,
|
|
505
513
|
overwrite,
|
|
514
|
+
dryRun,
|
|
506
515
|
},
|
|
507
516
|
dict,
|
|
508
517
|
options,
|
|
@@ -521,6 +530,7 @@ export class Executor {
|
|
|
521
530
|
targetPath: path.join(basePath, subdir),
|
|
522
531
|
scanInfo,
|
|
523
532
|
overwrite,
|
|
533
|
+
dryRun,
|
|
524
534
|
},
|
|
525
535
|
dict,
|
|
526
536
|
options,
|
|
@@ -533,6 +543,7 @@ export class Executor {
|
|
|
533
543
|
scanInfo,
|
|
534
544
|
dict,
|
|
535
545
|
overwrite,
|
|
546
|
+
dryRun,
|
|
536
547
|
options,
|
|
537
548
|
});
|
|
538
549
|
}),
|
|
@@ -558,6 +569,7 @@ export class Executor {
|
|
|
558
569
|
dict?: { [key: string]: string };
|
|
559
570
|
options?: { [key: string]: unknown };
|
|
560
571
|
overwrite?: boolean;
|
|
572
|
+
dryRun?: boolean;
|
|
561
573
|
}): Promise<FileContent[]> {
|
|
562
574
|
const fileContents = await this._applyTemplate({ ...options, dict: withCapitalizedDict(options.dict) });
|
|
563
575
|
await this.#formatAppliedTemplate(fileContents.filter((fileContent) => fileContent.written));
|
|
@@ -712,6 +724,10 @@ export class WorkspaceExecutor extends Executor {
|
|
|
712
724
|
defaulted,
|
|
713
725
|
};
|
|
714
726
|
}
|
|
727
|
+
//? A clone has no `.env` (gitignored for its secrets): it runs local, as a new workspace and env.server.ts do, not debug.
|
|
728
|
+
static defaultCloneToLocalEnv(workspaceRoot: string) {
|
|
729
|
+
if (!existsSync(path.join(workspaceRoot, ".env"))) process.env.AKAN_PUBLIC_ENV ??= "local";
|
|
730
|
+
}
|
|
715
731
|
//? A build or tunnel for a real environment carries these into an image, a bundle or a host name it dials.
|
|
716
732
|
static assertDeployableEnv(baseDevEnv: BaseDevEnv, environment: string, purpose: string) {
|
|
717
733
|
const defaulted = environment === "local" ? [] : (baseDevEnv.defaulted ?? []);
|
|
@@ -804,11 +820,19 @@ export class WorkspaceExecutor extends Executor {
|
|
|
804
820
|
};
|
|
805
821
|
return await getDirs(basePath);
|
|
806
822
|
}
|
|
807
|
-
async commit(
|
|
823
|
+
async commit(
|
|
824
|
+
message: string,
|
|
825
|
+
{
|
|
826
|
+
init = false,
|
|
827
|
+
add = true,
|
|
828
|
+
identity,
|
|
829
|
+
}: { init?: boolean; add?: boolean; identity?: { name: string; email: string } } = {},
|
|
830
|
+
) {
|
|
808
831
|
if (init) await this.spawn("git", ["init", "--quiet"]);
|
|
809
832
|
if (add) await this.spawn("git", ["add", "."]);
|
|
833
|
+
const author = identity ? ["-c", `user.name=${identity.name}`, "-c", `user.email=${identity.email}`] : [];
|
|
810
834
|
// Argument vector, not a shell string: a message carrying a double quote breaks the interpolated form.
|
|
811
|
-
await this.spawn("git", ["commit", "--quiet", "-m", message]);
|
|
835
|
+
await this.spawn("git", [...author, "commit", "--quiet", "-m", message]);
|
|
812
836
|
}
|
|
813
837
|
/** `git commit` exits non-zero on an empty index, so a re-runnable caller has to ask first. */
|
|
814
838
|
async hasChanges() {
|
|
@@ -923,8 +947,23 @@ export class SysExecutor extends Executor {
|
|
|
923
947
|
if (!this.hasScanInfo() && !allowEmpty) throw new Error("Scan info is not available");
|
|
924
948
|
return this.#scanInfo as AllowEmpty extends true ? AppInfo | LibInfo | null : AppInfo | LibInfo;
|
|
925
949
|
}
|
|
950
|
+
async #seedEnvFiles(): Promise<FileContent[]> {
|
|
951
|
+
const envDir = path.join(this.cwdPath, "env");
|
|
952
|
+
const missing = await new EnvSeed(envDir).missing();
|
|
953
|
+
if (!missing.length) return [];
|
|
954
|
+
const written = await Promise.all(
|
|
955
|
+
missing.map(({ fileName, content }) =>
|
|
956
|
+
this.writeFile(path.join(envDir, fileName), content, { overwrite: false, silent: true }),
|
|
957
|
+
),
|
|
958
|
+
);
|
|
959
|
+
this.logger.info(
|
|
960
|
+
`${missing.map(({ fileName }) => `env/${fileName}`).join(", ")} were missing (gitignored, so a clone has none) and now hold local defaults: put this machine's values in them, or fetch the team's with \`akan download-env\`.`,
|
|
961
|
+
);
|
|
962
|
+
return written;
|
|
963
|
+
}
|
|
926
964
|
#getScanTemplateTasks(scanInfo: AppInfo | LibInfo): (Promise<FileContent[]> | null)[] {
|
|
927
965
|
return [
|
|
966
|
+
this.#seedEnvFiles(),
|
|
928
967
|
this._applyTemplate({ basePath: "env", template: "env", scanInfo }),
|
|
929
968
|
this._applyTemplate({ basePath: "lib", template: "lib", scanInfo }),
|
|
930
969
|
this._applyTemplate({ basePath: ".", template: "server.ts", scanInfo }),
|
package/fileSys.test.ts
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
|
-
import { describe, expect, test } from "bun:test";
|
|
1
|
+
import { describe, expect, spyOn, test } from "bun:test";
|
|
2
|
+
import { chmodSync, mkdirSync, mkdtempSync, rmSync, statSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
2
4
|
import path from "node:path";
|
|
3
5
|
import { pathToFileURL } from "node:url";
|
|
4
|
-
import { getDirname } from "./fileSys";
|
|
6
|
+
import { FileSys, getDirname } from "./fileSys";
|
|
5
7
|
|
|
6
8
|
describe("getDirname", () => {
|
|
7
9
|
test("converts file URLs to filesystem paths", () => {
|
|
@@ -19,3 +21,29 @@ describe("getDirname", () => {
|
|
|
19
21
|
expect(dirname).toBe("C:\\Users\\ken78\\.bun\\install\\global\\node_modules\\@akanjs\\devkit");
|
|
20
22
|
});
|
|
21
23
|
});
|
|
24
|
+
|
|
25
|
+
describe("FileSys.ensurePrivateDir", () => {
|
|
26
|
+
//? macOS refuses even the owner a chmod of a folder flagged user-immutable; Linux needs root for the same.
|
|
27
|
+
test.skipIf(process.platform !== "darwin")(
|
|
28
|
+
"warns instead of throwing when the folder's mode cannot be changed",
|
|
29
|
+
() => {
|
|
30
|
+
const root = mkdtempSync(path.join(tmpdir(), "akan-private-dir-"));
|
|
31
|
+
const dir = path.join(root, "locked");
|
|
32
|
+
mkdirSync(dir);
|
|
33
|
+
chmodSync(dir, 0o755);
|
|
34
|
+
Bun.spawnSync(["chflags", "uchg", dir]);
|
|
35
|
+
const warn = spyOn(FileSys.logger, "warn").mockImplementation(() => undefined);
|
|
36
|
+
try {
|
|
37
|
+
expect(FileSys.ensurePrivateDir(dir)).toBe(dir);
|
|
38
|
+
expect(statSync(dir).mode & 0o777).toBe(0o755);
|
|
39
|
+
expect(warn.mock.calls.map(([line]) => String(line))).toEqual([
|
|
40
|
+
expect.stringContaining(`${dir} stays at mode 755`),
|
|
41
|
+
]);
|
|
42
|
+
} finally {
|
|
43
|
+
warn.mockRestore();
|
|
44
|
+
Bun.spawnSync(["chflags", "nouchg", dir]);
|
|
45
|
+
rmSync(root, { recursive: true, force: true });
|
|
46
|
+
}
|
|
47
|
+
},
|
|
48
|
+
);
|
|
49
|
+
});
|
package/fileSys.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { chmodSync, mkdirSync, statSync } from "node:fs";
|
|
1
2
|
import { lstat, rename, rm, stat } from "node:fs/promises";
|
|
2
3
|
import nodePath from "node:path";
|
|
3
4
|
import { fileURLToPath } from "node:url";
|
|
@@ -70,6 +71,19 @@ export class FileSys {
|
|
|
70
71
|
}
|
|
71
72
|
}
|
|
72
73
|
}
|
|
74
|
+
//? `mkdir`'s mode only applies to a directory it creates, so one an older build made is tightened here.
|
|
75
|
+
static ensurePrivateDir(dir: string) {
|
|
76
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
77
|
+
if (process.platform === "win32") return dir;
|
|
78
|
+
const { mode, uid } = statSync(dir);
|
|
79
|
+
if ((mode & 0o077) === 0 || uid !== process.getuid?.()) return dir;
|
|
80
|
+
try {
|
|
81
|
+
chmodSync(dir, 0o700);
|
|
82
|
+
} catch (error) {
|
|
83
|
+
FileSys.logger.warn(`${dir} stays at mode ${(mode & 0o777).toString(8)}: ${(error as Error).message}`);
|
|
84
|
+
}
|
|
85
|
+
return dir;
|
|
86
|
+
}
|
|
73
87
|
static async writeJson(path: string, content: object) {
|
|
74
88
|
return await Bun.file(path).write(`${JSON.stringify(content, null, 2)}\n`);
|
|
75
89
|
}
|
|
@@ -10,8 +10,26 @@ const fileOf = (file: string, content: string): SourceFileInfo => ({
|
|
|
10
10
|
sourceFile: ts.createSourceFile(file, content, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX),
|
|
11
11
|
});
|
|
12
12
|
|
|
13
|
+
const taskConstant = fileOf(
|
|
14
|
+
"apps/demo/lib/task/task.constant.ts",
|
|
15
|
+
`export class TaskInput extends via((field) => ({
|
|
16
|
+
title: field(String),
|
|
17
|
+
type: field(String),
|
|
18
|
+
phone: field(String),
|
|
19
|
+
project: field(Project).optional(),
|
|
20
|
+
members: field([User]),
|
|
21
|
+
})) {}
|
|
22
|
+
|
|
23
|
+
export class TaskObject extends via(TaskInput, (field) => {
|
|
24
|
+
return { status: field(String) };
|
|
25
|
+
}) {}
|
|
26
|
+
|
|
27
|
+
export class TaskInsight extends via(Task, (field) => ({ onSale: field(Int) })) {}
|
|
28
|
+
`,
|
|
29
|
+
);
|
|
30
|
+
|
|
13
31
|
const scan = (content: string) =>
|
|
14
|
-
new FormSetterScanner().scan([fileOf("apps/demo/lib/task/Task.Template.tsx", content)]);
|
|
32
|
+
new FormSetterScanner().scan([taskConstant, fileOf("apps/demo/lib/task/Task.Template.tsx", content)]);
|
|
15
33
|
|
|
16
34
|
describe("FormSetterScanner", () => {
|
|
17
35
|
test("a setter passed by reference publishes, so nothing is reported", () => {
|
|
@@ -73,6 +91,31 @@ describe("FormSetterScanner", () => {
|
|
|
73
91
|
).toEqual([]);
|
|
74
92
|
});
|
|
75
93
|
|
|
94
|
+
test("a slice setter is not a field setter, though its name reads set…On…", () => {
|
|
95
|
+
expect(
|
|
96
|
+
scan(`export const Filters = () => (
|
|
97
|
+
<Select onChange={(page) => st.do.setPageOfTaskOnSale(page)} />
|
|
98
|
+
);
|
|
99
|
+
`),
|
|
100
|
+
).toEqual([]);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test("a field is known from its model's constant, so another model's setter is not counted", () => {
|
|
104
|
+
expect(
|
|
105
|
+
scan(`export const General = () => (
|
|
106
|
+
<>
|
|
107
|
+
<Field.Text onChange={(title) => st.do.setTitleOnProject(title)} />
|
|
108
|
+
<Field.Text onChange={(onSale) => st.do.setOnSaleOnTask(onSale)} />
|
|
109
|
+
</>
|
|
110
|
+
);
|
|
111
|
+
`),
|
|
112
|
+
).toEqual([]);
|
|
113
|
+
expect(
|
|
114
|
+
scan(`export const General = () => <Field.Text onChange={(status) => st.do.setStatusOnTask(status)} />;\n`)[0]
|
|
115
|
+
?.message,
|
|
116
|
+
).toContain("setStatusOnTask");
|
|
117
|
+
});
|
|
118
|
+
|
|
76
119
|
test("only .tsx is scanned", () => {
|
|
77
120
|
const content = `export const set = (v: string) => <X onChange={(t) => st.do.setTitleOnTask(t)} />;\n`;
|
|
78
121
|
expect(new FormSetterScanner().scan([fileOf("apps/demo/lib/task/task.store.ts", content)])).toEqual([]);
|
package/formSetterScanner.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { capitalize, formSetterNames } from "akanjs/common";
|
|
1
2
|
import ts from "typescript";
|
|
2
3
|
import type { QualityWarning, SourceFileInfo } from "./qualityScanner";
|
|
3
4
|
|
|
@@ -5,17 +6,61 @@ import type { QualityWarning, SourceFileInfo } from "./qualityScanner";
|
|
|
5
6
|
// forwarding wrappers; this counts every field left unpublished whatever the reason. A zero-parameter handler is a
|
|
6
7
|
// button setting a constant, not a form control, so it is not counted.
|
|
7
8
|
export class FormSetterScanner {
|
|
8
|
-
static #
|
|
9
|
+
static #constantPath = /(?:^|\/)lib\/([^_/][^/]*)\/\1\.constant\.ts$/;
|
|
10
|
+
static #baseFields = ["id", "createdAt", "updatedAt", "removedAt"];
|
|
9
11
|
|
|
10
12
|
scan(sourceFiles: SourceFileInfo[]): QualityWarning[] {
|
|
11
|
-
|
|
13
|
+
const fieldSetters = FormSetterScanner.#fieldSettersOf(sourceFiles);
|
|
14
|
+
return sourceFiles
|
|
15
|
+
.filter((sourceFile) => sourceFile.file.endsWith(".tsx"))
|
|
16
|
+
.flatMap((f) => this.#scanFile(f, fieldSetters));
|
|
12
17
|
}
|
|
13
18
|
|
|
14
|
-
|
|
19
|
+
// Built forward from the constants: matching `set…On…` also caught slice setters such as `setPageOfProductOnSale`.
|
|
20
|
+
static #fieldSettersOf(sourceFiles: SourceFileInfo[]) {
|
|
21
|
+
const setters = new Set<string>();
|
|
22
|
+
for (const { file, sourceFile } of sourceFiles) {
|
|
23
|
+
const refName = FormSetterScanner.#constantPath.exec(file)?.[1];
|
|
24
|
+
if (!refName) continue;
|
|
25
|
+
const className = capitalize(refName);
|
|
26
|
+
const fullModelClasses = new Set([`${className}Input`, `${className}Object`, `Light${className}`, className]);
|
|
27
|
+
const keys = new Set(FormSetterScanner.#baseFields);
|
|
28
|
+
for (const statement of sourceFile.statements)
|
|
29
|
+
if (ts.isClassDeclaration(statement) && statement.name && fullModelClasses.has(statement.name.text))
|
|
30
|
+
for (const key of FormSetterScanner.#viaFieldKeysOf(statement)) keys.add(key);
|
|
31
|
+
for (const key of keys) setters.add(formSetterNames(className, key).setFieldOnModel);
|
|
32
|
+
}
|
|
33
|
+
return setters;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
static #viaFieldKeysOf(declaration: ts.ClassDeclaration): string[] {
|
|
37
|
+
const via = declaration.heritageClauses?.find((clause) => clause.token === ts.SyntaxKind.ExtendsKeyword)?.types[0]
|
|
38
|
+
?.expression;
|
|
39
|
+
if (!via || !ts.isCallExpression(via)) return [];
|
|
40
|
+
return via.arguments.flatMap((argument) => {
|
|
41
|
+
if (!ts.isArrowFunction(argument) && !ts.isFunctionExpression(argument)) return [];
|
|
42
|
+
const fields = FormSetterScanner.#returnedObjectOf(argument.body);
|
|
43
|
+
return (fields?.properties ?? []).flatMap((property) =>
|
|
44
|
+
property.name && (ts.isIdentifier(property.name) || ts.isStringLiteral(property.name))
|
|
45
|
+
? [property.name.text]
|
|
46
|
+
: [],
|
|
47
|
+
);
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
static #returnedObjectOf(body: ts.ConciseBody): ts.ObjectLiteralExpression | null {
|
|
52
|
+
let expression: ts.Node | undefined = body;
|
|
53
|
+
if (ts.isBlock(body)) expression = body.statements.find(ts.isReturnStatement)?.expression;
|
|
54
|
+
while (expression && (ts.isParenthesizedExpression(expression) || ts.isAsExpression(expression)))
|
|
55
|
+
expression = expression.expression;
|
|
56
|
+
return expression && ts.isObjectLiteralExpression(expression) ? expression : null;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
#scanFile({ file, sourceFile }: SourceFileInfo, fieldSetters: Set<string>): QualityWarning[] {
|
|
15
60
|
const wrapped: { setter: string; line: number }[] = [];
|
|
16
61
|
const visit = (node: ts.Node) => {
|
|
17
62
|
if (ts.isJsxAttribute(node)) {
|
|
18
|
-
const setter = FormSetterScanner.#wrappedSetterOf(node);
|
|
63
|
+
const setter = FormSetterScanner.#wrappedSetterOf(node, fieldSetters);
|
|
19
64
|
if (setter) wrapped.push({ setter, line: FormSetterScanner.#lineOf(sourceFile, node) });
|
|
20
65
|
}
|
|
21
66
|
ts.forEachChild(node, visit);
|
|
@@ -40,17 +85,17 @@ export class FormSetterScanner {
|
|
|
40
85
|
}
|
|
41
86
|
|
|
42
87
|
/** The setter a handler prop writes behind a wrapper, or null when it is passed by reference or absent. */
|
|
43
|
-
static #wrappedSetterOf(attribute: ts.JsxAttribute): string | null {
|
|
88
|
+
static #wrappedSetterOf(attribute: ts.JsxAttribute, fieldSetters: Set<string>): string | null {
|
|
44
89
|
if (!ts.isIdentifier(attribute.name) || !/^on[A-Z]/.test(attribute.name.text)) return null;
|
|
45
90
|
const initializer = attribute.initializer;
|
|
46
91
|
if (!initializer || !ts.isJsxExpression(initializer) || !initializer.expression) return null;
|
|
47
92
|
const handler = initializer.expression;
|
|
48
93
|
if (!ts.isArrowFunction(handler) && !ts.isFunctionExpression(handler)) return null;
|
|
49
94
|
if (!handler.parameters.length) return null;
|
|
50
|
-
return FormSetterScanner.#setterCallIn(handler.body);
|
|
95
|
+
return FormSetterScanner.#setterCallIn(handler.body, fieldSetters);
|
|
51
96
|
}
|
|
52
97
|
|
|
53
|
-
static #setterCallIn(node: ts.Node): string | null {
|
|
98
|
+
static #setterCallIn(node: ts.Node, fieldSetters: Set<string>): string | null {
|
|
54
99
|
let found: string | null = null;
|
|
55
100
|
const visit = (current: ts.Node) => {
|
|
56
101
|
if (found) return;
|
|
@@ -61,7 +106,7 @@ export class FormSetterScanner {
|
|
|
61
106
|
ts.isIdentifier(expression.expression) &&
|
|
62
107
|
expression.expression.text === "st" &&
|
|
63
108
|
expression.name.text === "do" &&
|
|
64
|
-
|
|
109
|
+
fieldSetters.has(name.text)
|
|
65
110
|
) {
|
|
66
111
|
found = name.text;
|
|
67
112
|
return;
|
|
@@ -3,6 +3,7 @@ import path from "node:path";
|
|
|
3
3
|
import type { BaseBuildArtifact } from "akanjs/server";
|
|
4
4
|
import { type PageEntry, resolveSsrPageEntriesForApp } from "../artifact/implicitRootLayout";
|
|
5
5
|
import type { App } from "../commandDecorators";
|
|
6
|
+
import { type BarrelImportsPluginOptions, createBarrelImportsPlugin } from "../transforms/barrelImportsPlugin";
|
|
6
7
|
import { createBrowserDirnamePlugin } from "../transforms/browserDirnamePlugin";
|
|
7
8
|
import { SideEffectImportOrder } from "../transforms/sideEffectImportOrder";
|
|
8
9
|
import { bundleDefine } from "./bundleDefine";
|
|
@@ -67,6 +68,7 @@ export class CsrArtifactBuilder {
|
|
|
67
68
|
const options = CsrArtifactBuilder.bundleOptions(this.#app, this.#command, akanConfig.optimizeImports);
|
|
68
69
|
const result = await Bun.build({
|
|
69
70
|
...options,
|
|
71
|
+
plugins: [...options.plugins, await CsrArtifactBuilder.barrelImportsPlugin(this.#app)],
|
|
70
72
|
entrypoints: htmlBasePaths.map((basePath) => this.#generatedPath(CsrArtifactBuilder.htmlFilename(basePath))),
|
|
71
73
|
files: generatedFiles,
|
|
72
74
|
root: this.#generatedDir,
|
|
@@ -118,6 +120,11 @@ export class CsrArtifactBuilder {
|
|
|
118
120
|
};
|
|
119
121
|
}
|
|
120
122
|
|
|
123
|
+
//? Whole, the akanjs/ui barrel bundles every component and the optional peers (react-icons) they import.
|
|
124
|
+
static async barrelImportsPlugin(app: App, pipeAfter?: BarrelImportsPluginOptions["pipeAfter"]) {
|
|
125
|
+
return await createBarrelImportsPlugin(app, { barrels: ["akanjs/ui"], pipeAfter });
|
|
126
|
+
}
|
|
127
|
+
|
|
121
128
|
/** Where a build records the `AKAN_PUBLIC_*` it inlined, which nothing in the minified bundle names. */
|
|
122
129
|
static publicEnvPath(app: App) {
|
|
123
130
|
return path.join(app.dist.cwdPath, ".akan", "artifact", "csr-public-env.json");
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { describe, expect, test } from "bun:test";
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import { tempDirs, writeText as write } from "../testHelpers";
|
|
4
|
+
import { CsrArtifactBuilder } from "./csrArtifactBuilder";
|
|
4
5
|
import { McpAppViewBuilder } from "./mcpAppViewBuilder";
|
|
5
6
|
|
|
6
7
|
const makeTempRoot = tempDirs("akan-mcp-app-views-");
|
|
@@ -132,6 +133,68 @@ describe("McpAppViewBuilder.discover", () => {
|
|
|
132
133
|
});
|
|
133
134
|
});
|
|
134
135
|
|
|
136
|
+
describe("McpAppViewBuilder.build", () => {
|
|
137
|
+
test("rewrites the akanjs/ui barrel, trims the page to its apps and drops a lazy component nothing renders", async () => {
|
|
138
|
+
const root = await makeTempRoot();
|
|
139
|
+
const file = (rel: string) => path.join(root, rel);
|
|
140
|
+
await write(
|
|
141
|
+
file("node_modules/akanjs/package.json"),
|
|
142
|
+
JSON.stringify({
|
|
143
|
+
name: "akanjs",
|
|
144
|
+
exports: { "./ui": "./ui/index.ts", "./ui/*": "./ui/*", "./webkit": "./webkit.ts" },
|
|
145
|
+
}),
|
|
146
|
+
);
|
|
147
|
+
await write(
|
|
148
|
+
file("node_modules/akanjs/webkit.ts"),
|
|
149
|
+
"export const McpAppBoot = { run: (view: unknown) => console.info(view) };\n",
|
|
150
|
+
);
|
|
151
|
+
await write(
|
|
152
|
+
file("node_modules/akanjs/ui/index.ts"),
|
|
153
|
+
'export { Link } from "./Link";\nexport { Toolbox } from "./Toolbox";\n',
|
|
154
|
+
);
|
|
155
|
+
await write(file("node_modules/akanjs/ui/Link.ts"), 'export const Link = "a link";\n');
|
|
156
|
+
await write(
|
|
157
|
+
file("node_modules/akanjs/ui/Toolbox.ts"),
|
|
158
|
+
'console.info("a toolbox loaded");\nexport const Toolbox = "a toolbox";\n',
|
|
159
|
+
);
|
|
160
|
+
await write(file("apps/demo/ui/lazy.ts"), "export const lazy = (load: () => Promise<unknown>) => ({ load });\n");
|
|
161
|
+
await write(file("apps/demo/ui/Heavy.ts"), 'export default "a heavy chunk";\n');
|
|
162
|
+
await write(
|
|
163
|
+
file("apps/demo/ui/cards.ts"),
|
|
164
|
+
'import { lazy } from "./lazy";\nexport const Heavy = lazy(() => import("./Heavy"));\nexport const Card = "a card";\n',
|
|
165
|
+
);
|
|
166
|
+
await write(
|
|
167
|
+
file("apps/demo/page/home.tsx"),
|
|
168
|
+
[
|
|
169
|
+
'import { Link } from "akanjs/ui";',
|
|
170
|
+
'import { Card } from "../ui/cards";',
|
|
171
|
+
"const page = () => ({ app: (name: string, description: string, draw: () => unknown) => ({ render: (render: () => unknown) => [name, description, draw, render] }) });",
|
|
172
|
+
'export default page().app("showHome", "Show home.", () => [Link, Card]).render(() => "drawn by the render");',
|
|
173
|
+
].join("\n"),
|
|
174
|
+
);
|
|
175
|
+
const app = {
|
|
176
|
+
cwdPath: file("apps/demo"),
|
|
177
|
+
dist: { cwdPath: file("dist/apps/demo") },
|
|
178
|
+
workspace: { workspaceRoot: root },
|
|
179
|
+
getTsConfig: async () => ({ compilerOptions: {} }),
|
|
180
|
+
getPublicEnv: () => ({}),
|
|
181
|
+
logger: { warn: () => undefined },
|
|
182
|
+
};
|
|
183
|
+
|
|
184
|
+
await new McpAppViewBuilder(app as never, "build").build(
|
|
185
|
+
[{ key: "./home.tsx", moduleAbsPath: file("apps/demo/page/home.tsx") }],
|
|
186
|
+
{ basePaths: [], cssAssets: {}, options: CsrArtifactBuilder.bundleOptions(app as never, "build") },
|
|
187
|
+
);
|
|
188
|
+
|
|
189
|
+
const view = await Bun.file(file("dist/apps/demo/mcp-apps/showHome.html")).text();
|
|
190
|
+
expect(view).toContain("a link");
|
|
191
|
+
expect(view).toContain("a card");
|
|
192
|
+
expect(view).not.toContain("a toolbox");
|
|
193
|
+
expect(view).not.toContain("drawn by the render");
|
|
194
|
+
expect(view).not.toContain("a heavy chunk");
|
|
195
|
+
});
|
|
196
|
+
});
|
|
197
|
+
|
|
135
198
|
describe("McpAppViewBuilder.entryOf", () => {
|
|
136
199
|
test("boots the one app with its page, the root layout and overrides it sits under, by relative path", () => {
|
|
137
200
|
const view = {
|
|
@@ -78,7 +78,13 @@ export class McpAppViewBuilder {
|
|
|
78
78
|
const pagePaths = [...new Set(views.map((view) => view.page.moduleAbsPath))];
|
|
79
79
|
const result = await Bun.build({
|
|
80
80
|
...options,
|
|
81
|
-
plugins: [
|
|
81
|
+
plugins: [
|
|
82
|
+
...options.plugins,
|
|
83
|
+
//? Bun runs one onLoad per module, so the one rewriting the barrel also trims the pages and marks `lazy()`; the
|
|
84
|
+
//? marker plugin takes the TypeScript that one leaves to Bun, a package's own outside node_modules/akanjs.
|
|
85
|
+
await CsrArtifactBuilder.barrelImportsPlugin(this.#app, McpAppPageTrimmer.pipe(pagePaths)),
|
|
86
|
+
PureLazyMarker.plugin(),
|
|
87
|
+
],
|
|
82
88
|
entrypoints: views.map((view) => path.join(generatedDir, `${view.name}.html`)),
|
|
83
89
|
files,
|
|
84
90
|
root: generatedDir,
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import { describe, expect, spyOn, test } from "bun:test";
|
|
2
|
+
import { mkdir, symlink } from "node:fs/promises";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import type { BunPlugin } from "bun";
|
|
5
|
+
import { resolveSsrPageEntries } from "../artifact/implicitRootLayout";
|
|
6
|
+
import { tempDirs, writeText as write } from "../testHelpers";
|
|
7
|
+
import { CsrArtifactBuilder } from "./csrArtifactBuilder";
|
|
8
|
+
import { VENDOR_SPECIFIERS } from "./vendorSpecifiers";
|
|
9
|
+
|
|
10
|
+
const makeTempRoot = tempDirs("akan-optional-peers-");
|
|
11
|
+
const akanjsRoot = path.resolve(import.meta.dir, "../../../akanjs");
|
|
12
|
+
const { peerDependenciesMeta } = (await Bun.file(path.join(akanjsRoot, "package.json")).json()) as {
|
|
13
|
+
peerDependenciesMeta: Record<string, { optional?: boolean }>;
|
|
14
|
+
};
|
|
15
|
+
//? The app installs these itself: they are peers so that it renders with one React.
|
|
16
|
+
const appRenderer = new Set(["react", "react-dom", "react-server-dom-webpack"]);
|
|
17
|
+
const optionalPeers = Object.entries(peerDependenciesMeta)
|
|
18
|
+
.filter(([name, meta]) => meta.optional && !appRenderer.has(name))
|
|
19
|
+
.map(([name]) => name);
|
|
20
|
+
const optionalPeerImport = new RegExp(
|
|
21
|
+
`^(?:${optionalPeers.map((name) => name.replace(/[.*+?^${}()|[\]\\/]/g, "\\$&")).join("|")})(?:/|$)`,
|
|
22
|
+
);
|
|
23
|
+
|
|
24
|
+
const recordOptionalPeers = (reached: string[]): BunPlugin => ({
|
|
25
|
+
name: "record-optional-peers",
|
|
26
|
+
setup(build) {
|
|
27
|
+
build.onResolve({ filter: optionalPeerImport }, ({ path: specifier, importer }) => {
|
|
28
|
+
reached.push(`${path.relative(akanjsRoot, importer)} -> ${specifier}`);
|
|
29
|
+
return { path: specifier, external: true };
|
|
30
|
+
});
|
|
31
|
+
},
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
const linkPackage = async (root: string, name: string, dir: string) => {
|
|
35
|
+
await mkdir(path.join(root, "node_modules"), { recursive: true });
|
|
36
|
+
await symlink(dir, path.join(root, "node_modules", name));
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
const writeApp = async (indexPage: string) => {
|
|
40
|
+
const root = await makeTempRoot();
|
|
41
|
+
const appDir = path.join(root, "apps/demo");
|
|
42
|
+
await linkPackage(root, "akanjs", akanjsRoot);
|
|
43
|
+
await linkPackage(root, "react", path.dirname(Bun.resolveSync("react/package.json", akanjsRoot)));
|
|
44
|
+
await write(
|
|
45
|
+
path.join(root, "tsconfig.json"),
|
|
46
|
+
JSON.stringify({ compilerOptions: { paths: { "@apps/*": ["./apps/*"] } } }),
|
|
47
|
+
);
|
|
48
|
+
await write(path.join(appDir, "client.ts"), "export {};\n");
|
|
49
|
+
await write(path.join(appDir, "env/env.client.ts"), "export const env = {};\n");
|
|
50
|
+
await write(path.join(appDir, "lib/dict.ts"), "export const getAllDictionary = () => ({});\n");
|
|
51
|
+
await write(path.join(appDir, "page/_index.tsx"), indexPage);
|
|
52
|
+
await write(path.join(appDir, "page/_overrides.tsx"), "export default {};\n");
|
|
53
|
+
return { root, appDir, pageKeys: ["./_index.tsx", "./_overrides.tsx"] };
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
const buildCsr = async (indexPage: string) => {
|
|
57
|
+
const { root, appDir, pageKeys } = await writeApp(indexPage);
|
|
58
|
+
const app = {
|
|
59
|
+
name: "demo",
|
|
60
|
+
cwdPath: appDir,
|
|
61
|
+
dist: { cwdPath: path.join(root, "dist/apps/demo") },
|
|
62
|
+
workspace: { workspaceRoot: root },
|
|
63
|
+
getPageKeys: async () => pageKeys,
|
|
64
|
+
getConfig: async () => ({ basePaths: [], optimizeImports: [], barrelImports: [] }),
|
|
65
|
+
getTsConfig: async () => ({ compilerOptions: { paths: { "@apps/*": ["./apps/*"] } } }),
|
|
66
|
+
getPublicEnv: () => ({}),
|
|
67
|
+
log: () => undefined,
|
|
68
|
+
verbose: () => undefined,
|
|
69
|
+
logger: { warn: () => undefined },
|
|
70
|
+
};
|
|
71
|
+
const reached: string[] = [];
|
|
72
|
+
const build = Bun.build;
|
|
73
|
+
const recorded = spyOn(Bun, "build").mockImplementation((config) =>
|
|
74
|
+
build({ ...config, plugins: [recordOptionalPeers(reached), ...(config.plugins ?? [])] }),
|
|
75
|
+
);
|
|
76
|
+
|
|
77
|
+
try {
|
|
78
|
+
await new CsrArtifactBuilder(app as never, "build").build();
|
|
79
|
+
} finally {
|
|
80
|
+
recorded.mockRestore();
|
|
81
|
+
}
|
|
82
|
+
return { root, reached };
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
describe("optional peers of akanjs", () => {
|
|
86
|
+
test("the generated root layout, the _overrides.tsx wrapper and the runtime entries import none", async () => {
|
|
87
|
+
const { appDir, pageKeys } = await writeApp("export default function Page() {\n return null;\n}\n");
|
|
88
|
+
const generated = (await resolveSsrPageEntries({ appCwdPath: appDir, appName: "demo", pageKeys }))
|
|
89
|
+
.map(({ moduleAbsPath }) => moduleAbsPath)
|
|
90
|
+
.filter((file) => file.includes(`${path.sep}.akan${path.sep}`));
|
|
91
|
+
const runtime = [
|
|
92
|
+
path.join(akanjsRoot, "server/rscClient.tsx"),
|
|
93
|
+
path.join(akanjsRoot, "server/rscSegmentOutlet.tsx"),
|
|
94
|
+
path.join(akanjsRoot, "webkit/index.ts"),
|
|
95
|
+
...VENDOR_SPECIFIERS.map((specifier) =>
|
|
96
|
+
path.join(akanjsRoot, "server/vendor", `${specifier.replaceAll("/", "-").replaceAll(".", "-")}.ts`),
|
|
97
|
+
),
|
|
98
|
+
];
|
|
99
|
+
expect(generated).toHaveLength(2);
|
|
100
|
+
|
|
101
|
+
//? No barrel rewrite, as a dev registry compiles every module an import names.
|
|
102
|
+
for (const target of ["browser", "bun"] as const) {
|
|
103
|
+
const reached: string[] = [];
|
|
104
|
+
const result = await Bun.build({
|
|
105
|
+
entrypoints: [...generated, ...runtime],
|
|
106
|
+
target,
|
|
107
|
+
throw: false,
|
|
108
|
+
plugins: [recordOptionalPeers(reached)],
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
expect(result.logs.map((log) => log.message)).toEqual([]);
|
|
112
|
+
expect(reached).toEqual([]);
|
|
113
|
+
}
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
test("the CSR shell build of a page drawing akanjs/ui components imports none", async () => {
|
|
117
|
+
const { reached } = await buildCsr(
|
|
118
|
+
'import { Link, System } from "akanjs/ui";\n\nexport default function Page() {\n return (\n <Link href="/">\n <System.ThemeToggle />\n </Link>\n );\n}\n',
|
|
119
|
+
);
|
|
120
|
+
|
|
121
|
+
expect(reached).toEqual([]);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
test("the MCP App view build of a page drawing akanjs/ui components imports none", async () => {
|
|
125
|
+
const { root, reached } = await buildCsr(
|
|
126
|
+
'import { page } from "akanjs/client";\nimport { Link } from "akanjs/ui";\n\nexport default page()\n .app("showHome", "Show the home link.", () => <Link href="/">home</Link>)\n .render(() => null);\n',
|
|
127
|
+
);
|
|
128
|
+
|
|
129
|
+
expect(await Bun.file(path.join(root, "dist/apps/demo/mcp-apps/showHome.html")).exists()).toBe(true);
|
|
130
|
+
expect(reached).toEqual([]);
|
|
131
|
+
});
|
|
132
|
+
});
|