create-checkstack-plugin 0.1.3 → 0.1.5
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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "create-checkstack-plugin",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.5",
|
|
4
4
|
"description": "Scaffold a standalone Checkstack plugin workspace (common + backend + frontend) with concrete published versions, ready to `bun install && bun run dev`.",
|
|
5
5
|
"license": "Elastic-2.0",
|
|
6
6
|
"type": "module",
|
|
@@ -21,12 +21,13 @@
|
|
|
21
21
|
"typecheck": "tsgo -b"
|
|
22
22
|
},
|
|
23
23
|
"dependencies": {
|
|
24
|
-
"@checkstack/scripts": "0.
|
|
24
|
+
"@checkstack/scripts": "0.6.0",
|
|
25
25
|
"inquirer": "^13.4.1",
|
|
26
26
|
"zod": "^4.2.1"
|
|
27
27
|
},
|
|
28
28
|
"devDependencies": {
|
|
29
29
|
"@checkstack/tsconfig": "0.0.7",
|
|
30
|
+
"@playwright/test": "^1.60.0",
|
|
30
31
|
"@types/inquirer": "^8.2.10",
|
|
31
32
|
"typescript": "^5.0.0"
|
|
32
33
|
}
|
|
@@ -0,0 +1,496 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Full external-plugin E2E: scaffold → pack → install into a REAL running
|
|
3
|
+
* Checkstack instance via the Plugin Manager UI → verify it loads.
|
|
4
|
+
*
|
|
5
|
+
* Distinct from `external-plugin-lifecycle.it.test.ts` (which boots the dev
|
|
6
|
+
* SERVER): this boots the real backend + built SPA on a port (same launcher the
|
|
7
|
+
* e2e suite uses), then drives a browser through the actual install wizard
|
|
8
|
+
* (tarball upload + typed confirmation) and asserts:
|
|
9
|
+
*
|
|
10
|
+
* 1. the packaged plugin installs and its backend loads (`POST /api/widget/*`),
|
|
11
|
+
* 2. the plugin's FRONTEND works (its route/nav entry + page render), incl.
|
|
12
|
+
* the host's shared Monaco editor mounting inside the plugin page,
|
|
13
|
+
* 3. the co-loaded CORE plugins still work (frontend nav + backend API) - i.e.
|
|
14
|
+
* installing the external plugin didn't break the platform.
|
|
15
|
+
*
|
|
16
|
+
* The instance's plugin install (`bun install` under the runtime dir) is pointed
|
|
17
|
+
* at the same throwaway Verdaccio that holds the freshly-published workspace, so
|
|
18
|
+
* the plugin's `@checkstack/*` deps resolve to versions the instance is running.
|
|
19
|
+
*
|
|
20
|
+
* Heavy (publish + full install + backend boot + browser); gated behind
|
|
21
|
+
* `CHECKSTACK_E2E_INSTALL=1`, needs Postgres + the repo `.env`, and the frontend
|
|
22
|
+
* must be built (`bun run --filter @checkstack/frontend build`).
|
|
23
|
+
*/
|
|
24
|
+
import { afterAll, beforeAll, describe, it } from "bun:test";
|
|
25
|
+
import { spawn, spawnSync, type ChildProcess } from "node:child_process";
|
|
26
|
+
import fs from "node:fs";
|
|
27
|
+
import os from "node:os";
|
|
28
|
+
import path from "node:path";
|
|
29
|
+
import { fileURLToPath } from "node:url";
|
|
30
|
+
// Playwright's `expect` carries the web-first locator assertions (`toBeVisible`,
|
|
31
|
+
// `toHaveURL`, …) and also handles value assertions (`toBe`, `toEqual`); using
|
|
32
|
+
// it throughout keeps one assertion surface under bun:test's runner.
|
|
33
|
+
import { chromium, expect, type Browser } from "@playwright/test";
|
|
34
|
+
import { scaffoldStandaloneWorkspace, localSiblingNames } from "./scaffold-standalone";
|
|
35
|
+
import { createNpmViewResolver } from "./npm-view-resolver";
|
|
36
|
+
import {
|
|
37
|
+
DEFAULT_REGISTRY_URL,
|
|
38
|
+
buildVerdaccioConfig,
|
|
39
|
+
buildNpmrc,
|
|
40
|
+
discoverWorkspacePackages,
|
|
41
|
+
publishWorkspacePackages,
|
|
42
|
+
findMonorepoRoot,
|
|
43
|
+
startVerdaccio,
|
|
44
|
+
type RegistryHandle,
|
|
45
|
+
} from "./local-registry";
|
|
46
|
+
|
|
47
|
+
const HERE = path.dirname(fileURLToPath(import.meta.url));
|
|
48
|
+
const BASE_NAME = "widget";
|
|
49
|
+
const PASCAL_NAME = "Widget";
|
|
50
|
+
const PLUGIN_ID = "widget";
|
|
51
|
+
const PACKAGE_SCOPE = "checkstackit";
|
|
52
|
+
const INSTANCE_PORT = 3199;
|
|
53
|
+
const INSTANCE_URL = `http://localhost:${INSTANCE_PORT}`;
|
|
54
|
+
const E2E_DB_NAME = "checkstack_e2e_extplugin";
|
|
55
|
+
const ADMIN = {
|
|
56
|
+
name: "E2E Admin",
|
|
57
|
+
email: "e2e-admin@example.com",
|
|
58
|
+
password: "E2eAdminPassw0rd!",
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
function run({
|
|
62
|
+
command,
|
|
63
|
+
args,
|
|
64
|
+
cwd,
|
|
65
|
+
env,
|
|
66
|
+
timeoutMs = 600_000,
|
|
67
|
+
}: {
|
|
68
|
+
command: string;
|
|
69
|
+
args: string[];
|
|
70
|
+
cwd: string;
|
|
71
|
+
env?: NodeJS.ProcessEnv;
|
|
72
|
+
timeoutMs?: number;
|
|
73
|
+
}): { status: number; stdout: string; stderr: string } {
|
|
74
|
+
const r = spawnSync(command, args, {
|
|
75
|
+
cwd,
|
|
76
|
+
env: { ...process.env, ...env },
|
|
77
|
+
encoding: "utf8",
|
|
78
|
+
timeout: timeoutMs,
|
|
79
|
+
maxBuffer: 64 * 1024 * 1024,
|
|
80
|
+
});
|
|
81
|
+
return {
|
|
82
|
+
status: r.status ?? 1,
|
|
83
|
+
stdout: r.stdout ?? "",
|
|
84
|
+
stderr: `${r.stderr ?? ""}${r.error ? r.error.message : ""}`,
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Replace the scaffolded frontend page with a minimal one that renders a
|
|
90
|
+
* `CodeEditor` UNCONDITIONALLY. The scaffold default does not use the editor,
|
|
91
|
+
* so without this the E2E would not exercise the shared-Monaco path at all.
|
|
92
|
+
*
|
|
93
|
+
* Why a full rewrite (not a surgical insert): the scaffold page gates its body
|
|
94
|
+
* behind `<PageLayout loading={isLoading}>` and an early error-return, so an
|
|
95
|
+
* editor placed inside it only renders once the plugin's `getItems` query
|
|
96
|
+
* resolves - coupling the editor assertion to query timing. Rendering the
|
|
97
|
+
* editor unconditionally tests exactly what we care about: a runtime plugin
|
|
98
|
+
* resolving the host's shared editor. If the host failed to provide it, the
|
|
99
|
+
* consume-only (`import: false`) share would throw and crash the page rather
|
|
100
|
+
* than silently hide the editor. The page name MUST match the route loader in
|
|
101
|
+
* `src/index.tsx` (`${pascalName}ListPage`).
|
|
102
|
+
*/
|
|
103
|
+
function injectCodeEditor({
|
|
104
|
+
frontendDir,
|
|
105
|
+
pascalName,
|
|
106
|
+
}: {
|
|
107
|
+
frontendDir: string;
|
|
108
|
+
pascalName: string;
|
|
109
|
+
}): void {
|
|
110
|
+
const pagePath = path.join(
|
|
111
|
+
frontendDir,
|
|
112
|
+
"src",
|
|
113
|
+
"components",
|
|
114
|
+
`${pascalName}ListPage.tsx`,
|
|
115
|
+
);
|
|
116
|
+
if (!fs.existsSync(pagePath)) {
|
|
117
|
+
throw new Error(
|
|
118
|
+
`injectCodeEditor: expected scaffolded page at ${pagePath} - update ` +
|
|
119
|
+
"this patch to match the frontend page template.",
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
const page = [
|
|
123
|
+
'import { PageLayout, CodeEditor } from "@checkstack/ui";',
|
|
124
|
+
'import { Boxes } from "lucide-react";',
|
|
125
|
+
"",
|
|
126
|
+
`export const ${pascalName}ListPage = () => (`,
|
|
127
|
+
` <PageLayout title="${pascalName}" icon={Boxes}>`,
|
|
128
|
+
' <div data-testid="plugin-code-editor">',
|
|
129
|
+
' <CodeEditor',
|
|
130
|
+
' value="const probe = 1;"',
|
|
131
|
+
' language="typescript"',
|
|
132
|
+
' minHeight="120px"',
|
|
133
|
+
" onChange={() => {}}",
|
|
134
|
+
" />",
|
|
135
|
+
" </div>",
|
|
136
|
+
" </PageLayout>",
|
|
137
|
+
");",
|
|
138
|
+
"",
|
|
139
|
+
].join("\n");
|
|
140
|
+
fs.writeFileSync(pagePath, page);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** Recursively collect dist filenames that look like bundled Monaco / workers. */
|
|
144
|
+
function findBundledMonaco({ frontendDir }: { frontendDir: string }): string[] {
|
|
145
|
+
const dist = path.join(frontendDir, "dist");
|
|
146
|
+
const hits: string[] = [];
|
|
147
|
+
const walk = (dir: string) => {
|
|
148
|
+
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
149
|
+
const p = path.join(dir, entry.name);
|
|
150
|
+
if (entry.isDirectory()) walk(p);
|
|
151
|
+
else if (/monaco|codingame|\.worker/i.test(entry.name)) {
|
|
152
|
+
hits.push(path.relative(dist, p));
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
};
|
|
156
|
+
if (fs.existsSync(dist)) walk(dist);
|
|
157
|
+
return hits;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** True if the built frontend references the shared `@checkstack/ui/code-editor`. */
|
|
161
|
+
function distReferencesSharedEditor({
|
|
162
|
+
frontendDir,
|
|
163
|
+
}: {
|
|
164
|
+
frontendDir: string;
|
|
165
|
+
}): boolean {
|
|
166
|
+
const dist = path.join(frontendDir, "dist");
|
|
167
|
+
if (!fs.existsSync(dist)) return false;
|
|
168
|
+
const stack = [dist];
|
|
169
|
+
while (stack.length > 0) {
|
|
170
|
+
const dir = stack.pop()!;
|
|
171
|
+
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
172
|
+
const p = path.join(dir, entry.name);
|
|
173
|
+
if (entry.isDirectory()) stack.push(p);
|
|
174
|
+
else if (/\.(js|mjs|json)$/.test(entry.name)) {
|
|
175
|
+
const text = fs.readFileSync(p, "utf8");
|
|
176
|
+
// The bare shared key, or MF's mangled loadShare virtual name for it
|
|
177
|
+
// (`@checkstack/ui/code-editor` → ...code_mf_2_editor...).
|
|
178
|
+
if (text.includes("code-editor") || text.includes("code_mf_2_editor")) {
|
|
179
|
+
return true;
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
return false;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
async function poll<T>({
|
|
188
|
+
fn,
|
|
189
|
+
timeoutMs,
|
|
190
|
+
intervalMs = 1000,
|
|
191
|
+
}: {
|
|
192
|
+
fn: () => Promise<T | undefined>;
|
|
193
|
+
timeoutMs: number;
|
|
194
|
+
intervalMs?: number;
|
|
195
|
+
}): Promise<T | undefined> {
|
|
196
|
+
const deadline = Date.now() + timeoutMs;
|
|
197
|
+
while (Date.now() < deadline) {
|
|
198
|
+
const result = await fn();
|
|
199
|
+
if (result !== undefined) return result;
|
|
200
|
+
await new Promise((r) => setTimeout(r, intervalMs));
|
|
201
|
+
}
|
|
202
|
+
return undefined;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
describe.skipIf(!process.env.CHECKSTACK_E2E_INSTALL)(
|
|
206
|
+
"external plugin install (real instance + UI)",
|
|
207
|
+
() => {
|
|
208
|
+
let monorepoRoot: string;
|
|
209
|
+
let tmpRoot: string;
|
|
210
|
+
let cacheDir: string;
|
|
211
|
+
let npmrcPath: string;
|
|
212
|
+
let registryUrl: string;
|
|
213
|
+
let ownedRegistry: RegistryHandle | undefined;
|
|
214
|
+
let instance: ChildProcess | undefined;
|
|
215
|
+
let bundleTarball: string;
|
|
216
|
+
let browser: Browser | undefined;
|
|
217
|
+
let instanceLog = "";
|
|
218
|
+
|
|
219
|
+
beforeAll(async () => {
|
|
220
|
+
monorepoRoot = findMonorepoRoot({ from: HERE });
|
|
221
|
+
|
|
222
|
+
const distIndex = path.join(monorepoRoot, "core", "frontend", "dist", "index.html");
|
|
223
|
+
if (!fs.existsSync(distIndex)) {
|
|
224
|
+
throw new Error(
|
|
225
|
+
`Frontend is not built (${distIndex} missing). Run \`bun run --filter @checkstack/frontend build\` first.`,
|
|
226
|
+
);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// Make the run hermetic. The instance installs runtime plugins into
|
|
230
|
+
// `<repoRoot>/runtime_plugins` (see core/backend/src/index.ts), which
|
|
231
|
+
// PERSISTS across runs. The plugin version is fixed at 0.0.1, so a prior
|
|
232
|
+
// run's install is reused and `bun install <tgz>` skips re-extracting the
|
|
233
|
+
// freshly-packed dist - serving a STALE plugin frontend. Wipe it (and any
|
|
234
|
+
// stray scope dir hoisted into the repo's node_modules) so the install
|
|
235
|
+
// always materialises the bundle we just built.
|
|
236
|
+
for (const stale of [
|
|
237
|
+
path.join(monorepoRoot, "runtime_plugins"),
|
|
238
|
+
path.join(monorepoRoot, "node_modules", `@${PACKAGE_SCOPE}`),
|
|
239
|
+
]) {
|
|
240
|
+
fs.rmSync(stale, { recursive: true, force: true });
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ext-install-"));
|
|
244
|
+
cacheDir = path.join(tmpRoot, "bun-cache");
|
|
245
|
+
fs.mkdirSync(cacheDir, { recursive: true });
|
|
246
|
+
registryUrl = process.env.CHECKSTACK_SCAFFOLD_REGISTRY ?? DEFAULT_REGISTRY_URL;
|
|
247
|
+
npmrcPath = path.join(tmpRoot, ".npmrc");
|
|
248
|
+
fs.writeFileSync(npmrcPath, buildNpmrc({ registryUrl }));
|
|
249
|
+
|
|
250
|
+
// 1. Verdaccio + publish the workspace (so both the scaffold AND the
|
|
251
|
+
// instance's plugin-install resolve the current versions).
|
|
252
|
+
if (!process.env.CHECKSTACK_SCAFFOLD_REGISTRY) {
|
|
253
|
+
const storageDir = path.join(tmpRoot, "verdaccio-storage");
|
|
254
|
+
const configPath = path.join(tmpRoot, "verdaccio.yaml");
|
|
255
|
+
fs.mkdirSync(storageDir, { recursive: true });
|
|
256
|
+
fs.writeFileSync(configPath, buildVerdaccioConfig({ storageDir }));
|
|
257
|
+
ownedRegistry = await startVerdaccio({ configPath, url: registryUrl });
|
|
258
|
+
const packages = discoverWorkspacePackages({ monorepoRoot });
|
|
259
|
+
const outcomes = publishWorkspacePackages({
|
|
260
|
+
packages,
|
|
261
|
+
registryUrl,
|
|
262
|
+
npmrcPath,
|
|
263
|
+
env: { ...process.env, BUN_INSTALL_CACHE_DIR: cacheDir },
|
|
264
|
+
});
|
|
265
|
+
const failed = outcomes.filter((o) => o.status !== 0);
|
|
266
|
+
expect(
|
|
267
|
+
failed,
|
|
268
|
+
`publish failures:\n${failed.map((f) => `${f.name}: ${f.stderr}`).join("\n")}`,
|
|
269
|
+
).toEqual([]);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// 2. Scaffold the plugin (resolver → Verdaccio), install, pack a bundle.
|
|
273
|
+
const workspaceDir = path.join(tmpRoot, BASE_NAME);
|
|
274
|
+
const resolveVersion = createNpmViewResolver({
|
|
275
|
+
registry: registryUrl,
|
|
276
|
+
localSiblings: localSiblingNames({ baseName: BASE_NAME, packageScope: PACKAGE_SCOPE }),
|
|
277
|
+
});
|
|
278
|
+
await scaffoldStandaloneWorkspace({
|
|
279
|
+
rootDir: workspaceDir,
|
|
280
|
+
baseName: BASE_NAME,
|
|
281
|
+
description: "Widget e2e plugin",
|
|
282
|
+
packageScope: PACKAGE_SCOPE,
|
|
283
|
+
resolveVersion,
|
|
284
|
+
});
|
|
285
|
+
const backendDir = path.join(workspaceDir, "packages", `${BASE_NAME}-backend`);
|
|
286
|
+
const frontendDir = path.join(workspaceDir, "packages", `${BASE_NAME}-frontend`);
|
|
287
|
+
// Make the plugin actually render the shared editor (scaffold default
|
|
288
|
+
// doesn't) so install exercises the shared-Monaco path end-to-end.
|
|
289
|
+
injectCodeEditor({ frontendDir, pascalName: PASCAL_NAME });
|
|
290
|
+
const installEnv = {
|
|
291
|
+
NPM_CONFIG_USERCONFIG: npmrcPath,
|
|
292
|
+
BUN_CONFIG_REGISTRY: registryUrl,
|
|
293
|
+
BUN_INSTALL_CACHE_DIR: cacheDir,
|
|
294
|
+
};
|
|
295
|
+
const install = run({ command: "bun", args: ["install"], cwd: workspaceDir, env: installEnv });
|
|
296
|
+
expect(install.status, install.stderr).toBe(0);
|
|
297
|
+
const bundle = run({
|
|
298
|
+
command: "bun",
|
|
299
|
+
args: ["run", "pack", "--", "--bundle"],
|
|
300
|
+
cwd: backendDir,
|
|
301
|
+
env: installEnv,
|
|
302
|
+
});
|
|
303
|
+
expect(bundle.status, bundle.stderr || bundle.stdout).toBe(0);
|
|
304
|
+
if (process.env.CHECKSTACK_E2E_KEEP) {
|
|
305
|
+
console.log("[e2e] bundle pack stdout:\n" + bundle.stdout);
|
|
306
|
+
console.log("[e2e] bundle pack stderr:\n" + bundle.stderr);
|
|
307
|
+
}
|
|
308
|
+
// Build-time proof of Option B: the plugin imports CodeEditor yet its
|
|
309
|
+
// frontend build must NOT bundle Monaco (it shares the host's), and it
|
|
310
|
+
// MUST reference the shared `@checkstack/ui/code-editor` module.
|
|
311
|
+
const bundledMonaco = findBundledMonaco({ frontendDir });
|
|
312
|
+
expect(
|
|
313
|
+
bundledMonaco,
|
|
314
|
+
`plugin frontend bundled Monaco instead of sharing it: ${bundledMonaco.join(", ")}`,
|
|
315
|
+
).toEqual([]);
|
|
316
|
+
expect(
|
|
317
|
+
distReferencesSharedEditor({ frontendDir }),
|
|
318
|
+
"frontend build does not reference the shared @checkstack/ui/code-editor",
|
|
319
|
+
).toBe(true);
|
|
320
|
+
|
|
321
|
+
const distDir = path.join(backendDir, "dist");
|
|
322
|
+
const tgz = fs.readdirSync(distDir).find((f) => f.endsWith("-bundle.tgz"));
|
|
323
|
+
expect(tgz, "expected a *-bundle.tgz").toBeDefined();
|
|
324
|
+
bundleTarball = path.join(distDir, tgz!);
|
|
325
|
+
|
|
326
|
+
// 3. Boot the real instance (fresh e2e DB) with its plugin-install pointed
|
|
327
|
+
// at Verdaccio. `--env-file` supplies the secrets; PORT / DB name /
|
|
328
|
+
// registry are NOT in `.env`, so our overrides win.
|
|
329
|
+
instance = spawn(
|
|
330
|
+
"bun",
|
|
331
|
+
["--env-file", path.join(monorepoRoot, ".env"), path.join("core", "e2e", "scripts", "start-e2e-server.ts")],
|
|
332
|
+
{
|
|
333
|
+
cwd: monorepoRoot,
|
|
334
|
+
env: {
|
|
335
|
+
...process.env,
|
|
336
|
+
PORT: String(INSTANCE_PORT),
|
|
337
|
+
CHECKSTACK_E2E_DB_NAME: E2E_DB_NAME,
|
|
338
|
+
BUN_CONFIG_REGISTRY: registryUrl,
|
|
339
|
+
NPM_CONFIG_USERCONFIG: npmrcPath,
|
|
340
|
+
// Use the SAME throwaway cache as the scaffold install. Without
|
|
341
|
+
// this the instance's plugin co-install (`bun install <tgz>`) uses
|
|
342
|
+
// the global bun cache, which can hold a stale same-version
|
|
343
|
+
// `@checkstackit/widget-*@0.0.1` from a previous run and skip
|
|
344
|
+
// re-extracting the freshly-built bundle (missing its new dist).
|
|
345
|
+
BUN_INSTALL_CACHE_DIR: cacheDir,
|
|
346
|
+
},
|
|
347
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
348
|
+
},
|
|
349
|
+
);
|
|
350
|
+
instance.stdout?.on("data", (c: Buffer) => (instanceLog += c.toString()));
|
|
351
|
+
instance.stderr?.on("data", (c: Buffer) => (instanceLog += c.toString()));
|
|
352
|
+
|
|
353
|
+
const ready = await poll({
|
|
354
|
+
timeoutMs: 120_000,
|
|
355
|
+
fn: async () => {
|
|
356
|
+
if (instance?.exitCode != null) {
|
|
357
|
+
throw new Error(`instance exited early (${instance.exitCode}):\n${instanceLog}`);
|
|
358
|
+
}
|
|
359
|
+
try {
|
|
360
|
+
const res = await fetch(`${INSTANCE_URL}/.checkstack/ready`);
|
|
361
|
+
const body = (await res.json()) as { ready?: boolean };
|
|
362
|
+
return body.ready === true ? true : undefined;
|
|
363
|
+
} catch {
|
|
364
|
+
return undefined;
|
|
365
|
+
}
|
|
366
|
+
},
|
|
367
|
+
});
|
|
368
|
+
expect(ready, `instance never became ready.\n${instanceLog}`).toBe(true);
|
|
369
|
+
}, 600_000);
|
|
370
|
+
|
|
371
|
+
afterAll(async () => {
|
|
372
|
+
await browser?.close();
|
|
373
|
+
if (instance && instance.exitCode == null) {
|
|
374
|
+
instance.kill("SIGTERM");
|
|
375
|
+
await new Promise((r) => setTimeout(r, 1000));
|
|
376
|
+
if (instance.exitCode == null) instance.kill("SIGKILL");
|
|
377
|
+
}
|
|
378
|
+
await ownedRegistry?.stop();
|
|
379
|
+
if (tmpRoot && !process.env.CHECKSTACK_E2E_KEEP) {
|
|
380
|
+
fs.rmSync(tmpRoot, { recursive: true, force: true });
|
|
381
|
+
} else if (tmpRoot) {
|
|
382
|
+
console.log(`[e2e] kept workspace: ${tmpRoot}`);
|
|
383
|
+
}
|
|
384
|
+
});
|
|
385
|
+
|
|
386
|
+
it("installs the packaged plugin via the UI; frontend + backend + core plugins load", async () => {
|
|
387
|
+
browser = await chromium.launch();
|
|
388
|
+
const page = await browser.newPage({ baseURL: INSTANCE_URL });
|
|
389
|
+
if (process.env.CHECKSTACK_E2E_KEEP) {
|
|
390
|
+
page.on("console", (m) => {
|
|
391
|
+
if (
|
|
392
|
+
m.type() === "error" ||
|
|
393
|
+
m.type() === "warning" ||
|
|
394
|
+
/plugin|editor|share|federation|remote/i.test(m.text())
|
|
395
|
+
) {
|
|
396
|
+
console.log(`[browser:${m.type()}] ${m.text()}`);
|
|
397
|
+
}
|
|
398
|
+
});
|
|
399
|
+
page.on("pageerror", (e) => console.log(`[browser:pageerror] ${e.message}`));
|
|
400
|
+
page.on("requestfailed", (r) =>
|
|
401
|
+
console.log(`[browser:reqfail] ${r.url()} ${r.failure()?.errorText}`),
|
|
402
|
+
);
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
// --- Onboard the first admin (fresh DB → onboarding) ---
|
|
406
|
+
await page.goto("/");
|
|
407
|
+
await page.waitForURL(/\/auth\/onboarding$/, { timeout: 30_000 });
|
|
408
|
+
await page.locator("#name").fill(ADMIN.name);
|
|
409
|
+
await page.locator("#email").fill(ADMIN.email);
|
|
410
|
+
await page.locator("#password").fill(ADMIN.password);
|
|
411
|
+
await page.locator("#confirmPassword").fill(ADMIN.password);
|
|
412
|
+
await page.getByRole("button", { name: "Complete Setup" }).click();
|
|
413
|
+
await page.waitForURL((url) => url.pathname === "/", { timeout: 30_000 });
|
|
414
|
+
await expect(page.getByRole("button", { name: ADMIN.name })).toBeVisible({ timeout: 15_000 });
|
|
415
|
+
|
|
416
|
+
// --- Install the bundle via the Plugin Manager UI ---
|
|
417
|
+
await page.goto("/pluginmanager/install");
|
|
418
|
+
await expect(page.getByRole("heading", { name: "Install plugin" })).toBeVisible();
|
|
419
|
+
await page.getByRole("tab", { name: "Tarball Upload" }).click();
|
|
420
|
+
await page.locator("#tarball-file").setInputFiles(bundleTarball);
|
|
421
|
+
await page.getByRole("button", { name: /Upload & preview/i }).click();
|
|
422
|
+
|
|
423
|
+
// Typed-confirmation modal: type the primary package name, then Install.
|
|
424
|
+
const primaryName = `@${PACKAGE_SCOPE}/${BASE_NAME}-backend`;
|
|
425
|
+
const confirmInput = page.getByRole("textbox").last();
|
|
426
|
+
await expect(confirmInput).toBeVisible({ timeout: 30_000 });
|
|
427
|
+
await confirmInput.fill(primaryName);
|
|
428
|
+
await page.getByRole("button", { name: /^Install/ }).click();
|
|
429
|
+
|
|
430
|
+
// Install lands on the installed list with a success toast.
|
|
431
|
+
await expect(page.getByText(/Installed \d+ package/)).toBeVisible({ timeout: 180_000 });
|
|
432
|
+
// The installed route is "/" in the pluginmanager namespace, so the page
|
|
433
|
+
// navigates to `/pluginmanager/` (trailing slash) on success.
|
|
434
|
+
await page.waitForURL(/\/pluginmanager\/?$/, { timeout: 30_000 });
|
|
435
|
+
await expect(page.getByRole("cell", { name: primaryName })).toBeVisible({ timeout: 15_000 });
|
|
436
|
+
|
|
437
|
+
// --- (backend) the plugin's API responds ---
|
|
438
|
+
// `getItems` is access-gated (widgetAccess.read), so call it through the
|
|
439
|
+
// authenticated browser context (page.request shares the session cookie),
|
|
440
|
+
// not a bare node fetch. Poll because the install broadcast loads the
|
|
441
|
+
// backend module asynchronously after the install RPC returns.
|
|
442
|
+
const apiOk = await poll({
|
|
443
|
+
timeoutMs: 60_000,
|
|
444
|
+
fn: async () => {
|
|
445
|
+
const res = await page.request.post(
|
|
446
|
+
`${INSTANCE_URL}/api/${PLUGIN_ID}/getItems`,
|
|
447
|
+
{ data: { json: {} } },
|
|
448
|
+
);
|
|
449
|
+
return res.status() === 200 ? true : undefined;
|
|
450
|
+
},
|
|
451
|
+
});
|
|
452
|
+
expect(apiOk, `plugin backend API never returned 200.\n${instanceLog}`).toBe(true);
|
|
453
|
+
|
|
454
|
+
// --- (frontend) the plugin's route/nav entry + page render ---
|
|
455
|
+
await page.goto("/");
|
|
456
|
+
const widgetNav = page.getByRole("link", { name: "Widget" });
|
|
457
|
+
await expect(widgetNav).toBeVisible({ timeout: 30_000 });
|
|
458
|
+
await widgetNav.click();
|
|
459
|
+
await expect(page).toHaveURL(/\/widget/, { timeout: 15_000 });
|
|
460
|
+
// The plugin's page rendered (not a 404/error boundary).
|
|
461
|
+
await expect(page.getByText(/not found|something went wrong/i)).toHaveCount(0);
|
|
462
|
+
if (process.env.CHECKSTACK_E2E_KEEP) {
|
|
463
|
+
await page
|
|
464
|
+
.waitForSelector('[data-testid="plugin-code-editor"]', { timeout: 8_000 })
|
|
465
|
+
.catch(() => undefined);
|
|
466
|
+
const html = await page.content();
|
|
467
|
+
const dump = path.join(tmpRoot, "widget-page.html");
|
|
468
|
+
fs.writeFileSync(dump, html);
|
|
469
|
+
await page.screenshot({ path: path.join(tmpRoot, "widget-page.png"), fullPage: true });
|
|
470
|
+
const main = await page.locator("main, #root").first().innerText().catch(() => "<no text>");
|
|
471
|
+
console.log(`[e2e] widget page URL: ${page.url()}`);
|
|
472
|
+
console.log(`[e2e] widget page dumped: ${dump} (${html.length} bytes)`);
|
|
473
|
+
console.log(`[e2e] widget main text (first 600):\n${main.slice(0, 600)}`);
|
|
474
|
+
console.log(`[e2e] testid count: ${await page.getByTestId("plugin-code-editor").count()}`);
|
|
475
|
+
console.log(`[e2e] .monaco-editor count: ${await page.locator(".monaco-editor").count()}`);
|
|
476
|
+
}
|
|
477
|
+
// The shared editor (provided by the HOST, never bundled into the plugin)
|
|
478
|
+
// mounts inside the plugin's page - runtime proof of Option B.
|
|
479
|
+
await expect(page.getByTestId("plugin-code-editor")).toBeVisible({
|
|
480
|
+
timeout: 15_000,
|
|
481
|
+
});
|
|
482
|
+
await expect(page.locator(".monaco-editor").first()).toBeVisible({
|
|
483
|
+
timeout: 30_000,
|
|
484
|
+
});
|
|
485
|
+
|
|
486
|
+
// --- (core plugins co-load) a core plugin still works, frontend + backend ---
|
|
487
|
+
const catalogRes = await page.request.post(
|
|
488
|
+
`${INSTANCE_URL}/api/catalog/getEntities`,
|
|
489
|
+
{ data: { json: {} } },
|
|
490
|
+
);
|
|
491
|
+
expect(catalogRes.status(), `catalog API status ${catalogRes.status()}`).toBe(200);
|
|
492
|
+
// Core nav entries are still present (the external plugin didn't break the shell).
|
|
493
|
+
await expect(page.getByRole("link", { name: "Catalog" })).toBeVisible();
|
|
494
|
+
}, 300_000);
|
|
495
|
+
},
|
|
496
|
+
);
|
|
@@ -246,7 +246,27 @@ describe.skipIf(!process.env.CHECKSTACK_IT)(
|
|
|
246
246
|
const original = fs.readFileSync(frontendIndex, "utf8");
|
|
247
247
|
fs.writeFileSync(
|
|
248
248
|
frontendIndex,
|
|
249
|
-
|
|
249
|
+
// e2e Monaco render probe: mount @checkstack/ui's CodeEditor into a body
|
|
250
|
+
// overlay at the plugin frontend's MODULE LOAD (eagerly imported by the
|
|
251
|
+
// dev shell to register the plugin, so it runs independently of
|
|
252
|
+
// routing/auth). The dev test asserts it actually renders in a browser —
|
|
253
|
+
// proving the dev server's pre-built Monaco workers + alias redirect
|
|
254
|
+
// work, not just that the boot log is clean.
|
|
255
|
+
`import { CodeEditor as __E2ECodeEditor } from "@checkstack/ui";\n` +
|
|
256
|
+
`import { createRoot as __e2eCreateRoot } from "react-dom/client";\n` +
|
|
257
|
+
`import { useState as __e2eUseState } from "react";\n` +
|
|
258
|
+
`if (typeof document !== "undefined") {\n` +
|
|
259
|
+
` const __el = document.createElement("div");\n` +
|
|
260
|
+
` __el.id = "__e2e_monaco__";\n` +
|
|
261
|
+
` __el.style.cssText = "position:fixed;bottom:0;right:0;width:480px;height:260px;z-index:99999;background:#fff";\n` +
|
|
262
|
+
` document.body.appendChild(__el);\n` +
|
|
263
|
+
` const __E2EEditor = () => {\n` +
|
|
264
|
+
` const [v, setV] = __e2eUseState("interface H { status: \\"ok\\" | \\"down\\" }\\nconst p: H = { status: \\"ok\\" }\\n");\n` +
|
|
265
|
+
` return <__E2ECodeEditor value={v} onChange={setV} language="typescript" minHeight="220px" />;\n` +
|
|
266
|
+
` };\n` +
|
|
267
|
+
` __e2eCreateRoot(__el).render(<__E2EEditor />);\n` +
|
|
268
|
+
`}\n` +
|
|
269
|
+
`// e2e Tailwind probe: a custom arbitrary utility class.\n` +
|
|
250
270
|
`export const __TailwindProbe = () => <div className="${TAILWIND_PROBE_CLASS}" />;\n` +
|
|
251
271
|
original,
|
|
252
272
|
);
|
|
@@ -473,6 +493,50 @@ describe.skipIf(!process.env.CHECKSTACK_IT)(
|
|
|
473
493
|
compiledCss,
|
|
474
494
|
`dev CSS did not contain the plugin's custom Tailwind class (${TAILWIND_PROBE_CLASS} → "${TAILWIND_PROBE_CSS}"); the dev shell is unstyled.\nboot log:\n${bootLog}`,
|
|
475
495
|
).toBeDefined();
|
|
496
|
+
|
|
497
|
+
// RENDER verification: @checkstack/ui's CodeEditor (Monaco) must actually
|
|
498
|
+
// MOUNT with syntax highlighting in a real browser against the standalone
|
|
499
|
+
// dev server. This is the end-to-end guard for the pre-built-worker fix
|
|
500
|
+
// (`@checkstack/ui` is a pre-bundled npm dep here; the dev server serves
|
|
501
|
+
// pre-built Monaco workers and redirects the `?worker&url` imports to
|
|
502
|
+
// them). The probe (scaffold step) mounts an editor into #__e2e_monaco__.
|
|
503
|
+
const { chromium } = await import("@playwright/test");
|
|
504
|
+
const browser = await chromium.launch();
|
|
505
|
+
try {
|
|
506
|
+
const pwPage = await browser.newPage();
|
|
507
|
+
const pageErrors: string[] = [];
|
|
508
|
+
pwPage.on("pageerror", (e) => pageErrors.push(String(e.message)));
|
|
509
|
+
await pwPage.goto(`http://localhost:${FRONTEND_PORT}/`, {
|
|
510
|
+
waitUntil: "load",
|
|
511
|
+
timeout: 60_000,
|
|
512
|
+
});
|
|
513
|
+
// Monaco token spans (`mtk1`, `mtk5`, …) prove the editor rendered AND
|
|
514
|
+
// tokenized — not just an empty shell. Vite may re-optimize deps on the
|
|
515
|
+
// first editor load (504 + reload), so retry across reloads.
|
|
516
|
+
const tokenSelector =
|
|
517
|
+
'#__e2e_monaco__ .monaco-editor .view-lines [class^="mtk"]';
|
|
518
|
+
let tokenCount = 0;
|
|
519
|
+
for (let i = 0; i < 8 && tokenCount === 0; i++) {
|
|
520
|
+
await pwPage.waitForTimeout(3000);
|
|
521
|
+
tokenCount = await pwPage.locator(tokenSelector).count().catch(() => 0);
|
|
522
|
+
if (tokenCount === 0)
|
|
523
|
+
await pwPage.reload({ waitUntil: "load" }).catch(() => {});
|
|
524
|
+
}
|
|
525
|
+
if (tokenCount === 0) {
|
|
526
|
+
const diag = await pwPage.evaluate(() => ({
|
|
527
|
+
overlay: !!document.getElementById("__e2e_monaco__"),
|
|
528
|
+
anyMonaco: document.querySelectorAll(".monaco-editor").length,
|
|
529
|
+
}));
|
|
530
|
+
throw new Error(
|
|
531
|
+
`@checkstack/ui's Monaco CodeEditor did not render in the standalone dev server.\n` +
|
|
532
|
+
`diag=${JSON.stringify(diag)} pageErrors=${JSON.stringify(pageErrors.slice(0, 6))}\n` +
|
|
533
|
+
`boot log:\n${bootLog}`,
|
|
534
|
+
);
|
|
535
|
+
}
|
|
536
|
+
expect(tokenCount).toBeGreaterThan(0);
|
|
537
|
+
} finally {
|
|
538
|
+
await browser.close();
|
|
539
|
+
}
|
|
476
540
|
}, 300_000);
|
|
477
541
|
},
|
|
478
542
|
);
|