feat(generators): wire events entry + e2e test
Add EVENTS_RULE_BLOCK constant, events entry in CORE_PACKAGE_GENERATORS dispatch table, events choice in prompts, and printEventsNextSteps helper. Events entry emits 15 template files + transpilePackages splice + ESLint no-restricted-syntax splice (E1 + J blocks). Add byte-identical e2e test that strips @repo/core-events deps before pnpm install, scaffolds via gen core-package events, and diffs against the snapshot. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
56
turbo/generators/__tests__/core-package-events.e2e.test.ts
Normal file
56
turbo/generators/__tests__/core-package-events.e2e.test.ts
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { mkdtempSync, cpSync, readFileSync, writeFileSync } from "node:fs";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import { execSync } from "node:child_process";
|
||||||
|
import { join, resolve } from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
import { computeSnapshot } from "../lib/snapshot";
|
||||||
|
import expectedSnapshot from "../__snapshots__/core-package/events.snapshot.json";
|
||||||
|
|
||||||
|
// Repo root is 2 levels up from turbo/generators/__tests__
|
||||||
|
const REPO_ROOT = resolve(fileURLToPath(import.meta.url), "..", "..", "..", "..");
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Strip "@repo/core-events" from a package.json file in the tmp tree.
|
||||||
|
* Required when simulating a fresh scaffold: core-events does not exist yet
|
||||||
|
* but the snapshot was captured before removal. Feature packages in the
|
||||||
|
* current tree may still list it as a dependency.
|
||||||
|
*/
|
||||||
|
function stripCoreEventsDep(pkgJsonPath: string): void {
|
||||||
|
const raw = readFileSync(pkgJsonPath, "utf8");
|
||||||
|
const parsed = JSON.parse(raw) as Record<string, Record<string, string>>;
|
||||||
|
for (const section of ["dependencies", "devDependencies", "peerDependencies"] as const) {
|
||||||
|
if (parsed[section]?.["@repo/core-events"]) {
|
||||||
|
delete parsed[section]["@repo/core-events"];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
writeFileSync(pkgJsonPath, JSON.stringify(parsed, null, 2) + "\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("e2e: core-package events", () => {
|
||||||
|
it("byte-identical reconstruction matches snapshot", { timeout: 120_000 }, () => {
|
||||||
|
const tmp = mkdtempSync(join(tmpdir(), "e2e-events-"));
|
||||||
|
cpSync(REPO_ROOT, tmp, {
|
||||||
|
recursive: true,
|
||||||
|
filter: (src) =>
|
||||||
|
!src.includes("node_modules") &&
|
||||||
|
!src.includes(".turbo") &&
|
||||||
|
!src.includes("packages/core-events"),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Strip @repo/core-events from feature package.json files so pnpm install
|
||||||
|
// succeeds without the package being present (simulating the post-removal state).
|
||||||
|
const featurePackages = ["auth", "blog", "media", "marketing-pages", "navigation"];
|
||||||
|
for (const pkg of featurePackages) {
|
||||||
|
const pkgJson = join(tmp, "packages", pkg, "package.json");
|
||||||
|
stripCoreEventsDep(pkgJson);
|
||||||
|
}
|
||||||
|
// Also strip from apps/web-next
|
||||||
|
stripCoreEventsDep(join(tmp, "apps", "web-next", "package.json"));
|
||||||
|
|
||||||
|
execSync(`cd ${tmp} && pnpm install --frozen-lockfile=false`, { stdio: "ignore" });
|
||||||
|
execSync(`cd ${tmp} && pnpm turbo gen core-package --args events`, { stdio: "ignore" });
|
||||||
|
const result = computeSnapshot(join(tmp, "packages/core-events"));
|
||||||
|
expect(result).toEqual(expectedSnapshot);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -3,7 +3,7 @@ import type { PlopTypes } from "@turbo/gen";
|
|||||||
import generator from "./config";
|
import generator from "./config";
|
||||||
|
|
||||||
describe("core-package generator", () => {
|
describe("core-package generator", () => {
|
||||||
it("is registered with realtime in choices list", () => {
|
it("is registered with realtime and events in choices list", () => {
|
||||||
const captured: Array<{ name: string; def: PlopTypes.PlopGeneratorConfig }> = [];
|
const captured: Array<{ name: string; def: PlopTypes.PlopGeneratorConfig }> = [];
|
||||||
const plopMock = {
|
const plopMock = {
|
||||||
setHelper: () => {},
|
setHelper: () => {},
|
||||||
@@ -16,6 +16,7 @@ describe("core-package generator", () => {
|
|||||||
const prompts = corePkg!.def.prompts as Array<{ name: string; choices: unknown[] }>;
|
const prompts = corePkg!.def.prompts as Array<{ name: string; choices: unknown[] }>;
|
||||||
expect(prompts[0]!.name).toBe("name");
|
expect(prompts[0]!.name).toBe("name");
|
||||||
expect(prompts[0]!.choices).toContain("realtime");
|
expect(prompts[0]!.choices).toContain("realtime");
|
||||||
|
expect(prompts[0]!.choices).toContain("events");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -36,3 +37,20 @@ describe("core-package realtime", () => {
|
|||||||
expect(actions.length).toBeGreaterThan(20); // 28 files + extras
|
expect(actions.length).toBeGreaterThan(20); // 28 files + extras
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("core-package events", () => {
|
||||||
|
it("emits actions covering package files, transpilePackages, and ESLint rule splice", () => {
|
||||||
|
const captured: Array<{ name: string; def: PlopTypes.PlopGeneratorConfig }> = [];
|
||||||
|
const plop = {
|
||||||
|
setHelper: () => {},
|
||||||
|
setGenerator: (n: string, d: unknown) => captured.push({ name: n, def: d as PlopTypes.PlopGeneratorConfig }),
|
||||||
|
} as unknown as PlopTypes.NodePlopAPI;
|
||||||
|
generator(plop);
|
||||||
|
const corePkg = captured.find((c) => c.name === "core-package")!.def;
|
||||||
|
const actions = (corePkg.actions as (a: { name: string }) => PlopTypes.ActionType[])(
|
||||||
|
{ name: "events" },
|
||||||
|
);
|
||||||
|
// 1 guard + 15 template files + transpilePackages + ESLint splice + printNextSteps = 19
|
||||||
|
expect(actions.length).toBeGreaterThanOrEqual(18);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -525,6 +525,65 @@ import noRealtimeHandlerReexport from "./rules/no-realtime-handler-reexport.js";
|
|||||||
},
|
},
|
||||||
},`;
|
},`;
|
||||||
|
|
||||||
|
const EVENTS_RULE_BLOCK = ` {
|
||||||
|
files: ["**/*.{ts,tsx,mjs,cjs,js}"],
|
||||||
|
rules: {
|
||||||
|
"no-restricted-syntax": [
|
||||||
|
"error",
|
||||||
|
{
|
||||||
|
selector:
|
||||||
|
"ExportNamedDeclaration[source.value=/\\/events\\/handlers\\//]",
|
||||||
|
message:
|
||||||
|
"Event handlers (events/handlers/*.handler.ts) must not be re-exported. Wire them only inside the consumer feature's bind-production / bind-dev-seed (Rule E1).",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
selector:
|
||||||
|
"ExportAllDeclaration[source.value=/\\/events\\/handlers\\//]",
|
||||||
|
message:
|
||||||
|
"Event handlers (events/handlers/*.handler.ts) must not be re-exported. Wire them only inside the consumer feature's bind-production / bind-dev-seed (Rule E1).",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
selector:
|
||||||
|
"MemberExpression[object.type='MemberExpression'][object.object.type='Identifier'][object.object.name='payload'][object.property.type='Identifier'][object.property.name='jobs']",
|
||||||
|
message:
|
||||||
|
"Direct \`payload.jobs.*\` access is not allowed here. Use IJobQueue (from @repo/core-shared/jobs) instead. Allowed only in **/integrations/cms/jobs/** and **/core-shared/src/jobs/**.",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
// J — \`payload.jobs.*\` is allowed only in the integration layer.
|
||||||
|
// In these paths, no-restricted-syntax is narrowed to keep E1 active but
|
||||||
|
// drop the payload.jobs check.
|
||||||
|
// Note: "**/core-shared/src/jobs/**" does not match from within a package-local
|
||||||
|
// ESLint run because ESLint resolves globs relative to the config file location.
|
||||||
|
// The pattern is kept for documentation; in practice, the PayloadJobQueue class
|
||||||
|
// uses \`this.payload.jobs.*\` which the selector already ignores (it only catches
|
||||||
|
// bare \`payload.jobs.*\`). Any new file added there that does use bare \`payload.jobs.*\`
|
||||||
|
// would need this allowlist to be expressed as "**/jobs/payload-*" or similar.
|
||||||
|
{
|
||||||
|
files: [
|
||||||
|
"**/integrations/cms/jobs/**",
|
||||||
|
"**/core-shared/src/jobs/**",
|
||||||
|
],
|
||||||
|
rules: {
|
||||||
|
"no-restricted-syntax": [
|
||||||
|
"error",
|
||||||
|
{
|
||||||
|
selector:
|
||||||
|
"ExportNamedDeclaration[source.value=/\\/events\\/handlers\\//]",
|
||||||
|
message:
|
||||||
|
"Event handlers (events/handlers/*.handler.ts) must not be re-exported. Wire them only inside the consumer feature's bind-production / bind-dev-seed (Rule E1).",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
selector:
|
||||||
|
"ExportAllDeclaration[source.value=/\\/events\\/handlers\\//]",
|
||||||
|
message:
|
||||||
|
"Event handlers (events/handlers/*.handler.ts) must not be re-exported. Wire them only inside the consumer feature's bind-production / bind-dev-seed (Rule E1).",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},`;
|
||||||
|
|
||||||
const CORE_PACKAGE_GENERATORS: Record<string, () => PlopTypes.ActionType[]> =
|
const CORE_PACKAGE_GENERATORS: Record<string, () => PlopTypes.ActionType[]> =
|
||||||
{
|
{
|
||||||
realtime: () => [
|
realtime: () => [
|
||||||
@@ -563,6 +622,26 @@ import noRealtimeHandlerReexport from "./rules/no-realtime-handler-reexport.js";
|
|||||||
},
|
},
|
||||||
printRealtimeNextSteps,
|
printRealtimeNextSteps,
|
||||||
],
|
],
|
||||||
|
events: () => [
|
||||||
|
() => {
|
||||||
|
assertOptionalPackageNotPresent("core-events");
|
||||||
|
return "Guard passed — packages/core-events does not exist yet.";
|
||||||
|
},
|
||||||
|
...emitTemplateTree("core-package/events", "packages/core-events"),
|
||||||
|
() => {
|
||||||
|
addToTranspilePackages("apps/web-next/next.config.mjs", "@repo/core-events");
|
||||||
|
return "Added @repo/core-events to transpilePackages.";
|
||||||
|
},
|
||||||
|
() => {
|
||||||
|
splicePluginRulesAt(
|
||||||
|
"packages/core-eslint/base.js",
|
||||||
|
"events-rules",
|
||||||
|
EVENTS_RULE_BLOCK,
|
||||||
|
);
|
||||||
|
return "Added events rule block to base.js.";
|
||||||
|
},
|
||||||
|
printEventsNextSteps,
|
||||||
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
plop.setGenerator("core-package", {
|
plop.setGenerator("core-package", {
|
||||||
@@ -572,7 +651,7 @@ import noRealtimeHandlerReexport from "./rules/no-realtime-handler-reexport.js";
|
|||||||
type: "list",
|
type: "list",
|
||||||
name: "name",
|
name: "name",
|
||||||
message: "Which optional core package?",
|
message: "Which optional core package?",
|
||||||
choices: ["realtime"],
|
choices: ["realtime", "events"],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
actions: (answers) => {
|
actions: (answers) => {
|
||||||
@@ -1067,6 +1146,28 @@ function printHandlerNextSteps(a: {
|
|||||||
].join("\n");
|
].join("\n");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function printEventsNextSteps(): string {
|
||||||
|
return [
|
||||||
|
"─────────────────────────────────────────────────────────────",
|
||||||
|
"@repo/core-events scaffolded.",
|
||||||
|
"",
|
||||||
|
"Next steps (manual wiring — not generated):",
|
||||||
|
"",
|
||||||
|
" 1. pnpm install # link the new workspace package",
|
||||||
|
"",
|
||||||
|
" 2. apps/web-next/src/server/bind-production.ts:",
|
||||||
|
' - import { InMemoryEventBus, PayloadJobsEventBus, type IEventBus } from "@repo/core-events";',
|
||||||
|
" - construct bus in resolveEventsAndJobsProduction + resolveEventsAndJobsDevSeed",
|
||||||
|
" - add bus to BindProductionContext generic arg and ctx object",
|
||||||
|
" - update BindAllDeps to include bus field",
|
||||||
|
"",
|
||||||
|
" 3. Add @repo/core-events to each feature package.json that publishes or subscribes events",
|
||||||
|
"",
|
||||||
|
" 4. pnpm typecheck && pnpm lint && pnpm test",
|
||||||
|
"─────────────────────────────────────────────────────────────",
|
||||||
|
].join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
function printRealtimeNextSteps(): string {
|
function printRealtimeNextSteps(): string {
|
||||||
return [
|
return [
|
||||||
"─────────────────────────────────────────────────────────────",
|
"─────────────────────────────────────────────────────────────",
|
||||||
|
|||||||
Reference in New Issue
Block a user