diff --git a/.changeset/swift-files-vanish.md b/.changeset/swift-files-vanish.md new file mode 100644 index 00000000000..5ebb91758cc --- /dev/null +++ b/.changeset/swift-files-vanish.md @@ -0,0 +1,5 @@ +--- +'@graphql-codegen/cli': patch +--- + +Trigger rebuilds in watch mode while respecting rules of precedence and negation, both in terms of global (top-level) config vs. local (per-output target) config, and in terms of watch patterns (higher priority) vs. documents/schemas (lower priority). This fixes an issue with overly-aggressive rebuilds during watch mode. diff --git a/dev-test-outer-dir/githunt/nothing-should-use-this-query.graphql b/dev-test-outer-dir/githunt/nothing-should-use-this-query.graphql new file mode 100644 index 00000000000..8b73049fdb8 --- /dev/null +++ b/dev-test-outer-dir/githunt/nothing-should-use-this-query.graphql @@ -0,0 +1,9 @@ +# This file should be excluded by all dev-test/codegen.ts patterns that otherwise +# include files from dev-test-outer-dir, so that when running `yarn watch:examples`, +# updating this file should _never_ trigger rebuild +query NothingShouldUseThisQuery { + currentUser { + login + avatar_url + } +} diff --git a/dev-test/codegen.ts b/dev-test/codegen.ts index 5106dfab8a8..9e94945ea40 100644 --- a/dev-test/codegen.ts +++ b/dev-test/codegen.ts @@ -55,7 +55,11 @@ const config: CodegenConfig = { }, './dev-test/githunt/graphql-declared-modules.d.ts': { schema: './dev-test/githunt/schema.json', - documents: ['./dev-test/githunt/**/*.graphql', './dev-test-outer-dir/githunt/**/*.graphql'], + documents: [ + './dev-test/githunt/**/*.graphql', + './dev-test-outer-dir/githunt/**/*.graphql', + '!**/nothing-should-use-this-query.graphql', + ], plugins: ['typescript-graphql-files-modules'], }, './dev-test/githunt/typed-document-nodes.ts': { @@ -121,6 +125,16 @@ const config: CodegenConfig = { documents: './dev-test/star-wars/**/*.graphql', plugins: ['typescript', 'typescript-operations'], }, + './dev-test/star-wars/types.excludeQueryAlpha.ts': { + schema: './dev-test/star-wars/schema.json', + documents: ['./dev-test/star-wars/**/*.graphql', '!./dev-test/star-wars/**/ExcludeQueryAlpha.graphql'], + plugins: ['typescript', 'typescript-operations'], + }, + './dev-test/star-wars/types.excludeQueryBeta.ts': { + schema: './dev-test/star-wars/schema.json', + documents: ['./dev-test/star-wars/**/*.graphql', '!./dev-test/star-wars/**/ExcludeQueryBeta.graphql'], + plugins: ['typescript', 'typescript-operations'], + }, './dev-test/star-wars/types.preResolveTypes.ts': { schema: './dev-test/star-wars/schema.json', documents: './dev-test/star-wars/**/*.graphql', diff --git a/dev-test/star-wars/ExcludeQueryAlpha.graphql b/dev-test/star-wars/ExcludeQueryAlpha.graphql new file mode 100644 index 00000000000..5546bacf57e --- /dev/null +++ b/dev-test/star-wars/ExcludeQueryAlpha.graphql @@ -0,0 +1,6 @@ +# This file should be excluded by pattern matching in types.excludeQueryAlpha +query ExcludeQueryAlpha($episode: Episode) { + hero(episode: $episode) { + name + } +} diff --git a/dev-test/star-wars/ExcludeQueryBeta.graphql b/dev-test/star-wars/ExcludeQueryBeta.graphql new file mode 100644 index 00000000000..f7d12cbd32e --- /dev/null +++ b/dev-test/star-wars/ExcludeQueryBeta.graphql @@ -0,0 +1,6 @@ +# This file should be excluded by pattern matching in types.excludeQueryBeta +query ExcludeQueryBeta($episode: Episode) { + hero(episode: $episode) { + name + } +} diff --git a/dev-test/star-wars/types.avoidOptionals.ts b/dev-test/star-wars/types.avoidOptionals.ts index 354473aa3b5..f38112251c6 100644 --- a/dev-test/star-wars/types.avoidOptionals.ts +++ b/dev-test/star-wars/types.avoidOptionals.ts @@ -250,6 +250,24 @@ export type CreateReviewForEpisodeMutation = { createReview: { __typename?: 'Review'; stars: number; commentary: string | null } | null; }; +export type ExcludeQueryAlphaQueryVariables = Exact<{ + episode: InputMaybe; +}>; + +export type ExcludeQueryAlphaQuery = { + __typename?: 'Query'; + hero: { __typename?: 'Droid'; name: string } | { __typename?: 'Human'; name: string } | null; +}; + +export type ExcludeQueryBetaQueryVariables = Exact<{ + episode: InputMaybe; +}>; + +export type ExcludeQueryBetaQuery = { + __typename?: 'Query'; + hero: { __typename?: 'Droid'; name: string } | { __typename?: 'Human'; name: string } | null; +}; + export type HeroAndFriendsNamesQueryVariables = Exact<{ episode: InputMaybe; }>; diff --git a/dev-test/star-wars/types.excludeQueryAlpha.ts b/dev-test/star-wars/types.excludeQueryAlpha.ts new file mode 100644 index 00000000000..c384e11ee2a --- /dev/null +++ b/dev-test/star-wars/types.excludeQueryAlpha.ts @@ -0,0 +1,397 @@ +export type Maybe = T | null; +export type InputMaybe = Maybe; +export type Exact = { [K in keyof T]: T[K] }; +export type MakeOptional = Omit & { [SubKey in K]?: Maybe }; +export type MakeMaybe = Omit & { [SubKey in K]: Maybe }; +/** All built-in and custom scalars, mapped to their actual values */ +export type Scalars = { + ID: string; + String: string; + Boolean: boolean; + Int: number; + Float: number; +}; + +/** A character from the Star Wars universe */ +export type Character = { + /** The movies this character appears in */ + appearsIn: Array>; + /** The friends of the character, or an empty list if they have none */ + friends?: Maybe>>; + /** The friends of the character exposed as a connection with edges */ + friendsConnection: FriendsConnection; + /** The ID of the character */ + id: Scalars['ID']; + /** The name of the character */ + name: Scalars['String']; +}; + +/** A character from the Star Wars universe */ +export type CharacterFriendsConnectionArgs = { + after?: InputMaybe; + first?: InputMaybe; +}; + +/** The input object sent when passing a color */ +export type ColorInput = { + blue: Scalars['Int']; + green: Scalars['Int']; + red: Scalars['Int']; +}; + +/** An autonomous mechanical character in the Star Wars universe */ +export type Droid = Character & { + __typename?: 'Droid'; + /** The movies this droid appears in */ + appearsIn: Array>; + /** This droid's friends, or an empty list if they have none */ + friends?: Maybe>>; + /** The friends of the droid exposed as a connection with edges */ + friendsConnection: FriendsConnection; + /** The ID of the droid */ + id: Scalars['ID']; + /** What others call this droid */ + name: Scalars['String']; + /** This droid's primary function */ + primaryFunction?: Maybe; +}; + +/** An autonomous mechanical character in the Star Wars universe */ +export type DroidFriendsConnectionArgs = { + after?: InputMaybe; + first?: InputMaybe; +}; + +/** The episodes in the Star Wars trilogy */ +export enum Episode { + /** Star Wars Episode V: The Empire Strikes Back, released in 1980. */ + Empire = 'EMPIRE', + /** Star Wars Episode VI: Return of the Jedi, released in 1983. */ + Jedi = 'JEDI', + /** Star Wars Episode IV: A New Hope, released in 1977. */ + Newhope = 'NEWHOPE', +} + +/** A connection object for a character's friends */ +export type FriendsConnection = { + __typename?: 'FriendsConnection'; + /** The edges for each of the character's friends. */ + edges?: Maybe>>; + /** A list of the friends, as a convenience when edges are not needed. */ + friends?: Maybe>>; + /** Information for paginating this connection */ + pageInfo: PageInfo; + /** The total number of friends */ + totalCount?: Maybe; +}; + +/** An edge object for a character's friends */ +export type FriendsEdge = { + __typename?: 'FriendsEdge'; + /** A cursor used for pagination */ + cursor: Scalars['ID']; + /** The character represented by this friendship edge */ + node?: Maybe; +}; + +/** A humanoid creature from the Star Wars universe */ +export type Human = Character & { + __typename?: 'Human'; + /** The movies this human appears in */ + appearsIn: Array>; + /** This human's friends, or an empty list if they have none */ + friends?: Maybe>>; + /** The friends of the human exposed as a connection with edges */ + friendsConnection: FriendsConnection; + /** Height in the preferred unit, default is meters */ + height?: Maybe; + /** The home planet of the human, or null if unknown */ + homePlanet?: Maybe; + /** The ID of the human */ + id: Scalars['ID']; + /** Mass in kilograms, or null if unknown */ + mass?: Maybe; + /** What this human calls themselves */ + name: Scalars['String']; + /** A list of starships this person has piloted, or an empty list if none */ + starships?: Maybe>>; +}; + +/** A humanoid creature from the Star Wars universe */ +export type HumanFriendsConnectionArgs = { + after?: InputMaybe; + first?: InputMaybe; +}; + +/** A humanoid creature from the Star Wars universe */ +export type HumanHeightArgs = { + unit?: InputMaybe; +}; + +/** Units of height */ +export enum LengthUnit { + /** Primarily used in the United States */ + Foot = 'FOOT', + /** The standard unit around the world */ + Meter = 'METER', +} + +/** The mutation type, represents all updates we can make to our data */ +export type Mutation = { + __typename?: 'Mutation'; + createReview?: Maybe; +}; + +/** The mutation type, represents all updates we can make to our data */ +export type MutationCreateReviewArgs = { + episode?: InputMaybe; + review: ReviewInput; +}; + +/** Information for paginating this connection */ +export type PageInfo = { + __typename?: 'PageInfo'; + endCursor?: Maybe; + hasNextPage: Scalars['Boolean']; + startCursor?: Maybe; +}; + +/** The query type, represents all of the entry points into our object graph */ +export type Query = { + __typename?: 'Query'; + character?: Maybe; + droid?: Maybe; + hero?: Maybe; + human?: Maybe; + reviews?: Maybe>>; + search?: Maybe>>; + starship?: Maybe; +}; + +/** The query type, represents all of the entry points into our object graph */ +export type QueryCharacterArgs = { + id: Scalars['ID']; +}; + +/** The query type, represents all of the entry points into our object graph */ +export type QueryDroidArgs = { + id: Scalars['ID']; +}; + +/** The query type, represents all of the entry points into our object graph */ +export type QueryHeroArgs = { + episode?: InputMaybe; +}; + +/** The query type, represents all of the entry points into our object graph */ +export type QueryHumanArgs = { + id: Scalars['ID']; +}; + +/** The query type, represents all of the entry points into our object graph */ +export type QueryReviewsArgs = { + episode: Episode; +}; + +/** The query type, represents all of the entry points into our object graph */ +export type QuerySearchArgs = { + text?: InputMaybe; +}; + +/** The query type, represents all of the entry points into our object graph */ +export type QueryStarshipArgs = { + id: Scalars['ID']; +}; + +/** Represents a review for a movie */ +export type Review = { + __typename?: 'Review'; + /** Comment about the movie */ + commentary?: Maybe; + /** The number of stars this review gave, 1-5 */ + stars: Scalars['Int']; +}; + +/** The input object sent when someone is creating a new review */ +export type ReviewInput = { + /** Comment about the movie, optional */ + commentary?: InputMaybe; + /** Favorite color, optional */ + favoriteColor?: InputMaybe; + /** 0-5 stars */ + stars: Scalars['Int']; +}; + +export type SearchResult = Droid | Human | Starship; + +export type Starship = { + __typename?: 'Starship'; + /** The ID of the starship */ + id: Scalars['ID']; + /** Length of the starship, along the longest axis */ + length?: Maybe; + /** The name of the starship */ + name: Scalars['String']; +}; + +export type StarshipLengthArgs = { + unit?: InputMaybe; +}; + +export type CreateReviewForEpisodeMutationVariables = Exact<{ + episode: Episode; + review: ReviewInput; +}>; + +export type CreateReviewForEpisodeMutation = { + __typename?: 'Mutation'; + createReview?: { __typename?: 'Review'; stars: number; commentary?: string | null } | null; +}; + +export type ExcludeQueryBetaQueryVariables = Exact<{ + episode?: InputMaybe; +}>; + +export type ExcludeQueryBetaQuery = { + __typename?: 'Query'; + hero?: { __typename?: 'Droid'; name: string } | { __typename?: 'Human'; name: string } | null; +}; + +export type HeroAndFriendsNamesQueryVariables = Exact<{ + episode?: InputMaybe; +}>; + +export type HeroAndFriendsNamesQuery = { + __typename?: 'Query'; + hero?: + | { + __typename?: 'Droid'; + name: string; + friends?: Array<{ __typename?: 'Droid'; name: string } | { __typename?: 'Human'; name: string } | null> | null; + } + | { + __typename?: 'Human'; + name: string; + friends?: Array<{ __typename?: 'Droid'; name: string } | { __typename?: 'Human'; name: string } | null> | null; + } + | null; +}; + +export type HeroAppearsInQueryVariables = Exact<{ [key: string]: never }>; + +export type HeroAppearsInQuery = { + __typename?: 'Query'; + hero?: + | { __typename?: 'Droid'; name: string; appearsIn: Array } + | { __typename?: 'Human'; name: string; appearsIn: Array } + | null; +}; + +export type HeroDetailsQueryVariables = Exact<{ + episode?: InputMaybe; +}>; + +export type HeroDetailsQuery = { + __typename?: 'Query'; + hero?: + | { __typename?: 'Droid'; primaryFunction?: string | null; name: string } + | { __typename?: 'Human'; height?: number | null; name: string } + | null; +}; + +type HeroDetails_Droid_Fragment = { __typename?: 'Droid'; primaryFunction?: string | null; name: string }; + +type HeroDetails_Human_Fragment = { __typename?: 'Human'; height?: number | null; name: string }; + +export type HeroDetailsFragment = HeroDetails_Droid_Fragment | HeroDetails_Human_Fragment; + +export type HeroDetailsWithFragmentQueryVariables = Exact<{ + episode?: InputMaybe; +}>; + +export type HeroDetailsWithFragmentQuery = { + __typename?: 'Query'; + hero?: + | { __typename?: 'Droid'; primaryFunction?: string | null; name: string } + | { __typename?: 'Human'; height?: number | null; name: string } + | null; +}; + +export type HeroNameQueryVariables = Exact<{ + episode?: InputMaybe; +}>; + +export type HeroNameQuery = { + __typename?: 'Query'; + hero?: { __typename?: 'Droid'; name: string } | { __typename?: 'Human'; name: string } | null; +}; + +export type HeroNameConditionalInclusionQueryVariables = Exact<{ + episode?: InputMaybe; + includeName: Scalars['Boolean']; +}>; + +export type HeroNameConditionalInclusionQuery = { + __typename?: 'Query'; + hero?: { __typename?: 'Droid'; name?: string } | { __typename?: 'Human'; name?: string } | null; +}; + +export type HeroNameConditionalExclusionQueryVariables = Exact<{ + episode?: InputMaybe; + skipName: Scalars['Boolean']; +}>; + +export type HeroNameConditionalExclusionQuery = { + __typename?: 'Query'; + hero?: { __typename?: 'Droid'; name?: string } | { __typename?: 'Human'; name?: string } | null; +}; + +export type HeroParentTypeDependentFieldQueryVariables = Exact<{ + episode?: InputMaybe; +}>; + +export type HeroParentTypeDependentFieldQuery = { + __typename?: 'Query'; + hero?: + | { + __typename?: 'Droid'; + name: string; + friends?: Array< + { __typename?: 'Droid'; name: string } | { __typename?: 'Human'; height?: number | null; name: string } | null + > | null; + } + | { + __typename?: 'Human'; + name: string; + friends?: Array< + { __typename?: 'Droid'; name: string } | { __typename?: 'Human'; height?: number | null; name: string } | null + > | null; + } + | null; +}; + +export type HeroTypeDependentAliasedFieldQueryVariables = Exact<{ + episode?: InputMaybe; +}>; + +export type HeroTypeDependentAliasedFieldQuery = { + __typename?: 'Query'; + hero?: { __typename?: 'Droid'; property?: string | null } | { __typename?: 'Human'; property?: string | null } | null; +}; + +export type HumanFieldsFragment = { __typename?: 'Human'; name: string; mass?: number | null }; + +export type HumanWithNullHeightQueryVariables = Exact<{ [key: string]: never }>; + +export type HumanWithNullHeightQuery = { + __typename?: 'Query'; + human?: { __typename?: 'Human'; name: string; mass?: number | null } | null; +}; + +export type TwoHeroesQueryVariables = Exact<{ [key: string]: never }>; + +export type TwoHeroesQuery = { + __typename?: 'Query'; + r2?: { __typename?: 'Droid'; name: string } | { __typename?: 'Human'; name: string } | null; + luke?: { __typename?: 'Droid'; name: string } | { __typename?: 'Human'; name: string } | null; +}; diff --git a/dev-test/star-wars/types.excludeQueryBeta.ts b/dev-test/star-wars/types.excludeQueryBeta.ts new file mode 100644 index 00000000000..8a1da7efd80 --- /dev/null +++ b/dev-test/star-wars/types.excludeQueryBeta.ts @@ -0,0 +1,397 @@ +export type Maybe = T | null; +export type InputMaybe = Maybe; +export type Exact = { [K in keyof T]: T[K] }; +export type MakeOptional = Omit & { [SubKey in K]?: Maybe }; +export type MakeMaybe = Omit & { [SubKey in K]: Maybe }; +/** All built-in and custom scalars, mapped to their actual values */ +export type Scalars = { + ID: string; + String: string; + Boolean: boolean; + Int: number; + Float: number; +}; + +/** A character from the Star Wars universe */ +export type Character = { + /** The movies this character appears in */ + appearsIn: Array>; + /** The friends of the character, or an empty list if they have none */ + friends?: Maybe>>; + /** The friends of the character exposed as a connection with edges */ + friendsConnection: FriendsConnection; + /** The ID of the character */ + id: Scalars['ID']; + /** The name of the character */ + name: Scalars['String']; +}; + +/** A character from the Star Wars universe */ +export type CharacterFriendsConnectionArgs = { + after?: InputMaybe; + first?: InputMaybe; +}; + +/** The input object sent when passing a color */ +export type ColorInput = { + blue: Scalars['Int']; + green: Scalars['Int']; + red: Scalars['Int']; +}; + +/** An autonomous mechanical character in the Star Wars universe */ +export type Droid = Character & { + __typename?: 'Droid'; + /** The movies this droid appears in */ + appearsIn: Array>; + /** This droid's friends, or an empty list if they have none */ + friends?: Maybe>>; + /** The friends of the droid exposed as a connection with edges */ + friendsConnection: FriendsConnection; + /** The ID of the droid */ + id: Scalars['ID']; + /** What others call this droid */ + name: Scalars['String']; + /** This droid's primary function */ + primaryFunction?: Maybe; +}; + +/** An autonomous mechanical character in the Star Wars universe */ +export type DroidFriendsConnectionArgs = { + after?: InputMaybe; + first?: InputMaybe; +}; + +/** The episodes in the Star Wars trilogy */ +export enum Episode { + /** Star Wars Episode V: The Empire Strikes Back, released in 1980. */ + Empire = 'EMPIRE', + /** Star Wars Episode VI: Return of the Jedi, released in 1983. */ + Jedi = 'JEDI', + /** Star Wars Episode IV: A New Hope, released in 1977. */ + Newhope = 'NEWHOPE', +} + +/** A connection object for a character's friends */ +export type FriendsConnection = { + __typename?: 'FriendsConnection'; + /** The edges for each of the character's friends. */ + edges?: Maybe>>; + /** A list of the friends, as a convenience when edges are not needed. */ + friends?: Maybe>>; + /** Information for paginating this connection */ + pageInfo: PageInfo; + /** The total number of friends */ + totalCount?: Maybe; +}; + +/** An edge object for a character's friends */ +export type FriendsEdge = { + __typename?: 'FriendsEdge'; + /** A cursor used for pagination */ + cursor: Scalars['ID']; + /** The character represented by this friendship edge */ + node?: Maybe; +}; + +/** A humanoid creature from the Star Wars universe */ +export type Human = Character & { + __typename?: 'Human'; + /** The movies this human appears in */ + appearsIn: Array>; + /** This human's friends, or an empty list if they have none */ + friends?: Maybe>>; + /** The friends of the human exposed as a connection with edges */ + friendsConnection: FriendsConnection; + /** Height in the preferred unit, default is meters */ + height?: Maybe; + /** The home planet of the human, or null if unknown */ + homePlanet?: Maybe; + /** The ID of the human */ + id: Scalars['ID']; + /** Mass in kilograms, or null if unknown */ + mass?: Maybe; + /** What this human calls themselves */ + name: Scalars['String']; + /** A list of starships this person has piloted, or an empty list if none */ + starships?: Maybe>>; +}; + +/** A humanoid creature from the Star Wars universe */ +export type HumanFriendsConnectionArgs = { + after?: InputMaybe; + first?: InputMaybe; +}; + +/** A humanoid creature from the Star Wars universe */ +export type HumanHeightArgs = { + unit?: InputMaybe; +}; + +/** Units of height */ +export enum LengthUnit { + /** Primarily used in the United States */ + Foot = 'FOOT', + /** The standard unit around the world */ + Meter = 'METER', +} + +/** The mutation type, represents all updates we can make to our data */ +export type Mutation = { + __typename?: 'Mutation'; + createReview?: Maybe; +}; + +/** The mutation type, represents all updates we can make to our data */ +export type MutationCreateReviewArgs = { + episode?: InputMaybe; + review: ReviewInput; +}; + +/** Information for paginating this connection */ +export type PageInfo = { + __typename?: 'PageInfo'; + endCursor?: Maybe; + hasNextPage: Scalars['Boolean']; + startCursor?: Maybe; +}; + +/** The query type, represents all of the entry points into our object graph */ +export type Query = { + __typename?: 'Query'; + character?: Maybe; + droid?: Maybe; + hero?: Maybe; + human?: Maybe; + reviews?: Maybe>>; + search?: Maybe>>; + starship?: Maybe; +}; + +/** The query type, represents all of the entry points into our object graph */ +export type QueryCharacterArgs = { + id: Scalars['ID']; +}; + +/** The query type, represents all of the entry points into our object graph */ +export type QueryDroidArgs = { + id: Scalars['ID']; +}; + +/** The query type, represents all of the entry points into our object graph */ +export type QueryHeroArgs = { + episode?: InputMaybe; +}; + +/** The query type, represents all of the entry points into our object graph */ +export type QueryHumanArgs = { + id: Scalars['ID']; +}; + +/** The query type, represents all of the entry points into our object graph */ +export type QueryReviewsArgs = { + episode: Episode; +}; + +/** The query type, represents all of the entry points into our object graph */ +export type QuerySearchArgs = { + text?: InputMaybe; +}; + +/** The query type, represents all of the entry points into our object graph */ +export type QueryStarshipArgs = { + id: Scalars['ID']; +}; + +/** Represents a review for a movie */ +export type Review = { + __typename?: 'Review'; + /** Comment about the movie */ + commentary?: Maybe; + /** The number of stars this review gave, 1-5 */ + stars: Scalars['Int']; +}; + +/** The input object sent when someone is creating a new review */ +export type ReviewInput = { + /** Comment about the movie, optional */ + commentary?: InputMaybe; + /** Favorite color, optional */ + favoriteColor?: InputMaybe; + /** 0-5 stars */ + stars: Scalars['Int']; +}; + +export type SearchResult = Droid | Human | Starship; + +export type Starship = { + __typename?: 'Starship'; + /** The ID of the starship */ + id: Scalars['ID']; + /** Length of the starship, along the longest axis */ + length?: Maybe; + /** The name of the starship */ + name: Scalars['String']; +}; + +export type StarshipLengthArgs = { + unit?: InputMaybe; +}; + +export type CreateReviewForEpisodeMutationVariables = Exact<{ + episode: Episode; + review: ReviewInput; +}>; + +export type CreateReviewForEpisodeMutation = { + __typename?: 'Mutation'; + createReview?: { __typename?: 'Review'; stars: number; commentary?: string | null } | null; +}; + +export type ExcludeQueryAlphaQueryVariables = Exact<{ + episode?: InputMaybe; +}>; + +export type ExcludeQueryAlphaQuery = { + __typename?: 'Query'; + hero?: { __typename?: 'Droid'; name: string } | { __typename?: 'Human'; name: string } | null; +}; + +export type HeroAndFriendsNamesQueryVariables = Exact<{ + episode?: InputMaybe; +}>; + +export type HeroAndFriendsNamesQuery = { + __typename?: 'Query'; + hero?: + | { + __typename?: 'Droid'; + name: string; + friends?: Array<{ __typename?: 'Droid'; name: string } | { __typename?: 'Human'; name: string } | null> | null; + } + | { + __typename?: 'Human'; + name: string; + friends?: Array<{ __typename?: 'Droid'; name: string } | { __typename?: 'Human'; name: string } | null> | null; + } + | null; +}; + +export type HeroAppearsInQueryVariables = Exact<{ [key: string]: never }>; + +export type HeroAppearsInQuery = { + __typename?: 'Query'; + hero?: + | { __typename?: 'Droid'; name: string; appearsIn: Array } + | { __typename?: 'Human'; name: string; appearsIn: Array } + | null; +}; + +export type HeroDetailsQueryVariables = Exact<{ + episode?: InputMaybe; +}>; + +export type HeroDetailsQuery = { + __typename?: 'Query'; + hero?: + | { __typename?: 'Droid'; primaryFunction?: string | null; name: string } + | { __typename?: 'Human'; height?: number | null; name: string } + | null; +}; + +type HeroDetails_Droid_Fragment = { __typename?: 'Droid'; primaryFunction?: string | null; name: string }; + +type HeroDetails_Human_Fragment = { __typename?: 'Human'; height?: number | null; name: string }; + +export type HeroDetailsFragment = HeroDetails_Droid_Fragment | HeroDetails_Human_Fragment; + +export type HeroDetailsWithFragmentQueryVariables = Exact<{ + episode?: InputMaybe; +}>; + +export type HeroDetailsWithFragmentQuery = { + __typename?: 'Query'; + hero?: + | { __typename?: 'Droid'; primaryFunction?: string | null; name: string } + | { __typename?: 'Human'; height?: number | null; name: string } + | null; +}; + +export type HeroNameQueryVariables = Exact<{ + episode?: InputMaybe; +}>; + +export type HeroNameQuery = { + __typename?: 'Query'; + hero?: { __typename?: 'Droid'; name: string } | { __typename?: 'Human'; name: string } | null; +}; + +export type HeroNameConditionalInclusionQueryVariables = Exact<{ + episode?: InputMaybe; + includeName: Scalars['Boolean']; +}>; + +export type HeroNameConditionalInclusionQuery = { + __typename?: 'Query'; + hero?: { __typename?: 'Droid'; name?: string } | { __typename?: 'Human'; name?: string } | null; +}; + +export type HeroNameConditionalExclusionQueryVariables = Exact<{ + episode?: InputMaybe; + skipName: Scalars['Boolean']; +}>; + +export type HeroNameConditionalExclusionQuery = { + __typename?: 'Query'; + hero?: { __typename?: 'Droid'; name?: string } | { __typename?: 'Human'; name?: string } | null; +}; + +export type HeroParentTypeDependentFieldQueryVariables = Exact<{ + episode?: InputMaybe; +}>; + +export type HeroParentTypeDependentFieldQuery = { + __typename?: 'Query'; + hero?: + | { + __typename?: 'Droid'; + name: string; + friends?: Array< + { __typename?: 'Droid'; name: string } | { __typename?: 'Human'; height?: number | null; name: string } | null + > | null; + } + | { + __typename?: 'Human'; + name: string; + friends?: Array< + { __typename?: 'Droid'; name: string } | { __typename?: 'Human'; height?: number | null; name: string } | null + > | null; + } + | null; +}; + +export type HeroTypeDependentAliasedFieldQueryVariables = Exact<{ + episode?: InputMaybe; +}>; + +export type HeroTypeDependentAliasedFieldQuery = { + __typename?: 'Query'; + hero?: { __typename?: 'Droid'; property?: string | null } | { __typename?: 'Human'; property?: string | null } | null; +}; + +export type HumanFieldsFragment = { __typename?: 'Human'; name: string; mass?: number | null }; + +export type HumanWithNullHeightQueryVariables = Exact<{ [key: string]: never }>; + +export type HumanWithNullHeightQuery = { + __typename?: 'Query'; + human?: { __typename?: 'Human'; name: string; mass?: number | null } | null; +}; + +export type TwoHeroesQueryVariables = Exact<{ [key: string]: never }>; + +export type TwoHeroesQuery = { + __typename?: 'Query'; + r2?: { __typename?: 'Droid'; name: string } | { __typename?: 'Human'; name: string } | null; + luke?: { __typename?: 'Droid'; name: string } | { __typename?: 'Human'; name: string } | null; +}; diff --git a/dev-test/star-wars/types.globallyAvailable.d.ts b/dev-test/star-wars/types.globallyAvailable.d.ts index a3feed5792c..48637e5d4bf 100644 --- a/dev-test/star-wars/types.globallyAvailable.d.ts +++ b/dev-test/star-wars/types.globallyAvailable.d.ts @@ -248,6 +248,24 @@ type CreateReviewForEpisodeMutation = { createReview?: { __typename?: 'Review'; stars: number; commentary?: string | null } | null; }; +type ExcludeQueryAlphaQueryVariables = Exact<{ + episode?: InputMaybe; +}>; + +type ExcludeQueryAlphaQuery = { + __typename?: 'Query'; + hero?: { __typename?: 'Droid'; name: string } | { __typename?: 'Human'; name: string } | null; +}; + +type ExcludeQueryBetaQueryVariables = Exact<{ + episode?: InputMaybe; +}>; + +type ExcludeQueryBetaQuery = { + __typename?: 'Query'; + hero?: { __typename?: 'Droid'; name: string } | { __typename?: 'Human'; name: string } | null; +}; + type HeroAndFriendsNamesQueryVariables = Exact<{ episode?: InputMaybe; }>; diff --git a/dev-test/star-wars/types.immutableTypes.ts b/dev-test/star-wars/types.immutableTypes.ts index aebbd6f24c4..8bed5b77103 100644 --- a/dev-test/star-wars/types.immutableTypes.ts +++ b/dev-test/star-wars/types.immutableTypes.ts @@ -254,6 +254,30 @@ export type CreateReviewForEpisodeMutation = { } | null; }; +export type ExcludeQueryAlphaQueryVariables = Exact<{ + episode?: InputMaybe; +}>; + +export type ExcludeQueryAlphaQuery = { + readonly __typename?: 'Query'; + readonly hero?: + | { readonly __typename?: 'Droid'; readonly name: string } + | { readonly __typename?: 'Human'; readonly name: string } + | null; +}; + +export type ExcludeQueryBetaQueryVariables = Exact<{ + episode?: InputMaybe; +}>; + +export type ExcludeQueryBetaQuery = { + readonly __typename?: 'Query'; + readonly hero?: + | { readonly __typename?: 'Droid'; readonly name: string } + | { readonly __typename?: 'Human'; readonly name: string } + | null; +}; + export type HeroAndFriendsNamesQueryVariables = Exact<{ episode?: InputMaybe; }>; diff --git a/dev-test/star-wars/types.preResolveTypes.onlyOperationTypes.ts b/dev-test/star-wars/types.preResolveTypes.onlyOperationTypes.ts index 4fddfe57b71..6a1495d94c0 100644 --- a/dev-test/star-wars/types.preResolveTypes.onlyOperationTypes.ts +++ b/dev-test/star-wars/types.preResolveTypes.onlyOperationTypes.ts @@ -59,6 +59,24 @@ export type CreateReviewForEpisodeMutation = { createReview?: { __typename?: 'Review'; stars: number; commentary?: string | null } | null; }; +export type ExcludeQueryAlphaQueryVariables = Exact<{ + episode?: InputMaybe; +}>; + +export type ExcludeQueryAlphaQuery = { + __typename?: 'Query'; + hero?: { __typename?: 'Droid'; name: string } | { __typename?: 'Human'; name: string } | null; +}; + +export type ExcludeQueryBetaQueryVariables = Exact<{ + episode?: InputMaybe; +}>; + +export type ExcludeQueryBetaQuery = { + __typename?: 'Query'; + hero?: { __typename?: 'Droid'; name: string } | { __typename?: 'Human'; name: string } | null; +}; + export type HeroAndFriendsNamesQueryVariables = Exact<{ episode?: InputMaybe; }>; diff --git a/dev-test/star-wars/types.preResolveTypes.ts b/dev-test/star-wars/types.preResolveTypes.ts index 894ebd04e37..b9821b8b422 100644 --- a/dev-test/star-wars/types.preResolveTypes.ts +++ b/dev-test/star-wars/types.preResolveTypes.ts @@ -250,6 +250,24 @@ export type CreateReviewForEpisodeMutation = { createReview?: { __typename?: 'Review'; stars: number; commentary?: string | null } | null; }; +export type ExcludeQueryAlphaQueryVariables = Exact<{ + episode?: InputMaybe; +}>; + +export type ExcludeQueryAlphaQuery = { + __typename?: 'Query'; + hero?: { __typename?: 'Droid'; name: string } | { __typename?: 'Human'; name: string } | null; +}; + +export type ExcludeQueryBetaQueryVariables = Exact<{ + episode?: InputMaybe; +}>; + +export type ExcludeQueryBetaQuery = { + __typename?: 'Query'; + hero?: { __typename?: 'Droid'; name: string } | { __typename?: 'Human'; name: string } | null; +}; + export type HeroAndFriendsNamesQueryVariables = Exact<{ episode?: InputMaybe; }>; diff --git a/dev-test/star-wars/types.skipSchema.ts b/dev-test/star-wars/types.skipSchema.ts index 894ebd04e37..b9821b8b422 100644 --- a/dev-test/star-wars/types.skipSchema.ts +++ b/dev-test/star-wars/types.skipSchema.ts @@ -250,6 +250,24 @@ export type CreateReviewForEpisodeMutation = { createReview?: { __typename?: 'Review'; stars: number; commentary?: string | null } | null; }; +export type ExcludeQueryAlphaQueryVariables = Exact<{ + episode?: InputMaybe; +}>; + +export type ExcludeQueryAlphaQuery = { + __typename?: 'Query'; + hero?: { __typename?: 'Droid'; name: string } | { __typename?: 'Human'; name: string } | null; +}; + +export type ExcludeQueryBetaQueryVariables = Exact<{ + episode?: InputMaybe; +}>; + +export type ExcludeQueryBetaQuery = { + __typename?: 'Query'; + hero?: { __typename?: 'Droid'; name: string } | { __typename?: 'Human'; name: string } | null; +}; + export type HeroAndFriendsNamesQueryVariables = Exact<{ episode?: InputMaybe; }>; diff --git a/dev-test/star-wars/types.ts b/dev-test/star-wars/types.ts index 894ebd04e37..b9821b8b422 100644 --- a/dev-test/star-wars/types.ts +++ b/dev-test/star-wars/types.ts @@ -250,6 +250,24 @@ export type CreateReviewForEpisodeMutation = { createReview?: { __typename?: 'Review'; stars: number; commentary?: string | null } | null; }; +export type ExcludeQueryAlphaQueryVariables = Exact<{ + episode?: InputMaybe; +}>; + +export type ExcludeQueryAlphaQuery = { + __typename?: 'Query'; + hero?: { __typename?: 'Droid'; name: string } | { __typename?: 'Human'; name: string } | null; +}; + +export type ExcludeQueryBetaQueryVariables = Exact<{ + episode?: InputMaybe; +}>; + +export type ExcludeQueryBetaQuery = { + __typename?: 'Query'; + hero?: { __typename?: 'Droid'; name: string } | { __typename?: 'Human'; name: string } | null; +}; + export type HeroAndFriendsNamesQueryVariables = Exact<{ episode?: InputMaybe; }>; diff --git a/packages/graphql-codegen-cli/src/generate-and-save.ts b/packages/graphql-codegen-cli/src/generate-and-save.ts index 843eac0b683..b95511219b7 100644 --- a/packages/graphql-codegen-cli/src/generate-and-save.ts +++ b/packages/graphql-codegen-cli/src/generate-and-save.ts @@ -130,7 +130,7 @@ export async function generate( // watch mode if (config.watch) { - return createWatcher(context, writeOutput); + return createWatcher(context, writeOutput).runningWatcher; } const outputFiles = await context.profiler.run(() => executeCodegen(context), 'executeCodegen'); diff --git a/packages/graphql-codegen-cli/src/utils/abort-controller-polyfill.ts b/packages/graphql-codegen-cli/src/utils/abort-controller-polyfill.ts new file mode 100644 index 00000000000..bf89b920007 --- /dev/null +++ b/packages/graphql-codegen-cli/src/utils/abort-controller-polyfill.ts @@ -0,0 +1,105 @@ +import { EventEmitter } from 'events'; +import { debugLog } from './debugging.js'; + +/** + * Node v14 does not have AbortSignal or AbortController, so to safely use it in + * another module, you can import it from here. + * + * Node v14.7+ does have it, but only with flag --experimental-abortcontroller + * + * We don't actually use AbortController anywhere except in tests, but it + * still gets called in watcher.ts, so by polyfilling it we can avoid breaking + * existing installations using Node v14 without flag --experimental-abortcontroller, + * and we also ensure that tests continue to pass under Node v14 without any new flags. + * + * This polyfill was adapted (TypeScript-ified) from here: + * https://github.com/southpolesteve/node-abort-controller/blob/master/index.js + */ + +class AbortSignalPolyfill implements AbortSignal { + eventEmitter: EventEmitter; + onabort: EventListener; + aborted: boolean; + reason: any | undefined; + + constructor() { + this.eventEmitter = new EventEmitter(); + this.onabort = null; + this.aborted = false; + this.reason = undefined; + } + toString() { + return '[object AbortSignal]'; + } + get [Symbol.toStringTag]() { + return 'AbortSignal'; + } + removeEventListener(name, handler) { + this.eventEmitter.removeListener(name, handler); + } + addEventListener(name, handler) { + this.eventEmitter.on(name, handler); + } + // @ts-expect-error No Event type in Node 14 + dispatchEvent(type: string) { + const event = { type, target: this }; + const handlerName = `on${event.type}`; + + if (typeof this[handlerName] === 'function') this[handlerName](event); + + return this.eventEmitter.emit(event.type, event); + } + throwIfAborted() { + if (this.aborted) { + throw this.reason; + } + } + static abort(reason: any) { + const controller = new AbortController(); + controller.abort(reason); + return controller.signal; + } + static timeout(time) { + const controller = new AbortController(); + setTimeout(() => controller.abort(new Error('TimeoutError')), time); + return controller.signal; + } +} +const AbortSignal = global.AbortSignal ?? AbortSignalPolyfill; + +class AbortControllerPolyfill implements AbortController { + signal: AbortSignal; + + constructor() { + debugLog('Using polyfilled AbortController'); + // @ts-expect-error No Event type in Node 14 + this.signal = new AbortSignal(); + } + abort(reason?: any) { + if (this.signal.aborted) return; + + // @ts-expect-error Not a read only property when polyfilling + this.signal.aborted = true; + + if (reason) { + // @ts-expect-error Not a read only property when polyfilling + this.signal.reason = reason; + } else { + // @ts-expect-error Not a read only property when polyfilling + this.signal.reason = new Error('AbortError'); + } + + // @ts-expect-error No Event type in Node 14 + this.signal.dispatchEvent('abort'); + } + toString() { + return '[object AbortController]'; + } + get [Symbol.toStringTag]() { + return 'AbortController'; + } +} + +const AbortController = global.AbortController ?? AbortControllerPolyfill; + +export { AbortController }; diff --git a/packages/graphql-codegen-cli/src/utils/file-system.ts b/packages/graphql-codegen-cli/src/utils/file-system.ts index b3b8709e2aa..9fd9b3189ef 100644 --- a/packages/graphql-codegen-cli/src/utils/file-system.ts +++ b/packages/graphql-codegen-cli/src/utils/file-system.ts @@ -1,6 +1,10 @@ import { promises, unlink as fsUnlink } from 'fs'; -const { writeFile: fsWriteFile, readFile: fsReadFile, mkdir } = promises; +const { access: fsAccess, writeFile: fsWriteFile, readFile: fsReadFile, mkdir } = promises; + +export function access(...args: Parameters) { + return fsAccess(...args); +} export function writeFile(filepath: string, content: string) { return fsWriteFile(filepath, content); diff --git a/packages/graphql-codegen-cli/src/utils/patterns.ts b/packages/graphql-codegen-cli/src/utils/patterns.ts new file mode 100644 index 00000000000..f35280b7817 --- /dev/null +++ b/packages/graphql-codegen-cli/src/utils/patterns.ts @@ -0,0 +1,295 @@ +import { isAbsolute, relative } from 'path'; +import { isValidPath } from '@graphql-tools/utils'; +import { normalizeInstanceOrArray, Types } from '@graphql-codegen/plugin-helpers'; +import isGlob from 'is-glob'; +import mm from 'micromatch'; +import { CodegenContext } from '../config.js'; + +type NegatedPattern = `!${string}`; + +/** + * Flatten a list of pattern sets to be a list of only the affirmative patterns + * are contained in all of them. + * + * This can be used, for example, to find the "longest common prefix directory" + * by examining `mm.scan(pattern).base` for each `pattern`. + */ +export const allAffirmativePatternsFromPatternSets = (patternSets: PatternSet[]) => { + return patternSets.flatMap(patternSet => [ + ...patternSet.watch.affirmative, + ...patternSet.documents.affirmative, + ...patternSet.schemas.affirmative, + ]); +}; + +/** + * Create a rebuild trigger that follows the algorithm described here: + * https://github.com/dotansimha/graphql-code-generator/issues/9270#issuecomment-1496765045 + * + * There is a flow chart diagram in that comment. + * + * Basically: + * + * * "Global" patterns are defined at top level of config file, and "local" + * patterns are defined for each output target + * * Each pattern can have "watch", "documents", and "schemas" + * * Watch patterns (global and local) always take precedence over documents and + * schemas patterns, i.e. a watch negation always negates, and a watch match is + * a match even if it would be negated by some pattern in documents or schemas + * * The trigger returns true if any output target's local patterns result in + * a match, after considering the precedence of any global and local negations + */ +export const makeShouldRebuild = ({ + globalPatternSet, + localPatternSets, +}: { + globalPatternSet: PatternSet; + localPatternSets: PatternSet[]; +}) => { + const localMatchers = localPatternSets.map(localPatternSet => { + return (path: string) => { + // Is path negated by any negating watch pattern? + if (matchesAnyNegatedPattern(path, [...globalPatternSet.watch.negated, ...localPatternSet.watch.negated])) { + // Short circut: negations in watch patterns take priority + return false; + } + + // Does path match any affirmative watch pattern? + if ( + matchesAnyAffirmativePattern(path, [ + ...globalPatternSet.watch.affirmative, + ...localPatternSet.watch.affirmative, + ]) + ) { + // Immediately return true: Watch pattern takes priority, even if documents or schema would negate it + return true; + } + + // Does path match documents patterns (without being negated)? + if ( + matchesAnyAffirmativePattern(path, [ + ...globalPatternSet.documents.affirmative, + ...localPatternSet.documents.affirmative, + ]) && + !matchesAnyNegatedPattern(path, [...globalPatternSet.documents.negated, ...localPatternSet.documents.negated]) + ) { + return true; + } + + // Does path match schemas patterns (without being negated)? + if ( + matchesAnyAffirmativePattern(path, [ + ...globalPatternSet.schemas.affirmative, + ...localPatternSet.schemas.affirmative, + ]) && + !matchesAnyNegatedPattern(path, [...globalPatternSet.schemas.negated, ...localPatternSet.schemas.negated]) + ) { + return true; + } + + // Otherwise, there is no match + return false; + }; + }); + + /** + * Return `true` if `path` should trigger a rebuild + */ + return ({ path: absolutePath }: { path: string }) => { + if (!isAbsolute(absolutePath)) { + throw new Error('shouldRebuild trigger should be called with absolute path'); + } + + const path = relative(process.cwd(), absolutePath); + const shouldRebuild = localMatchers.some(matcher => matcher(path)); + return shouldRebuild; + }; +}; + +/** + * Create the pattern set for the "global" (top level) config. + * + * In the `shouldRebuild` algorithm, any of these watch patterns will take + * precedence over local configs, and any schemas and documents patterns will be + * mixed into the pattern set of each local config. + */ +export const makeGlobalPatternSet = (initialContext: CodegenContext) => { + const config: Types.Config & { configFilePath?: string } = initialContext.getConfig(); + + return { + watch: sortPatterns([ + ...(typeof config.watch === 'boolean' ? [] : normalizeInstanceOrArray(config.watch ?? [])), + relative(process.cwd(), initialContext.filepath), + ]), + schemas: sortPatterns(makePatternsFromSchemas(normalizeInstanceOrArray(config.schema))), + documents: sortPatterns( + makePatternsFromDocuments(normalizeInstanceOrArray(config.documents)) + ), + }; +}; + +/** + * Create the pattern set for a "local" (output target) config + * + * In the `shouldRebuild` algorithm, any of these watch patterns will take + * precedence over documents or schemas patterns, and the documents and schemas + * patterns will be mixed into the pattern set of their respective gobal pattern + * set equivalents. + */ +export const makeLocalPatternSet = (conf: Types.ConfiguredOutput) => { + return { + watch: sortPatterns(normalizeInstanceOrArray(conf.watchPattern)), + documents: sortPatterns( + makePatternsFromDocuments(normalizeInstanceOrArray(conf.documents)) + ), + schemas: sortPatterns(makePatternsFromSchemas(normalizeInstanceOrArray(conf.schema))), + }; +}; + +/** + * Parse a list of micromatch patterns from a list of documents, which should + * already have been normalized from their raw config values. + */ +const makePatternsFromDocuments = (documents: Types.OperationDocument[]): string[] => { + const patterns: string[] = []; + + if (documents) { + for (const doc of documents) { + if (typeof doc === 'string') { + patterns.push(doc); + } else { + patterns.push(...Object.keys(doc)); + } + } + } + + return patterns; +}; + +/** + * Parse a list of micromatch patterns from a list of schemas, which should + * already have been normalized from their raw config values. + */ +const makePatternsFromSchemas = (schemas: Types.Schema[]): string[] => { + const patterns: string[] = []; + + for (const s of schemas) { + const schema = s as string; + if (isGlob(schema) || isValidPath(schema)) { + patterns.push(schema); + } + } + + return patterns; +}; + +/** + * Given a list of micromatch patterns, sort them into `patterns` (all of them), + * `affirmative` (only the affirmative patterns), and `negated` (only the negated patterns) + * + * @param patterns List of micromatch patterns + */ +export const sortPatterns =

(patterns: P[]): SortedPatterns

=> ({ + patterns, + affirmative: onlyAffirmativePatterns(patterns) as P[], + negated: onlyNegatedPatterns(patterns) as Extract[], +}); + +/** + * A type that "sorts" (or "groups") patterns. For a given list of `patterns`, + * this type will include the original list in `patterns`, all of its + * "affirmative" (non-negated) patterns in `affirmative`, and all of its + * "negated" patterns in `negated` + */ +type SortedPatterns = { + /** List of patterns, which could include both negated and affirmative patterns */ + patterns: PP[]; + /** List of only the affirmative (non-negated) patterns in `patterns` */ + affirmative: PP[]; + /** List of only the negated patterns in `patterns` */ + negated: Extract[]; +}; + +/** + * The global (top-level) config and each local (output target) config can have + * patterns which are separable into "watch" (always takes precedence), "documents", + * and "schemas". This type can hold sorted versions of these patterns. + */ +type PatternSet = { + watch: SortedPatterns; + documents: SortedPatterns; + schemas: SortedPatterns; +}; + +/** + * Filter the provided list of patterns to include only "affirmative" (non-negated) patterns. + * + * @param patterns List of micromatch patterns (or paths) to filter + */ +const onlyAffirmativePatterns = (patterns: string[]) => { + return patterns.filter(pattern => !mm.scan(pattern).negated); +}; + +/** + * Filter the provided list of patterns to include only negated patterns. + * + * @param patterns List of micromatch patterns (or paths) to filter + */ +const onlyNegatedPatterns = (patterns: string[]) => { + return patterns.filter(pattern => mm.scan(pattern).negated); +}; + +/** + * Given a list of negated patterns, invert them by removing their negation prefix + * + * If there is a non-negated pattern in the list, throw an error, because this + * function should only be called after filtering the list to be only negated patterns + * + * @param patterns List of negated micromatch patterns + */ +const invertNegatedPatterns = (patterns: string[]) => { + return patterns.map(pattern => { + const scanned = mm.scan(pattern); + if (!scanned.negated) { + throw new Error(`onlyNegatedPatterns got a non-negated pattern: ${pattern}`); + } + + // Remove the leading prefix (NOTE: this is not always "!") + // e.g. mm.scan("!./foo/bar/never-watch.graphql").prefix === '!./' + return pattern.slice(scanned.prefix.length); + }); +}; + +/** + * Return true if relativeCandidatePath matches any of the affirmativePatterns + * + * @param relativeCandidatePath A relative path to evaluate against the supplied affirmativePatterns + * @param affirmativePatterns A list of patterns, containing no negated patterns, to evaluate + */ +const matchesAnyAffirmativePattern = (relativeCandidatePath: string, affirmativePatterns: string[]) => { + if (isAbsolute(relativeCandidatePath)) { + throw new Error('matchesAny should only be called with relative candidate path'); + } + + // Developer error: This function is not intended to work with pattern sets including negations + if (affirmativePatterns.some(pattern => mm.scan(pattern).negated)) { + throw new Error('matchesAnyAffirmativePattern should only include affirmative patterns'); + } + + // micromatch.isMatch does not omit matches that are negated by negation patterns, + // which is why we require this function only examine affirmative patterns + return mm.isMatch(relativeCandidatePath, affirmativePatterns); +}; + +/** + * Return true if relativeCandidatePath matches any of the negatedPatterns + * + * This function will invert the negated patterns and then call matchesAnyAffirmativePattern + * + * @param relativeCandidatePath A relative path to evaluate against the suppliednegatedPatterns + * @param negatedPatterns A list of patterns, containing no negated patterns, to evaluate + */ +const matchesAnyNegatedPattern = (relativeCandidatePath: string, negatedPatterns: string[]) => { + // NOTE: No safety check that negatedPatterns contains only negated, because that will happen in invertedNegatedPatterns + return matchesAnyAffirmativePattern(relativeCandidatePath, invertNegatedPatterns(negatedPatterns)); +}; diff --git a/packages/graphql-codegen-cli/src/utils/watcher.ts b/packages/graphql-codegen-cli/src/utils/watcher.ts index 55f300eecdd..40c077a1f08 100644 --- a/packages/graphql-codegen-cli/src/utils/watcher.ts +++ b/packages/graphql-codegen-cli/src/utils/watcher.ts @@ -1,17 +1,22 @@ -import { access } from 'node:fs/promises'; -import { join, isAbsolute, resolve, sep } from 'path'; -import { normalizeInstanceOrArray, normalizeOutputParam, Types } from '@graphql-codegen/plugin-helpers'; -import { isValidPath } from '@graphql-tools/utils'; +import { join, isAbsolute, relative, resolve, sep } from 'path'; +import { normalizeOutputParam, Types } from '@graphql-codegen/plugin-helpers'; import type { subscribe } from '@parcel/watcher'; import debounce from 'debounce'; -import isGlob from 'is-glob'; import mm from 'micromatch'; import logSymbols from 'log-symbols'; import { executeCodegen } from '../codegen.js'; import { CodegenContext, loadContext } from '../config.js'; import { lifecycleHooks } from '../hooks.js'; +import { access } from './file-system.js'; import { debugLog } from './debugging.js'; import { getLogger } from './logger.js'; +import { + allAffirmativePatternsFromPatternSets, + makeGlobalPatternSet, + makeLocalPatternSet, + makeShouldRebuild, +} from './patterns.js'; +import { AbortController } from './abort-controller-polyfill.js'; function log(msg: string) { // double spaces to inline the message with Listr @@ -23,47 +28,37 @@ function emitWatching(watchDir: string) { } export const createWatcher = ( - initalContext: CodegenContext, + initialContext: CodegenContext, onNext: (result: Types.FileOutput[]) => Promise -): Promise => { +): { + /** + * Call this function to stop the running watch server + * + * @returns Promise that resolves when watcher has terminated ({@link runningWatcher} promise settled) + * */ + stopWatching: () => Promise; + /** + * Promise that will never resolve as long as the watcher is running. To stop + * the watcher, call {@link stopWatching}, which will send a stop signal and + * then return a promise that doesn't resolve until `runningWatcher` has resolved. + * */ + runningWatcher: Promise; +} => { debugLog(`[Watcher] Starting watcher...`); - let config: Types.Config & { configFilePath?: string } = initalContext.getConfig(); - const files: string[] = [initalContext.filepath].filter(a => a); - const documents = normalizeInstanceOrArray(config.documents); - const schemas = normalizeInstanceOrArray(config.schema); - - // Add schemas and documents from "generates" - for (const conf of Object.keys(config.generates).map(filename => normalizeOutputParam(config.generates[filename]))) { - schemas.push(...normalizeInstanceOrArray(conf.schema)); - documents.push(...normalizeInstanceOrArray(conf.documents)); - files.push(...normalizeInstanceOrArray(conf.watchPattern)); - } - - if (documents) { - for (const doc of documents) { - if (typeof doc === 'string') { - files.push(doc); - } else { - files.push(...Object.keys(doc)); - } - } - } + let config: Types.Config & { configFilePath?: string } = initialContext.getConfig(); - for (const s of schemas) { - const schema = s as string; - if (isGlob(schema) || isValidPath(schema)) { - files.push(schema); - } - } + const globalPatternSet = makeGlobalPatternSet(initialContext); + const localPatternSets = Object.keys(config.generates) + .map(filename => normalizeOutputParam(config.generates[filename])) + .map(conf => makeLocalPatternSet(conf)); + const allAffirmativePatterns = allAffirmativePatternsFromPatternSets([globalPatternSet, ...localPatternSets]); - if (typeof config.watch !== 'boolean') { - files.push(...normalizeInstanceOrArray(config.watch)); - } + const shouldRebuild = makeShouldRebuild({ globalPatternSet, localPatternSets }); let watcherSubscription: Awaited>; - const runWatcher = async () => { - const watchDirectory = await findHighestCommonDirectory(files); + const runWatcher = async (abortSignal: AbortSignal) => { + const watchDirectory = await findHighestCommonDirectory(allAffirmativePatterns); const parcelWatcher = await import('@parcel/watcher'); debugLog(`[Watcher] Parcel watcher loaded...`); @@ -72,7 +67,7 @@ export const createWatcher = ( const debouncedExec = debounce(() => { if (!isShutdown) { - executeCodegen(initalContext) + executeCodegen(initialContext) .then(onNext, () => Promise.resolve()) .then(() => emitWatching(watchDirectory)); } @@ -84,13 +79,17 @@ export const createWatcher = ( filename, config: normalizeOutputParam(config.generates[filename]), }))) { + // ParcelWatcher expects relative ignore patterns to be relative from watchDirectory, + // but we expect filename from config to be relative from cwd, so we need to convert + const filenameRelativeFromWatchDirectory = relative(watchDirectory, resolve(process.cwd(), entry.filename)); + if (entry.config.preset) { const extension = entry.config.presetConfig?.extension; if (extension) { - ignored.push(join(entry.filename, '**', '*' + extension)); + ignored.push(join(filenameRelativeFromWatchDirectory, '**', '*' + extension)); } } else { - ignored.push(entry.filename); + ignored.push(filenameRelativeFromWatchDirectory); } } @@ -99,22 +98,20 @@ export const createWatcher = ( async (_, events) => { // it doesn't matter what has changed, need to run whole process anyway await Promise.all( + // NOTE: @parcel/watcher always provides path as an absolute path events.map(async ({ type: eventName, path }) => { - /** - * @parcel/watcher has no way to run watcher on specific files (https://github.com/parcel-bundler/watcher/issues/42) - * But we can use micromatch to filter out events that we don't care about - */ - if (!mm.contains(path, files)) return; + if (!shouldRebuild({ path })) { + return; + } lifecycleHooks(config.hooks).onWatchTriggered(eventName, path); debugLog(`[Watcher] triggered due to a file ${eventName} event: ${path}`); - const fullPath = join(watchDirectory, path); // In ESM require is not defined try { - delete require.cache[fullPath]; + delete require.cache[path]; } catch (err) {} - if (eventName === 'update' && config.configFilePath && fullPath === config.configFilePath) { + if (eventName === 'update' && config.configFilePath && path === config.configFilePath) { log(`${logSymbols.info} Config file has changed, reloading...`); const context = await loadContext(config.configFilePath); @@ -124,7 +121,7 @@ export const createWatcher = ( newParsedConfig.overwrite = config.overwrite; newParsedConfig.configFilePath = config.configFilePath; config = newParsedConfig; - initalContext.updateConfig(config); + initialContext.updateConfig(config); } debouncedExec(); @@ -136,32 +133,82 @@ export const createWatcher = ( debugLog(`[Watcher] Started`); - const shutdown = () => { + const shutdown = ( + /** Optional callback to execute after shutdown has completed its async tasks */ + afterShutdown?: () => void + ) => { isShutdown = true; debugLog(`[Watcher] Shutting down`); log(`Shutting down watch...`); - watcherSubscription.unsubscribe(); - lifecycleHooks(config.hooks).beforeDone(); + + const pendingUnsubscribe = watcherSubscription.unsubscribe(); + const pendingBeforeDoneHook = lifecycleHooks(config.hooks).beforeDone(); + + if (afterShutdown && typeof afterShutdown === 'function') { + Promise.allSettled([pendingUnsubscribe, pendingBeforeDoneHook]).then(afterShutdown); + } }; - process.once('SIGINT', shutdown); - process.once('SIGTERM', shutdown); + abortSignal.addEventListener('abort', () => shutdown(abortSignal.reason)); + + process.once('SIGINT', () => shutdown()); + process.once('SIGTERM', () => shutdown()); + }; + + // Use an AbortController for shutdown signals + // NOTE: This will be polyfilled on Node 14 (or any environment without it defined) + const abortController = new AbortController(); + + /** + * Send shutdown signal and return a promise that only resolves after the + * runningWatcher has resolved, which only resolved after the shutdown signal has been handled + */ + const stopWatching = async function () { + // stopWatching.afterShutdown is lazily set to resolve pendingShutdown promise + abortController.abort(stopWatching.afterShutdown); + + // SUBTLE: runningWatcher waits for pendingShutdown before it resolves itself, so + // by awaiting it here, we are awaiting both the shutdown handler, and runningWatcher itself + await stopWatching.runningWatcher; + }; + stopWatching.afterShutdown = () => { + debugLog('Shutdown watcher before it started'); }; + stopWatching.runningWatcher = Promise.resolve(); + + /** Promise will resolve after the shutdown() handler completes */ + const pendingShutdown = new Promise(afterShutdown => { + // afterShutdown will be passed to shutdown() handler via abortSignal.reason + stopWatching.afterShutdown = afterShutdown; + }); - // the promise never resolves to keep process running - return new Promise((resolve, reject) => { - executeCodegen(initalContext) + /** + * Promise that resolves after the watch server has shutdown, either because + * stopWatching() was called or there was an error inside it + */ + stopWatching.runningWatcher = new Promise((resolve, reject) => { + executeCodegen(initialContext) .then(onNext, () => Promise.resolve()) - .then(runWatcher) + .then(() => runWatcher(abortController.signal)) .catch(err => { watcherSubscription.unsubscribe(); reject(err); + }) + .then(() => pendingShutdown) + .finally(() => { + debugLog('Done watching.'); + resolve(); }); }); + + return { + stopWatching, + runningWatcher: stopWatching.runningWatcher, + }; }; /** - * Given a list of file paths (each of which may be absolute, or relative to + * Given a list of file paths (each of which may be absolute, or relative from * `process.cwd()`), find absolute path of the "highest" common directory, * i.e. the directory that contains all the files in the list. * diff --git a/packages/graphql-codegen-cli/tests/watcher-test-helpers/assert-watcher-build-triggers.ts b/packages/graphql-codegen-cli/tests/watcher-test-helpers/assert-watcher-build-triggers.ts new file mode 100644 index 00000000000..055e159f4b8 --- /dev/null +++ b/packages/graphql-codegen-cli/tests/watcher-test-helpers/assert-watcher-build-triggers.ts @@ -0,0 +1,308 @@ +import { join, isAbsolute, relative, resolve, sep } from 'path'; +import ParcelWatcher from '@parcel/watcher'; +import isGlob from 'is-glob'; + +import type { setupMockWatcher } from './setup-mock-watcher.js'; +import { + formatBuildTriggerErrorPrelude, + formatErrorGlobNotIgnoredByParcelWatcher, + formatErrorPathNotIgnoredByParcelWatcher, +} from './format-watcher-assertion-errors'; + +/** + * Helper function for asserting that multiple paths did or did not trigger a build, + * and for asserting the values of paths and globs passed to {@link ParcelWatcher.Options}`["ignore"]` + */ +export const assertBuildTriggers = async ( + mockWatcher: Awaited>, + { + shouldTriggerBuild, + shouldNotTriggerBuild, + globsWouldBeIgnoredByParcelWatcher, + pathsWouldBeIgnoredByParcelWatcher, + keepWatching, + }: { + /** + * Optional array of relative (from CWD) paths that SHOULD trigger build during watch mode + * + * Each path will be converted to an absolute path before dispatching it as + * a change event, which is consistent with how ParcelWatcher dispatches + * events (always containing an absolute path). + */ + shouldTriggerBuild?: string[]; + /** + * Optional array of relative (from CWD) paths that SHOULD NOT trigger build during watch mode + * + * Each path will be converted to an absolute path before dispatching it as + * a change event, which is consistent with how ParcelWatcher dispatches + * events (always containing an absolute path). + * + * NOTE: If a path would match one of the ignore patterns passed to Parcel, + * because we do not implement the C++ code that evaluates those paths, it + * will still be evaluated by the subscribe trigger. That's probably fine, + * if you expect that our JS level matchers should also ignore the path, + * but keep in mind that in production, the real Parcel watcher would (hopefully) + * never dispatch an event with an ignored path to the subscribe callback. + */ + shouldNotTriggerBuild?: string[]; + /** + * Optional array specifying paths (_not_ globs) that should be included + * in the `options.ignore` value passed to {@link ParcelWatcher.subscribe}. + * + * Any paths expected to be ignored should be specified _relative from cwd_, + * as they would be in the config file. Note that ParcelWatcher expects + * these paths to be relative from the `watchDirectory`, and the assertion + * helper will do the conversion, by converting each item in the `options.ignore` + * array to be relative from the cwd, and _then_ searching for a match to the + * specified path. + * + * This is different from {@link globsWouldBeIgnoredByParcelWatcher} which + * does no conversion and only looks for exact matches. + * + * For each path in this array: + * + * * It will be checked for equality with an item in `options.ignore`, but + * only after all paths in options.ignore have been converted to also be relative from cwd + * + * See: {@link https://github.com/parcel-bundler/watcher#options} + * + * NOTE: Because our mock does not implement Parcel Watcher's C++ code that + * checks whether a path should be ignored, that means that every dispatched + * event, regardless of path, will always call the subscribe callback on our mock, + * even if Parcel would have otherwise ignored it. + */ + pathsWouldBeIgnoredByParcelWatcher?: string[]; + /** + * Optional array specifying glob patterns (_not_ paths) that should be included + * in the `options.ignore` value passed to {@link ParcelWatcher.subscribe}. + * + * This assertion helper will look for an **exact match** of each string + * in this array. Any relative globs should be specified relative from the + * `watchDirectory`, because this asertion helper will not attempt to convert + * them (unlike with {@link pathsWouldBeIgnoredByParcelWatcher}). + * + * For each string in this array: + * + * * It will be checked for exact equality with an item in options.ignore + * + * See: {@link https://github.com/parcel-bundler/watcher#options} + * + * NOTE: Because our mock does not implement Parcel Watcher's C++ code that + * checks whether a path should be ignored, that means that every dispatched + * event, regardless of path, will always call the subscribe callback on our mock, + * even if Parcel would have otherwise ignored it. + */ + globsWouldBeIgnoredByParcelWatcher?: string[]; + /** + * Set this to `true` if the helper function should not call `stopWatching()` + * (for example, if you want to continue making assertions within the test). + * + * By default, the helper will stop the watcher when it's done, even if it + * encounters an error. + */ + keepWatching?: true; + } +) => { + const { + onWatchTriggered, + dispatchChange, + stopWatching, + subscribeCallbackSpy, + unsubscribeSpy, + watchDirectory, + subscribeOpts, + } = mockWatcher; + + // These are optional, but to avoid if/else nesting, set them to empty list if not specified + shouldTriggerBuild ??= []; + shouldNotTriggerBuild ??= []; + + // Wrap in a try/finally block so even if there's an error, we can stop the watcher + // This way, we avoid misleading "cannot log after tests are done" error + try { + for (const relPath of shouldTriggerBuild) { + const path = join(process.cwd(), relPath); + await assertTriggeredBuild(path, { dispatchChange, subscribeCallbackSpy, onWatchTriggered }); + } + + expect(subscribeCallbackSpy).toHaveBeenCalledTimes(shouldTriggerBuild.length); + expect(onWatchTriggered).toHaveBeenCalledTimes(shouldTriggerBuild.length); + + for (const relPath of shouldNotTriggerBuild) { + const path = join(process.cwd(), relPath); + await assertDidNotTriggerBuild(path, { dispatchChange, subscribeCallbackSpy, onWatchTriggered }); + } + + expect(subscribeCallbackSpy).toHaveBeenCalledTimes(shouldTriggerBuild.length + shouldNotTriggerBuild.length); + expect(onWatchTriggered).toHaveBeenCalledTimes(shouldTriggerBuild.length); + + const ignore = subscribeOpts.ignore ?? []; + if (pathsWouldBeIgnoredByParcelWatcher) { + for (const relPathFromCwd of pathsWouldBeIgnoredByParcelWatcher) { + if (isGlob(relPathFromCwd)) { + throw new Error( + [ + `expected path, got glob: ${relPathFromCwd}`, + 'pass globs to globsWouldBeIgnoredByParcelWatcher, not pathsWouldBeIgnoredByParcelWatcher', + ].join('\n') + ); + } + + if (isAbsolute(relPathFromCwd)) { + throw new Error('pathsWouldBeIgnoredByParcelWatcher should only include relative paths from cwd'); + } + assertParcelWouldIgnorePath(relPathFromCwd, { watchDirectory, ignore }); + } + } + + if (globsWouldBeIgnoredByParcelWatcher) { + for (const expectedIgnoredGlob of globsWouldBeIgnoredByParcelWatcher) { + if (!isGlob(expectedIgnoredGlob)) { + throw new Error( + [ + `expected glob, got path (or something that is not a glob): ${expectedIgnoredGlob}`, + 'pass paths to pathsWouldBeIgnoredByParcelWatcher, not globsWouldBeIgnoredByParcelWatcher', + ].join('\n') + ); + } + + assertParcelWouldIgnoreGlob(expectedIgnoredGlob, { watchDirectory, ignore }); + } + } + } finally { + if (keepWatching !== true) { + await stopWatching(); + expect(unsubscribeSpy).toHaveBeenCalled(); + } + } +}; + +/** + * Given a glob pattern, assert that {@link ParcelWatcher.options}`["ignore"]` + * contains that glob pattern (exact match). + * + * We don't implement actual globbing logic, because Parcel Watcher does that + * from C++ and it would be a leaky mock. + */ +const assertParcelWouldIgnoreGlob = ( + /** Glob pattern expected to exist in {@link ParcelWatcher.Options}`["ignore"]` */ + expectToIgnoreGlob: string, + { ignore, watchDirectory }: { watchDirectory: string; ignore: Required['ignore'] } +) => { + const parcelIgnoredGlobs = ignore.filter(pathOrGlob => isGlob(pathOrGlob)); + + const hasMatch = parcelIgnoredGlobs.includes(expectToIgnoreGlob); + + try { + expect(hasMatch).toBe(true); + } catch (error) { + error.message = formatErrorGlobNotIgnoredByParcelWatcher({ + expectedGlob: expectToIgnoreGlob, + parcelIgnoredGlobs, + jestErrorMessage: error.message, + watchDirectory, + }); + Error.captureStackTrace(error, assertParcelWouldIgnoreGlob); + throw error; + } +}; + +/** + * Given a path, and the `ignore` option passed to the mocked {@link ParcelWatcher.Options}, + * assert that ParcelWatcher "would" ignore the path if given it as part of the ignore option. + * + * Note that ParcelWatcher expects paths relative from the watchDirectory, but + * our assertion helper expects paths relative from cwd. + */ +const assertParcelWouldIgnorePath = ( + /** + * Relative path from cwd, as given to {@link assertBuildTriggers} + * `pathsWouldBeIgnoredByParcelWatcher` option + */ + expectToIgnoreRelPathFromCwd: string, + { + watchDirectory, + ignore, + }: { + watchDirectory: string; + ignore: Required['ignore']; + } +) => { + const parcelIgnoredPaths = ignore.filter(pathOrGlob => !isGlob(pathOrGlob)); + + const parcelIgnoredPathsRelativeFromCwd = parcelIgnoredPaths.map(relOrAbsolutePath => { + // NOTE: ParcelWatcher considers relative ignore paths relative from the given watchDirectory + const relPathFromWatchDir = isAbsolute(relOrAbsolutePath) + ? relative(watchDirectory, relOrAbsolutePath) + : relOrAbsolutePath; + + // ...but we want to assert relative from cwd + const absPath = resolve(process.cwd(), relative(process.cwd(), watchDirectory), relPathFromWatchDir); + const relPathFromCwd = relative(process.cwd(), absPath); + + // NOTE: This will not include "./" + return relPathFromCwd; + }); + + // Match on exact match, or exact match with ./ prefix (or .\ on windows) + const hasMatch = parcelIgnoredPathsRelativeFromCwd.some( + ignorePathRelFromCwd => + expectToIgnoreRelPathFromCwd === ignorePathRelFromCwd || + expectToIgnoreRelPathFromCwd === `.${sep}${ignorePathRelFromCwd}` + ); + + try { + expect(hasMatch).toBe(true); + } catch (error) { + error.message = formatErrorPathNotIgnoredByParcelWatcher({ + expectedPath: expectToIgnoreRelPathFromCwd, + parcelIgnoredPaths, + parcelIgnoredPathsRelativeFromCwd, + jestErrorMessage: error.message, + watchDirectory, + }); + Error.captureStackTrace(error, assertParcelWouldIgnorePath); + throw error; + } +}; + +type MockWatcherAssertionHelpers = Pick< + Awaited>, + 'dispatchChange' | 'subscribeCallbackSpy' | 'onWatchTriggered' +>; + +/** + * Assertion helper to assert that the given (absolute) path triggered a build + */ +const assertTriggeredBuild = async ( + /** Absolute path */ path: string, + { dispatchChange, subscribeCallbackSpy, onWatchTriggered }: MockWatcherAssertionHelpers +) => { + try { + await dispatchChange(path); + expect(subscribeCallbackSpy).toHaveBeenLastCalledWith(undefined, [{ path, type: 'update' }]); + expect(onWatchTriggered).toHaveBeenLastCalledWith('update', path); + } catch (error) { + error.message = formatBuildTriggerErrorPrelude(path, true, error.message); + Error.captureStackTrace(error, assertTriggeredBuild); + throw error; + } +}; + +/** + * Assertion helper to assert that the given (absolute) path did NOT trigger a build + */ +const assertDidNotTriggerBuild = async ( + /** Absolute path */ path: string, + { dispatchChange, subscribeCallbackSpy, onWatchTriggered }: MockWatcherAssertionHelpers +) => { + try { + await dispatchChange(path); + expect(subscribeCallbackSpy).toHaveBeenLastCalledWith(undefined, [{ path, type: 'update' }]); + expect(onWatchTriggered).not.toHaveBeenLastCalledWith('update', path); + } catch (error) { + error.message = formatBuildTriggerErrorPrelude(path, false, error.message); + Error.captureStackTrace(error, assertDidNotTriggerBuild); + throw error; + } +}; diff --git a/packages/graphql-codegen-cli/tests/watcher-test-helpers/format-watcher-assertion-errors.ts b/packages/graphql-codegen-cli/tests/watcher-test-helpers/format-watcher-assertion-errors.ts new file mode 100644 index 00000000000..b16f5e64786 --- /dev/null +++ b/packages/graphql-codegen-cli/tests/watcher-test-helpers/format-watcher-assertion-errors.ts @@ -0,0 +1,175 @@ +import { relative } from 'path'; +import chalk from 'chalk'; + +/** + * Format an error message when a glob is not ignored by Parcel Watcher + */ +export const formatErrorGlobNotIgnoredByParcelWatcher = ({ + expectedGlob, + parcelIgnoredGlobs, + jestErrorMessage, + watchDirectory, +}: { + expectedGlob: string; + parcelIgnoredGlobs: string[]; + jestErrorMessage: string; + watchDirectory: string; +}) => { + const rawGlobTableLines = [ + 'Expected glob not found:', + expectedGlob, + 'globs received by ParcelWatcher.Options.ignore', + '----------------------------------------------------', + ...parcelIgnoredGlobs, + ]; + + const maxGlobLength = Math.max(...rawGlobTableLines.map(s => s.length)); + + const globTableLineFormatters: ((s: string) => string)[] = [s => s, s => chalk.red(s), s => chalk.bold(s)]; + + const tableLines = rawGlobTableLines.map((line, rowNum) => { + const formatLine = globTableLineFormatters[rowNum] ?? (s => s); + + return `| ${chalk.reset(formatLine(line.padStart(maxGlobLength)))} |`; + }); + + return `${[ + chalk.gray( + '-----------------------------------------', + 'Watch Mode Parcel Ignore Assertion Failure:', + '-----------------------------------------' + ), + '', + chalk.gray( + chalk.bold('Note:'), + 'Assertion should specify relative glob paths relative from watchDirectory (_not_ cwd),' + ), + chalk.gray( + ' i.e. exactly as given to ParcelWatcher (unlike path assertions which should be relative from cwd),' + ), + chalk.gray(' because glob assertion looks for an exact match and does not try to convert them.'), + '', + chalk.gray(chalk.bold('watchDirectory:'), watchDirectory), + ' ', + ' ', + ...tableLines, + ' ', + chalk.gray( + '----------------------------------------------------', + 'Raw Error (from Jest):', + '---------------------------------------------------' + ), + ].join('\n')}\n${jestErrorMessage}`; +}; + +/** + * Format an error message when a path is not ignored by Parcel Watcher + */ +export const formatErrorPathNotIgnoredByParcelWatcher = ({ + expectedPath, + parcelIgnoredPaths, + parcelIgnoredPathsRelativeFromCwd, + jestErrorMessage, + watchDirectory, +}: { + expectedPath: string; + watchDirectory: string; + parcelIgnoredPaths: string[]; + parcelIgnoredPathsRelativeFromCwd: string[]; + jestErrorMessage: string; +}) => { + const leftCol = [ + '', + '', + 'ParcelWatcher.Options[ignore]', + 'These raw values were received in the options.ignore argument', + 'of ParcelWatcher.subscribe, which ParcelWatcher expects to be', + 'either an absolute path, or relative from watchDirectory', + '--------------------------------------------------------------+', + ...parcelIgnoredPaths, + ]; + + const rightCol = [ + 'Match not found:', + expectedPath, + 'Converted to be relative from CWD', + 'Each value was converted to be relative from CWD, (assuming', + 'that the inputs were correctly relative from watchDirectory),', + 'and then we scanned this column looking for a match.', + '+--------------------------------------------------------------', + ...parcelIgnoredPathsRelativeFromCwd, + ]; + + const headerFormatters: [(s: string) => string, (s: string) => string][] = [ + [s => chalk.gray(s), s => chalk.gray(s)], + [s => s, s => chalk.red(s)], + [s => chalk.bold(s), s => chalk.bold(s)], + [s => chalk.gray(s), s => chalk.gray(s)], + [s => chalk.gray(s), s => chalk.gray(s)], + [s => chalk.gray(s), s => chalk.gray(s)], + ]; + + const maxLeftCol = Math.max(...leftCol.map(c => c.length)); + const maxRightCol = Math.max(...rightCol.map(c => c.length)); + + if (leftCol.length !== rightCol.length) { + throw new Error('Formatting error: columns different height'); + } + + const tableLines = leftCol.map((leftCell, rowNum) => { + const [formatLeft, formatRight] = headerFormatters[rowNum] ?? [(s: string) => s, (s: string) => s]; + + return `${formatLeft(leftCell.padStart(maxLeftCol))} | ${formatRight(rightCol[rowNum].padEnd(maxRightCol))}`; + }); + + return `${[ + chalk.gray( + '-----------------------------------------', + 'Watch Mode Parcel Ignore Assertion Failure:', + '-----------------------------------------' + ), + chalk.red(`<${expectedPath}> ` + chalk.bold('would not have been ignored by Parcel Watcher')), + chalk.gray(chalk.bold('Note:'), 'Assertion should specify path relative from current working directory,'), + chalk.gray(' but code should give path to ParcelWatcher relative from', chalk.bold('watchDirectory')), + '', + chalk.gray(chalk.bold('watchDirectory:'), watchDirectory), + '', + '', + ...tableLines, + '', + chalk.gray( + '----------------------------------------------------', + 'Raw Error (from Jest):', + '---------------------------------------------------' + ), + ].join('\n')}\n${jestErrorMessage}`; +}; + +/** + * Format a readable error message to print when the assertion fails, so that + * the developer can immediately see which path was expected to trigger (or not trigger) + * the rebuild. + * + * Since we're using auto-assertions, this makes for much more readable errors + * than the raw Jest message (which can be misleading because, e.g. if the assertion + * is that `onWatchTriggered` was last called with the right path, but it wasn't, + * then the Jest error message will misleadingly print the previous path). + */ +export const formatBuildTriggerErrorPrelude = ( + /** Absolute path */ path: string, + expectedToBuild: boolean, + jestErrorMessage: string +) => { + const relPath = relative(process.cwd(), path); + const should = `${expectedToBuild ? 'have' : 'not have'} triggered build`; + const but = expectedToBuild ? 'it did not' : 'it did'; + return `${[ + chalk.gray('---------------------- Watch Mode Build Trigger Assertion Failure: ----------------------'), + chalk.red(`<${relPath}>` + chalk.bold(` should ${should}, but ${but}.`)), + ` Expected: ${chalk.green(expectedToBuild ? 'to trigger build' : 'not to trigger build')}`, + ` Received: ${chalk.red(expectedToBuild ? 'did not trigger build' : 'triggered build')}`, + chalk.gray(`Absolute Path: ${path}`), + '', + chalk.gray('-------------------------------- Raw Error (from Jest): --------------------------------'), + ].join('\n')}\n${jestErrorMessage}`; +}; diff --git a/packages/graphql-codegen-cli/tests/watcher-test-helpers/setup-mock-watcher.ts b/packages/graphql-codegen-cli/tests/watcher-test-helpers/setup-mock-watcher.ts new file mode 100644 index 00000000000..bf0883a3044 --- /dev/null +++ b/packages/graphql-codegen-cli/tests/watcher-test-helpers/setup-mock-watcher.ts @@ -0,0 +1,249 @@ +import type { SubscribeCallback } from '@parcel/watcher'; +import ParcelWatcher from '@parcel/watcher'; +import { CodegenContext } from '../../src/config.js'; +import * as fs from '../../src/utils/file-system.js'; +import { createWatcher } from '../../src/utils/watcher.js'; +import { Types } from '@graphql-codegen/plugin-helpers'; + +/** + * Setup mocking infrastructure for a fake watcher. + * + * **IMPORTANT**: Make sure to call the returned {@link stopWatching `stopWatching()`} + * function at the end of each test that uses this, or Jest will complain about unterminated promises. + * If you're using the `assertBuildTriggers` helper, it will call `stopWatching` for you. + * + * @returns Various helpers and spies for making assertions about change events and rebuild triggers + */ +export const setupMockWatcher = async ( + /** + * Same argument as first parameter of {@link CodegenContext} constructor + * + * If `config.hooks.onWatchTriggered` lifecycle hook is not provided, then + * it will be set to a mocked function (which in all cases will be part of + * the return value of {@link setupMockWatcher}). + */ + contextOpts: ConstructorParameters[0] +) => { + const onWatchTriggered = createMockOnWatchTriggered(); + + contextOpts.config = { + hooks: { + onWatchTriggered, + ...contextOpts.config?.hooks, + }, + ...contextOpts.config, + }; + + const context = new CodegenContext(contextOpts); + + const deferredParcelWatcher = deferMockedParcelWatcher(); + const { stopWatching, runningWatcher } = createWatcher(context, async _ => Promise.resolve([])); + + const { dispatchChange, subscribeCallbackSpy, unsubscribeSpy, watchDirectory, subscribeOpts } = + await deferredParcelWatcher; + + return { + /** + * The {@link CodegenContext} created from the given contextOpts, which represents + * the same {@link CodegenContext} that would be created with `new CodegenContext(contextOpts)`, + * but is guaranteed to have defined lifecycle hook `Config.hooks.onWatchTriggered` + */ + context, + /** + * The function passed to `config.hooks.onWatchTriggered`. If it was not provided + * in the first parameter of {@link setupMockWatcher}, then it will be a mocked function. + * + * This lifecycle hook should only be expected to be called when a rebuild + * is triggered for the watcher, i.e. not for every event. To make assertions + * on every event, use {@link subscribeCallbackSpy} that is returned along with this function. + */ + onWatchTriggered: contextOpts.config.hooks.onWatchTriggered, + /** + * Call this functon to stop the mock watching promise (which otherwise will not terminate). + * + * This _must be called_ at the end of each test to avoid unhandled promises. + * + * Note that the assertion helper `assertBuildTriggers` will call `await stopWatching()`, + * so any test using that helper does not need to call it. + */ + stopWatching, + /** + * Promise that is pending as long as the watcher is running. + * + * There should be no need to manually await this, because `await stopWatching()` + * will also wait for this same `runningWatcher` promise to resolve. + */ + runningWatcher, + /** + * Asynchronous function for dispatching file change events, + * _which only resolves after the {@link ParcelWatcher.SubscribeCallback | subscription callback} + * has completed consuming the event._ + */ + dispatchChange, + /** + * Spy on the value of the asynchronous {@link ParcelWatcher.SubscribeCallback} + * that the implementing code provided as an argument + * to {@link ParcelWatcher.subscribe | `@parcel/watch.subscribe`} + * + * This function is called for _every_ change event, so it's a useful spy + * for making assertions about events that did _not_ call {@link onWatchTriggered} + * + * NOTE: Our mock does not implement {@link ParcelWatcher.Options}`["ignore"]` + * logic, so even if a path would be ignored, this spy will still be called. + * But it's possible to make assertions on ignored paths/globs by testing + * their values in {@link subscribeOpts} (or using the `assertBuildTriggers` helper). + */ + subscribeCallbackSpy, + /** + * Mocked function implementing {@link ParcelWatcher.AsyncSubscription}`['unsubscribe']` + * to spy on `subscription.unsubscribe()` which should be called by implementing + * code when closing the subscription + */ + unsubscribeSpy, + /** + * The argument that was provided to {@link ParcelWatcher.subscribe | @parcel/watch.subscribe} + * which indicates the directory for the Parcel Watcher to watch. + */ + watchDirectory, + /** + * The {@link ParcelWatcher.Options} argument that may have been provided to + * {@link ParcelWatcher.subscribe | @parcel/watch.subscribe}, which will + * include, for example, the `ignore` key that contains ignored paths and globs that + * should never cause the subscription callback to be called. + * + * NOTE: This mock does _not_ implement the Parcel Watcher `shouldIgnore` check, + * because that's implemented by Parcel Watcher in C++ and there is no sense + * duplicating it in JS. So if you want to make assertions about ignored paths, + * you should limit it to assertions about which paths end up in `subscribeOpts.ignore`, + * and otherwise assume that Parcel Watcher will work as expected. For making + * these assertions, see the `assertBuildTriggers` helper. + */ + subscribeOpts, + }; +}; + +/** + * Setup global mocks for [file-system.ts](../src/utils/file-system.ts) and {@link process.cwd}. + */ +export const setupMockFilesystem = ( + /** + * Optionally provide the mock implementations for any {@link fs} functions + * exported from [file-system.ts](../src/utils/file-system.ts) + * + * Default: + * * {@link fs.writeFile | `writeFile`}: no-op + * * {@link fs.readFile | `readFile`}: return blank string + * * {@link fs.access | `access` }: return `void` (indicates file is accessible, since no error is thrown) + */ + implementations?: Partial +) => { + const mockedFsSpies = { + /** Don't write any file */ + writeFile: jest.spyOn(fs, 'writeFile').mockImplementation(implementations?.writeFile), + /** Read a blank file */ + readFile: jest.spyOn(fs, 'readFile').mockImplementation(implementations?.readFile ?? (async () => '')), + /** Always accessible (void means accesible, it throws otherwise) */ + access: jest.spyOn(fs, 'access').mockImplementation(implementations?.access ?? (async () => {})), + }; + + return { + /** + * The spy functions created for the {@link fs} module, either those provided + * by {@link implementations} or {@link mockedFsSpies | the defaults}. + */ + fsSpies: mockedFsSpies, + }; +}; + +/** + * Create a mocked function for the `onWatchTriggered` lifecycle hook, which can + * be pased as a value to `Config.hooks.onWatchTriggered`, and is useful for making + * assertions about when a rebuild was triggered, since this lifecycle hook is + * only supposed to be called when a file change event triggers a rebuild. + * + * @returns Mocked function that can be passed as a lifecycle hook to `Config.hooks.onWatchTriggered` + */ +const createMockOnWatchTriggered = () => jest.fn, Parameters>(); + +/** Function to call to dispatch a change and wait for it to be processed by subscription listener */ +type DispatchChange = (path: string, eventType?: ParcelWatcher.EventType) => Promise; +/** Mocked @parcel/watcher.SubscribeCallback */ +type SubscribeCallbackMock = jest.Mock, Parameters>; +/** Convenience type alias for the unsubscribe function of ParcelWatcher subcription */ +type ParcelUnsubscribe = ParcelWatcher.AsyncSubscription['unsubscribe']; +/** Mocked ParcelUnsubscribe */ +type UnsubscribeMock = jest.Mock, Parameters>; + +/** + * Mock {@link ParcelWatcher | `@parcel/watcher`} module to override {@link ParcelWatcher.subscribe | `@parcel/watcher.subscribe`} + * with a function that intercepts the provided {@link ParcelWatcher.SubscribeCallback}, + * spies on it, and then calls the {@link mockOnSubscribed} function once it's been setup. + */ +const mockParcelWatcher = ( + /** + * Callback to execute once {@link ParcelWatcher.subscribe | `@parcel/watcher.subscribe`} has been called + * + * NOTE: Prefixed with mock to opt out of Jest warning about uninitialized mocked + * variables, since we're intentionally initializing it lazily + */ + mockOnSubscribed: (opts: { + watchDirectory: string; + subscribeOpts?: ParcelWatcher.Options; + dispatchChange: DispatchChange; + subscribeCallbackSpy: SubscribeCallbackMock; + unsubscribeSpy: UnsubscribeMock; + }) => void +) => { + let mockOnEvent: SubscribeCallbackMock; + const mockUnsubscribe: UnsubscribeMock = jest.fn, undefined>(() => Promise.resolve()); + + jest.mock('@parcel/watcher', () => ({ + subscribe: async ( + watchDirectory: string, + subscribeCallback: SubscribeCallbackMock, + subscribeOpts?: ParcelWatcher.Options + ) => { + mockOnEvent = jest.fn(subscribeCallback); + + mockOnSubscribed({ + watchDirectory, + subscribeOpts, + unsubscribeSpy: mockUnsubscribe, + subscribeCallbackSpy: mockOnEvent, + dispatchChange: async (path: string, eventType: ParcelWatcher.EventType = 'update') => { + await mockOnEvent(undefined, [ + { + type: eventType, + path, + }, + ]); + }, + }); + + return { + unsubscribe: mockUnsubscribe, + }; + }, + })); +}; + +/** + * Return a Promise that will mock the global {@link ParcelWatcher | `@parcel/watcher`} module, + * and that will only resolve once {@link ParcelWatcher.subscribe | `@parcel/watcher.subscribe`} + * has been called (presumably by the implementing code that is implicitly consuming the mock). + * + * @returns Promise that resolves after mocked @parcel/watcher.subscribe has been called + */ +const deferMockedParcelWatcher = () => { + return new Promise<{ + dispatchChange: DispatchChange; + subscribeCallbackSpy: SubscribeCallbackMock; + unsubscribeSpy: UnsubscribeMock; + watchDirectory: string; + subscribeOpts?: ParcelWatcher.Options; + }>((resolve, _reject) => { + mockParcelWatcher(opts => { + resolve(opts); + }); + }); +}; diff --git a/packages/graphql-codegen-cli/tests/watcher.spec.ts b/packages/graphql-codegen-cli/tests/watcher.spec.ts new file mode 100644 index 00000000000..565f086623a --- /dev/null +++ b/packages/graphql-codegen-cli/tests/watcher.spec.ts @@ -0,0 +1,548 @@ +import { setupMockFilesystem, setupMockWatcher } from './watcher-test-helpers/setup-mock-watcher.js'; +import { assertBuildTriggers } from './watcher-test-helpers/assert-watcher-build-triggers.js'; +import { join } from 'path'; + +describe('Watch targets', () => { + beforeEach(() => { + // Silence logs + jest.spyOn(console, 'log').mockImplementation(); + jest.spyOn(console, 'info').mockImplementation(); + + setupMockFilesystem(); + }); + + afterEach(() => { + jest.resetAllMocks(); + // IMPORTANT: setupMockWatcher() mocks @parcel/watcher module, so we must reset modules + jest.resetModules(); + }); + + test('watches the longest common prefix directory', async () => { + const { stopWatching, watchDirectory } = await setupMockWatcher({ + filepath: './foo/some-config.ts', + config: { + schema: './foo/something.ts', + generates: { + ['./foo/some-output.ts']: { + documents: ['./foo/bar/*.graphql'], + }, + }, + }, + }); + + expect(watchDirectory).toBe(join(process.cwd(), 'foo')); + await stopWatching(); + }); + + test('watches process.cwd() when longest common prefix directory is not accessible', async () => { + setupMockFilesystem({ + access: async path => { + if (path === join(process.cwd(), 'foo')) { + throw new Error(); + } + }, + }); + + const { stopWatching, watchDirectory } = await setupMockWatcher({ + filepath: './foo/some-config.ts', + config: { + schema: './foo/something.ts', + generates: { + ['./foo/some-output.ts']: { + documents: ['./foo/bar/*.graphql'], + }, + }, + }, + }); + + expect(watchDirectory).toBe(join(process.cwd())); + await stopWatching(); + }); + + // This test uses manual assertions to make sure they're tested individually, + // but note that `assertBuildTriggers` can do most of this work for you + test('triggers a rebuild for basic case', async () => { + const { onWatchTriggered, dispatchChange, stopWatching, subscribeCallbackSpy, unsubscribeSpy, watchDirectory } = + await setupMockWatcher({ + filepath: './foo/some-config.ts', + config: { + schema: './foo/something.ts', + generates: { + ['./foo/some-output.ts']: { + documents: ['./foo/bar/*.graphql'], + }, + }, + }, + }); + + expect(watchDirectory).toBe(join(process.cwd(), 'foo')); + + const shouldTriggerBuild = join(process.cwd(), './foo/bar/fizzbuzz.graphql'); + const shouldNotTriggerBuild = join(process.cwd(), './foo/bar/something.ts'); + + await dispatchChange(shouldTriggerBuild); + expect(subscribeCallbackSpy).toHaveBeenLastCalledWith(undefined, [{ path: shouldTriggerBuild, type: 'update' }]); + expect(onWatchTriggered).toHaveBeenLastCalledWith('update', shouldTriggerBuild); + + await dispatchChange(shouldNotTriggerBuild); + expect(subscribeCallbackSpy).toHaveBeenLastCalledWith(undefined, [{ path: shouldNotTriggerBuild, type: 'update' }]); + expect(onWatchTriggered).not.toHaveBeenLastCalledWith('update', shouldNotTriggerBuild); + expect(onWatchTriggered).toHaveBeenCalledTimes(1); + + expect(subscribeCallbackSpy).toHaveBeenCalledTimes(2); + + await stopWatching(); + expect(unsubscribeSpy).toHaveBeenCalled(); + }); + + test('globally included paths should be included even when a local pattern negates them', async () => { + const mockWatcher = await setupMockWatcher({ + filepath: './foo/some-config.ts', + config: { + schema: ['./foo/**/match-schema-everywhere.graphql'], + watch: [ + 'foo/**/match-watch-everywhere.graphql', + 'foo/**/match-watch-doc-everywhere.graphql', + 'foo/**/match-watch-schema-everywhere.graphql', + ], + documents: ['foo/**/match-doc-everywhere.graphql'], + generates: { + // globally inclued paths should be included even when a local pattern negates them + ['./foo/local-exclusions-dont-precede-global-inclusions.ts']: { + watchPattern: ['!foo/global-beats-local/match-watch-everywhere.graphql'], + documents: [ + '!foo/global-beats-local/match-doc-everywhere.graphql', + '!foo/global-beats-local/match-watch-doc-everywhere.graphql', + ], + schema: [ + '!foo/global-beats-local/match-schema-everywhere.graphql', + '!foo/global-beats-local/match-watch-schema-everywhere.graphql', + ], + }, + }, + }, + }); + + expect(mockWatcher.watchDirectory).toBe(join(process.cwd(), 'foo')); + + await assertBuildTriggers(mockWatcher, { + shouldTriggerBuild: [ + // watch + './foo/match-watch-everywhere.graphql', + './foo/fizz/match-watch-everywhere.graphql', + './foo/fizz/buzz/match-watch-everywhere.graphql', + './foo/fizz/buzz/foobarbaz/match-watch-everywhere.graphql', + // watch-doc (matched in global watch, excluded in local doc) + './foo/match-watch-doc-everywhere.graphql', + './foo/fizz/match-watch-doc-everywhere.graphql', + './foo/fizz/buzz/match-watch-doc-everywhere.graphql', + './foo/fizz/buzz/foobarbaz/match-watch-doc-everywhere.graphql', + // watch-schema (matched in global watch, excluded in local schema) + './foo/match-watch-schema-everywhere.graphql', + './foo/fizz/match-watch-schema-everywhere.graphql', + './foo/fizz/buzz/match-watch-schema-everywhere.graphql', + './foo/fizz/buzz/foobarbaz/match-watch-schema-everywhere.graphql', + // doc + './foo/match-doc-everywhere.graphql', + './foo/fizz/match-doc-everywhere.graphql', + './foo/fizz/buzz/match-doc-everywhere.graphql', + './foo/fizz/buzz/foobarbaz/match-doc-everywhere.graphql', + // schema + './foo/match-schema-everywhere.graphql', + './foo/fizz/match-schema-everywhere.graphql', + './foo/fizz/buzz/match-schema-everywhere.graphql', + './foo/fizz/buzz/foobarbaz/match-schema-everywhere.graphql', + ], + }); + }); + + test('globally negated paths should be excluded even when a local pattern matches them', async () => { + const mockWatcher = await setupMockWatcher({ + filepath: './foo/some-config.ts', + config: { + schema: ['!**/exclude-schema-everywhere.graphql'], + watch: [ + '!**/exclude-watch-everywhere.graphql', + '!**/exclude-watch-doc-everywhere.graphql', + '!**/exclude-watch-schema-everywhere.graphql', + ], + documents: ['!**/exclude-doc-everywhere.graphql'], + generates: { + ['./foo/local-inclusions-dont-precede-global-exclusions.ts']: { + watchPattern: [ + 'foo/global-beats-local/exclude-watch-everywhere.graphql', + 'foo/global-beats-local/exclude-watch-doc-everywhere.graphql', + 'foo/global-beats-local/exclude-watch-schema-everywhere.graphql', + ], + documents: [ + 'foo/global-beats-local/exclude-doc-everywhere.graphql', + 'foo/global-beats-local/exclude-watch-doc-everywhere.graphql', + ], + schema: [ + 'foo/global-beats-local/exclude-schema-everywhere.graphql', + 'foo/global-beats-local/exclude-watch-schema-everywhere.graphql', + ], + }, + }, + }, + }); + + expect(mockWatcher.watchDirectory).toBe(join(process.cwd(), 'foo')); + + await assertBuildTriggers(mockWatcher, { + shouldNotTriggerBuild: [ + 'foo/global-beats-local/exclude-watch-everywhere.graphql', + 'foo/global-beats-local/exclude-doc-everywhere.graphql', + 'foo/global-beats-local/exclude-schema-everywhere.graphql', + 'foo/global-beats-local/exclude-watch-doc-everywhere.graphql', + 'foo/global-beats-local/exclude-watch-schema-everywhere.graphql', + ], + }); + }); + + test('local watchPattern negation should override local documents match', async () => { + const mockWatcher = await setupMockWatcher({ + filepath: './foo/some-config.ts', + config: { + schema: './foo/something.ts', + generates: { + ['./foo/some-output.ts']: { + watchPattern: '!./foo/bar/never-watch.graphql', + documents: ['./foo/bar/*.graphql'], + }, + }, + }, + }); + + expect(mockWatcher.watchDirectory).toBe(join(process.cwd(), 'foo')); + + await assertBuildTriggers(mockWatcher, { + shouldTriggerBuild: ['./foo/bar/okay-doc.graphql'], + shouldNotTriggerBuild: ['./foo/bar/never-watch.graphql'], + }); + }); + + test('local negations in documents set should override match in same documents set', async () => { + const mockWatcher = await setupMockWatcher({ + filepath: './foo/some-config.ts', + config: { + schema: './foo/something.ts', + generates: { + ['./foo/some-output.ts']: { + documents: ['./foo/bar/*.graphql', '!./foo/bar/never.graphql'], + }, + }, + }, + }); + + expect(mockWatcher.watchDirectory).toBe(join(process.cwd(), 'foo')); + + await assertBuildTriggers(mockWatcher, { + shouldTriggerBuild: ['./foo/bar/okay.graphql'], + shouldNotTriggerBuild: ['./foo/bar/never.graphql'], + }); + }); + + test('local watchPattern negation should override local schema match', async () => { + const mockWatcher = await setupMockWatcher({ + filepath: './foo/some-config.ts', + config: { + schema: './foo/something.ts', + generates: { + ['./foo/some-output.ts']: { + watchPattern: '!./foo/bar/never-watch.graphql', + schema: ['./foo/bar/*.graphql'], + }, + }, + }, + }); + + expect(mockWatcher.watchDirectory).toBe(join(process.cwd(), 'foo')); + + await assertBuildTriggers(mockWatcher, { + shouldTriggerBuild: ['./foo/bar/okay-doc.graphql'], + shouldNotTriggerBuild: ['./foo/bar/never-watch.graphql'], + }); + }); + + test('local negations in schema set should override match in same schema set', async () => { + const mockWatcher = await setupMockWatcher({ + filepath: './foo/some-config.ts', + config: { + schema: './foo/something.ts', + generates: { + ['./foo/some-output.ts']: { + schema: ['./foo/bar/*.graphql', '!./foo/bar/never.graphql'], + }, + }, + }, + }); + + expect(mockWatcher.watchDirectory).toBe(join(process.cwd(), 'foo')); + + await assertBuildTriggers(mockWatcher, { + shouldTriggerBuild: ['./foo/bar/okay.graphql'], + shouldNotTriggerBuild: ['./foo/bar/never.graphql'], + }); + }); + + test('match in one local group, negated in another group, should still match', async () => { + const mockWatcher = await setupMockWatcher({ + filepath: './foo/some-config.ts', + config: { + schema: './foo/something.ts', + generates: { + // match in one local group, negation in another local group, should still match + ['./foo/alphabet/types-no-sigma.ts']: { + schema: [ + './foo/alphabet/schema/delta.graphql', // delta explicitly included + './foo/alphabet/schema/zeta.graphql', // zeta explicitly included + '!foo/alphabet/schema/sigma.graphql', // sigma explicitly excluded + ], + documents: [ + './foo/alphabet/docs/*.graphql', // zeta implicitly included + '!**/sigma.graphql', // sigma excluded here + ], + }, + // match in one local group, negation in another local group, should still match + ['./foo/alphabet/types-no-zeta.ts']: { + watchPattern: [ + // local watch pattern doesnt take priority over other groups schema + '!./foo/alphabet/schema/delta.graphql', // delta explicitly excluded + ], + schema: [ + './foo/alphabet/schema/sigma.graphql', // sigma explicitly included + '!foo/alphabet/schema/zeta.graphql', // zeta explicitly excluded + ], + documents: [ + './foo/alphabet/docs/sigma.graphql', // sigma explicitly included (should always match) + '!./foo/alphabet/docs/zeta.graphql', // zeta excluded here + ], + }, + }, + }, + }); + + expect(mockWatcher.watchDirectory).toBe(join(process.cwd(), 'foo')); + + await assertBuildTriggers(mockWatcher, { + shouldTriggerBuild: [ + './foo/alphabet/docs/zeta.graphql', + './foo/alphabet/docs/sigma.graphql', + './foo/alphabet/schema/zeta.graphql', + './foo/alphabet/schema/sigma.graphql', + './foo/alphabet/schema/delta.graphql', + ], + }); + }); + + test('output directories with presetConfig create glob patterns ignored by parcel watcher', async () => { + const mockWatcher = await setupMockWatcher({ + filepath: './foo/some-config.ts', + config: { + generates: { + ['./foo/some-preset-bar/']: { + preset: 'near-operation-file', + presetConfig: { + extension: '.generated.tsx', + baseTypesPath: 'types.ts', + }, + documents: ['./foo/some-preset-bar/*.graphql'], + }, + ['./foo/some-preset-without-trailing-slash']: { + // no trailing slash after directory + preset: 'near-operation-file', + presetConfig: { + extension: '.fizzbuzz.tsx', + baseTypesPath: 'types.ts', + }, + documents: ['./foo/some-preset-without-trailing-slash/*.graphql'], + }, + }, + }, + }); + + expect(mockWatcher.watchDirectory).toBe(join(process.cwd(), 'foo')); + + await assertBuildTriggers(mockWatcher, { + // note: since our mock does not implement ParcelWatcher's shouldIgnore logic, + // we can't actually test shouldNotTriggerBuild, because the subscription callback + // will still be called. For that reason, we only check that the globs were passed + // to ParcelWatcher.Options["ignore"] as expected (hence _would_BeIgnoredByParcelWatcher) + shouldNotTriggerBuild: [], + globsWouldBeIgnoredByParcelWatcher: [ + // note: globs are tested for exact match with argument passed to subscribe options, + // so they should be specified relative from watchDirectory, _not_ cwd (see typedoc) + 'some-preset-bar/**/*.generated.tsx', + 'some-preset-without-trailing-slash/**/*.fizzbuzz.tsx', + ], + }); + }); + + test('output files are ignored by parcel watcher, but would not trigger rebuild anyway', async () => { + const mockWatcher = await setupMockWatcher({ + filepath: './foo/some-config.ts', + config: { + generates: { + ['./foo/some-output.ts']: { + documents: ['./foo/bar/*.graphql', '!./foo/bar/never.graphql'], + }, + }, + }, + }); + + expect(mockWatcher.watchDirectory).toBe(join(process.cwd(), 'foo')); + + await assertBuildTriggers(mockWatcher, { + // NOTE: Unlike the test with output _directories_, we can actually assert + // that we wouldn't build output files, even if they were _not_ passed + // to ParcelWatcher.Options[ignore], because we don't include output files + // in our pattern matching (but there is no logic for output directories) + shouldNotTriggerBuild: [ + './foo/some-output.ts', // output file (note: should be ignored by parcel anyway) + ], + pathsWouldBeIgnoredByParcelWatcher: [ + // note: expectations should be relative from cwd; assertion helper converts + // the values received by parcelWatcher to match before testing them (see typedoc) + './foo/some-output.ts', // output file + 'foo/some-output.ts', // output file + ], + }); + }); + + // NOTE: Each individual aspect of this test should be covered by its own isolated test above, + // so if one of those is failing, this should be failing too. This big test was written first, + // and then broken into the individual tests, but we may as well keep it here. + // However, all instances of "foo" have been changed to "fuzz", so that if a test fails, + // ctrl+f for the failing expectation will be easier to find the right place + test('all expectations also work in a big combined config', async () => { + const mockWatcher = await setupMockWatcher({ + filepath: './fuzz/some-config.ts', + config: { + schema: [ + './fuzz/something.ts', + './fuzz/**/match-schema-everywhere.graphql', + '!**/exclude-schema-everywhere.graphql', + ], + watch: ['!**/exclude-watch-everywhere.graphql', 'fuzz/**/match-watch-everywhere.graphql'], + documents: ['fuzz/**/match-doc-everywhere.graphql', '!**/exclude-doc-everywhere.graphql'], + generates: { + // globally inclued paths should be included even when a local pattern negates them + ['./fuzz/local-exclusions-dont-precede-global-inclusions.ts']: { + watchPattern: ['!fuzz/global-beats-local/match-watch-everywhere.graphql'], + documents: ['!fuzz/global-beats-local/match-doc-everywhere.graphql'], + schema: ['!fuzz/global-beats-local/match-schema-everywhere.graphql'], + }, + // globally negated paths should be excluded even when a local pattern matches them + ['./fuzz/local-inclusions-dont-precede-global-exclusions.ts']: { + watchPattern: ['fuzz/global-beats-local/exclude-watch-everywhere.graphql'], + documents: ['fuzz/global-beats-local/exclude-doc-everywhere.graphql'], + schema: ['fuzz/global-beats-local/exclude-schema-everywhere.graphql'], + }, + // local watchPattern negation should override local documents match + ['./fuzz/some-output.ts']: { + watchPattern: '!./fuzz/bar/never-watch.graphql', + documents: ['./fuzz/bar/*.graphql', '!./fuzz/bar/never.graphql'], + }, + // local watchPattern negation should override local schema match + ['./fuzz/some-other-output.ts']: { + documents: './fuzz/some-other-bar/*.graphql', + watchPattern: ['!fuzz/some-other-bar/schemas/never-watch-schema.graphql'], + schema: ['./fuzz/some-other-bar/schemas/*.graphql', '!fuzz/some-other-bar/schemas/never-schema.graphql'], + }, + // match in one local group, negation in another local group, should still match + ['./fuzz/alphabet/types-no-sigma.ts']: { + schema: './fuzz/alphabet/schema/no-sigma.graphql', + documents: [ + './fuzz/alphabet/queries/*.graphql', // zeta implicitly included (should always match) + '!**/sigma.graphql', // sigma excluded here + ], + }, + // match in one local group, negation in another local group, should still match + ['./fuzz/alphabet/types-no-zeta.ts']: { + schema: './fuzz/alphabet/schema/no-sigma.graphql', + documents: [ + './fuzz/alphabet/queries/sigma.graphql', // sigma explicitly included (should always match) + '!./fuzz/alphabet/queries/zeta.graphql', // zeta excluded here + ], + }, + ['./fuzz/some-preset-bar/']: { + preset: 'near-operation-file', + presetConfig: { + extension: '.generated.tsx', + baseTypesPath: 'types.ts', + }, + documents: ['./fuzz/some-preset-bar/*.graphql'], + }, + }, + }, + }); + + expect(mockWatcher.watchDirectory).toBe(join(process.cwd(), 'fuzz')); + + await assertBuildTriggers(mockWatcher, { + shouldTriggerBuild: [ + './fuzz/some-config.ts', // config file + './fuzz/bar/fizzbuzz.graphql', + './fuzz/some-other-bar/schemas/fizzbuzz.graphql', // included by wildcard + // + // match in one local group, negation in another local group, should still match + './fuzz/alphabet/queries/zeta.graphql', // excluded in types-no-zeta, but included in types-no-sigma + './fuzz/alphabet/queries/sigma.graphql', // excluded in types-no-sigma, but included in types-no-sigma + // + // globally inclued paths should be included even when a local pattern negates them + // watch + './fuzz/match-watch-everywhere.graphql', + './fuzz/fizz/match-watch-everywhere.graphql', + './fuzz/fizz/buzz/match-watch-everywhere.graphql', + './fuzz/fizz/buzz/fuzzbarbaz/match-watch-everywhere.graphql', + // doc + './fuzz/match-doc-everywhere.graphql', + './fuzz/fizz/match-doc-everywhere.graphql', + './fuzz/fizz/buzz/match-doc-everywhere.graphql', + './fuzz/fizz/buzz/fuzzbarbaz/match-doc-everywhere.graphql', + // schema + './fuzz/match-schema-everywhere.graphql', + './fuzz/fizz/match-schema-everywhere.graphql', + './fuzz/fizz/buzz/match-schema-everywhere.graphql', + './fuzz/fizz/buzz/fuzzbarbaz/match-schema-everywhere.graphql', + ], + shouldNotTriggerBuild: [ + // + // paths outside of watch directory should be excluded + '.git/index.lock', // totally unrelated + 'match-watch-everywhere.graphql', // would match pattern if under fuzz + // + // pattern matching should work as expected + './fuzz/bar/something.ts', // unrelated file (non-matching extension) + './fuzz/some-other-bar/nested/directory/blah.graphql', // no greedy pattern (**/*) to match + // + // output files should be excluded + './fuzz/some-output.ts', // output file (note: should be ignored by parcel anyway) + // + // locally negated paths should be excluded even when a local pattern matches them + './fuzz/bar/never.graphql', // excluded in same document set + './fuzz/bar/never-watch.graphql', // excluded by local watchPattern, matched by local docs + './fuzz/some-other-bar/schemas/never-schema.graphql', // excluded by local schema group + './fuzz/some-other-bar/schemas/never-watch-schema.graphql', // excluded by local watchPattern group + // + // globally negated paths should be excluded even when a local pattern matches them + './fuzz/alphabet/queries/exclude-watch-everywhere.graphql', // included in types-no-sigma.ts, but globaly excluded + 'fuzz/global-beats-local/exclude-watch-everywhere.graphql', + 'fuzz/global-beats-local/exclude-doc-everywhere.graphql', + 'fuzz/global-beats-local/exclude-schema-everywhere.graphql', + ], + pathsWouldBeIgnoredByParcelWatcher: [ + // note: expectations should be relative from cwd; assertion helper converts + // the values received by parcelWatcher to match before testing them (see typedoc) + './fuzz/some-output.ts', // output file + 'fuzz/some-output.ts', // output file + ], + globsWouldBeIgnoredByParcelWatcher: [ + // note: globs are tested for exact match with argument passed to subscribe options, + // so they should be specified relative from watchDirectory, _not_ cwd (see typedoc) + 'some-preset-bar/**/*.generated.tsx', // output of preset + ], + }); + }); +});