Skip to content

Commit

Permalink
Add makeSyncScenario
Browse files Browse the repository at this point in the history
  • Loading branch information
scott-rc committed Dec 5, 2023
1 parent fd87d0b commit f27ae38
Show file tree
Hide file tree
Showing 6 changed files with 671 additions and 529 deletions.
35 changes: 13 additions & 22 deletions spec/__support__/edit-graphql.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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 = <Data extends JsonObject, Variables extends JsonObject, Extensions extends JsonObject>({
export const nockEditGraphQLResponse = <Query extends GraphQLQuery>({
query,
app = testApp,
...opts
}: {
query: Query<Data, Variables, Extensions>;
expectVariables?: Variables | ((actual: any) => void);
response:
| ExecutionResult<Data, Extensions>
| ((body: { query: Query<Data, Variables, Extensions>; variables?: Variables }) => Promisable<ExecutionResult<Data, Extensions>>);
query: Query;
expectVariables?: Query["Variables"] | ((actual: any) => void);
response: Query["Result"] | ((body: { query: Query; variables?: Query["Variables"] }) => Promisable<Query["Result"]>);
app?: App;
persist?: boolean;
optional?: boolean;
Expand Down Expand Up @@ -66,7 +63,7 @@ export const nockEditGraphQLResponse = <Data extends JsonObject, Variables exten
return true;
}),
})
.parse(rawBody) as { query: Query<Data, Variables, Extensions>; variables?: Variables };
.parse(rawBody) as { query: Query; variables?: Query["Variables"] };

let response;
if (isFunction(opts.response)) {
Expand All @@ -89,24 +86,18 @@ export const nockEditGraphQLResponse = <Data extends JsonObject, Variables exten
return handledRequest;
};

export type MockSubscription<
Data extends JsonObject = JsonObject,
Variables extends JsonObject = JsonObject,
Extensions extends JsonObject = JsonObject,
> = {
variables?: Variables | null;
emitNext(value: ExecutionResult<Data, Extensions>): void;
export type MockSubscription<Query extends GraphQLQuery = GraphQLQuery> = {
variables?: Query["Variables"] | null;
emitResult(value: Query["Result"]): void;
emitError(error: EditGraphQLError): void;
emitComplete(): void;
};

export type MockEditGraphQL = {
expectSubscription<Data extends JsonObject, Variables extends JsonObject>(
query: Query<Data, Variables>,
): MockSubscription<Data, Variables>;
expectSubscription<Query extends GraphQLQuery>(query: Query): MockSubscription<Query>;
};

export const createMockEditGraphQL = (): MockEditGraphQL => {
export const makeMockEditGraphQL = (): MockEditGraphQL => {
const subscriptions = new Map<string, MockSubscription>();

const mockEditGraphQL: MockEditGraphQL = {
Expand All @@ -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,
});
Expand Down
251 changes: 246 additions & 5 deletions spec/__support__/filesync.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -18,7 +38,6 @@ export const makeFile = (options: PartialExcept<File, "path">): File => {
encoding: FileSyncEncoding.Base64,
});

assert(f.encoding);
f.content = Buffer.from(f.content).toString(f.encoding);

return f;
Expand Down Expand Up @@ -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<string, string>;
localFiles: Record<string, string>;
filesVersion1Files: Record<string, string>;
};

export type SyncScenario = {
filesync: FileSync;
expectGadgetChangesSubscription: () => MockSubscription<REMOTE_FILE_SYNC_EVENTS_SUBSCRIPTION>;
emitGadgetChanges: (result: REMOTE_FILE_SYNC_EVENTS_SUBSCRIPTION["Data"]["remoteFileSyncEvents"]) => Promise<void>;

localDir: Directory;
expectLocalDir: () => Assertion<Promise<Files>>;
waitUntilLocalFilesVersion: (filesVersion: bigint) => PromiseSignal;

gadgetDir: Directory;
expectGadgetDir: () => Assertion<Promise<Files>>;
waitUntilGadgetFilesVersion: (filesVersion: bigint) => PromiseSignal;

filesVersionDirs: Map<bigint, Directory>;
expectFilesVersionDirs: () => Assertion<Promise<Record<string, Files>>>;
};

export const makeSyncScenario = async ({
filesVersion1Files,
localFiles,
gadgetFiles,
gadgetFilesVersion = 1n,
}: Partial<SyncScenarioOptions> = {}): Promise<SyncScenario> => {
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<bigint, Directory>();
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<void> => {
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<void> => {
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<string, Files>;
for (const [filesVersion, dir] of filesVersionDirs) {
dirs[String(filesVersion)] = await readDir(dir.path);
}
return dirs;
})(),
),
};
};
2 changes: 1 addition & 1 deletion spec/__support__/sleep.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ export const sleep = (duration: string): Promise<void> => {
return new Promise((resolve) => (milliseconds === 0 ? setImmediate(resolve) : setTimeout(resolve, milliseconds)));
};

export const sleepUntil = async (fn: () => boolean, { interval = "0ms", timeout = timeoutMs("5s") } = {}): Promise<void> => {
export const sleepUntil = async (fn: () => boolean, { interval = "100ms", timeout = timeoutMs("5s") } = {}): Promise<void> => {
const start = isFinite(timeout) && Date.now();

// eslint-disable-next-line no-constant-condition, @typescript-eslint/no-unnecessary-condition
Expand Down
Loading

0 comments on commit f27ae38

Please sign in to comment.