From ddf129b13a3e60e79a1362dc2b48c9ca76b0b9ea Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miha=20Erz=CC=8Cen?= Date: Thu, 19 Mar 2026 11:09:40 +0100 Subject: [PATCH 1/2] feat: Add skip option --- README.md | 10 ++ src/index.ts | 44 ++++++-- test/skip/skip.spec.ts | 233 +++++++++++++++++++++++++++++++++++++++++ 3 files changed, 279 insertions(+), 8 deletions(-) create mode 100644 test/skip/skip.spec.ts diff --git a/README.md b/README.md index 4bf1e3d..b71f528 100644 --- a/README.md +++ b/README.md @@ -113,6 +113,16 @@ codegen({ * Override the codegen config file path. */ configFilePathOverride: string, + /** + * Skip codegen for a given cycle when true. + * + * The callback receives the current trigger and the changed file path + * for watcher-driven runs. + */ + skip: + boolean | + ((context: { trigger: "start" | "build" | "watch"; filePath?: string }) => + boolean | Promise), /** * Log various steps to aid in tracking down bugs. * diff --git a/src/index.ts b/src/index.ts index a4b1f3c..bd0c5bb 100644 --- a/src/index.ts +++ b/src/index.ts @@ -11,6 +11,13 @@ import { createMatchCache } from "./utils/matchCache"; import { isBuildMode, isServeMode, type ViteMode } from "./utils/viteModes"; import type { Plugin } from "vite"; +export interface SkipContext { + trigger: "start" | "build" | "watch"; + filePath?: string; +} + +export type SkipFn = (context: SkipContext) => boolean | Promise; + export interface Options { /** * Run codegen on server start. @@ -89,6 +96,12 @@ export interface Options { * Override the codegen config file path. */ configFilePathOverride?: string; + /** + * Skip codegen for a given cycle. + * + * @default false + */ + skip?: boolean | SkipFn; /** * Log various steps to aid in tracking down bugs. * @@ -117,6 +130,7 @@ export function GraphQLCodegen(options?: Options): Plugin { configOverrideOnBuild = {}, configOverrideWatcher = {}, configFilePathOverride, + skip = false, debug = false, } = options ?? {}; @@ -125,18 +139,28 @@ export function GraphQLCodegen(options?: Options): Plugin { debugLog(...args); }; + const shouldSkipGeneration = async (context: SkipContext) => + typeof skip === "function" ? await skip(context) : skip; + const generateWithOverride = async ( overrideConfig: Partial, + skipContext: SkipContext, ) => { - const currentConfig = codegenContext.getConfig(); + if (await shouldSkipGeneration(skipContext)) { + log("Generation skipped", skipContext); + return; + } - return await generate({ + const currentConfig = codegenContext.getConfig(); + await generate({ ...currentConfig, ...configOverride, ...overrideConfig, // Vite handles file watching watch: false, }); + + log(`Generation successful on ${skipContext.trigger}`); }; if (options) log("Plugin initialized with options:", options); @@ -167,8 +191,9 @@ export function GraphQLCodegen(options?: Options): Plugin { if (!runOnStart) return; try { - await generateWithOverride(configOverrideOnStart); - log("Generation successful on start"); + await generateWithOverride(configOverrideOnStart, { + trigger: "start", + }); } catch (error) { // GraphQL Codegen handles logging useful errors log("Generation failed on start"); @@ -180,8 +205,9 @@ export function GraphQLCodegen(options?: Options): Plugin { if (!runOnBuild) return; try { - await generateWithOverride(configOverrideOnBuild); - log("Generation successful on build"); + await generateWithOverride(configOverrideOnBuild, { + trigger: "build", + }); } catch (error) { // GraphQL Codegen handles logging useful errors log("Generation failed on build"); @@ -204,8 +230,10 @@ export function GraphQLCodegen(options?: Options): Plugin { log("File is in match cache"); try { - await generateWithOverride(configOverrideWatcher); - log("Generation successful in file watcher"); + await generateWithOverride(configOverrideWatcher, { + trigger: "watch", + filePath, + }); } catch { // GraphQL Codegen handles logging useful errors log("Generation failed in file watcher"); diff --git a/test/skip/skip.spec.ts b/test/skip/skip.spec.ts new file mode 100644 index 0000000..d09b7c4 --- /dev/null +++ b/test/skip/skip.spec.ts @@ -0,0 +1,233 @@ +import { promises as fs } from "node:fs"; +import { createServer, type UserConfig } from "vite"; +import { afterEach, describe, expect, it, vi, type TestContext } from "vitest"; +import codegen, { type Options, type SkipContext } from "../../src/index"; + +const codegenGenerateMock = vi.hoisted(() => vi.fn()); +vi.mock("@graphql-codegen/cli", async (importOriginal) => { + const actual = await importOriginal(); + + return { + ...actual, + generate: codegenGenerateMock, + }; +}); + +const TEST_PATH = "./test/skip" as const; +const DOCUMENT_PATH = `${TEST_PATH}/graphql` as const; +const SCHEMA_FILE = `${TEST_PATH}/schema.graphql` as const; +const QUERY_FILE = `${DOCUMENT_PATH}/Foo.graphql` as const; +const OUTPUT_PATH = `${TEST_PATH}/generated` as const; +const OUTPUT_FILE = `${OUTPUT_PATH}/graphql.ts` as const; + +const setupFiles = async () => { + await fs.mkdir(DOCUMENT_PATH, { recursive: true }); + await fs.writeFile( + SCHEMA_FILE, + ` + type Query { + foo: String + bar: String + } + `, + ); + await fs.writeFile(QUERY_FILE, "query Foo { foo }"); +}; + +const updateQueryFile = async (content: string) => { + await fs.writeFile(QUERY_FILE, content); + await new Promise((resolve) => setTimeout(resolve, 200)); +}; + +interface TestContextWithServer extends TestContext { + viteServer: Awaited> | null; +} + +const startServer = async ( + options: Options = {}, + context: TestContextWithServer, +) => { + await setupFiles(); + + const config = { + root: import.meta.dirname, + logLevel: "silent", + server: { + host: "127.0.0.1", + port: 0, + strictPort: false, + }, + plugins: [ + codegen({ + config: { + schema: SCHEMA_FILE, + documents: `${DOCUMENT_PATH}/**/*.graphql`, + generates: { + [OUTPUT_FILE]: { + plugins: ["typescript", "typescript-operations"], + }, + }, + }, + ...options, + }), + ], + } satisfies UserConfig; + + context.viteServer = await createServer(config).then((server) => + server.listen(), + ); + + // ensure the watcher is ready before proceeding with tests + await vi.waitFor(() => { + expect( + context.viteServer?.watcher.listeners("change").length, + ).toBeGreaterThan(1); + }); +}; + +describe("skip", () => { + afterEach(async (context) => { + codegenGenerateMock.mockReset(); + context.viteServer?.watcher.close(); + await context.viteServer?.close(); + context.viteServer = null; + + await fs.rm(SCHEMA_FILE, { force: true }); + await fs.rm(DOCUMENT_PATH, { recursive: true, force: true }); + await fs.rm(OUTPUT_PATH, { recursive: true, force: true }); + }); + + describe("on server start", () => { + it.for([ + { + describe: "when skip is not set", + skip: undefined, + }, + { + describe: "when skip is false", + skip: false, + }, + { + describe: "when skip returns false by matching start trigger", + skip: ({ trigger }: SkipContext) => trigger !== "start", + }, + { + describe: "when skip resolves false by matching start trigger", + skip: ({ trigger }: SkipContext) => + Promise.resolve(trigger !== "start"), + }, + ])("it should run codegen $describe", async ({ skip }, context) => { + await startServer({ skip }, context as TestContextWithServer); + + expect(codegenGenerateMock).toHaveBeenCalledWith({ + pluginContext: {}, + schema: SCHEMA_FILE, + documents: `${DOCUMENT_PATH}/**/*.graphql`, + watch: false, + generates: { + [OUTPUT_FILE]: { + plugins: ["typescript", "typescript-operations"], + }, + }, + }); + }); + + it.for([ + { + describe: "when skip is true", + skip: true, + }, + { + describe: "when skip returns true by matching start trigger", + skip: ({ trigger }: SkipContext) => trigger === "start", + }, + { + describe: "when skip resolves true by matching start trigger", + skip: ({ trigger }: SkipContext) => + Promise.resolve(trigger === "start"), + }, + ])("it should skip codegen $describe", async ({ skip }, context) => { + await startServer({ skip }, context as TestContextWithServer); + + expect(codegenGenerateMock.mock.calls).toEqual([]); + }); + }); + + describe("on watch triggered", () => { + it.for([ + { + describe: "when skip is not set", + skip: undefined, + }, + { + describe: "when skip is false", + skip: false, + }, + { + describe: "when skip returns false by matching watch trigger", + skip: ({ trigger }: SkipContext) => trigger !== "watch", + }, + { + describe: "when skip resolves false by matching watch trigger", + skip: ({ trigger }: SkipContext) => + Promise.resolve(trigger !== "watch"), + }, + { + describe: "when skip resolves false by matching filePath", + skip: async ({ filePath }: SkipContext) => + filePath !== (await fs.realpath(QUERY_FILE)), + }, + ])("it should run codegen $describe", async ({ skip }, context) => { + await startServer( + { skip, enableWatcher: true }, + context as TestContextWithServer, + ); + codegenGenerateMock.mockReset(); + + await updateQueryFile("query Foo { foo bar }"); + + expect(codegenGenerateMock).toHaveBeenCalledWith({ + pluginContext: {}, + schema: SCHEMA_FILE, + documents: `${DOCUMENT_PATH}/**/*.graphql`, + watch: false, + generates: { + [OUTPUT_FILE]: { + plugins: ["typescript", "typescript-operations"], + }, + }, + }); + }); + + it.for([ + { + describe: "when skip is true", + skip: true, + }, + { + describe: "when skip returns true by matching watch trigger", + skip: ({ trigger }: SkipContext) => trigger === "watch", + }, + { + describe: "when skip resolves true by matching watch trigger", + skip: ({ trigger }: SkipContext) => + Promise.resolve(trigger === "watch"), + }, + { + describe: "when skip resolves true by matching filePath", + skip: async ({ filePath }: SkipContext) => + filePath === (await fs.realpath(QUERY_FILE)), + }, + ])("it should skip codegen $describe", async ({ skip }, context) => { + await startServer( + { skip, enableWatcher: true }, + context as TestContextWithServer, + ); + codegenGenerateMock.mockReset(); + + await updateQueryFile("query Foo { foo bar }"); + + expect(codegenGenerateMock.mock.calls).toEqual([]); + }); + }); +}); From 15df193107bef8a285d5ee55f4e84997492afc5a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miha=20Erz=CC=8Cen?= Date: Thu, 19 Mar 2026 16:40:11 +0100 Subject: [PATCH 2/2] test: use not.toHaveBeenCalled assertion --- test/skip/skip.spec.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/skip/skip.spec.ts b/test/skip/skip.spec.ts index d09b7c4..62c4703 100644 --- a/test/skip/skip.spec.ts +++ b/test/skip/skip.spec.ts @@ -149,7 +149,7 @@ describe("skip", () => { ])("it should skip codegen $describe", async ({ skip }, context) => { await startServer({ skip }, context as TestContextWithServer); - expect(codegenGenerateMock.mock.calls).toEqual([]); + expect(codegenGenerateMock).not.toHaveBeenCalled(); }); }); @@ -227,7 +227,7 @@ describe("skip", () => { await updateQueryFile("query Foo { foo bar }"); - expect(codegenGenerateMock.mock.calls).toEqual([]); + expect(codegenGenerateMock).not.toHaveBeenCalled(); }); }); });