File size: 4,970 Bytes
4c76b0d
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
// Runtime import side-effect contract tests cover cold import behavior for plugin runtime code.
import fs from "node:fs";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { assertNoImportTimeSideEffects } from "../../plugin-sdk/test-helpers/import-side-effects.js";

const listChannelPlugins = vi.hoisted(() =>
  vi.fn(() => [
    {
      id: "telegram",
      messaging: {
        defaultMarkdownTableMode: "bullets",
      },
    },
  ]),
);
const getActivePluginChannelRegistryVersion = vi.hoisted(() => vi.fn(() => 1));

const CHANNEL_REGISTRY_SEAM = "listChannelPlugins()";
const CHANNEL_REGISTRY_WHY =
  "it boots active channel metadata on hot runtime/config import paths and turns cheap module evaluation into plugin registry work.";
const CHANNEL_REGISTRY_FIX =
  "keep the seam behind a lazy getter/runtime boundary so import stays cold and the first real lookup loads once.";
const HOT_RUNTIME_IMPORT_CASES = [
  ["src/config/markdown-tables.ts", () => import("../../config/markdown-tables.js")],
  [
    "src/plugin-sdk/approval-handler-adapter-runtime.ts",
    () => import("../../plugin-sdk/approval-handler-adapter-runtime.js"),
  ],
  [
    "src/plugin-sdk/approval-gateway-runtime.ts",
    () => import("../../plugin-sdk/approval-gateway-runtime.js"),
  ],
  [
    "src/plugin-sdk/approval-reference-runtime.ts",
    () => import("../../plugin-sdk/approval-reference-runtime.js"),
  ],
  ["src/plugins/runtime/runtime-system.ts", () => import("../runtime/runtime-system.js")],
  ["src/web-search/runtime.ts", () => import("../../web-search/runtime.js")],
  ["src/web-fetch/runtime.ts", () => import("../../web-fetch/runtime.js")],
] as const;

function mockChannelRegistry() {
  vi.doMock("../../channels/plugins/index.js", async () => {
    const actual = await vi.importActual<typeof import("../../channels/plugins/index.js")>(
      "../../channels/plugins/index.js",
    );
    return {
      ...actual,
      normalizeChannelId: (raw?: string | null) => raw ?? null,
    };
  });
  vi.doMock("../../channels/plugins/registry.js", async () => {
    const actual = await vi.importActual<typeof import("../../channels/plugins/registry.js")>(
      "../../channels/plugins/registry.js",
    );
    return {
      ...actual,
      listChannelPlugins,
    };
  });
  vi.doMock("../../plugins/runtime.js", async () => {
    const actual = await vi.importActual<typeof import("../../plugins/runtime.js")>(
      "../../plugins/runtime.js",
    );
    return {
      ...actual,
      getActivePluginChannelRegistryVersion,
    };
  });
}

function expectNoChannelRegistryDuringImport(moduleId: string) {
  assertNoImportTimeSideEffects({
    moduleId,
    forbiddenSeam: CHANNEL_REGISTRY_SEAM,
    calls: listChannelPlugins.mock.calls,
    why: CHANNEL_REGISTRY_WHY,
    fixHint: CHANNEL_REGISTRY_FIX,
  });
  expect(getActivePluginChannelRegistryVersion).not.toHaveBeenCalled();
}

afterEach(() => {
  vi.resetModules();
  vi.restoreAllMocks();
  vi.doUnmock("../../channels/plugins/index.js");
  vi.doUnmock("../../channels/plugins/registry.js");
  vi.doUnmock("../../plugins/runtime.js");
});

describe("runtime import side-effect contracts", () => {
  beforeEach(() => {
    listChannelPlugins.mockClear();
    getActivePluginChannelRegistryVersion.mockClear().mockReturnValue(1);
  });

  it("keeps markdown table defaults lazy and memoized after import", async () => {
    mockChannelRegistry();
    const markdownTables = await import("../../config/markdown-tables.js");

    expectNoChannelRegistryDuringImport("src/config/markdown-tables.ts");

    expect(
      markdownTables.resolveMarkdownTableMode({
        channel: "telegram",
        supportsBlockTables: true,
      }),
    ).toBe("bullets");
    expect(getActivePluginChannelRegistryVersion).toHaveBeenCalled();
    expect(listChannelPlugins).toHaveBeenCalledTimes(1);
    expect(
      markdownTables.resolveMarkdownTableMode({
        channel: "telegram",
        supportsBlockTables: true,
      }),
    ).toBe("bullets");
    expect(getActivePluginChannelRegistryVersion).toHaveBeenCalled();
    expect(listChannelPlugins).toHaveBeenCalledTimes(1);
  });

  it.each(HOT_RUNTIME_IMPORT_CASES)("keeps %s cold", async (moduleId, importModule) => {
    mockChannelRegistry();
    await importModule();
    expectNoChannelRegistryDuringImport(moduleId);
  });

  it("keeps runtime-channel off direct channel registry imports", () => {
    const source = fs.readFileSync("src/plugins/runtime/runtime-channel.ts", "utf8");
    expect(source).not.toContain("../../channels/plugins/registry");
    expect(source).not.toContain("../channels/plugins/registry");
  });

  it("keeps runtime index off direct channel registry imports", () => {
    const source = fs.readFileSync("src/plugins/runtime/index.ts", "utf8");
    expect(source).not.toContain("../../channels/plugins/registry");
    expect(source).not.toContain("../channels/plugins/registry");
  });
});