Skip to content

Commit

Permalink
Merge pull request #19 from get-convex/sshader-add-tests
Browse files Browse the repository at this point in the history
Add tests using a local backend
  • Loading branch information
sshader committed Mar 12, 2024
2 parents d7fb7e1 + 548cfd7 commit 65d733a
Show file tree
Hide file tree
Showing 8 changed files with 5,914 additions and 692 deletions.
34 changes: 34 additions & 0 deletions Justfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
set fallback := true
set shell := ["bash", "-uc"]
set windows-shell := ["sh", "-uc"]

# `just --list` (or just `just`) will print all the recipes in
# the current Justfile. `just RECIPE` will run the macro/job.
#
# In several places there are recipes for running common scripts or commands.
# Instead of `Makefile`s, Convex uses Justfiles, which are similar, but avoid
# several footguns associated with Makefiles, since using make as a macro runner
# can sometimes conflict with Makefiles desire to have some rudimentary
# understanding of build artifacts and associated dependencies.
#
# Read up on just here: https://github.com/casey/just

_default:
@just --list

set positional-arguments

reset-local-backend:
cd $CONVEX_LOCAL_BACKEND_PATH; rm -rf convex_local_storage && rm -f convex_local_backend.sqlite3

# (*) Run the open source convex backend on port 3210
run-local-backend *ARGS:
cd $CONVEX_LOCAL_BACKEND_PATH && just run-local-backend --port 3210

# Taken from https://github.com/get-convex/convex-backend/blob/main/Justfile
# (*) Run convex CLI commands like `convex dev` against local backend from `just run-local-backend`.
# This uses the default admin key for local backends, which is safe as long as the backend is
# running locally.
convex *ARGS:
npx convex "$@" --admin-key 0135d8598650f8f5cb0f30c34ec2e2bb62793bc28717c8eb6fb577996d50be5f4281b59181095065c5d0f86a2c31ddbe9b597ec62b47ded69782cd --url "http://127.0.0.1:3210"

98 changes: 98 additions & 0 deletions backendHarness.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
// Taken from https://github.com/get-convex/convex-helpers/blob/main/backendHarness.js

const http = require("http");
const { spawn, exec, execSync } = require("child_process");

// Run a command against a fresh local backend, handling setting up and tearing down the backend.

// Checks for a local backend running on port 3210.
const parsedUrl = new URL("http://127.0.0.1:3210");

async function isBackendRunning(backendUrl) {
return new Promise ((resolve) => {
http
.request(
{
hostname: backendUrl.hostname,
port: backendUrl.port,
path: "/version",
method: "GET",
},
(res) => {
resolve(res.statusCode === 200)
}
)
.on("error", () => { resolve(false) })
.end();
})
}

function sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}

const waitForLocalBackendRunning = async (backendUrl) => {
let isRunning = await isBackendRunning(backendUrl);
let i = 0
while (!isRunning) {
if (i % 10 === 0) {
// Progress messages every ~5 seconds
console.log("Waiting for backend to be running...")
}
await sleep(500);
isRunning = await isBackendRunning(backendUrl);
i += 1
}
return

}

let backendProcess = null

function cleanup() {
if (backendProcess !== null) {
console.log("Cleaning up running backend")
backendProcess.kill("SIGTERM")
execSync("just reset-local-backend")
}
}

async function runWithLocalBackend(command, backendUrl) {
if (process.env.CONVEX_LOCAL_BACKEND_PATH === undefined) {
console.error("Please set environment variable CONVEX_LOCAL_BACKEND_PATH first")
process.exit(1)
}
const isRunning = await isBackendRunning(backendUrl);
if (isRunning) {
console.error("Looks like local backend is already running. Cancel it and restart this command.")
process.exit(1)
}
execSync("just reset-local-backend")
backendProcess = exec("CONVEX_TRACE_FILE=1 just run-local-backend")
await waitForLocalBackendRunning(backendUrl)
console.log("Backend running! Logs can be found in $CONVEX_LOCAL_BACKEND_PATH/convex-local-backend.log")
const innerCommand = new Promise((resolve) => {
const c = spawn(command, { shell: true, stdio: "pipe", env: {...process.env, FORCE_COLOR: true } })
c.stdout.on('data', (data) => {
process.stdout.write(data);
})

c.stderr.on('data', (data) => {
process.stderr.write(data);
})

c.on('exit', (code) => {
console.log('inner command exited with code ' + code.toString())
resolve(code)
})
});
return innerCommand;
}

runWithLocalBackend(process.argv[2], parsedUrl).then((code) => {
cleanup()
process.exit(code)
}).catch(() => {
cleanup()
process.exit(1)
})
2 changes: 2 additions & 0 deletions convex/_generated/api.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import type * as engine from "../engine.js";
import type * as games from "../games.js";
import type * as lib_openai from "../lib/openai.js";
import type * as search from "../search.js";
import type * as testing from "../testing.js";
import type * as users from "../users.js";
import type * as utils from "../utils.js";

Expand All @@ -34,6 +35,7 @@ declare const fullApi: ApiFromModules<{
games: typeof games;
"lib/openai": typeof lib_openai;
search: typeof search;
testing: typeof testing;
users: typeof users;
utils: typeof utils;
}>;
Expand Down
69 changes: 69 additions & 0 deletions convex/testing.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import {
customAction,
customMutation,
customQuery,
} from "convex-helpers/server/customFunctions";
import { action, mutation, query } from "./_generated/server";
import schema from "./schema";
import { WithoutSystemFields } from "convex/server";
import { Doc } from "./_generated/dataModel";
import { getOrCreateUser } from "./users";

// Wrappers to use for function that should only be called from tests
export const testingQuery = customQuery(query, {
args: {},
input: async (_ctx, _args) => {
if (process.env.IS_TEST === undefined) {
throw new Error(
"Calling a test only function in an unexpected environment"
);
}
return { ctx: {}, args: {} };
},
});

export const testingMutation = customMutation(mutation, {
args: {},
input: async (_ctx, _args) => {
if (process.env.IS_TEST === undefined) {
throw new Error(
"Calling a test only function in an unexpected environment"
);
}
return { ctx: {}, args: {} };
},
});

export const testingAction = customAction(action, {
args: {},
input: async (_ctx, _args) => {
if (process.env.IS_TEST === undefined) {
throw new Error(
"Calling a test only function in an unexpected environment"
);
}
return { ctx: {}, args: {} };
},
});


export const clearAll = testingMutation(async ({ db, scheduler, storage }) => {
for (const table of Object.keys(schema.tables)) {
const docs = await db.query(table as any).collect();
await Promise.all(docs.map((doc) => db.delete(doc._id)));
}
const scheduled = await db.system.query("_scheduled_functions").collect();
await Promise.all(scheduled.map((s) => scheduler.cancel(s._id)));
const storedFiles = await db.system.query("_storage").collect();
await Promise.all(storedFiles.map((s) => storage.delete(s._id)));
});

export const setupGame = testingMutation(
async ({ db }, args: WithoutSystemFields<Doc<"games">>) => {
return db.insert("games", args);
}
);

export const setupUser = testingMutation(async ({ db, auth }) => {
return getOrCreateUser(db, auth);
});
81 changes: 81 additions & 0 deletions games.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import { api } from "./convex/_generated/api";
import { ConvexTestingHelper } from "convex-helpers/testing";

describe("games", () => {
let t: ConvexTestingHelper;

beforeEach(() => {
t = new ConvexTestingHelper();
});

afterEach(async () => {
await t.mutation(api.testing.clearAll, {});
await t.close();
});

test("two players can join game", async () => {
const sarahIdentity = t.newIdentity({ name: "Sarah" });
const asSarah = t.withIdentity(sarahIdentity);

const leeIdentity = t.newIdentity({ name: "Lee" });
const asLee = t.withIdentity(leeIdentity);

const gameId = await asSarah.mutation(api.games.newGame, {
player1: "Me",
player2: null,
});

let game = await t.query(api.games.get, { id: gameId });
expect(game.player1Name).toEqual("Sarah");

await asLee.mutation(api.games.joinGame, { id: gameId });
game = await t.query(api.games.get, { id: gameId });
expect(game.player2Name).toEqual("Lee");

await asSarah.mutation(api.games.move, { gameId, from: "c2", to: "c3" });
game = await t.query(api.games.get, { id: gameId });
expect(game.pgn).toEqual("1. c3");

// Invalid move -- out of turn
expect(() =>
asSarah.mutation(api.games.move, { gameId, from: "d2", to: "d3" })
).rejects.toThrow(/invalid move d2-d3/);
game = await t.query(api.games.get, { id: gameId });
expect(game.pgn).toEqual("1. c3");
});

test("game finishes", async () => {
// Set up data using test only functions
const sarahIdentity = t.newIdentity({ name: "Sarah" });
const asSarah = t.withIdentity(sarahIdentity);
const sarahId = await asSarah.mutation(api.testing.setupUser, {});

const leeIdentity = t.newIdentity({ name: "Lee" });
const asLee = t.withIdentity(leeIdentity);
const leeId = await asLee.mutation(api.testing.setupUser, {});

// Two moves before the end of the game
const gameAlmostFinishedPgn =
"1. Nf3 Nf6 2. d4 Nc6 3. e4 Nxe4 4. Bd3 Nf6 5. Nc3 Nxd4 6. Nxd4 b6 7. O-O Bb7 8. g3 Qb8 9. Be3 c5 10. Nf5 a6 11. f3 b5 12. Bxc5 d6 13. Bd4 b4 14. Bxf6 gxf6 15. Ne2 e6 16. Nfd4 e5 17. Nf5 Qc8 18. Ne3 d5 19. Re1 d4 20. Nf1 Bh6 21. Nxd4 O-O 22. Ne2 f5 23. a3 bxa3 24. bxa3 Re8 25. Qb1 e4 26. fxe4 fxe4 27. Bxe4 Rxe4 28. Ne3 Rxe3 29. c3 Be4 30. Qb2 Bg7 31. g4 Bxc3 32. Nxc3 Rxc3 33. Rxe4 Kg7 34. g5 Kg6 35. Re7 Rc7 36. Qf6+ Kh5 37. Re5 Rb8 38. Rae1 Rc6 39. Qxf7+ Kg4 40. Qxh7 Rb7 41. Qd3 Rbc7 42. Rd1 Rc3 43. Qd4+ Kh5 44. a4 R7c4 45. g6+ Kxg6 46. Qd6+ Kf7 47. Re7+ Kf8 48. Rc7+";

const gameId = await t.mutation(api.testing.setupGame, {
player1: sarahId,
player2: leeId,
pgn: gameAlmostFinishedPgn,
finished: false,
});

// Test that winning the game marks the game as finished
await asLee.mutation(api.games.move, { gameId, from: "f8", to: "e8" });
let game = await t.query(api.games.get, { id: gameId });
let ongoingGames = await t.query(api.games.ongoingGames, {})
expect(game.finished).toBe(false);
expect(ongoingGames.length).toStrictEqual(1);

await asSarah.mutation(api.games.move, { gameId, from: "d6", to: "e7" });
game = await t.query(api.games.get, { id: gameId });
ongoingGames = await t.query(api.games.ongoingGames, {})
expect(game.finished).toBe(true);
expect(ongoingGames.length).toStrictEqual(0)
});
});
10 changes: 10 additions & 0 deletions jest.config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
/** @type {import('ts-jest/dist/types').InitialOptionsTsJest} */
module.exports = {
preset: "ts-jest/presets/js-with-ts",
testEnvironment: "node",
transformIgnorePatterns: ['/node_modules/(?!(convex-helpers)/)'],

// Only run one suite at a time because all of our tests are running against
// the same backend and we don't want to leak state.
maxWorkers: 1,
};

0 comments on commit 65d733a

Please sign in to comment.