create-checkstack-plugin 0.1.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/package.json +33 -0
- package/src/cli-args.test.ts +72 -0
- package/src/cli-args.ts +130 -0
- package/src/cli.ts +177 -0
- package/src/external-plugin-lifecycle.it.test.ts +478 -0
- package/src/git.ts +39 -0
- package/src/local-registry.test.ts +143 -0
- package/src/local-registry.ts +313 -0
- package/src/npm-view-resolver.test.ts +140 -0
- package/src/npm-view-resolver.ts +140 -0
- package/src/scaffold-standalone.test.ts +211 -0
- package/src/scaffold-standalone.ts +104 -0
|
@@ -0,0 +1,478 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* End-to-end integration test for the external-plugin authoring lifecycle
|
|
3
|
+
* against PUBLISHED tarballs (#251 Phase 3, plan §7).
|
|
4
|
+
*
|
|
5
|
+
* This is the single heavy lane that guards the whole external story: it
|
|
6
|
+
* publishes the CURRENT working tree's `@checkstack/*` packages to a
|
|
7
|
+
* throwaway local Verdaccio registry, scaffolds a standalone plugin
|
|
8
|
+
* workspace on the fly (Phase-2 engine, resolver pointed at the local
|
|
9
|
+
* registry), `bun install`s it from that registry, packs + bundles it, then
|
|
10
|
+
* boots the published dev server and asserts the plugin serves
|
|
11
|
+
* `POST /api/<pluginId>/*` == 200 and the frontend Vite server comes up
|
|
12
|
+
* and answers HTTP (a hard assertion — #251 bug 2 is fixed).
|
|
13
|
+
*
|
|
14
|
+
* Because it publishes the live tree, it implicitly catches "a package.json
|
|
15
|
+
* change broke the published shape" regressions — the rot this feature
|
|
16
|
+
* exists to prevent. Finer-grained logic (version rewriting, env
|
|
17
|
+
* construction, frontend-entry picking) lives in the fast unit suites; this
|
|
18
|
+
* lane is intentionally one test.
|
|
19
|
+
*
|
|
20
|
+
* Gated behind `CHECKSTACK_IT=1` so the default `bun test` never runs it.
|
|
21
|
+
* The `integration` CI job sets that flag, provides Postgres, and (Phase 3)
|
|
22
|
+
* provides a Verdaccio registry. Connection comes from
|
|
23
|
+
* `CHECKSTACK_IT_PG_URL`; the registry from `CHECKSTACK_SCAFFOLD_REGISTRY`
|
|
24
|
+
* (defaulting to the harness-managed local registry).
|
|
25
|
+
*
|
|
26
|
+
* Heavy by design (publish + full `bun install` + backend boot is minutes,
|
|
27
|
+
* not seconds), so the suite uses a generous timeout.
|
|
28
|
+
*/
|
|
29
|
+
import { afterAll, beforeAll, describe, expect, it } from "bun:test";
|
|
30
|
+
import { spawn, spawnSync, type ChildProcess } from "node:child_process";
|
|
31
|
+
import fs from "node:fs";
|
|
32
|
+
import os from "node:os";
|
|
33
|
+
import path from "node:path";
|
|
34
|
+
import { fileURLToPath } from "node:url";
|
|
35
|
+
import { scaffoldStandaloneWorkspace, localSiblingNames } from "./scaffold-standalone";
|
|
36
|
+
import { createNpmViewResolver } from "./npm-view-resolver";
|
|
37
|
+
import {
|
|
38
|
+
DEFAULT_REGISTRY_URL,
|
|
39
|
+
buildVerdaccioConfig,
|
|
40
|
+
buildNpmrc,
|
|
41
|
+
buildNpmViewVersionArgs,
|
|
42
|
+
discoverWorkspacePackages,
|
|
43
|
+
publishWorkspacePackages,
|
|
44
|
+
findMonorepoRoot,
|
|
45
|
+
startVerdaccio,
|
|
46
|
+
type RegistryHandle,
|
|
47
|
+
} from "./local-registry";
|
|
48
|
+
|
|
49
|
+
const HERE = path.dirname(fileURLToPath(import.meta.url));
|
|
50
|
+
const PG_URL =
|
|
51
|
+
process.env.CHECKSTACK_IT_PG_URL ??
|
|
52
|
+
"postgres://checkstack:checkstack@localhost:5432/checkstack";
|
|
53
|
+
|
|
54
|
+
const BASE_NAME = "widget";
|
|
55
|
+
const PLUGIN_ID = "widget"; // pluginId is the unscoped base, used in /api/<pluginId>/*
|
|
56
|
+
// A dedicated npm scope for the generated trio so its package names
|
|
57
|
+
// (@checkstackit/widget-*) never collide with the real @checkstack/* packages
|
|
58
|
+
// we publish to the same local registry. The routing pluginId stays unscoped
|
|
59
|
+
// (PLUGIN_ID above) regardless of the package scope.
|
|
60
|
+
const PACKAGE_SCOPE = "checkstackit";
|
|
61
|
+
// The published @checkstack/backend default-export server binds a fixed
|
|
62
|
+
// port (it does not honour $PORT), so the backend always listens on 3000.
|
|
63
|
+
// We poll that port rather than overriding PORT, which would be ignored.
|
|
64
|
+
const BACKEND_PORT = 3000;
|
|
65
|
+
const FRONTEND_PORT = 5917;
|
|
66
|
+
// A custom (arbitrary) Tailwind utility class we inject into the scaffolded
|
|
67
|
+
// plugin's frontend. `251px` is an uncommon value unlikely to appear in the
|
|
68
|
+
// platform's own CSS, so finding it in the compiled dev CSS proves the dev
|
|
69
|
+
// server compiled the PLUGIN'S OWN classes.
|
|
70
|
+
const TAILWIND_PROBE_CLASS = "p-[251px]";
|
|
71
|
+
const TAILWIND_PROBE_CSS = "251px";
|
|
72
|
+
|
|
73
|
+
/** Run a command, returning status + captured output (no inherited stdio). */
|
|
74
|
+
function run({
|
|
75
|
+
command,
|
|
76
|
+
args,
|
|
77
|
+
cwd,
|
|
78
|
+
env,
|
|
79
|
+
timeoutMs = 600_000,
|
|
80
|
+
}: {
|
|
81
|
+
command: string;
|
|
82
|
+
args: string[];
|
|
83
|
+
cwd: string;
|
|
84
|
+
env?: NodeJS.ProcessEnv;
|
|
85
|
+
timeoutMs?: number;
|
|
86
|
+
}): { status: number; stdout: string; stderr: string } {
|
|
87
|
+
const result = spawnSync(command, args, {
|
|
88
|
+
cwd,
|
|
89
|
+
env: { ...process.env, ...env },
|
|
90
|
+
encoding: "utf8",
|
|
91
|
+
timeout: timeoutMs,
|
|
92
|
+
maxBuffer: 64 * 1024 * 1024,
|
|
93
|
+
});
|
|
94
|
+
return {
|
|
95
|
+
status: result.status ?? 1,
|
|
96
|
+
stdout: result.stdout ?? "",
|
|
97
|
+
stderr: `${result.stderr ?? ""}${result.error ? result.error.message : ""}`,
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
async function poll<T>({
|
|
102
|
+
fn,
|
|
103
|
+
timeoutMs,
|
|
104
|
+
intervalMs = 1000,
|
|
105
|
+
}: {
|
|
106
|
+
fn: () => Promise<T | undefined>;
|
|
107
|
+
timeoutMs: number;
|
|
108
|
+
intervalMs?: number;
|
|
109
|
+
}): Promise<T | undefined> {
|
|
110
|
+
const deadline = Date.now() + timeoutMs;
|
|
111
|
+
while (Date.now() < deadline) {
|
|
112
|
+
const result = await fn();
|
|
113
|
+
if (result !== undefined) return result;
|
|
114
|
+
await new Promise((r) => setTimeout(r, intervalMs));
|
|
115
|
+
}
|
|
116
|
+
return undefined;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
describe.skipIf(!process.env.CHECKSTACK_IT)(
|
|
120
|
+
"external plugin lifecycle (published tarballs)",
|
|
121
|
+
() => {
|
|
122
|
+
let tmpRoot: string;
|
|
123
|
+
let workspaceDir: string;
|
|
124
|
+
let backendDir: string;
|
|
125
|
+
let npmrcPath: string;
|
|
126
|
+
let registryUrl: string;
|
|
127
|
+
let cacheDir: string;
|
|
128
|
+
let ownedRegistry: RegistryHandle | undefined;
|
|
129
|
+
let devChild: ChildProcess | undefined;
|
|
130
|
+
let monorepoRoot: string;
|
|
131
|
+
|
|
132
|
+
beforeAll(async () => {
|
|
133
|
+
monorepoRoot = findMonorepoRoot({ from: HERE });
|
|
134
|
+
tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ccp-e2e-"));
|
|
135
|
+
|
|
136
|
+
// Per-run isolated bun install cache. CRITICAL: bun's global
|
|
137
|
+
// content-addressed cache is keyed by name@version, so re-publishing the
|
|
138
|
+
// SAME @checkstack/* version (the tree's current version, unchanged
|
|
139
|
+
// between local runs) would serve a STALE tarball from a previous run
|
|
140
|
+
// instead of the just-published one. An isolated cache dir forces every
|
|
141
|
+
// run to fetch the freshly-published tarballs from the local registry.
|
|
142
|
+
cacheDir = path.join(tmpRoot, "bun-cache");
|
|
143
|
+
fs.mkdirSync(cacheDir, { recursive: true });
|
|
144
|
+
|
|
145
|
+
// The throwaway .npmrc routing every fetch/publish at the local
|
|
146
|
+
// registry, plus the fake auth token that satisfies the publish proto.
|
|
147
|
+
registryUrl = process.env.CHECKSTACK_SCAFFOLD_REGISTRY ?? DEFAULT_REGISTRY_URL;
|
|
148
|
+
npmrcPath = path.join(tmpRoot, ".npmrc");
|
|
149
|
+
fs.writeFileSync(npmrcPath, buildNpmrc({ registryUrl }));
|
|
150
|
+
|
|
151
|
+
// Two modes:
|
|
152
|
+
// - CI provides Verdaccio AND publishes the workspace itself (a
|
|
153
|
+
// dedicated step, so a publish failure is attributed clearly).
|
|
154
|
+
// Detected via CHECKSTACK_SCAFFOLD_REGISTRY being preset — the test
|
|
155
|
+
// then only verifies the registry answers and does NOT re-publish
|
|
156
|
+
// (that would collide on already-published versions).
|
|
157
|
+
// - Local runs own the lifecycle: spin up Verdaccio here and publish
|
|
158
|
+
// the full non-private workspace into it.
|
|
159
|
+
const registryProvidedByCi = Boolean(process.env.CHECKSTACK_SCAFFOLD_REGISTRY);
|
|
160
|
+
if (!registryProvidedByCi) {
|
|
161
|
+
const storageDir = path.join(tmpRoot, "verdaccio-storage");
|
|
162
|
+
const configPath = path.join(tmpRoot, "verdaccio.yaml");
|
|
163
|
+
fs.mkdirSync(storageDir, { recursive: true });
|
|
164
|
+
fs.writeFileSync(configPath, buildVerdaccioConfig({ storageDir }));
|
|
165
|
+
ownedRegistry = await startVerdaccio({ configPath, url: registryUrl });
|
|
166
|
+
|
|
167
|
+
// Publish the full non-private workspace to the local registry. We
|
|
168
|
+
// use `bun publish --registry <url>` (resolves workspace:* to
|
|
169
|
+
// concrete versions) rather than the real-registry
|
|
170
|
+
// `publish-packages.ts`. We publish the FULL set because the
|
|
171
|
+
// scaffolded plugin depends on @checkstack/backend, whose transitive
|
|
172
|
+
// closure spans most of the platform.
|
|
173
|
+
const packages = discoverWorkspacePackages({ monorepoRoot });
|
|
174
|
+
const outcomes = publishWorkspacePackages({
|
|
175
|
+
packages,
|
|
176
|
+
registryUrl,
|
|
177
|
+
npmrcPath,
|
|
178
|
+
env: { ...process.env, BUN_INSTALL_CACHE_DIR: cacheDir },
|
|
179
|
+
});
|
|
180
|
+
const failed = outcomes.filter((o) => o.status !== 0);
|
|
181
|
+
expect(
|
|
182
|
+
failed,
|
|
183
|
+
`publish failures:\n${failed.map((f) => `${f.name}: ${f.stderr}`).join("\n")}`,
|
|
184
|
+
).toEqual([]);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// Sanity: @checkstack/backend and create-checkstack-plugin answer.
|
|
188
|
+
for (const name of ["@checkstack/backend", "create-checkstack-plugin"]) {
|
|
189
|
+
const view = run({
|
|
190
|
+
command: "npm",
|
|
191
|
+
args: buildNpmViewVersionArgs({ packageName: name, registryUrl }),
|
|
192
|
+
cwd: tmpRoot,
|
|
193
|
+
env: { NPM_CONFIG_USERCONFIG: npmrcPath },
|
|
194
|
+
timeoutMs: 60_000,
|
|
195
|
+
});
|
|
196
|
+
expect(view.status, `npm view ${name}: ${view.stderr}`).toBe(0);
|
|
197
|
+
expect(view.stdout.trim().length).toBeGreaterThan(0);
|
|
198
|
+
}
|
|
199
|
+
}, 600_000);
|
|
200
|
+
|
|
201
|
+
afterAll(async () => {
|
|
202
|
+
if (devChild && devChild.exitCode === null) {
|
|
203
|
+
devChild.kill("SIGTERM");
|
|
204
|
+
await new Promise((r) => setTimeout(r, 500));
|
|
205
|
+
if (devChild.exitCode === null) devChild.kill("SIGKILL");
|
|
206
|
+
}
|
|
207
|
+
await ownedRegistry?.stop();
|
|
208
|
+
if (tmpRoot) fs.rmSync(tmpRoot, { recursive: true, force: true });
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
it("scaffolds with zero workspace ranges and a concrete version per @checkstack dep", async () => {
|
|
212
|
+
workspaceDir = path.join(tmpRoot, BASE_NAME);
|
|
213
|
+
// Resolver pointed at the local registry — exactly the injected seam
|
|
214
|
+
// the create-checkstack-plugin CLI exposes via --registry. Local trio
|
|
215
|
+
// siblings stay workspace:* (resolved from the local Bun workspace).
|
|
216
|
+
const resolveVersion = createNpmViewResolver({
|
|
217
|
+
registry: registryUrl,
|
|
218
|
+
localSiblings: localSiblingNames({ baseName: BASE_NAME, packageScope: PACKAGE_SCOPE }),
|
|
219
|
+
});
|
|
220
|
+
await scaffoldStandaloneWorkspace({
|
|
221
|
+
rootDir: workspaceDir,
|
|
222
|
+
baseName: BASE_NAME,
|
|
223
|
+
description: "Widget e2e plugin",
|
|
224
|
+
packageScope: PACKAGE_SCOPE,
|
|
225
|
+
resolveVersion,
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
backendDir = path.join(workspaceDir, "packages", `${BASE_NAME}-backend`);
|
|
229
|
+
expect(fs.existsSync(backendDir)).toBe(true);
|
|
230
|
+
expect(fs.existsSync(path.join(workspaceDir, "packages", `${BASE_NAME}-common`))).toBe(true);
|
|
231
|
+
expect(fs.existsSync(path.join(workspaceDir, "packages", `${BASE_NAME}-frontend`))).toBe(true);
|
|
232
|
+
|
|
233
|
+
// Inject a probe element using a CUSTOM (arbitrary) Tailwind utility
|
|
234
|
+
// class into the plugin's own frontend source. The dev server must
|
|
235
|
+
// compile it into the dev CSS from a published install (proving the
|
|
236
|
+
// @checkstack/frontend Tailwind-toolchain-as-runtime-deps + preset
|
|
237
|
+
// export + plugin-glob injection actually styles author code, not just
|
|
238
|
+
// the precompiled @checkstack/ui components). Asserted in the dev test.
|
|
239
|
+
const frontendIndex = path.join(
|
|
240
|
+
workspaceDir,
|
|
241
|
+
"packages",
|
|
242
|
+
`${BASE_NAME}-frontend`,
|
|
243
|
+
"src",
|
|
244
|
+
"index.tsx",
|
|
245
|
+
);
|
|
246
|
+
const original = fs.readFileSync(frontendIndex, "utf8");
|
|
247
|
+
fs.writeFileSync(
|
|
248
|
+
frontendIndex,
|
|
249
|
+
`// e2e Tailwind probe: a custom arbitrary utility class.\n` +
|
|
250
|
+
`export const __TailwindProbe = () => <div className="${TAILWIND_PROBE_CLASS}" />;\n` +
|
|
251
|
+
original,
|
|
252
|
+
);
|
|
253
|
+
|
|
254
|
+
// Every @checkstack/* dep must be a concrete (caret) version, never
|
|
255
|
+
// workspace:* — install rejects workspace ranges. Only the local trio
|
|
256
|
+
// siblings keep workspace:* (they resolve from the local workspace).
|
|
257
|
+
const siblings = localSiblingNames({ baseName: BASE_NAME, packageScope: PACKAGE_SCOPE });
|
|
258
|
+
for (const type of ["common", "backend", "frontend"]) {
|
|
259
|
+
const pkg = JSON.parse(
|
|
260
|
+
fs.readFileSync(
|
|
261
|
+
path.join(workspaceDir, "packages", `${BASE_NAME}-${type}`, "package.json"),
|
|
262
|
+
"utf8",
|
|
263
|
+
),
|
|
264
|
+
) as Record<string, Record<string, string> | unknown>;
|
|
265
|
+
for (const section of ["dependencies", "devDependencies"] as const) {
|
|
266
|
+
const deps = (pkg[section] as Record<string, string> | undefined) ?? {};
|
|
267
|
+
for (const [name, range] of Object.entries(deps)) {
|
|
268
|
+
if (name.startsWith("@checkstack/")) {
|
|
269
|
+
expect(range, `${name} in ${type}`).not.toContain("workspace:");
|
|
270
|
+
expect(range).toMatch(/^\^?\d/);
|
|
271
|
+
} else if (siblings.has(name)) {
|
|
272
|
+
expect(range).toBe("workspace:*");
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
// The backend primary must carry checkstack.bundle (required for
|
|
279
|
+
// plugin-pack --bundle). Siblings must NOT.
|
|
280
|
+
const backendPkg = JSON.parse(
|
|
281
|
+
fs.readFileSync(path.join(backendDir, "package.json"), "utf8"),
|
|
282
|
+
) as { checkstack?: { bundle?: string[] } };
|
|
283
|
+
expect(backendPkg.checkstack?.bundle).toEqual([
|
|
284
|
+
`@${PACKAGE_SCOPE}/${BASE_NAME}-common`,
|
|
285
|
+
`@${PACKAGE_SCOPE}/${BASE_NAME}-frontend`,
|
|
286
|
+
]);
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
it("installs from the local registry", () => {
|
|
290
|
+
const install = run({
|
|
291
|
+
command: "bun",
|
|
292
|
+
args: ["install"],
|
|
293
|
+
cwd: workspaceDir,
|
|
294
|
+
env: {
|
|
295
|
+
NPM_CONFIG_USERCONFIG: npmrcPath,
|
|
296
|
+
BUN_CONFIG_REGISTRY: registryUrl,
|
|
297
|
+
// Isolated cache so the just-published tarballs win over any
|
|
298
|
+
// same-version copy in bun's global cache (see beforeAll).
|
|
299
|
+
BUN_INSTALL_CACHE_DIR: cacheDir,
|
|
300
|
+
},
|
|
301
|
+
});
|
|
302
|
+
expect(install.status, install.stderr).toBe(0);
|
|
303
|
+
// @checkstack/backend + @checkstack/dev-server must resolve in the
|
|
304
|
+
// backend package's reachable node_modules (hoisted or local).
|
|
305
|
+
const resolves = (name: string) => {
|
|
306
|
+
const r = run({
|
|
307
|
+
command: "bun",
|
|
308
|
+
args: ["-e", `require.resolve(${JSON.stringify(`${name}/package.json`)})`],
|
|
309
|
+
cwd: backendDir,
|
|
310
|
+
});
|
|
311
|
+
return r.status === 0;
|
|
312
|
+
};
|
|
313
|
+
expect(resolves("@checkstack/backend")).toBe(true);
|
|
314
|
+
expect(resolves("@checkstack/dev-server")).toBe(true);
|
|
315
|
+
}, 600_000);
|
|
316
|
+
|
|
317
|
+
it("validates, packs, and bundles (workspace rewrite is a no-op for @checkstack deps)", () => {
|
|
318
|
+
const packEnv = {
|
|
319
|
+
NPM_CONFIG_USERCONFIG: npmrcPath,
|
|
320
|
+
BUN_CONFIG_REGISTRY: registryUrl,
|
|
321
|
+
BUN_INSTALL_CACHE_DIR: cacheDir,
|
|
322
|
+
};
|
|
323
|
+
const validate = run({
|
|
324
|
+
command: "bun",
|
|
325
|
+
args: ["run", "pack", "--", "--validate-only"],
|
|
326
|
+
cwd: backendDir,
|
|
327
|
+
env: packEnv,
|
|
328
|
+
});
|
|
329
|
+
expect(validate.status, validate.stderr || validate.stdout).toBe(0);
|
|
330
|
+
|
|
331
|
+
const bundle = run({
|
|
332
|
+
command: "bun",
|
|
333
|
+
args: ["run", "pack", "--", "--bundle"],
|
|
334
|
+
cwd: backendDir,
|
|
335
|
+
env: packEnv,
|
|
336
|
+
});
|
|
337
|
+
expect(bundle.status, bundle.stderr || bundle.stdout).toBe(0);
|
|
338
|
+
|
|
339
|
+
const distDir = path.join(backendDir, "dist");
|
|
340
|
+
const bundleTarball = fs
|
|
341
|
+
.readdirSync(distDir)
|
|
342
|
+
.find((f) => f.endsWith("-bundle.tgz"));
|
|
343
|
+
expect(bundleTarball, "expected a *-bundle.tgz in dist/").toBeDefined();
|
|
344
|
+
}, 600_000);
|
|
345
|
+
|
|
346
|
+
it("boots the dev server and serves POST /api/<pluginId>/* == 200 with a JSON array", async () => {
|
|
347
|
+
// Boot the published dev server via the scaffolded `dev` script. It
|
|
348
|
+
// spawns the real @checkstack/backend with the plugin loaded under
|
|
349
|
+
// synthetic dev auth, against the integration Postgres.
|
|
350
|
+
devChild = spawn("bun", ["run", "dev"], {
|
|
351
|
+
cwd: backendDir,
|
|
352
|
+
env: {
|
|
353
|
+
...process.env,
|
|
354
|
+
NPM_CONFIG_USERCONFIG: npmrcPath,
|
|
355
|
+
BUN_CONFIG_REGISTRY: registryUrl,
|
|
356
|
+
BUN_INSTALL_CACHE_DIR: cacheDir,
|
|
357
|
+
DATABASE_URL: PG_URL,
|
|
358
|
+
// The published backend binds a fixed port; PORT is not honoured,
|
|
359
|
+
// so we poll BACKEND_PORT (3000) below. The Vite dev port IS
|
|
360
|
+
// configurable, so we set it to avoid a 5173 collision.
|
|
361
|
+
FRONTEND_PORT: String(FRONTEND_PORT),
|
|
362
|
+
NODE_ENV: "development",
|
|
363
|
+
},
|
|
364
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
365
|
+
});
|
|
366
|
+
let bootLog = "";
|
|
367
|
+
devChild.stdout?.on("data", (c: Buffer) => {
|
|
368
|
+
bootLog += c.toString();
|
|
369
|
+
});
|
|
370
|
+
devChild.stderr?.on("data", (c: Buffer) => {
|
|
371
|
+
bootLog += c.toString();
|
|
372
|
+
});
|
|
373
|
+
|
|
374
|
+
// Poll the plugin endpoint until the backend is ready (migrations +
|
|
375
|
+
// plugin load take time on a cold install).
|
|
376
|
+
const body = await poll({
|
|
377
|
+
timeoutMs: 180_000,
|
|
378
|
+
fn: async () => {
|
|
379
|
+
if (devChild?.exitCode !== null && devChild?.exitCode !== undefined) {
|
|
380
|
+
throw new Error(`dev server exited early (${devChild.exitCode}):\n${bootLog}`);
|
|
381
|
+
}
|
|
382
|
+
try {
|
|
383
|
+
const res = await fetch(`http://localhost:${BACKEND_PORT}/api/${PLUGIN_ID}/getItems`, {
|
|
384
|
+
method: "POST",
|
|
385
|
+
headers: { "content-type": "application/json" },
|
|
386
|
+
body: JSON.stringify({ json: {} }),
|
|
387
|
+
});
|
|
388
|
+
if (res.status !== 200) return undefined;
|
|
389
|
+
return await res.json();
|
|
390
|
+
} catch {
|
|
391
|
+
return undefined;
|
|
392
|
+
}
|
|
393
|
+
},
|
|
394
|
+
});
|
|
395
|
+
expect(body, `no 200 from /api/${PLUGIN_ID}/getItems.\nboot log:\n${bootLog}`).toBeDefined();
|
|
396
|
+
// oRPC wraps the array output in a { json: [...] } envelope.
|
|
397
|
+
const payload =
|
|
398
|
+
body && typeof body === "object" && "json" in body
|
|
399
|
+
? (body as { json: unknown }).json
|
|
400
|
+
: body;
|
|
401
|
+
expect(Array.isArray(payload)).toBe(true);
|
|
402
|
+
|
|
403
|
+
// (backend+frontend case) The dev server must recognise the
|
|
404
|
+
// -frontend sibling in the bundle and bring up the Vite frontend dev
|
|
405
|
+
// server from the PUBLISHED install. The dev server resolves
|
|
406
|
+
// @checkstack/frontend (its own peer dep, NOT reachable from the
|
|
407
|
+
// plugin's package under a clean `bun install`) from its own install
|
|
408
|
+
// location, and picks up the workspace `-frontend` sibling by
|
|
409
|
+
// scanning sibling directories — #251 bug 2, fixed in
|
|
410
|
+
// @checkstack/dev-server's dev-frontend.ts. Both the "started" log
|
|
411
|
+
// line AND a live HTTP response are now HARD assertions, so this lane
|
|
412
|
+
// is fully green including the frontend.
|
|
413
|
+
const startedFrontend = await poll({
|
|
414
|
+
timeoutMs: 60_000,
|
|
415
|
+
fn: async () =>
|
|
416
|
+
/🎨 Frontend dev server/.test(bootLog) ? true : undefined,
|
|
417
|
+
});
|
|
418
|
+
expect(
|
|
419
|
+
startedFrontend,
|
|
420
|
+
`dev server never started the Vite frontend.\nboot log:\n${bootLog}`,
|
|
421
|
+
).toBe(true);
|
|
422
|
+
expect(
|
|
423
|
+
/Frontend dev server failed to start/.test(bootLog),
|
|
424
|
+
`dev server logged a Vite startup failure (regression of #251 bug 2).\nboot log:\n${bootLog}`,
|
|
425
|
+
).toBe(false);
|
|
426
|
+
|
|
427
|
+
const viteUp = await poll({
|
|
428
|
+
timeoutMs: 60_000,
|
|
429
|
+
fn: async () => {
|
|
430
|
+
if (devChild?.exitCode !== null && devChild?.exitCode !== undefined) {
|
|
431
|
+
throw new Error(
|
|
432
|
+
`dev server exited before Vite answered (${devChild.exitCode}):\n${bootLog}`,
|
|
433
|
+
);
|
|
434
|
+
}
|
|
435
|
+
try {
|
|
436
|
+
const res = await fetch(`http://localhost:${FRONTEND_PORT}/`);
|
|
437
|
+
return res.status < 500 ? true : undefined;
|
|
438
|
+
} catch {
|
|
439
|
+
return undefined;
|
|
440
|
+
}
|
|
441
|
+
},
|
|
442
|
+
});
|
|
443
|
+
expect(
|
|
444
|
+
viteUp,
|
|
445
|
+
`Vite did not answer on :${FRONTEND_PORT} (frontend HMR unavailable).\nboot log:\n${bootLog}`,
|
|
446
|
+
).toBe(true);
|
|
447
|
+
|
|
448
|
+
// The dev shell must be STYLED from a published install: fetch the
|
|
449
|
+
// compiled dev CSS (Vite serves index.css's processed output via the
|
|
450
|
+
// `?direct` query) and assert the PLUGIN'S OWN custom Tailwind class
|
|
451
|
+
// compiled. This proves @checkstack/frontend shipping the Tailwind
|
|
452
|
+
// toolchain as runtime deps + exporting `tailwind-preset` + the dev
|
|
453
|
+
// server injecting the plugin's source globs all work end-to-end.
|
|
454
|
+
const compiledCss = await poll({
|
|
455
|
+
timeoutMs: 60_000,
|
|
456
|
+
fn: async () => {
|
|
457
|
+
try {
|
|
458
|
+
const res = await fetch(
|
|
459
|
+
`http://localhost:${FRONTEND_PORT}/src/index.css?direct`,
|
|
460
|
+
);
|
|
461
|
+
if (res.status >= 400) return undefined;
|
|
462
|
+
const text = await res.text();
|
|
463
|
+
// Skip Vite's HTML error-overlay response (a failed PostCSS load
|
|
464
|
+
// returns 200 with an HTML doc, not CSS).
|
|
465
|
+
if (text.includes("Internal Server Error")) return undefined;
|
|
466
|
+
return text.includes(TAILWIND_PROBE_CSS) ? text : undefined;
|
|
467
|
+
} catch {
|
|
468
|
+
return undefined;
|
|
469
|
+
}
|
|
470
|
+
},
|
|
471
|
+
});
|
|
472
|
+
expect(
|
|
473
|
+
compiledCss,
|
|
474
|
+
`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
|
+
).toBeDefined();
|
|
476
|
+
}, 300_000);
|
|
477
|
+
},
|
|
478
|
+
);
|
package/src/git.ts
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Initialise a git repo in `dir` and make an initial commit.
|
|
5
|
+
*
|
|
6
|
+
* Runs in the thin CLI shell only (never the shared engine), so the
|
|
7
|
+
* in-monorepo `create` path is unaffected. Failures are non-fatal: a
|
|
8
|
+
* generated repo without git is still usable, so we warn and continue.
|
|
9
|
+
*/
|
|
10
|
+
export function initGitRepo({
|
|
11
|
+
dir,
|
|
12
|
+
log = console.log,
|
|
13
|
+
warn = console.warn,
|
|
14
|
+
}: {
|
|
15
|
+
dir: string;
|
|
16
|
+
log?: (message: string) => void;
|
|
17
|
+
warn?: (message: string) => void;
|
|
18
|
+
}): boolean {
|
|
19
|
+
const steps: { args: string[]; label: string }[] = [
|
|
20
|
+
{ args: ["init"], label: "git init" },
|
|
21
|
+
{ args: ["add", "-A"], label: "git add" },
|
|
22
|
+
{
|
|
23
|
+
args: ["commit", "-m", "chore: scaffold plugin with create-checkstack-plugin"],
|
|
24
|
+
label: "git commit",
|
|
25
|
+
},
|
|
26
|
+
];
|
|
27
|
+
|
|
28
|
+
for (const { args, label } of steps) {
|
|
29
|
+
const result = spawnSync("git", args, { cwd: dir, stdio: "ignore" });
|
|
30
|
+
if (result.status !== 0) {
|
|
31
|
+
warn(
|
|
32
|
+
`Skipped git setup (${label} failed). Initialise git manually if you want version control.`,
|
|
33
|
+
);
|
|
34
|
+
return false;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
log("Initialised a git repository with an initial commit.");
|
|
38
|
+
return true;
|
|
39
|
+
}
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import { describe, it, expect, afterEach } from "bun:test";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import {
|
|
6
|
+
buildVerdaccioConfig,
|
|
7
|
+
buildNpmrc,
|
|
8
|
+
buildBunPublishArgs,
|
|
9
|
+
buildNpmViewVersionArgs,
|
|
10
|
+
discoverWorkspacePackages,
|
|
11
|
+
publishWorkspacePackages,
|
|
12
|
+
findMonorepoRoot,
|
|
13
|
+
DEFAULT_REGISTRY_URL,
|
|
14
|
+
type WorkspacePackage,
|
|
15
|
+
} from "./local-registry";
|
|
16
|
+
|
|
17
|
+
const tmpDirs: string[] = [];
|
|
18
|
+
|
|
19
|
+
function makeTmpDir(): string {
|
|
20
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "ccp-localreg-"));
|
|
21
|
+
tmpDirs.push(dir);
|
|
22
|
+
return dir;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
afterEach(() => {
|
|
26
|
+
for (const dir of tmpDirs.splice(0)) {
|
|
27
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
28
|
+
}
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
describe("buildVerdaccioConfig", () => {
|
|
32
|
+
it("grants anonymous publish to @checkstack/* and create-checkstack-plugin", () => {
|
|
33
|
+
const yaml = buildVerdaccioConfig({ storageDir: "/tmp/store", port: 4999 });
|
|
34
|
+
expect(yaml).toContain("'@checkstack/*'");
|
|
35
|
+
expect(yaml).toContain("'create-checkstack-plugin'");
|
|
36
|
+
expect(yaml).toContain("publish: $anonymous");
|
|
37
|
+
expect(yaml).toContain("listen: 0.0.0.0:4999");
|
|
38
|
+
expect(yaml).toContain('storage: "/tmp/store"');
|
|
39
|
+
// Falls back to the public registry for third-party transitive deps.
|
|
40
|
+
expect(yaml).toContain("proxy: npmjs");
|
|
41
|
+
});
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
describe("buildNpmrc", () => {
|
|
45
|
+
it("pins the scope to the local registry and carries a throwaway token", () => {
|
|
46
|
+
const npmrc = buildNpmrc({ registryUrl: "http://localhost:4873" });
|
|
47
|
+
expect(npmrc).toContain("@checkstack:registry=http://localhost:4873");
|
|
48
|
+
expect(npmrc).toContain("//localhost:4873/:_authToken=fake-checkstack-it-token");
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it("defaults to the default registry url", () => {
|
|
52
|
+
expect(buildNpmrc()).toContain(`registry=${DEFAULT_REGISTRY_URL}`);
|
|
53
|
+
});
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
describe("buildBunPublishArgs / buildNpmViewVersionArgs", () => {
|
|
57
|
+
it("targets the local registry", () => {
|
|
58
|
+
expect(buildBunPublishArgs({ registryUrl: "http://x:1" })).toEqual([
|
|
59
|
+
"publish",
|
|
60
|
+
"--registry",
|
|
61
|
+
"http://x:1",
|
|
62
|
+
"--access",
|
|
63
|
+
"public",
|
|
64
|
+
]);
|
|
65
|
+
expect(
|
|
66
|
+
buildNpmViewVersionArgs({ packageName: "@checkstack/common", registryUrl: "http://x:1" }),
|
|
67
|
+
).toEqual(["view", "@checkstack/common", "version", "--registry", "http://x:1"]);
|
|
68
|
+
});
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
describe("discoverWorkspacePackages", () => {
|
|
72
|
+
it("collects core/* + plugins/* package.jsons, skipping _-dirs and missing names", () => {
|
|
73
|
+
const root = makeTmpDir();
|
|
74
|
+
const mk = (rel: string, json: Record<string, unknown>) => {
|
|
75
|
+
const dir = path.join(root, rel);
|
|
76
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
77
|
+
fs.writeFileSync(path.join(dir, "package.json"), JSON.stringify(json));
|
|
78
|
+
};
|
|
79
|
+
mk("core/alpha", { name: "@checkstack/alpha", version: "1.0.0" });
|
|
80
|
+
mk("core/secret", { name: "@checkstack/release", version: "1.0.0", private: true });
|
|
81
|
+
mk("core/_test-scaffolds", { name: "@checkstack/scaffolds", version: "1.0.0" });
|
|
82
|
+
mk("plugins/beta", { name: "@checkstack/beta", version: "2.0.0" });
|
|
83
|
+
mk("plugins/nameless", { version: "3.0.0" });
|
|
84
|
+
|
|
85
|
+
const found = discoverWorkspacePackages({ monorepoRoot: root });
|
|
86
|
+
const names = found.map((p) => p.name).sort();
|
|
87
|
+
expect(names).toEqual(["@checkstack/alpha", "@checkstack/beta", "@checkstack/release"]);
|
|
88
|
+
expect(found.find((p) => p.name === "@checkstack/release")?.private).toBe(true);
|
|
89
|
+
expect(found.find((p) => p.name === "@checkstack/alpha")?.private).toBe(false);
|
|
90
|
+
});
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
describe("publishWorkspacePackages", () => {
|
|
94
|
+
it("publishes only non-private packages via the injected runner with registry env", () => {
|
|
95
|
+
const packages: WorkspacePackage[] = [
|
|
96
|
+
{ name: "@checkstack/a", version: "1.0.0", dir: "/repo/core/a", private: false },
|
|
97
|
+
{ name: "@checkstack/priv", version: "1.0.0", dir: "/repo/core/priv", private: true },
|
|
98
|
+
{ name: "@checkstack/b", version: "2.0.0", dir: "/repo/core/b", private: false },
|
|
99
|
+
];
|
|
100
|
+
const calls: { command: string; args: string[]; cwd: string; userconfig?: string; registry?: string }[] = [];
|
|
101
|
+
const outcomes = publishWorkspacePackages({
|
|
102
|
+
packages,
|
|
103
|
+
registryUrl: "http://localhost:4873",
|
|
104
|
+
npmrcPath: "/tmp/.npmrc",
|
|
105
|
+
env: {},
|
|
106
|
+
run: ({ command, args, cwd, env }) => {
|
|
107
|
+
calls.push({
|
|
108
|
+
command,
|
|
109
|
+
args,
|
|
110
|
+
cwd,
|
|
111
|
+
userconfig: env.NPM_CONFIG_USERCONFIG,
|
|
112
|
+
registry: env.BUN_CONFIG_REGISTRY,
|
|
113
|
+
});
|
|
114
|
+
return { status: 0, stderr: "" };
|
|
115
|
+
},
|
|
116
|
+
});
|
|
117
|
+
expect(outcomes.map((o) => o.name)).toEqual(["@checkstack/a", "@checkstack/b"]);
|
|
118
|
+
expect(outcomes.every((o) => o.status === 0)).toBe(true);
|
|
119
|
+
expect(calls).toHaveLength(2);
|
|
120
|
+
expect(calls[0]?.command).toBe("bun");
|
|
121
|
+
expect(calls[0]?.args).toContain("publish");
|
|
122
|
+
expect(calls[0]?.userconfig).toBe("/tmp/.npmrc");
|
|
123
|
+
expect(calls[0]?.registry).toBe("http://localhost:4873");
|
|
124
|
+
});
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
describe("findMonorepoRoot", () => {
|
|
128
|
+
it("walks up to the checkstack-monorepo package.json", () => {
|
|
129
|
+
const root = makeTmpDir();
|
|
130
|
+
fs.writeFileSync(
|
|
131
|
+
path.join(root, "package.json"),
|
|
132
|
+
JSON.stringify({ name: "checkstack-monorepo" }),
|
|
133
|
+
);
|
|
134
|
+
const nested = path.join(root, "core", "create-checkstack-plugin", "src");
|
|
135
|
+
fs.mkdirSync(nested, { recursive: true });
|
|
136
|
+
expect(findMonorepoRoot({ from: nested })).toBe(root);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it("throws when no monorepo root is found", () => {
|
|
140
|
+
const root = makeTmpDir();
|
|
141
|
+
expect(() => findMonorepoRoot({ from: root })).toThrow(/checkstack-monorepo/);
|
|
142
|
+
});
|
|
143
|
+
});
|