From 306d1c9e22481f96ad70d1f3a62f2d06c2be95d8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Scott=20C=C3=B4t=C3=A9?= Date: Mon, 4 Dec 2023 22:42:48 -0500 Subject: [PATCH] Add makeSyncScenario --- spec/__support__/edit-graphql.ts | 35 +- spec/__support__/filesync.ts | 251 ++++++++- spec/__support__/sleep.ts | 2 +- spec/commands/sync.spec.ts | 838 ++++++++++++++----------------- src/services/app/edit-graphql.ts | 69 +-- src/services/error/error.ts | 5 +- 6 files changed, 671 insertions(+), 529 deletions(-) diff --git a/spec/__support__/edit-graphql.ts b/spec/__support__/edit-graphql.ts index 30326ee5b..f4ce7ef43 100644 --- a/spec/__support__/edit-graphql.ts +++ b/spec/__support__/edit-graphql.ts @@ -1,10 +1,9 @@ -import type { ExecutionResult } from "graphql"; import nock from "nock"; -import type { JsonObject, Promisable } from "type-fest"; +import type { Promisable } from "type-fest"; import { expect, vi } from "vitest"; import { z } from "zod"; import type { App } from "../../src/services/app/app.js"; -import type { Query } from "../../src/services/app/edit-graphql.js"; +import type { GraphQLQuery } from "../../src/services/app/edit-graphql.js"; import { EditGraphQL } from "../../src/services/app/edit-graphql.js"; import { config } from "../../src/services/config/config.js"; import type { EditGraphQLError } from "../../src/services/error/error.js"; @@ -15,16 +14,14 @@ import { PromiseSignal } from "../../src/services/util/promise.js"; import { testApp } from "./app.js"; import { log } from "./debug.js"; -export const nockEditGraphQLResponse = ({ +export const nockEditGraphQLResponse = ({ query, app = testApp, ...opts }: { - query: Query; - expectVariables?: Variables | ((actual: any) => void); - response: - | ExecutionResult - | ((body: { query: Query; variables?: Variables }) => Promisable>); + query: Query; + expectVariables?: Query["Variables"] | ((actual: any) => void); + response: Query["Result"] | ((body: { query: Query; variables?: Query["Variables"] }) => Promisable); app?: App; persist?: boolean; optional?: boolean; @@ -66,7 +63,7 @@ export const nockEditGraphQLResponse = ; variables?: Variables }; + .parse(rawBody) as { query: Query; variables?: Query["Variables"] }; let response; if (isFunction(opts.response)) { @@ -89,24 +86,18 @@ export const nockEditGraphQLResponse = = { - variables?: Variables | null; - emitNext(value: ExecutionResult): void; +export type MockSubscription = { + variables?: Query["Variables"] | null; + emitResult(value: Query["Result"]): void; emitError(error: EditGraphQLError): void; emitComplete(): void; }; export type MockEditGraphQL = { - expectSubscription( - query: Query, - ): MockSubscription; + expectSubscription(query: Query): MockSubscription; }; -export const createMockEditGraphQL = (): MockEditGraphQL => { +export const makeMockEditGraphQL = (): MockEditGraphQL => { const subscriptions = new Map(); const mockEditGraphQL: MockEditGraphQL = { @@ -128,7 +119,7 @@ export const createMockEditGraphQL = (): MockEditGraphQL => { subscriptions.set(options.query, { variables: unthunk(options.variables), - emitNext: options.onResult, + emitResult: options.onResult, emitError: options.onError, emitComplete: options.onComplete, }); diff --git a/spec/__support__/filesync.ts b/spec/__support__/filesync.ts index 69bd6b435..6d319209d 100644 --- a/spec/__support__/filesync.ts +++ b/spec/__support__/filesync.ts @@ -1,12 +1,32 @@ +import fs from "fs-extra"; import assert from "node:assert"; import os from "node:os"; -import { expect } from "vitest"; -import { FileSyncEncoding, type MutationPublishFileSyncEventsArgs } from "../../src/__generated__/graphql.js"; -import { type File, type FileSync } from "../../src/services/filesync/filesync.js"; +import { expect, vi, type Assertion } from "vitest"; +import { z } from "zod"; +import { + FileSyncEncoding, + type FileSyncChangedEventInput, + type FileSyncDeletedEventInput, + type MutationPublishFileSyncEventsArgs, +} from "../../src/__generated__/graphql.js"; +import { + PUBLISH_FILE_SYNC_EVENTS_MUTATION, + REMOTE_FILES_VERSION_QUERY, + REMOTE_FILE_SYNC_EVENTS_SUBSCRIPTION, +} from "../../src/services/app/edit-graphql.js"; +import { Directory } from "../../src/services/filesync/directory.js"; +import { FileSync, isEmptyOrNonExistentDir, type File } from "../../src/services/filesync/filesync.js"; import { isNil } from "../../src/services/util/is.js"; -import { defaults } from "../../src/services/util/object.js"; +import { defaults, omit } from "../../src/services/util/object.js"; +import { PromiseSignal } from "../../src/services/util/promise.js"; import type { PartialExcept } from "../types.js"; +import { testApp } from "./app.js"; +import { log } from "./debug.js"; +import { makeMockEditGraphQL, nockEditGraphQLResponse, type MockSubscription } from "./edit-graphql.js"; +import { readDir, writeDir, type Files } from "./files.js"; import { prettyJSON } from "./json.js"; +import { testDirPath } from "./paths.js"; +import { testUser } from "./user.js"; export const defaultFileMode = os.platform() === "win32" ? 0o100666 : 0o100644; export const defaultDirMode = os.platform() === "win32" ? 0o40666 : 0o40755; @@ -18,7 +38,6 @@ export const makeFile = (options: PartialExcept): File => { encoding: FileSyncEncoding.Base64, }); - assert(f.encoding); f.content = Buffer.from(f.content).toString(f.encoding); return f; @@ -55,3 +74,225 @@ export const expectSyncJson = (filesync: FileSync, expected: PartialSyncJson = { expect(state).toMatchObject(expected); return prettyJSON(state); }; + +export const makeSyncJson = ({ app = testApp.slug, filesVersion = 1n, mtime = Date.now() }: PartialSyncJson = {}): string => { + return prettyJSON({ app, filesVersion: String(filesVersion), mtime }); +}; + +export type SyncScenarioOptions = { + gadgetFilesVersion: bigint; + gadgetFiles: Record; + localFiles: Record; + filesVersion1Files: Record; +}; + +export type SyncScenario = { + filesync: FileSync; + expectGadgetChangesSubscription: () => MockSubscription; + emitGadgetChanges: (result: REMOTE_FILE_SYNC_EVENTS_SUBSCRIPTION["Data"]["remoteFileSyncEvents"]) => Promise; + + localDir: Directory; + expectLocalDir: () => Assertion>; + waitUntilLocalFilesVersion: (filesVersion: bigint) => PromiseSignal; + + gadgetDir: Directory; + expectGadgetDir: () => Assertion>; + waitUntilGadgetFilesVersion: (filesVersion: bigint) => PromiseSignal; + + filesVersionDirs: Map; + expectFilesVersionDirs: () => Assertion>>; +}; + +export const makeSyncScenario = async ({ + filesVersion1Files, + localFiles, + gadgetFiles, + gadgetFilesVersion = 1n, +}: Partial = {}): Promise => { + await writeDir(testDirPath("local"), { ".gadget/sync.json": makeSyncJson(), ...localFiles }); + const localDir = await Directory.init(testDirPath("local")); + + await writeDir(testDirPath("gadget"), { ".gadget/": "", ...gadgetFiles }); + const gadgetDir = await Directory.init(testDirPath("gadget")); + + await writeDir(testDirPath("fv-1"), { ".gadget/": "", ...filesVersion1Files }); + const filesVersion1Dir = await Directory.init(testDirPath("fv-1")); + + const filesVersionDirs = new Map(); + filesVersionDirs.set(1n, filesVersion1Dir); + + if (gadgetFilesVersion === 2n) { + await fs.copy(gadgetDir.path, testDirPath("fv-2")); + filesVersionDirs.set(2n, await Directory.init(testDirPath("fv-2"))); + } + + const processGadgetChanges = async ({ + changed, + deleted, + }: { + changed: FileSyncChangedEventInput[]; + deleted: FileSyncDeletedEventInput[]; + }): Promise => { + for (const file of deleted) { + if (file.path.endsWith("/")) { + // replicate dl and only delete the dir if it's empty + if (await isEmptyOrNonExistentDir(gadgetDir.absolute(file.path))) { + await fs.remove(gadgetDir.absolute(file.path)); + } + } else { + await fs.remove(gadgetDir.absolute(file.path)); + } + } + + for (const file of changed) { + if (file.oldPath) { + await fs.move(gadgetDir.absolute(file.oldPath), gadgetDir.absolute(file.path)); + } else if (file.path.endsWith("/")) { + await fs.ensureDir(gadgetDir.absolute(file.path)); + } else { + await fs.writeFile(gadgetDir.absolute(file.path), file.content, { encoding: file.encoding }); + } + + await fs.chmod(gadgetDir.absolute(file.path), file.mode & 0o777); + } + + gadgetFilesVersion += 1n; + + const newFilesVersionDir = await Directory.init(testDirPath(`fv-${gadgetFilesVersion}`)); + await fs.copy(gadgetDir.path, newFilesVersionDir.path); + filesVersionDirs.set(gadgetFilesVersion, newFilesVersionDir); + }; + + FileSync.init.mockRestore?.(); + const filesync = await FileSync.init({ user: testUser, dir: localDir.path }); + // save to update the mtime and not require a sync + // @ts-expect-error _save is private + await filesync._save(filesync.filesVersion); + vi.spyOn(FileSync, "init").mockResolvedValue(filesync); + + void nockEditGraphQLResponse({ + persist: true, + query: REMOTE_FILES_VERSION_QUERY, + response: () => { + return { + data: { + remoteFilesVersion: String(gadgetFilesVersion), + }, + }; + }, + }); + + void nockEditGraphQLResponse({ + optional: true, + persist: true, + query: PUBLISH_FILE_SYNC_EVENTS_MUTATION, + response: async (body) => { + const { changed, deleted } = z + .object({ + variables: z.object({ + input: z.object({ + expectedRemoteFilesVersion: z.literal(String(gadgetFilesVersion)), + changed: z.array( + z.object({ + path: z.string(), + mode: z.number(), + content: z.string(), + encoding: z.nativeEnum(FileSyncEncoding), + }), + ), + deleted: z.array(z.object({ path: z.string() })), + }), + }), + }) + .refine( + (query) => { + const { changed, deleted } = query.variables.input; + return changed.every((change) => deleted.every((del) => del.path !== change.path)); + }, + { message: "changed and deleted files must not overlap" }, + ) + .parse(body).variables.input; + + await processGadgetChanges({ changed, deleted }); + + return { + data: { + publishFileSyncEvents: { + remoteFilesVersion: String(gadgetFilesVersion), + }, + }, + }; + }, + }); + + const mockEditGraphQL = makeMockEditGraphQL(); + + return { + filesync, + expectGadgetChangesSubscription: () => mockEditGraphQL.expectSubscription(REMOTE_FILE_SYNC_EVENTS_SUBSCRIPTION), + emitGadgetChanges: async (changes) => { + await processGadgetChanges(changes); + mockEditGraphQL.expectSubscription(REMOTE_FILE_SYNC_EVENTS_SUBSCRIPTION).emitResult({ data: { remoteFileSyncEvents: changes } }); + }, + + localDir, + expectLocalDir: (expectedSyncJson?: PartialSyncJson) => + expect( + (async () => { + const dir = await readDir(localDir.path); + expect(dir[".gadget/sync.json"]).toEqual(expectSyncJson(filesync, expectedSyncJson)); + // omit mtime from the snapshot + dir[".gadget/sync.json"] = JSON.stringify(omit(JSON.parse(dir[".gadget/sync.json"]!), ["mtime"])); + return dir; + })(), + ), + waitUntilLocalFilesVersion: (filesVersion) => { + log.trace("waiting for local files version", { filesVersion }); + const signal = new PromiseSignal(); + const localSyncJsonPath = localDir.absolute(".gadget/sync.json"); + + const signalIfFilesVersion = async (): Promise => { + const syncJson = await fs.readJSON(localSyncJsonPath); + if (BigInt(syncJson.filesVersion) === filesVersion) { + log.trace("signaling local files version", { filesVersion }); + signal.resolve(); + } + }; + + fs.watch(localSyncJsonPath, () => void signalIfFilesVersion()); + + return signal; + }, + + gadgetDir, + expectGadgetDir: () => expect(readDir(gadgetDir.path)), + waitUntilGadgetFilesVersion: (filesVersion) => { + log.trace("waiting for gadget files version", { filesVersion }); + const signal = new PromiseSignal(); + + const interval = setInterval(() => signalIfFilesVersion(), 100); + + const signalIfFilesVersion = (): void => { + if (filesVersionDirs.has(filesVersion)) { + log.trace("signaling gadget files version", { filesVersion }); + signal.resolve(); + clearInterval(interval); + } + }; + + return signal; + }, + + filesVersionDirs, + expectFilesVersionDirs: () => + expect( + (async () => { + const dirs = {} as Record; + for (const [filesVersion, dir] of filesVersionDirs) { + dirs[String(filesVersion)] = await readDir(dir.path); + } + return dirs; + })(), + ), + }; +}; diff --git a/spec/__support__/sleep.ts b/spec/__support__/sleep.ts index 0cfe54385..d8f1b58d0 100644 --- a/spec/__support__/sleep.ts +++ b/spec/__support__/sleep.ts @@ -6,7 +6,7 @@ export const sleep = (duration: string): Promise => { return new Promise((resolve) => (milliseconds === 0 ? setImmediate(resolve) : setTimeout(resolve, milliseconds))); }; -export const sleepUntil = async (fn: () => boolean, { interval = "0ms", timeout = timeoutMs("5s") } = {}): Promise => { +export const sleepUntil = async (fn: () => boolean, { interval = "100ms", timeout = timeoutMs("5s") } = {}): Promise => { const start = isFinite(timeout) && Date.now(); // eslint-disable-next-line no-constant-condition, @typescript-eslint/no-unnecessary-condition diff --git a/spec/commands/sync.spec.ts b/spec/commands/sync.spec.ts index 446d1a2b8..c15bec691 100644 --- a/spec/commands/sync.spec.ts +++ b/spec/commands/sync.spec.ts @@ -3,86 +3,49 @@ import fs from "fs-extra"; import ms from "ms"; import nock from "nock"; import notifier from "node-notifier"; -import process from "node:process"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import which from "which"; import { command as sync } from "../../src/commands/sync.js"; -import { - PUBLISH_FILE_SYNC_EVENTS_MUTATION, - REMOTE_FILES_VERSION_QUERY, - REMOTE_FILE_SYNC_EVENTS_SUBSCRIPTION, -} from "../../src/services/app/edit-graphql.js"; +import { REMOTE_FILE_SYNC_EVENTS_SUBSCRIPTION } from "../../src/services/app/edit-graphql.js"; import { Context } from "../../src/services/command/context.js"; import { assetsPath } from "../../src/services/config/paths.js"; import { EditGraphQLError, YarnNotFoundError } from "../../src/services/error/error.js"; -import { FileSync } from "../../src/services/filesync/filesync.js"; import * as prompt from "../../src/services/output/prompt.js"; import { PromiseSignal } from "../../src/services/util/promise.js"; import { nockTestApps, testApp } from "../__support__/app.js"; -import { log } from "../__support__/debug.js"; -import type { MockEditGraphQL } from "../__support__/edit-graphql.js"; -import { createMockEditGraphQL, nockEditGraphQLResponse } from "../__support__/edit-graphql.js"; import { expectReportErrorAndExit } from "../__support__/error.js"; -import { expectDir, writeDir } from "../__support__/files.js"; -import { expectPublishVariables, expectSyncJson, makeDir, makeFile } from "../__support__/filesync.js"; -import { prettyJSON } from "../__support__/json.js"; +import { makeDir, makeFile, makeSyncScenario } from "../__support__/filesync.js"; import { testDirPath } from "../__support__/paths.js"; -import { sleep, sleepUntil } from "../__support__/sleep.js"; +import { sleep } from "../__support__/sleep.js"; import { loginTestUser } from "../__support__/user.js"; describe("sync", () => { - let appDir: string; - let appDirPath: (...segments: string[]) => string; - let filesync: FileSync; - let mockEditGraphQL: MockEditGraphQL; let ctx: Context; beforeEach(() => { loginTestUser(); nockTestApps(); - mockEditGraphQL = createMockEditGraphQL(); - - appDirPath = (...segments: string[]) => testDirPath("app", ...segments); - appDir = appDirPath(); ctx = new Context({ _: [ - appDir, + testDirPath("local"), "--app", testApp.slug, "--file-push-delay", - "10", // default 100ms + ms("10ms" /* default 100ms */), "--file-watch-debounce", - "300", // default 300ms + ms("300ms" /* default 300ms */), "--file-watch-poll-interval", - "30", // default 3_000ms + ms("30ms" /* default 3_000ms */), "--file-watch-poll-timeout", - "20", // default 20_000ms + ms("20ms" /* default 20_000ms */), "--file-watch-rename-timeout", - "50", // default 1_250ms - ], - }); - - const originalInit = FileSync.init; - vi.spyOn(FileSync, "init").mockImplementation(async (args) => { - try { - filesync = await originalInit(args); - return filesync; - } catch (error) { - log.error("failed to initialize filesync", { error }); - process.exit(1); - } + ms("50ms" /* default 1_250ms */), + ].map(String), }); - vi.spyOn(prompt, "confirm").mockImplementation(() => { - log.error("prompt.confirm() should not be called"); - process.exit(1); - }); - - vi.spyOn(prompt, "select").mockImplementation(() => { - log.error("prompt.select() should not be called"); - process.exit(1); - }); + vi.spyOn(prompt, "confirm").mockRejectedValueOnce(new Error("prompt.confirm() should not be called")); + vi.spyOn(prompt, "select").mockRejectedValueOnce(new Error("prompt.select() should not be called")); }); afterEach(() => { @@ -91,223 +54,209 @@ describe("sync", () => { }); it("writes changes from gadget to the local filesystem", async () => { - await sync(ctx); + const { waitUntilLocalFilesVersion, emitGadgetChanges, expectLocalDir } = await makeSyncScenario(); - const gadgetChangesSubscription = mockEditGraphQL.expectSubscription(REMOTE_FILE_SYNC_EVENTS_SUBSCRIPTION); + await sync(ctx); // receive a new file - gadgetChangesSubscription.emitNext({ - data: { - remoteFileSyncEvents: { - remoteFilesVersion: "1", - changed: [makeFile({ path: "file.js", content: "foo" })], - deleted: [], - }, - }, + await emitGadgetChanges({ + remoteFilesVersion: "1", + changed: [makeFile({ path: "file.js", content: "foo" })], + deleted: [], }); - await sleepUntil(() => filesync.filesVersion === 1n); + await waitUntilLocalFilesVersion(1n); - await expectDir(appDir, { - ".gadget/": "", - ".gadget/sync.json": expectSyncJson(filesync), - "file.js": "foo", - }); + await expectLocalDir().resolves.toMatchInlineSnapshot(` + { + ".gadget/": "", + ".gadget/sync.json": "{\\"app\\":\\"test\\",\\"filesVersion\\":\\"1\\"}", + "file.js": "foo", + } + `); // receive an update to a file - gadgetChangesSubscription.emitNext({ - data: { - remoteFileSyncEvents: { - remoteFilesVersion: "2", - changed: [makeFile({ path: "file.js", content: "foo v2" })], - deleted: [], - }, - }, + await emitGadgetChanges({ + remoteFilesVersion: "2", + changed: [makeFile({ path: "file.js", content: "foo v2" })], + deleted: [], }); - await sleepUntil(() => filesync.filesVersion === 2n); + await waitUntilLocalFilesVersion(2n); - await expectDir(appDir, { - ".gadget/": "", - ".gadget/sync.json": expectSyncJson(filesync), - "file.js": "foo v2", - }); + await expectLocalDir().resolves.toMatchInlineSnapshot(` + { + ".gadget/": "", + ".gadget/sync.json": "{\\"app\\":\\"test\\",\\"filesVersion\\":\\"2\\"}", + "file.js": "foo v2", + } + `); // receive a delete to a file - gadgetChangesSubscription.emitNext({ - data: { - remoteFileSyncEvents: { - remoteFilesVersion: "3", - changed: [], - deleted: [{ path: "file.js" }], - }, - }, + await emitGadgetChanges({ + remoteFilesVersion: "3", + changed: [], + deleted: [{ path: "file.js" }], }); - await sleepUntil(() => filesync.filesVersion === 3n); + await waitUntilLocalFilesVersion(3n); - await expectDir(appDir, { - ".gadget/": "", - ".gadget/sync.json": expectSyncJson(filesync), - ".gadget/backup/": "", - ".gadget/backup/file.js": "foo v2", - }); + await expectLocalDir().resolves.toMatchInlineSnapshot(` + { + ".gadget/": "", + ".gadget/backup/": "", + ".gadget/backup/file.js": "foo v2", + ".gadget/sync.json": "{\\"app\\":\\"test\\",\\"filesVersion\\":\\"3\\"}", + } + `); // receive a new directory - gadgetChangesSubscription.emitNext({ - data: { - remoteFileSyncEvents: { - remoteFilesVersion: "4", - changed: [makeDir({ path: "directory/" })], - deleted: [], - }, - }, + await emitGadgetChanges({ + remoteFilesVersion: "4", + changed: [makeDir({ path: "directory/" })], + deleted: [], }); - await sleepUntil(() => filesync.filesVersion === 4n); + await waitUntilLocalFilesVersion(4n); - await expectDir(appDir, { - ".gadget/": "", - ".gadget/sync.json": expectSyncJson(filesync), - ".gadget/backup/": "", - ".gadget/backup/file.js": "foo v2", - "directory/": "", - }); + await expectLocalDir().resolves.toMatchInlineSnapshot(` + { + ".gadget/": "", + ".gadget/backup/": "", + ".gadget/backup/file.js": "foo v2", + ".gadget/sync.json": "{\\"app\\":\\"test\\",\\"filesVersion\\":\\"4\\"}", + "directory/": "", + } + `); // receive a delete to a directory - gadgetChangesSubscription.emitNext({ - data: { - remoteFileSyncEvents: { - remoteFilesVersion: "5", - changed: [], - deleted: [{ path: "directory/" }], - }, - }, + await emitGadgetChanges({ + remoteFilesVersion: "5", + changed: [], + deleted: [{ path: "directory/" }], }); - await sleepUntil(() => filesync.filesVersion === 5n); + await waitUntilLocalFilesVersion(5n); - await expectDir(appDir, { - ".gadget/": "", - ".gadget/sync.json": expectSyncJson(filesync), - ".gadget/backup/": "", - ".gadget/backup/file.js": "foo v2", - ".gadget/backup/directory/": "", - }); + await expectLocalDir().resolves.toMatchInlineSnapshot(` + { + ".gadget/": "", + ".gadget/backup/": "", + ".gadget/backup/directory/": "", + ".gadget/backup/file.js": "foo v2", + ".gadget/sync.json": "{\\"app\\":\\"test\\",\\"filesVersion\\":\\"5\\"}", + } + `); // receive a bunch of files const files = Array.from({ length: 10 }, (_, i) => `file${i + 1}.js`); - gadgetChangesSubscription.emitNext({ - data: { - remoteFileSyncEvents: { - remoteFilesVersion: "6", - changed: files.map((filename) => makeFile({ path: filename, content: filename })), - deleted: [], - }, - }, + await emitGadgetChanges({ + remoteFilesVersion: "6", + changed: files.map((filename) => makeFile({ path: filename, content: filename })), + deleted: [], }); - await sleepUntil(() => filesync.filesVersion === 6n); + await waitUntilLocalFilesVersion(6n); - await expectDir(appDir, { - ".gadget/": "", - ".gadget/sync.json": expectSyncJson(filesync), - ".gadget/backup/": "", - ".gadget/backup/file.js": "foo v2", - ".gadget/backup/directory/": "", - ...files.reduce((acc, filename) => ({ ...acc, [filename]: filename }), {}), - }); + await expectLocalDir().resolves.toMatchInlineSnapshot(` + { + ".gadget/": "", + ".gadget/backup/": "", + ".gadget/backup/directory/": "", + ".gadget/backup/file.js": "foo v2", + ".gadget/sync.json": "{\\"app\\":\\"test\\",\\"filesVersion\\":\\"6\\"}", + "file1.js": "file1.js", + "file10.js": "file10.js", + "file2.js": "file2.js", + "file3.js": "file3.js", + "file4.js": "file4.js", + "file5.js": "file5.js", + "file6.js": "file6.js", + "file7.js": "file7.js", + "file8.js": "file8.js", + "file9.js": "file9.js", + } + `); }); it("writes changes from gadget in the order they were received", async () => { // this test is exactly the same as the previous one, except we just // wait for the final filesVersion and expect the same result - await sync(ctx); + const { waitUntilLocalFilesVersion, emitGadgetChanges, expectLocalDir } = await makeSyncScenario(); - const gadgetChangesSubscription = mockEditGraphQL.expectSubscription(REMOTE_FILE_SYNC_EVENTS_SUBSCRIPTION); + await sync(ctx); // receive a new file - gadgetChangesSubscription.emitNext({ - data: { - remoteFileSyncEvents: { - remoteFilesVersion: "1", - changed: [makeFile({ path: "file.js", content: "foo" })], - deleted: [], - }, - }, + await emitGadgetChanges({ + remoteFilesVersion: "1", + changed: [makeFile({ path: "file.js", content: "foo" })], + deleted: [], }); // receive an update to a file - gadgetChangesSubscription.emitNext({ - data: { - remoteFileSyncEvents: { - remoteFilesVersion: "2", - changed: [makeFile({ path: "file.js", content: "foo v2" })], - deleted: [], - }, - }, + await emitGadgetChanges({ + remoteFilesVersion: "2", + changed: [makeFile({ path: "file.js", content: "foo v2" })], + deleted: [], }); // receive a delete to a file - gadgetChangesSubscription.emitNext({ - data: { - remoteFileSyncEvents: { - remoteFilesVersion: "3", - changed: [], - deleted: [{ path: "file.js" }], - }, - }, + await emitGadgetChanges({ + remoteFilesVersion: "3", + changed: [], + deleted: [{ path: "file.js" }], }); // receive a new directory - gadgetChangesSubscription.emitNext({ - data: { - remoteFileSyncEvents: { - remoteFilesVersion: "4", - changed: [makeDir({ path: "directory/" })], - deleted: [], - }, - }, + await emitGadgetChanges({ + remoteFilesVersion: "4", + changed: [makeDir({ path: "directory/" })], + deleted: [], }); // receive a delete to a directory - gadgetChangesSubscription.emitNext({ - data: { - remoteFileSyncEvents: { - remoteFilesVersion: "5", - changed: [], - deleted: [{ path: "directory/" }], - }, - }, + await emitGadgetChanges({ + remoteFilesVersion: "5", + changed: [], + deleted: [{ path: "directory/" }], }); // receive a bunch of files const files = Array.from({ length: 10 }, (_, i) => `file${i + 1}.js`); - gadgetChangesSubscription.emitNext({ - data: { - remoteFileSyncEvents: { - remoteFilesVersion: "6", - changed: files.map((filename) => makeFile({ path: filename, content: filename })), - deleted: [], - }, - }, + await emitGadgetChanges({ + remoteFilesVersion: "6", + changed: files.map((filename) => makeFile({ path: filename, content: filename })), + deleted: [], }); - await sleepUntil(() => filesync.filesVersion === 6n); + await waitUntilLocalFilesVersion(6n); - await expectDir(appDir, { - ".gadget/": "", - ".gadget/sync.json": expectSyncJson(filesync), - ".gadget/backup/": "", - ".gadget/backup/file.js": "foo v2", - ".gadget/backup/directory/": "", - ...files.reduce((acc, filename) => ({ ...acc, [filename]: filename }), {}), - }); + await expectLocalDir().resolves.toMatchInlineSnapshot(` + { + ".gadget/": "", + ".gadget/backup/": "", + ".gadget/backup/directory/": "", + ".gadget/backup/file.js": "foo v2", + ".gadget/sync.json": "{\\"app\\":\\"test\\",\\"filesVersion\\":\\"6\\"}", + "file1.js": "file1.js", + "file10.js": "file10.js", + "file2.js": "file2.js", + "file3.js": "file3.js", + "file4.js": "file4.js", + "file5.js": "file5.js", + "file6.js": "file6.js", + "file7.js": "file7.js", + "file8.js": "file8.js", + "file9.js": "file9.js", + } + `); }); it("writes all received files before stopping", async () => { // this test is exactly the same as the previous one, except we just // wait for stop() to finish and expect the same result + const { emitGadgetChanges, expectLocalDir } = await makeSyncScenario(); + let stop: (() => Promise) | undefined = undefined; vi.spyOn(ctx.signal, "addEventListener").mockImplementationOnce((_, listener) => { stop = listener as () => Promise; @@ -315,321 +264,286 @@ describe("sync", () => { await sync(ctx); - const gadgetChangesSubscription = mockEditGraphQL.expectSubscription(REMOTE_FILE_SYNC_EVENTS_SUBSCRIPTION); - // receive a new file - gadgetChangesSubscription.emitNext({ - data: { - remoteFileSyncEvents: { - remoteFilesVersion: "1", - changed: [makeFile({ path: "file.js", content: "foo" })], - deleted: [], - }, - }, + await emitGadgetChanges({ + remoteFilesVersion: "1", + changed: [makeFile({ path: "file.js", content: "foo" })], + deleted: [], }); // receive an update to a file - gadgetChangesSubscription.emitNext({ - data: { - remoteFileSyncEvents: { - remoteFilesVersion: "2", - changed: [makeFile({ path: "file.js", content: "foo v2" })], - deleted: [], - }, - }, + await emitGadgetChanges({ + remoteFilesVersion: "2", + changed: [makeFile({ path: "file.js", content: "foo v2" })], + deleted: [], }); // receive a delete to a file - gadgetChangesSubscription.emitNext({ - data: { - remoteFileSyncEvents: { - remoteFilesVersion: "3", - changed: [], - deleted: [{ path: "file.js" }], - }, - }, + await emitGadgetChanges({ + remoteFilesVersion: "3", + changed: [], + deleted: [{ path: "file.js" }], }); // receive a new directory - gadgetChangesSubscription.emitNext({ - data: { - remoteFileSyncEvents: { - remoteFilesVersion: "4", - changed: [makeDir({ path: "directory/" })], - deleted: [], - }, - }, + await emitGadgetChanges({ + remoteFilesVersion: "4", + changed: [makeDir({ path: "directory/" })], + deleted: [], }); // receive a delete to a directory - gadgetChangesSubscription.emitNext({ - data: { - remoteFileSyncEvents: { - remoteFilesVersion: "5", - changed: [], - deleted: [{ path: "directory/" }], - }, - }, + await emitGadgetChanges({ + remoteFilesVersion: "5", + changed: [], + deleted: [{ path: "directory/" }], }); // receive a bunch of files const files = Array.from({ length: 10 }, (_, i) => `file${i + 1}.js`); - gadgetChangesSubscription.emitNext({ - data: { - remoteFileSyncEvents: { - remoteFilesVersion: "6", - changed: files.map((filename) => makeFile({ path: filename, content: filename })), - deleted: [], - }, - }, + await emitGadgetChanges({ + remoteFilesVersion: "6", + changed: files.map((filename) => makeFile({ path: filename, content: filename })), + deleted: [], }); ctx.abort(); await stop!(); - await expectDir(appDir, { - ".gadget/": "", - ".gadget/sync.json": expectSyncJson(filesync), - ".gadget/backup/": "", - ".gadget/backup/file.js": "foo v2", - ".gadget/backup/directory/": "", - ...files.reduce((acc, filename) => ({ ...acc, [filename]: filename }), {}), - }); + await expectLocalDir().resolves.toMatchInlineSnapshot(` + { + ".gadget/": "", + ".gadget/backup/": "", + ".gadget/backup/directory/": "", + ".gadget/backup/file.js": "foo v2", + ".gadget/sync.json": "{\\"app\\":\\"test\\",\\"filesVersion\\":\\"6\\"}", + "file1.js": "file1.js", + "file10.js": "file10.js", + "file2.js": "file2.js", + "file3.js": "file3.js", + "file4.js": "file4.js", + "file5.js": "file5.js", + "file6.js": "file6.js", + "file7.js": "file7.js", + "file8.js": "file8.js", + "file9.js": "file9.js", + } + `); }); it("doesn't write changes from gadget to the local filesystem if the file is ignored", async () => { - void nockEditGraphQLResponse({ query: REMOTE_FILES_VERSION_QUERY, response: { data: { remoteFilesVersion: "0" } } }); - await writeDir(appDir, { - ".gadget/sync.json": prettyJSON({ app: testApp.slug, filesVersion: "0", mtime: Date.now() + ms("1s") }), - ".ignore": "tmp", - "tmp/file.js": "foo", - "tmp/file2.js": "bar", + const { waitUntilLocalFilesVersion, emitGadgetChanges, expectLocalDir } = await makeSyncScenario({ + localFiles: { + ".ignore": "tmp", + "tmp/file.js": "foo", + "tmp/file2.js": "bar", + }, + gadgetFiles: { + ".ignore": "tmp", + "tmp/file.js": "foo", + "tmp/file2.js": "bar", + }, }); await sync(ctx); - const gadgetChangesSubscription = mockEditGraphQL.expectSubscription(REMOTE_FILE_SYNC_EVENTS_SUBSCRIPTION); - - gadgetChangesSubscription.emitNext({ - data: { - remoteFileSyncEvents: { - remoteFilesVersion: "1", - changed: [makeFile({ path: "tmp/file.js", content: "foo changed" })], - deleted: [{ path: "tmp/file2.js" }], - }, - }, + await emitGadgetChanges({ + remoteFilesVersion: "2", + changed: [makeFile({ path: "tmp/file.js", content: "foo changed" })], + deleted: [{ path: "tmp/file2.js" }], }); // it should still update the filesVersion - await sleepUntil(() => filesync.filesVersion === 1n); - - await expectDir(appDir, { - ".gadget/": "", - ".gadget/sync.json": expectSyncJson(filesync), - ".ignore": "tmp", - "tmp/": "", - "tmp/file.js": "foo", - "tmp/file2.js": "bar", - }); + await waitUntilLocalFilesVersion(2n); + + await expectLocalDir().resolves.toMatchInlineSnapshot(` + { + ".gadget/": "", + ".gadget/sync.json": "{\\"app\\":\\"test\\",\\"filesVersion\\":\\"2\\"}", + ".ignore": "tmp", + "tmp/": "", + "tmp/file.js": "foo", + "tmp/file2.js": "bar", + } + `); }); it("sends changes from the local filesystem to gadget", async () => { + const { localDir, waitUntilGadgetFilesVersion, expectGadgetDir } = await makeSyncScenario(); + await sync(ctx); // add a file - let published = nockEditGraphQLResponse({ - query: PUBLISH_FILE_SYNC_EVENTS_MUTATION, - response: { data: { publishFileSyncEvents: { remoteFilesVersion: "1" } } }, - expectVariables: expectPublishVariables({ - input: { - expectedRemoteFilesVersion: "0", - changed: [makeFile({ path: "file.js", content: "foo" })], - deleted: [], - }, - }), - }); - - await fs.outputFile(appDirPath("file.js"), "foo"); - await published; + await fs.outputFile(localDir.absolute("file.js"), "foo"); + await waitUntilGadgetFilesVersion(2n); + await expectGadgetDir().resolves.toMatchInlineSnapshot(` + { + ".gadget/": "", + "file.js": "foo", + } + `); // update a file - published = nockEditGraphQLResponse({ - query: PUBLISH_FILE_SYNC_EVENTS_MUTATION, - response: { data: { publishFileSyncEvents: { remoteFilesVersion: "2" } } }, - expectVariables: expectPublishVariables({ - input: { - expectedRemoteFilesVersion: "1", - changed: [makeFile({ path: "file.js", content: "foo v2" })], - deleted: [], - }, - }), - }); - - await fs.outputFile(appDirPath("file.js"), "foo v2"); - await published; + await fs.outputFile(localDir.absolute("file.js"), "foo v2"); + await waitUntilGadgetFilesVersion(3n); + await expectGadgetDir().resolves.toMatchInlineSnapshot(` + { + ".gadget/": "", + "file.js": "foo v2", + } + `); // move a file - published = nockEditGraphQLResponse({ - query: PUBLISH_FILE_SYNC_EVENTS_MUTATION, - response: { data: { publishFileSyncEvents: { remoteFilesVersion: "3" } } }, - expectVariables: expectPublishVariables({ - input: { - expectedRemoteFilesVersion: "2", - changed: [makeFile({ oldPath: "file.js", path: "renamed-file.js", content: "foo v2" })], - deleted: [], - }, - }), - }); - - await fs.move(appDirPath("file.js"), appDirPath("renamed-file.js")); - await published; + await fs.move(localDir.absolute("file.js"), localDir.absolute("renamed-file.js")); + await waitUntilGadgetFilesVersion(4n); + await expectGadgetDir().resolves.toMatchInlineSnapshot(` + { + ".gadget/": "", + "file.js": "foo v2", + "renamed-file.js": "foo v2", + } + `); // delete a file - published = nockEditGraphQLResponse({ - query: PUBLISH_FILE_SYNC_EVENTS_MUTATION, - response: { data: { publishFileSyncEvents: { remoteFilesVersion: "4" } } }, - expectVariables: expectPublishVariables({ - input: { - expectedRemoteFilesVersion: "3", - changed: [], - deleted: [{ path: "renamed-file.js" }], - }, - }), - }); - - await fs.remove(appDirPath("renamed-file.js")); - await published; + await fs.remove(localDir.absolute("renamed-file.js")); + await waitUntilGadgetFilesVersion(5n); + await expectGadgetDir().resolves.toMatchInlineSnapshot(` + { + ".gadget/": "", + "file.js": "foo v2", + } + `); // add a directory - published = nockEditGraphQLResponse({ - query: PUBLISH_FILE_SYNC_EVENTS_MUTATION, - response: { data: { publishFileSyncEvents: { remoteFilesVersion: "5" } } }, - expectVariables: expectPublishVariables({ - input: { - expectedRemoteFilesVersion: "4", - changed: [makeDir({ path: "directory/" })], - deleted: [], - }, - }), - }); - - await fs.mkdir(appDirPath("directory")); - await published; + await fs.mkdir(localDir.absolute("directory")); + await waitUntilGadgetFilesVersion(6n); + await expectGadgetDir().resolves.toMatchInlineSnapshot(` + { + ".gadget/": "", + "directory/": "", + "file.js": "foo v2", + } + `); // rename a directory - published = nockEditGraphQLResponse({ - query: PUBLISH_FILE_SYNC_EVENTS_MUTATION, - response: { data: { publishFileSyncEvents: { remoteFilesVersion: "6" } } }, - expectVariables: expectPublishVariables({ - input: { - expectedRemoteFilesVersion: "5", - changed: [makeDir({ oldPath: "directory/", path: "renamed-directory/" })], - deleted: [], - }, - }), - }); - - await fs.move(appDirPath("directory"), appDirPath("renamed-directory")); - await published; + await fs.move(localDir.absolute("directory"), localDir.absolute("renamed-directory")); + await waitUntilGadgetFilesVersion(7n); + await expectGadgetDir().resolves.toMatchInlineSnapshot(` + { + ".gadget/": "", + "directory/": "", + "file.js": "foo v2", + "renamed-directory/": "", + } + `); // delete a directory - published = nockEditGraphQLResponse({ - query: PUBLISH_FILE_SYNC_EVENTS_MUTATION, - response: { data: { publishFileSyncEvents: { remoteFilesVersion: "7" } } }, - expectVariables: expectPublishVariables({ - input: { - expectedRemoteFilesVersion: "6", - changed: [], - deleted: [{ path: "renamed-directory/" }], - }, - }), - }); - - await fs.remove(appDirPath("renamed-directory")); - await published; + await fs.remove(localDir.absolute("renamed-directory")); + await waitUntilGadgetFilesVersion(8n); + await expectGadgetDir().resolves.toMatchInlineSnapshot(` + { + ".gadget/": "", + "directory/": "", + "file.js": "foo v2", + } + `); // add a bunch of files const files = Array.from({ length: 10 }, (_, i) => `file${i + 1}.js`); - published = nockEditGraphQLResponse({ - query: PUBLISH_FILE_SYNC_EVENTS_MUTATION, - response: { data: { publishFileSyncEvents: { remoteFilesVersion: "8" } } }, - expectVariables: expectPublishVariables({ - input: { - expectedRemoteFilesVersion: "7", - changed: files.map((filename) => makeFile({ path: filename, content: filename })), - deleted: [], - }, - }), - }); // sleep a bit between each one to simulate a slow filesystem for (const filename of files) { - await fs.outputFile(appDirPath(filename), filename); + await fs.outputFile(localDir.absolute(filename), filename); await sleep("5ms"); } - await published; + await waitUntilGadgetFilesVersion(9n); + await expectGadgetDir().resolves.toMatchInlineSnapshot(` + { + ".gadget/": "", + "directory/": "", + "file.js": "foo v2", + "file1.js": "file1.js", + "file10.js": "file10.js", + "file2.js": "file2.js", + "file3.js": "file3.js", + "file4.js": "file4.js", + "file5.js": "file5.js", + "file6.js": "file6.js", + "file7.js": "file7.js", + "file8.js": "file8.js", + "file9.js": "file9.js", + } + `); }); it("doesn't send multiple changes to the same file at once", async () => { - await sync(ctx); + const { localDir, waitUntilGadgetFilesVersion, expectGadgetDir } = await makeSyncScenario(); - // add a file - const published = nockEditGraphQLResponse({ - query: PUBLISH_FILE_SYNC_EVENTS_MUTATION, - response: { data: { publishFileSyncEvents: { remoteFilesVersion: "1" } } }, - expectVariables: expectPublishVariables({ - input: { - expectedRemoteFilesVersion: "0", - changed: [makeFile({ path: "file.js", content: "v10" })], - deleted: [], - }, - }), - }); + await sync(ctx); + // update a file 10 times for (let i = 0; i < 10; i++) { - await fs.outputFile(appDirPath("file.js"), `v${i + 1}`); + await fs.outputFile(localDir.absolute("file.js"), `v${i + 1}`); } - await published; + await waitUntilGadgetFilesVersion(2n); + await expectGadgetDir().resolves.toMatchInlineSnapshot(` + { + ".gadget/": "", + "file.js": "v10", + } + `); }); it("doesn't send changes from the local filesystem to gadget if the file is ignored", async () => { - void nockEditGraphQLResponse({ query: REMOTE_FILES_VERSION_QUERY, response: { data: { remoteFilesVersion: "0" } } }); - await writeDir(appDir, { - ".gadget/sync.json": prettyJSON({ app: testApp.slug, filesVersion: "0", mtime: Date.now() + ms("1s") }), - ".ignore": "tmp", + const { localDir, expectGadgetDir } = await makeSyncScenario({ + localFiles: { + ".ignore": "tmp", + }, }); await sync(ctx); - vi.spyOn(filesync, "sendChangesToGadget"); - // add a file - await fs.outputFile(appDirPath("tmp/file.js"), "foo"); + await fs.outputFile(localDir.absolute("tmp/file.js"), "foo"); + // update a file - await fs.outputFile(appDirPath("tmp/file.js"), "foo v2"); + await fs.outputFile(localDir.absolute("tmp/file.js"), "foo v2"); + // move a file - await fs.move(appDirPath("tmp/file.js"), appDirPath("tmp/renamed-file.js")); + await fs.move(localDir.absolute("tmp/file.js"), localDir.absolute("tmp/renamed-file.js")); + // delete a file - await fs.remove(appDirPath("tmp/renamed-file.js")); + await fs.remove(localDir.absolute("tmp/renamed-file.js")); + // add a directory - await fs.mkdir(appDirPath("tmp/directory")); + await fs.mkdir(localDir.absolute("tmp/directory")); + // rename a directory - await fs.move(appDirPath("tmp/directory"), appDirPath("tmp/renamed-directory")); + await fs.move(localDir.absolute("tmp/directory"), localDir.absolute("tmp/renamed-directory")); + // delete a directory - await fs.remove(appDirPath("tmp/renamed-directory")); + await fs.remove(localDir.absolute("tmp/renamed-directory")); + // add a bunch of files const files = Array.from({ length: 10 }, (_, i) => `file${i + 1}.js`); for (const filename of files) { - await fs.outputFile(appDirPath(`tmp/${filename}`), filename); + await fs.outputFile(localDir.absolute(`tmp/${filename}`), filename); } - await sleep("1s"); - expect(filesync.sendChangesToGadget).not.toHaveBeenCalled(); + await sleep("2s"); + + await expectGadgetDir().resolves.toMatchInlineSnapshot(` + { + ".gadget/": "", + } + `); }); it("runs `yarn install --check-files` when yarn.lock changes", async () => { + const { filesync, localDir, emitGadgetChanges } = await makeSyncScenario(); + const execaCalled = new PromiseSignal(); execa.mockImplementationOnce(() => { execaCalled.resolve(); @@ -638,69 +552,56 @@ describe("sync", () => { await sync(ctx); - const gadgetChangesSubscription = mockEditGraphQL.expectSubscription(REMOTE_FILE_SYNC_EVENTS_SUBSCRIPTION); - - gadgetChangesSubscription.emitNext({ - data: { - remoteFileSyncEvents: { - remoteFilesVersion: "1", - changed: [makeFile({ path: "yarn.lock", content: "# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY." })], - deleted: [], - }, - }, + await emitGadgetChanges({ + remoteFilesVersion: "1", + changed: [makeFile({ path: "yarn.lock", content: "# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY." })], + deleted: [], }); await execaCalled; expect(filesync.filesVersion).toBe(1n); - expect(execa.mock.lastCall).toEqual(["yarn", ["install", "--check-files"], { cwd: appDir }]); + expect(execa.mock.lastCall).toEqual(["yarn", ["install", "--check-files"], { cwd: localDir.path }]); }); it("reloads the ignore file when .ignore changes", async () => { + const { filesync, waitUntilLocalFilesVersion, localDir, expectGadgetDir, waitUntilGadgetFilesVersion, emitGadgetChanges } = + await makeSyncScenario(); + await sync(ctx); vi.spyOn(filesync.directory, "loadIgnoreFile"); - const published = nockEditGraphQLResponse({ - query: PUBLISH_FILE_SYNC_EVENTS_MUTATION, - response: { data: { publishFileSyncEvents: { remoteFilesVersion: "1" } } }, - expectVariables: expectPublishVariables({ - input: { - expectedRemoteFilesVersion: "0", - changed: [makeFile({ path: ".ignore", content: "# watch it all" })], - deleted: [], - }, - }), - }); - - await fs.outputFile(appDirPath(".ignore"), "# watch it all"); - await published; + await fs.outputFile(localDir.absolute(".ignore"), "# watch it all"); + await waitUntilGadgetFilesVersion(2n); + await expectGadgetDir().resolves.toMatchInlineSnapshot(` + { + ".gadget/": "", + ".ignore": "# watch it all", + } + `); expect(filesync.directory.loadIgnoreFile).toHaveBeenCalledTimes(1); - const gadgetChangesSubscription = mockEditGraphQL.expectSubscription(REMOTE_FILE_SYNC_EVENTS_SUBSCRIPTION); - - gadgetChangesSubscription.emitNext({ - data: { - remoteFileSyncEvents: { - remoteFilesVersion: "2", - changed: [makeFile({ path: ".ignore", content: "tmp" })], - deleted: [], - }, - }, + await emitGadgetChanges({ + remoteFilesVersion: "3", + changed: [makeFile({ path: ".ignore", content: "tmp" })], + deleted: [], }); - await sleepUntil(() => filesync.filesVersion === 2n); + await waitUntilLocalFilesVersion(3n); expect(filesync.directory.loadIgnoreFile).toHaveBeenCalledTimes(2); }); it("notifies the user when an error occurs", async () => { + const { expectGadgetChangesSubscription } = await makeSyncScenario(); + await sync(ctx); - const error = new EditGraphQLError("", "test"); + const error = new EditGraphQLError(REMOTE_FILE_SYNC_EVENTS_SUBSCRIPTION, "test"); - const gadgetChangesSubscription = mockEditGraphQL.expectSubscription(REMOTE_FILE_SYNC_EVENTS_SUBSCRIPTION); + const gadgetChangesSubscription = expectGadgetChangesSubscription(); gadgetChangesSubscription.emitError(error); await expectReportErrorAndExit(error); @@ -720,15 +621,14 @@ describe("sync", () => { }); it("throws YarnNotFoundError if yarn is not found", async () => { + await makeSyncScenario(); which.sync.mockReturnValue(undefined); - await expect(sync(ctx)).rejects.toThrow(YarnNotFoundError); }); it("does not throw YarnNotFoundError if yarn is found", async () => { + await makeSyncScenario(); which.sync.mockReturnValue("/path/to/yarn"); await sync(ctx); }); - - it.todo("sends all changes before stopping"); }); diff --git a/src/services/app/edit-graphql.ts b/src/services/app/edit-graphql.ts index aedf09208..ad89dfc30 100644 --- a/src/services/app/edit-graphql.ts +++ b/src/services/app/edit-graphql.ts @@ -123,13 +123,13 @@ export class EditGraphQL { * @param sink The callbacks to invoke when the server responds. * @returns A function to unsubscribe from the subscription. */ - subscribe({ + subscribe({ onData, ...options }: { - query: Query; - variables?: Thunk | null; - onData: (data: Data) => void; + query: Query; + variables?: Thunk | null; + onData: (data: Query["Data"]) => void; onError: (error: EditGraphQLError) => void; onComplete?: () => void; }): () => void { @@ -161,16 +161,19 @@ export class EditGraphQL { * @param payload The query and variables to send to the server. * @returns The data returned by the server. */ - async query(payload: { - query: Query; - variables?: Thunk | null; - }): Promise { - const result = await this._query(payload); + async query({ + query, + variables, + }: { + query: Query; + variables?: Thunk | null; + }): Promise { + const result = await this._query({ query, variables }); if (result.errors) { - throw new EditGraphQLError(payload.query, result.errors); + throw new EditGraphQLError(query, result.errors); } if (!result.data) { - throw new EditGraphQLError(payload.query, "We received a response without data"); + throw new EditGraphQLError(query, "We received a response without data"); } return result.data; } @@ -188,16 +191,16 @@ export class EditGraphQL { * This method is only exposed for testing and shouldn't be used * directly. */ - _subscribe({ + _subscribe({ query, variables, onResult, onError, onComplete = noop, }: { - query: Query; - variables?: Thunk | null; - onResult: (result: ExecutionResult) => void; + query: Query; + variables?: Thunk | null; + onResult: (result: ExecutionResult) => void; onError: (error: EditGraphQLError) => void; onComplete?: () => void; }): () => void { @@ -212,7 +215,7 @@ export class EditGraphQL { }); log.info("sending graphql query via ws", { type, operation }, { variables: payload.variables }); - const unsubscribe = this._client.subscribe(payload, { + const unsubscribe = this._client.subscribe(payload, { next: (result) => onResult(result), error: (error) => onError(new EditGraphQLError(query, error)), complete: onComplete, @@ -224,10 +227,10 @@ export class EditGraphQL { }; } - private async _query(input: { - query: Query; - variables?: Thunk | null; - }): Promise> { + private async _query(input: { + query: Query; + variables?: Thunk | null; + }): Promise> { const cookie = loadCookie(); assert(cookie, "missing cookie when connecting to GraphQL API"); @@ -249,18 +252,20 @@ export class EditGraphQL { resolveBodyOnly: true, }); - return json as ExecutionResult; + return json as Query["Result"]; } } -export type Query< - Data extends JsonObject, +export type GraphQLQuery< + Data extends JsonObject = JsonObject, Variables extends JsonObject = JsonObject, Extensions extends JsonObject = JsonObject, + Result extends ExecutionResult = ExecutionResult, > = string & { - __TData?: Data; - __TVariables?: Variables; - __TExtensions?: Extensions; + Data: Data; + Variables: Variables; + Extensions: Extensions; + Result: Result; }; export const REMOTE_FILE_SYNC_EVENTS_SUBSCRIPTION = dedent(/* GraphQL */ ` @@ -278,13 +283,17 @@ export const REMOTE_FILE_SYNC_EVENTS_SUBSCRIPTION = dedent(/* GraphQL */ ` } } } -`) as Query; +`) as GraphQLQuery; + +export type REMOTE_FILE_SYNC_EVENTS_SUBSCRIPTION = typeof REMOTE_FILE_SYNC_EVENTS_SUBSCRIPTION; export const REMOTE_FILES_VERSION_QUERY = dedent(/* GraphQL */ ` query RemoteFilesVersion { remoteFilesVersion } -`) as Query; +`) as GraphQLQuery; + +export type REMOTE_FILES_VERSION_QUERY = typeof REMOTE_FILES_VERSION_QUERY; export const PUBLISH_FILE_SYNC_EVENTS_MUTATION = dedent(/* GraphQL */ ` mutation PublishFileSyncEvents($input: PublishFileSyncEventsInput!) { @@ -292,4 +301,6 @@ export const PUBLISH_FILE_SYNC_EVENTS_MUTATION = dedent(/* GraphQL */ ` remoteFilesVersion } } -`) as Query; +`) as GraphQLQuery; + +export type PUBLISH_FILE_SYNC_EVENTS_MUTATION = typeof PUBLISH_FILE_SYNC_EVENTS_MUTATION; diff --git a/src/services/error/error.ts b/src/services/error/error.ts index e6c12b3c8..9e182c3c2 100644 --- a/src/services/error/error.ts +++ b/src/services/error/error.ts @@ -4,9 +4,8 @@ import type { GraphQLError } from "graphql"; import assert from "node:assert"; import { randomUUID } from "node:crypto"; import { dedent } from "ts-dedent"; -import type { JsonObject } from "type-fest"; import type { CloseEvent, ErrorEvent } from "ws"; -import type { Query } from "../app/edit-graphql.js"; +import type { GraphQLQuery } from "../app/edit-graphql.js"; import { env } from "../config/env.js"; import { sprintln } from "../output/sprint.js"; import { compact, uniq } from "../util/collection.js"; @@ -124,7 +123,7 @@ export class EditGraphQLError extends CLIError { override cause: string | Error | readonly GraphQLError[] | CloseEvent | ErrorEvent; constructor( - readonly query: Query, + readonly query: GraphQLQuery, cause: unknown, ) { super("GGT_CLI_CLIENT_ERROR", "An error occurred while communicating with Gadget");