@aprovan/mcp-app-server 0.1.0-dev.4d82df8
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/.turbo/turbo-build.log +23 -0
- package/E2E_TESTING.md +224 -0
- package/LICENSE +373 -0
- package/README.md +164 -0
- package/dist/index.d.ts +128 -0
- package/dist/index.js +990 -0
- package/dist/index.js.map +1 -0
- package/dist/runtime/assets/__vite-browser-external-BIHI7g3E.js +1 -0
- package/dist/runtime/assets/index-tRNuU9ap.js +1059 -0
- package/dist/runtime/index.html +38 -0
- package/dist/server.d.ts +6 -0
- package/dist/server.js +1511 -0
- package/dist/server.js.map +1 -0
- package/dist/shell/shell.js +67 -0
- package/docs/widget-preview.png +0 -0
- package/e2e/__snapshots__/.gitkeep +0 -0
- package/e2e/global-setup.ts +114 -0
- package/e2e/global-teardown.ts +15 -0
- package/e2e/visual-regression.test.ts +86 -0
- package/e2e/widget-smoke.test.ts +109 -0
- package/index.html +32 -0
- package/package.json +51 -0
- package/playwright.config.ts +43 -0
- package/src/__tests__/live-update.test.ts +158 -0
- package/src/__tests__/local-backend.test.ts +100 -0
- package/src/__tests__/memory-backend.ts +144 -0
- package/src/__tests__/registry-backend.test.ts +256 -0
- package/src/__tests__/services.test.ts +153 -0
- package/src/__tests__/shim.test.ts +60 -0
- package/src/__tests__/widget-store.test.ts +188 -0
- package/src/e2e-visual.ts +148 -0
- package/src/index.ts +608 -0
- package/src/live-update.ts +150 -0
- package/src/logger.ts +44 -0
- package/src/reference-widgets/live-dashboard.ts +162 -0
- package/src/registry-backend.ts +306 -0
- package/src/runtime/index.html +38 -0
- package/src/runtime/main.ts +164 -0
- package/src/scripts/export-artifacts.ts +51 -0
- package/src/server.ts +398 -0
- package/src/services.ts +307 -0
- package/src/shell/main.ts +172 -0
- package/src/shim.ts +106 -0
- package/src/tunnel.ts +123 -0
- package/src/widget-store/index.ts +10 -0
- package/src/widget-store/local-backend.ts +77 -0
- package/src/widget-store/store.ts +242 -0
- package/src/widget-store/types.ts +53 -0
- package/tsconfig.json +10 -0
- package/tsup.config.ts +14 -0
- package/vite.runtime.config.ts +28 -0
- package/vite.shell.config.ts +30 -0
- package/vitest.config.ts +7 -0
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { generateBridgeShim } from "../shim.js";
|
|
3
|
+
|
|
4
|
+
describe("generateBridgeShim", () => {
|
|
5
|
+
it("exposes window.patchwork with subscribe/updateContext/fireEvent", () => {
|
|
6
|
+
const shim = generateBridgeShim({ namespaces: [] });
|
|
7
|
+
expect(shim).toContain("window.patchwork");
|
|
8
|
+
expect(shim).toContain("subscribe:");
|
|
9
|
+
expect(shim).toContain("updateContext:");
|
|
10
|
+
expect(shim).toContain("fireEvent:");
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
it("forwards calls to the parent shell over postMessage", () => {
|
|
14
|
+
const shim = generateBridgeShim({ namespaces: ["weather"] });
|
|
15
|
+
expect(shim).toContain("window.parent.postMessage");
|
|
16
|
+
expect(shim).toContain("'patchwork'");
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it("creates a proxy for each service namespace", () => {
|
|
20
|
+
const shim = generateBridgeShim({ namespaces: ["weather", "stripe"] });
|
|
21
|
+
expect(shim).toContain('"weather"');
|
|
22
|
+
expect(shim).toContain('"stripe"');
|
|
23
|
+
expect(shim).toContain("new Proxy");
|
|
24
|
+
expect(shim).toContain("kind: 'service'");
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it("sends a fire event for fireEvent calls", () => {
|
|
28
|
+
const shim = generateBridgeShim({ namespaces: [] });
|
|
29
|
+
expect(shim).toContain("kind: 'fire'");
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it("sends a subscribe message for subscribe calls", () => {
|
|
33
|
+
const shim = generateBridgeShim({ namespaces: [] });
|
|
34
|
+
expect(shim).toContain("kind: 'subscribe'");
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it("sends a context message for updateContext calls", () => {
|
|
38
|
+
const shim = generateBridgeShim({ namespaces: [] });
|
|
39
|
+
expect(shim).toContain("kind: 'context'");
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it("listens for host messages and resolves pending requests", () => {
|
|
43
|
+
const shim = generateBridgeShim({ namespaces: ["weather"] });
|
|
44
|
+
expect(shim).toContain("'patchwork-host'");
|
|
45
|
+
expect(shim).toContain("stream-event");
|
|
46
|
+
expect(shim).toContain("p.resolve");
|
|
47
|
+
expect(shim).toContain("p.reject");
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("guards against double-injection", () => {
|
|
51
|
+
const shim = generateBridgeShim({ namespaces: [] });
|
|
52
|
+
expect(shim).toContain("if (window.patchwork) return");
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it("is valid, evaluable JavaScript", () => {
|
|
56
|
+
const shim = generateBridgeShim({ namespaces: ["weather"] });
|
|
57
|
+
// Should parse without throwing.
|
|
58
|
+
expect(() => new Function(shim)).not.toThrow();
|
|
59
|
+
});
|
|
60
|
+
});
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach } from "vitest";
|
|
2
|
+
import { WidgetStore, resetWidgetStore } from "../widget-store/store.js";
|
|
3
|
+
import { MemoryBackend } from "./memory-backend.js";
|
|
4
|
+
import type { Manifest, VirtualFile } from "@aprovan/patchwork-compiler";
|
|
5
|
+
|
|
6
|
+
const TEST_MANIFEST: Manifest = {
|
|
7
|
+
name: "test-widget",
|
|
8
|
+
version: "0.1.0",
|
|
9
|
+
platform: "browser",
|
|
10
|
+
image: "@aprovan/patchwork-image-shadcn",
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
const SINGLE_FILE: VirtualFile[] = [
|
|
14
|
+
{ path: "main.tsx", content: "export default () => <div>test</div>;" },
|
|
15
|
+
];
|
|
16
|
+
|
|
17
|
+
function createStore(): WidgetStore {
|
|
18
|
+
resetWidgetStore();
|
|
19
|
+
return new WidgetStore({ backend: new MemoryBackend() });
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
describe("WidgetStore", () => {
|
|
23
|
+
let store: WidgetStore;
|
|
24
|
+
|
|
25
|
+
beforeEach(() => {
|
|
26
|
+
store = createStore();
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
describe("saveWidget", () => {
|
|
30
|
+
it("persists raw widget files and returns stored metadata", async () => {
|
|
31
|
+
const result = await store.saveWidget("abc123", SINGLE_FILE, TEST_MANIFEST, "main.tsx");
|
|
32
|
+
|
|
33
|
+
expect(result.path).toBe("widgets/test-widget/abc123");
|
|
34
|
+
expect(result.resourceUri).toBe("ui://widgets/test-widget/abc123/view.html");
|
|
35
|
+
expect(result.files).toEqual(SINGLE_FILE);
|
|
36
|
+
expect(result.entry).toBe("main.tsx");
|
|
37
|
+
expect(result.manifest.name).toBe("test-widget");
|
|
38
|
+
expect(result.createdAt).toBeTypeOf("number");
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it("persists multi-file projects with nested paths", async () => {
|
|
42
|
+
const files: VirtualFile[] = [
|
|
43
|
+
{ path: "main.tsx", content: "import './ui/card';" },
|
|
44
|
+
{ path: "ui/card.tsx", content: "export const Card = () => null;" },
|
|
45
|
+
];
|
|
46
|
+
await store.saveWidget("def456", files, { ...TEST_MANIFEST, name: "multi" }, "main.tsx");
|
|
47
|
+
|
|
48
|
+
const widget = await store.getWidget("multi", "def456");
|
|
49
|
+
expect(widget!.files).toHaveLength(2);
|
|
50
|
+
expect(widget!.files.map((f) => f.path).sort()).toEqual(["main.tsx", "ui/card.tsx"]);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("stores manifest with services metadata", async () => {
|
|
54
|
+
const manifest: Manifest = { ...TEST_MANIFEST, services: ["weather", "github"] };
|
|
55
|
+
const result = await store.saveWidget("svc123", SINGLE_FILE, manifest, "main.tsx");
|
|
56
|
+
|
|
57
|
+
expect(result.manifest.services).toEqual(["weather", "github"]);
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
describe("getWidget", () => {
|
|
62
|
+
it("retrieves stored raw files by name and hash", async () => {
|
|
63
|
+
await store.saveWidget("abc123", SINGLE_FILE, TEST_MANIFEST, "main.tsx");
|
|
64
|
+
const widget = await store.getWidget("test-widget", "abc123");
|
|
65
|
+
|
|
66
|
+
expect(widget).not.toBeNull();
|
|
67
|
+
expect(widget!.files).toEqual(SINGLE_FILE);
|
|
68
|
+
expect(widget!.manifest.name).toBe("test-widget");
|
|
69
|
+
expect(widget!.resourceUri).toBe("ui://widgets/test-widget/abc123/view.html");
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it("returns null for non-existent widget", async () => {
|
|
73
|
+
const widget = await store.getWidget("nonexistent", "nothash");
|
|
74
|
+
expect(widget).toBeNull();
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it("retrieves entry point from manifest", async () => {
|
|
78
|
+
await store.saveWidget("ent123", SINGLE_FILE, TEST_MANIFEST, "app.tsx");
|
|
79
|
+
const widget = await store.getWidget("test-widget", "ent123");
|
|
80
|
+
|
|
81
|
+
expect(widget!.entry).toBe("app.tsx");
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
describe("listWidgets", () => {
|
|
86
|
+
it("returns empty list when no widgets stored", async () => {
|
|
87
|
+
const widgets = await store.listWidgets();
|
|
88
|
+
expect(widgets).toEqual([]);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it("lists all stored widgets with metadata", async () => {
|
|
92
|
+
await store.saveWidget("abc123", SINGLE_FILE, TEST_MANIFEST, "main.tsx");
|
|
93
|
+
await store.saveWidget(
|
|
94
|
+
"def456",
|
|
95
|
+
SINGLE_FILE,
|
|
96
|
+
{ ...TEST_MANIFEST, name: "other-widget", description: "Another widget" },
|
|
97
|
+
"main.tsx",
|
|
98
|
+
);
|
|
99
|
+
|
|
100
|
+
const widgets = await store.listWidgets();
|
|
101
|
+
expect(widgets).toHaveLength(2);
|
|
102
|
+
expect(widgets.map((w) => w.name)).toContain("test-widget");
|
|
103
|
+
expect(widgets.map((w) => w.name)).toContain("other-widget");
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it("includes services and entry in listing", async () => {
|
|
107
|
+
await store.saveWidget(
|
|
108
|
+
"svc123",
|
|
109
|
+
SINGLE_FILE,
|
|
110
|
+
{ ...TEST_MANIFEST, services: ["stripe"] },
|
|
111
|
+
"main.tsx",
|
|
112
|
+
);
|
|
113
|
+
|
|
114
|
+
const widgets = await store.listWidgets();
|
|
115
|
+
const entry = widgets.find((w) => w.name === "test-widget");
|
|
116
|
+
expect(entry!.services).toEqual(["stripe"]);
|
|
117
|
+
expect(entry!.entry).toBe("main.tsx");
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it("returns widgets sorted by most recent first", async () => {
|
|
121
|
+
const store = createStore();
|
|
122
|
+
await store.saveWidget("old", SINGLE_FILE, { ...TEST_MANIFEST, name: "old-widget" }, "main.tsx");
|
|
123
|
+
|
|
124
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
125
|
+
|
|
126
|
+
await store.saveWidget("new", SINGLE_FILE, { ...TEST_MANIFEST, name: "new-widget" }, "main.tsx");
|
|
127
|
+
|
|
128
|
+
const widgets = await store.listWidgets();
|
|
129
|
+
expect(widgets[0]!.name).toBe("new-widget");
|
|
130
|
+
});
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
describe("deleteWidget", () => {
|
|
134
|
+
it("deletes a stored widget", async () => {
|
|
135
|
+
await store.saveWidget("del123", SINGLE_FILE, TEST_MANIFEST, "main.tsx");
|
|
136
|
+
const deleted = await store.deleteWidget("test-widget", "del123");
|
|
137
|
+
expect(deleted).toBe(true);
|
|
138
|
+
|
|
139
|
+
const widget = await store.getWidget("test-widget", "del123");
|
|
140
|
+
expect(widget).toBeNull();
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it("returns false for non-existent widget", async () => {
|
|
144
|
+
const deleted = await store.deleteWidget("nonexistent", "nothash");
|
|
145
|
+
expect(deleted).toBe(false);
|
|
146
|
+
});
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
describe("hasWidget", () => {
|
|
150
|
+
it("returns true for stored widget", async () => {
|
|
151
|
+
await store.saveWidget("has123", SINGLE_FILE, TEST_MANIFEST, "main.tsx");
|
|
152
|
+
expect(await store.hasWidget("test-widget", "has123")).toBe(true);
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it("returns false for non-existent widget", async () => {
|
|
156
|
+
expect(await store.hasWidget("nonexistent", "nothash")).toBe(false);
|
|
157
|
+
});
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
describe("resourceUriFor", () => {
|
|
161
|
+
it("generates correct resource URI", () => {
|
|
162
|
+
const uri = store.resourceUriFor("my-widget", "abc123");
|
|
163
|
+
expect(uri).toBe("ui://widgets/my-widget/abc123/view.html");
|
|
164
|
+
});
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
describe("loadAll", () => {
|
|
168
|
+
it("loads all stored widgets", async () => {
|
|
169
|
+
await store.saveWidget("abc123", SINGLE_FILE, TEST_MANIFEST, "main.tsx");
|
|
170
|
+
await store.saveWidget(
|
|
171
|
+
"def456",
|
|
172
|
+
SINGLE_FILE,
|
|
173
|
+
{ ...TEST_MANIFEST, name: "other-widget" },
|
|
174
|
+
"main.tsx",
|
|
175
|
+
);
|
|
176
|
+
|
|
177
|
+
const widgets = await store.loadAll();
|
|
178
|
+
expect(widgets).toHaveLength(2);
|
|
179
|
+
expect(widgets.map((w) => w.manifest.name)).toContain("test-widget");
|
|
180
|
+
expect(widgets.map((w) => w.manifest.name)).toContain("other-widget");
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
it("returns empty array when no widgets stored", async () => {
|
|
184
|
+
const widgets = await store.loadAll();
|
|
185
|
+
expect(widgets).toEqual([]);
|
|
186
|
+
});
|
|
187
|
+
});
|
|
188
|
+
});
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import { mkdir, writeFile } from "node:fs/promises";
|
|
2
|
+
import { existsSync } from "node:fs";
|
|
3
|
+
import { resolve } from "node:path";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
import cors from "cors";
|
|
6
|
+
import express from "express";
|
|
7
|
+
import { log, error } from "./logger.js";
|
|
8
|
+
import {
|
|
9
|
+
REFERENCE_WIDGET_FILES,
|
|
10
|
+
REFERENCE_WIDGET_MANIFEST,
|
|
11
|
+
} from "./reference-widgets/live-dashboard.js";
|
|
12
|
+
import { startTunnel, stopTunnel } from "./tunnel.js";
|
|
13
|
+
import { getWidgetStore, resetWidgetStore } from "./widget-store/index.js";
|
|
14
|
+
|
|
15
|
+
const WIDGET_PORT = Number(process.env["WIDGET_PORT"] ?? 3002);
|
|
16
|
+
const ARTIFACTS_DIR = resolve(process.cwd(), ".artifacts");
|
|
17
|
+
const RUNTIME_DIR = fileURLToPath(new URL("../dist/runtime", import.meta.url));
|
|
18
|
+
|
|
19
|
+
interface WidgetPreviewEntry {
|
|
20
|
+
name: string;
|
|
21
|
+
hash: string;
|
|
22
|
+
url: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function parseArgs(): { tunnel: boolean } {
|
|
26
|
+
return {
|
|
27
|
+
tunnel: process.argv.includes("--tunnel"),
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
async function saveReferenceWidgets(): Promise<Array<{ name: string; hash: string }>> {
|
|
32
|
+
resetWidgetStore();
|
|
33
|
+
const store = getWidgetStore();
|
|
34
|
+
|
|
35
|
+
const hash = "reference";
|
|
36
|
+
await store.saveWidget(hash, REFERENCE_WIDGET_FILES, REFERENCE_WIDGET_MANIFEST, "main.tsx");
|
|
37
|
+
|
|
38
|
+
return [{ name: REFERENCE_WIDGET_MANIFEST.name, hash }];
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async function startWidgetServer(): Promise<void> {
|
|
42
|
+
if (!existsSync(RUNTIME_DIR)) {
|
|
43
|
+
throw new Error(`Runtime bundle missing at ${RUNTIME_DIR}. Run \`pnpm build:runtime\` first.`);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const app = express();
|
|
47
|
+
app.use(cors());
|
|
48
|
+
app.use("/runtime", express.static(RUNTIME_DIR));
|
|
49
|
+
|
|
50
|
+
const store = getWidgetStore();
|
|
51
|
+
|
|
52
|
+
app.get("/widget/:name/:hash/files", async (req, res) => {
|
|
53
|
+
const { name, hash } = req.params;
|
|
54
|
+
try {
|
|
55
|
+
const widget = await store.getWidget(name, hash);
|
|
56
|
+
if (!widget) {
|
|
57
|
+
res.status(404).json({ error: "Widget not found" });
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
res.json({ files: widget.files, entry: widget.entry, manifest: widget.manifest });
|
|
61
|
+
} catch (err) {
|
|
62
|
+
error("e2e-visual", "Error serving widget files:", err);
|
|
63
|
+
res.status(500).json({ error: "Internal server error" });
|
|
64
|
+
}
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
app.get("/health", (_req, res) => {
|
|
68
|
+
res.json({ status: "ok", service: "patchwork-e2e-visual" });
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
await new Promise<void>((resolve) => {
|
|
72
|
+
app.listen(WIDGET_PORT, "0.0.0.0", () => {
|
|
73
|
+
log("e2e-visual", `Widget server listening on http://0.0.0.0:${WIDGET_PORT}`);
|
|
74
|
+
resolve();
|
|
75
|
+
});
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function printSummaryTable(entries: WidgetPreviewEntry[]): void {
|
|
80
|
+
console.log("\n┌─────────────────────┬─────────────────────────────────────────────────┐");
|
|
81
|
+
console.log("│ Widget │ Preview URL │");
|
|
82
|
+
console.log("├─────────────────────┼─────────────────────────────────────────────────┤");
|
|
83
|
+
for (const entry of entries) {
|
|
84
|
+
const namePad = entry.name.padEnd(19);
|
|
85
|
+
const urlPad = entry.url.length > 47 ? entry.url : entry.url.padEnd(47);
|
|
86
|
+
console.log(`│ ${namePad} │ ${urlPad} │`);
|
|
87
|
+
}
|
|
88
|
+
console.log("└─────────────────────┴─────────────────────────────────────────────────┘\n");
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
async function savePreviewManifest(entries: WidgetPreviewEntry[]): Promise<void> {
|
|
92
|
+
await mkdir(ARTIFACTS_DIR, { recursive: true });
|
|
93
|
+
const manifestPath = resolve(ARTIFACTS_DIR, "preview-urls.json");
|
|
94
|
+
await writeFile(manifestPath, JSON.stringify({ generatedAt: new Date().toISOString(), widgets: entries }, null, 2));
|
|
95
|
+
log("e2e-visual", `Preview manifest saved to ${manifestPath}`);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
async function main(): Promise<void> {
|
|
99
|
+
const { tunnel } = parseArgs();
|
|
100
|
+
|
|
101
|
+
log("e2e-visual", "Saving reference widgets...");
|
|
102
|
+
const widgets = await saveReferenceWidgets();
|
|
103
|
+
log("e2e-visual", `Saved ${widgets.length} widget(s)`);
|
|
104
|
+
|
|
105
|
+
log("e2e-visual", "Starting widget server...");
|
|
106
|
+
await startWidgetServer();
|
|
107
|
+
|
|
108
|
+
let baseUrl = `http://localhost:${WIDGET_PORT}`;
|
|
109
|
+
|
|
110
|
+
if (tunnel) {
|
|
111
|
+
log("e2e-visual", "Starting Cloudflare tunnel...");
|
|
112
|
+
try {
|
|
113
|
+
baseUrl = await startTunnel(WIDGET_PORT);
|
|
114
|
+
log("e2e-visual", `Tunnel established: ${baseUrl}`);
|
|
115
|
+
} catch (err) {
|
|
116
|
+
error("e2e-visual", "Failed to start tunnel, using localhost:", err);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const entries: WidgetPreviewEntry[] = widgets.map((w) => ({
|
|
121
|
+
name: w.name,
|
|
122
|
+
hash: w.hash,
|
|
123
|
+
url: `${baseUrl}/runtime/?widget=${w.name}/${w.hash}`,
|
|
124
|
+
}));
|
|
125
|
+
|
|
126
|
+
printSummaryTable(entries);
|
|
127
|
+
await savePreviewManifest(entries);
|
|
128
|
+
|
|
129
|
+
if (tunnel) {
|
|
130
|
+
log("e2e-visual", "Tunnel mode active. Press Ctrl+C to shut down cleanly.");
|
|
131
|
+
const shutdown = () => {
|
|
132
|
+
log("e2e-visual", "Shutting down...");
|
|
133
|
+
stopTunnel();
|
|
134
|
+
process.exit(0);
|
|
135
|
+
};
|
|
136
|
+
process.on("SIGINT", shutdown);
|
|
137
|
+
process.on("SIGTERM", shutdown);
|
|
138
|
+
} else {
|
|
139
|
+
log("e2e-visual", "Done (no tunnel). Use --tunnel for live preview URLs.");
|
|
140
|
+
process.exit(0);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
main().catch((err: unknown) => {
|
|
145
|
+
error("e2e-visual", "Fatal error:", err);
|
|
146
|
+
stopTunnel();
|
|
147
|
+
process.exit(1);
|
|
148
|
+
});
|