throw together some scuffed integration testing infra

This commit is contained in:
itsMapleLeaf
2022-08-06 00:05:30 -05:00
parent e974f0073d
commit 1cbd5e9bfd
8 changed files with 293 additions and 222 deletions

View File

@@ -5,7 +5,7 @@
"lint": "eslint --ext js,ts,tsx .", "lint": "eslint --ext js,ts,tsx .",
"lint-fix": "pnpm lint -- --fix", "lint-fix": "pnpm lint -- --fix",
"test": "vitest --coverage --no-watch", "test": "vitest --coverage --no-watch",
"test-dev": "vitest", "test-dev": "vitest --ui",
"format": "prettier --write .", "format": "prettier --write .",
"build": "pnpm -r run build", "build": "pnpm -r run build",
"start": "pnpm -C packages/website run start", "start": "pnpm -C packages/website run start",
@@ -16,11 +16,12 @@
"@itsmapleleaf/configs": "^1.1.5", "@itsmapleleaf/configs": "^1.1.5",
"@rushstack/eslint-patch": "^1.1.4", "@rushstack/eslint-patch": "^1.1.4",
"@types/eslint": "^8.4.5", "@types/eslint": "^8.4.5",
"@vitest/ui": "^0.21.0",
"c8": "^7.12.0",
"eslint": "^8.20.0", "eslint": "^8.20.0",
"node": "^16.16.0", "node": "^16.16.0",
"prettier": "^2.7.1", "prettier": "^2.7.1",
"typescript": "^4.7.4", "typescript": "^4.7.4",
"c8": "^7.12.0",
"vitest": "^0.20.3" "vitest": "^0.20.3"
}, },
"resolutions": { "resolutions": {

View File

@@ -74,6 +74,7 @@
"@reacord/helpers": "workspace:*", "@reacord/helpers": "workspace:*",
"@types/lodash-es": "^4.17.6", "@types/lodash-es": "^4.17.6",
"@types/prettier": "^2.6.4", "@types/prettier": "^2.6.4",
"date-fns": "^2.29.1",
"discord.js": "^14.1.2", "discord.js": "^14.1.2",
"dotenv": "^16.0.1", "dotenv": "^16.0.1",
"lodash-es": "^4.17.21", "lodash-es": "^4.17.21",

View File

@@ -1,60 +1,17 @@
import { raise } from "@reacord/helpers/raise" import { ComponentType } from "discord.js"
import type { TextBasedChannel } from "discord.js"
import {
CategoryChannel,
ChannelType,
ComponentType,
GatewayIntentBits,
} from "discord.js"
import React from "react" import React from "react"
import { beforeAll, expect, test } from "vitest" import { beforeAll, expect, test } from "vitest"
import { createDiscordClient } from "../library/create-discord-client" import { ActionRow, Button, Option, Select } from "../library/main"
import { import { ReacordTester } from "./tester"
ActionRow,
Button,
Option,
ReacordClient,
Select,
} from "../library/main"
import { testEnv } from "./test-env"
let channel: TextBasedChannel let tester: ReacordTester
beforeAll(async () => { beforeAll(async () => {
const client = await createDiscordClient(testEnv.TEST_BOT_TOKEN, { tester = await ReacordTester.create()
intents: GatewayIntentBits.Guilds | GatewayIntentBits.GuildMessages,
})
const category =
client.channels.cache.get(testEnv.TEST_CATEGORY_ID) ??
(await client.channels.fetch(testEnv.TEST_CATEGORY_ID))
if (!(category instanceof CategoryChannel)) {
throw new TypeError("Category channel not found")
}
const channelName = "test-channel"
let existing = category.children.cache.find((the) => the.name === channelName)
if (!existing || !existing.isTextBased()) {
existing = await category.children.create({
type: ChannelType.GuildText,
name: channelName,
})
}
channel = existing
for (const [, message] of await channel.messages.fetch()) {
await message.delete()
}
}) })
test("action row", async () => { test("action row", async () => {
const reacord = new ReacordClient({ const { message } = await tester.render(
token: testEnv.TEST_BOT_TOKEN, "action row",
})
reacord.send(
channel.id,
<> <>
<Button label="outside button" onClick={() => {}} /> <Button label="outside button" onClick={() => {}} />
<ActionRow> <ActionRow>
@@ -68,10 +25,6 @@ test("action row", async () => {
</>, </>,
) )
const message = await channel
.awaitMessages({ max: 1 })
.then((result) => result.first() ?? raise("message not found"))
expect(message.components.map((c) => c.toJSON())).toEqual([ expect(message.components.map((c) => c.toJSON())).toEqual([
{ {
type: ComponentType.ActionRow, type: ComponentType.ActionRow,
@@ -109,4 +62,4 @@ test("action row", async () => {
], ],
}, },
]) ])
}, 15_000) })

View File

@@ -1,5 +1,5 @@
import React from "react" import React from "react"
import { test } from "vitest" import { beforeAll, expect, test } from "vitest"
import { import {
Embed, Embed,
EmbedAuthor, EmbedAuthor,
@@ -9,14 +9,18 @@ import {
EmbedThumbnail, EmbedThumbnail,
EmbedTitle, EmbedTitle,
} from "../library/main" } from "../library/main"
import { ReacordTester } from "./test-adapter" import { ReacordTester } from "./tester"
const testing = new ReacordTester() let tester: ReacordTester
beforeAll(async () => {
tester = await ReacordTester.create()
})
test("kitchen sink", async () => { test("kitchen sink", async () => {
const now = new Date() const now = new Date()
await testing.assertRender( const { message } = await tester.render(
"kitchen sink",
<> <>
<Embed color={0xfe_ee_ef}> <Embed color={0xfe_ee_ef}>
<EmbedAuthor name="author" iconUrl="https://example.com/author.png" /> <EmbedAuthor name="author" iconUrl="https://example.com/author.png" />
@@ -33,17 +37,15 @@ test("kitchen sink", async () => {
/> />
</Embed> </Embed>
</>, </>,
[ )
{
actionRows: [], expect(message.embeds.map((e) => e.toJSON())).toEqual([
content: "", expect.objectContaining({
embeds: [
{
description: "description text", description: "description text",
author: { author: expect.objectContaining({
icon_url: "https://example.com/author.png", icon_url: "https://example.com/author.png",
name: "author", name: "author",
}, }),
color: 0xfe_ee_ef, color: 0xfe_ee_ef,
fields: [ fields: [
{ {
@@ -52,66 +54,66 @@ test("kitchen sink", async () => {
value: "field value", value: "field value",
}, },
{ {
inline: false,
name: "block field", name: "block field",
value: "block field value", value: "block field value",
}, },
], ],
footer: { footer: expect.objectContaining({
icon_url: "https://example.com/footer.png", icon_url: "https://example.com/footer.png",
text: "footer text", text: "footer text",
}, }),
image: { image: expect.objectContaining({
url: "https://example.com/image.png", url: "https://example.com/image.png",
}, }),
thumbnail: { thumbnail: expect.objectContaining({
url: "https://example.com/thumbnail.png", url: "https://example.com/thumbnail.png",
}, }),
timestamp: now.toISOString(),
title: "title text", title: "title text",
}, }),
], ])
},
], // the timestamp format from Discord is not the same one that JS makes
) expect(new Date(message.embeds[0]!.timestamp!)).toEqual(now)
}) })
test("author variants", async () => { test("author variants", async () => {
await testing.assertRender( const { message } = await tester.render(
"author variants",
<> <>
<Embed> <Embed>
<EmbedAuthor iconUrl="https://example.com/author.png"> <EmbedAuthor iconUrl="https://example.com/author.png">
author name author name 1
</EmbedAuthor> </EmbedAuthor>
</Embed> </Embed>
<Embed> <Embed>
<EmbedAuthor iconUrl="https://example.com/author.png" /> <EmbedAuthor
name="author name 2"
iconUrl="https://example.com/author.png"
/>
</Embed> </Embed>
</>, </>,
[
{
content: "",
actionRows: [],
embeds: [
{
author: {
icon_url: "https://example.com/author.png",
name: "author name",
},
},
{
author: {
icon_url: "https://example.com/author.png",
name: "",
},
},
],
},
],
) )
})
expect(message.embeds.map((e) => e.toJSON())).toEqual([
expect.objectContaining({
author: expect.objectContaining({
name: "author name 1",
icon_url: "https://example.com/author.png",
}),
}),
expect.objectContaining({
author: expect.objectContaining({
name: "author name 2",
icon_url: "https://example.com/author.png",
}),
}),
])
}, 20_000)
test("field variants", async () => { test("field variants", async () => {
await testing.assertRender( const { message } = await tester.render(
"field variants",
<> <>
<Embed> <Embed>
<EmbedField name="field name" value="field value" /> <EmbedField name="field name" value="field value" />
@@ -122,43 +124,41 @@ test("field variants", async () => {
<EmbedField name="field name" /> <EmbedField name="field name" />
</Embed> </Embed>
</>, </>,
[ )
{
content: "", expect(message.embeds.map((e) => e.toJSON())).toEqual([
actionRows: [], expect.objectContaining({
embeds: [
{
fields: [ fields: [
{ {
name: "field name", name: "field name",
value: "field value", value: "field value",
inline: false,
}, },
{ {
inline: true,
name: "field name", name: "field name",
value: "field value", value: "field value",
inline: true,
}, },
{ {
inline: true,
name: "field name", name: "field name",
value: "field value", value: "field value",
inline: true,
}, },
{ {
name: "field name", name: "field name",
value: "", value: "_ _",
inline: false,
}, },
], ],
}, }),
], ])
},
],
)
}) })
test("footer variants", async () => { test("footer variants", async () => {
const now = new Date() const now = new Date()
await testing.assertRender( const { message } = await tester.render(
"footer variants",
<> <>
<Embed> <Embed>
<EmbedFooter text="footer text" /> <EmbedFooter text="footer text" />
@@ -176,45 +176,37 @@ test("footer variants", async () => {
<EmbedFooter iconUrl="https://example.com/footer.png" timestamp={now} /> <EmbedFooter iconUrl="https://example.com/footer.png" timestamp={now} />
</Embed> </Embed>
</>, </>,
[
{
content: "",
actionRows: [],
embeds: [
{
footer: {
text: "footer text",
},
},
{
footer: {
icon_url: "https://example.com/footer.png",
text: "footer text",
},
},
{
footer: {
text: "footer text",
},
timestamp: now.toISOString(),
},
{
footer: {
icon_url: "https://example.com/footer.png",
text: "",
},
timestamp: now.toISOString(),
},
],
},
],
) )
expect(message.embeds.map((e) => e.toJSON())).toEqual([
expect.objectContaining({
footer: {
text: "footer text",
},
}),
expect.objectContaining({
footer: expect.objectContaining({
icon_url: "https://example.com/footer.png",
text: "footer text",
}),
}),
expect.objectContaining({
timestamp: expect.stringContaining(""),
}),
expect.objectContaining({
timestamp: expect.stringContaining(""),
}),
])
expect(new Date(message.embeds[2]!.timestamp!)).toEqual(now)
expect(new Date(message.embeds[3]!.timestamp!)).toEqual(now)
}) })
test("embed props", async () => { test.only("embed props", async () => {
const now = new Date() const now = new Date()
await testing.assertRender( const { message } = await tester.render(
"embed props",
<Embed <Embed
title="title text" title="title text"
description="description text" description="description text"
@@ -241,35 +233,33 @@ test("embed props", async () => {
{ name: "block field", value: "block field value" }, { name: "block field", value: "block field value" },
]} ]}
/>, />,
[ )
{
content: "", expect(message.embeds.map((e) => e.toJSON())).toEqual([
actionRows: [], expect.objectContaining({
embeds: [
{
title: "title text", title: "title text",
description: "description text", description: "description text",
url: "https://example.com/", url: "https://example.com/",
color: 0xfe_ee_ef, color: 0xfe_ee_ef,
timestamp: now.toISOString(), author: expect.objectContaining({
author: {
name: "author name", name: "author name",
url: "https://example.com/author", url: "https://example.com/author",
icon_url: "https://example.com/author.png", icon_url: "https://example.com/author.png",
}, }),
thumbnail: { url: "https://example.com/thumbnail.png" }, thumbnail: expect.objectContaining({
image: { url: "https://example.com/image.png" }, url: "https://example.com/thumbnail.png",
footer: { }),
image: expect.objectContaining({ url: "https://example.com/image.png" }),
footer: expect.objectContaining({
text: "footer text", text: "footer text",
icon_url: "https://example.com/footer.png", icon_url: "https://example.com/footer.png",
}, }),
fields: [ fields: [
{ name: "field name", value: "field value", inline: true }, { name: "field name", value: "field value", inline: true },
{ name: "block field", value: "block field value" }, { name: "block field", value: "block field value", inline: false },
], ],
}, }),
], ])
},
], expect(new Date(message.embeds[0]!.timestamp!)).toEqual(now)
)
}) })

View File

@@ -0,0 +1,5 @@
import { ReacordTester } from "./tester"
export async function setup() {
await ReacordTester.removeChannels()
}

View File

@@ -0,0 +1,85 @@
import { raise } from "@reacord/helpers/raise"
import type { Client } from "discord.js"
import { CategoryChannel, ChannelType, GatewayIntentBits } from "discord.js"
import { kebabCase } from "lodash-es"
import { randomBytes } from "node:crypto"
import type { ReactNode } from "react"
import { createDiscordClient } from "../library/create-discord-client"
import { ReacordClient } from "../library/reacord-client"
import { testEnv } from "./test-env"
export class ReacordTester {
private static client?: Client
static async removeChannels() {
const client = await ReacordTester.getClient()
const category = await ReacordTester.getCategory(client)
for (const [, channel] of category.children.cache) {
await channel.delete()
}
}
static async create() {
const client = await ReacordTester.getClient()
const category = await ReacordTester.getCategory(client)
return new ReacordTester(client, category)
}
private static async getClient() {
return (this.client ??= await createDiscordClient(testEnv.TEST_BOT_TOKEN, {
intents: GatewayIntentBits.Guilds | GatewayIntentBits.GuildMessages,
}))
}
private static async getCategory(client: Client<true>) {
const category =
client.channels.cache.get(testEnv.TEST_CATEGORY_ID) ??
(await client.channels.fetch(testEnv.TEST_CATEGORY_ID))
if (!(category instanceof CategoryChannel)) {
throw new TypeError("Category channel not found")
}
return category
}
private reacord?: ReacordClient
constructor(readonly client: Client, readonly category: CategoryChannel) {}
private async getTestChannel(testName: string) {
const hash = randomBytes(16).toString("hex").slice(0, 6)
const channelName = `${kebabCase(testName)}-${hash}`
let channel = this.category.children.cache.find(
(the) => the.name === channelName,
)
if (!channel || !channel.isTextBased()) {
channel = await this.category.children.create({
type: ChannelType.GuildText,
name: channelName,
})
}
for (const [, message] of await channel.messages.fetch()) {
await message.delete()
}
return channel
}
async render(testName: string, content?: ReactNode) {
this.reacord ??= new ReacordClient({
token: testEnv.TEST_BOT_TOKEN,
})
const channel = await this.getTestChannel(testName)
await channel.sendTyping()
const instance = this.reacord.send(channel.id, content)
const result = await channel.awaitMessages({ max: 1 })
const message = result.first() ?? raise("failed to send message")
return { message, instance }
}
}

33
pnpm-lock.yaml generated
View File

@@ -11,6 +11,7 @@ importers:
'@itsmapleleaf/configs': ^1.1.5 '@itsmapleleaf/configs': ^1.1.5
'@rushstack/eslint-patch': ^1.1.4 '@rushstack/eslint-patch': ^1.1.4
'@types/eslint': ^8.4.5 '@types/eslint': ^8.4.5
'@vitest/ui': ^0.21.0
c8: ^7.12.0 c8: ^7.12.0
eslint: ^8.20.0 eslint: ^8.20.0
node: ^16.16.0 node: ^16.16.0
@@ -22,12 +23,13 @@ importers:
'@itsmapleleaf/configs': 1.1.5_he2ccbldppg44uulnyq4rwocfa '@itsmapleleaf/configs': 1.1.5_he2ccbldppg44uulnyq4rwocfa
'@rushstack/eslint-patch': 1.1.4 '@rushstack/eslint-patch': 1.1.4
'@types/eslint': 8.4.5 '@types/eslint': 8.4.5
'@vitest/ui': 0.21.0
c8: 7.12.0 c8: 7.12.0
eslint: 8.20.0 eslint: 8.20.0
node: 16.16.0 node: 16.16.0
prettier: 2.7.1 prettier: 2.7.1
typescript: 4.7.4 typescript: 4.7.4
vitest: 0.20.3_c8@7.12.0 vitest: 0.20.3_y7ksokcqbrho27xsbc2olnpwva
packages/helpers: packages/helpers:
specifiers: specifiers:
@@ -47,6 +49,7 @@ importers:
'@types/prettier': ^2.6.4 '@types/prettier': ^2.6.4
'@types/react': '*' '@types/react': '*'
'@types/react-reconciler': '*' '@types/react-reconciler': '*'
date-fns: ^2.29.1
discord-api-types: ^0.36.3 discord-api-types: ^0.36.3
discord.js: ^14.1.2 discord.js: ^14.1.2
dotenv: ^16.0.1 dotenv: ^16.0.1
@@ -74,6 +77,7 @@ importers:
'@reacord/helpers': link:../helpers '@reacord/helpers': link:../helpers
'@types/lodash-es': 4.17.6 '@types/lodash-es': 4.17.6
'@types/prettier': 2.6.4 '@types/prettier': 2.6.4
date-fns: 2.29.1
discord.js: 14.1.2 discord.js: 14.1.2
dotenv: 16.0.1 dotenv: 16.0.1
lodash-es: 4.17.21 lodash-es: 4.17.21
@@ -2033,6 +2037,10 @@ packages:
config-chain: 1.1.13 config-chain: 1.1.13
dev: true dev: true
/@polka/url/1.0.0-next.21:
resolution: {integrity: sha512-a5Sab1C4/icpTZVzZc5Ghpz88yQtGOyNqYXcZgOssB2uuAr+wF/MvN6bgtW32q7HHrvBki+BsZ0OuNv6EV3K9g==}
dev: true
/@reach/observe-rect/1.2.0: /@reach/observe-rect/1.2.0:
resolution: {integrity: sha512-Ba7HmkFgfQxZqqaeIWWkNK0rEhpxVQHIoVyW1YDSkGsGIXzcaW4deC8B0pZrNSSyLTdIk7y+5olKt5+g0GmFIQ==} resolution: {integrity: sha512-Ba7HmkFgfQxZqqaeIWWkNK0rEhpxVQHIoVyW1YDSkGsGIXzcaW4deC8B0pZrNSSyLTdIk7y+5olKt5+g0GmFIQ==}
dev: false dev: false
@@ -2731,6 +2739,12 @@ packages:
eslint-visitor-keys: 3.3.0 eslint-visitor-keys: 3.3.0
dev: true dev: true
/@vitest/ui/0.21.0:
resolution: {integrity: sha512-xhMSwxsuaygIWn1jcTHbAVfNty6D2+hFVq+tvqNuSBE0WI3CWyeSOT1ISQ5urt3j5qoRbEXrZxWLC2dN3QeBSA==}
dependencies:
sirv: 2.0.2
dev: true
/@web3-storage/multipart-parser/1.0.0: /@web3-storage/multipart-parser/1.0.0:
resolution: {integrity: sha512-BEO6al7BYqcnfX15W2cnGR+Q566ACXAT9UQykORCWW80lmkpWsnEob6zJS1ZVBKsSJC8+7vJkHwlp+lXG1UCdw==} resolution: {integrity: sha512-BEO6al7BYqcnfX15W2cnGR+Q566ACXAT9UQykORCWW80lmkpWsnEob6zJS1ZVBKsSJC8+7vJkHwlp+lXG1UCdw==}
@@ -9635,6 +9649,15 @@ packages:
semver: 7.0.0 semver: 7.0.0
dev: true dev: true
/sirv/2.0.2:
resolution: {integrity: sha512-4Qog6aE29nIjAOKe/wowFTxOdmbEZKb+3tsLljaBRzJwtqto0BChD2zzH0LhgCSXiI+V7X+Y45v14wBZQ1TK3w==}
engines: {node: '>= 10'}
dependencies:
'@polka/url': 1.0.0-next.21
mrmime: 1.0.1
totalist: 3.0.0
dev: true
/slash/3.0.0: /slash/3.0.0:
resolution: {integrity: sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==} resolution: {integrity: sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==}
engines: {node: '>=8'} engines: {node: '>=8'}
@@ -10259,6 +10282,11 @@ packages:
resolution: {integrity: sha512-y/mWCZinnvxjTKYhJ+pYxwD0mRLVvOtdS2Awbgxln6iEnt4rk0yBxeSBHkGJcPucRiG0e55mwWp+g/05rsrd6w==} resolution: {integrity: sha512-y/mWCZinnvxjTKYhJ+pYxwD0mRLVvOtdS2Awbgxln6iEnt4rk0yBxeSBHkGJcPucRiG0e55mwWp+g/05rsrd6w==}
dev: true dev: true
/totalist/3.0.0:
resolution: {integrity: sha512-eM+pCBxXO/njtF7vdFsHuqb+ElbxqtI4r5EAvk6grfAFyJ6IvWlSkfZ5T9ozC6xWw3Fj1fGoSmrl0gUs46JVIw==}
engines: {node: '>=6'}
dev: true
/touch/3.1.0: /touch/3.1.0:
resolution: {integrity: sha512-WBx8Uy5TLtOSRtIq+M03/sKDrXCLHxwDcquSP2c43Le03/9serjQBIztjRz6FkJez9D/hleyAXTBGLwwZUw9lA==} resolution: {integrity: sha512-WBx8Uy5TLtOSRtIq+M03/sKDrXCLHxwDcquSP2c43Le03/9serjQBIztjRz6FkJez9D/hleyAXTBGLwwZUw9lA==}
hasBin: true hasBin: true
@@ -10872,7 +10900,7 @@ packages:
fsevents: 2.3.2 fsevents: 2.3.2
dev: true dev: true
/vitest/0.20.3_c8@7.12.0: /vitest/0.20.3_y7ksokcqbrho27xsbc2olnpwva:
resolution: {integrity: sha512-cXMjTbZxBBUUuIF3PUzEGPLJWtIMeURBDXVxckSHpk7xss4JxkiiWh5cnIlfGyfJne2Ii3QpbiRuFL5dMJtljw==} resolution: {integrity: sha512-cXMjTbZxBBUUuIF3PUzEGPLJWtIMeURBDXVxckSHpk7xss4JxkiiWh5cnIlfGyfJne2Ii3QpbiRuFL5dMJtljw==}
engines: {node: '>=v14.16.0'} engines: {node: '>=v14.16.0'}
hasBin: true hasBin: true
@@ -10900,6 +10928,7 @@ packages:
'@types/chai': 4.3.1 '@types/chai': 4.3.1
'@types/chai-subset': 1.3.3 '@types/chai-subset': 1.3.3
'@types/node': 18.6.3 '@types/node': 18.6.3
'@vitest/ui': 0.21.0
c8: 7.12.0 c8: 7.12.0
chai: 4.3.6 chai: 4.3.6
debug: 4.3.4 debug: 4.3.4

View File

@@ -5,4 +5,11 @@ export default defineConfig({
build: { build: {
sourcemap: true, sourcemap: true,
}, },
test: {
globalSetup: ["packages/reacord/test/global-setup.ts"],
threads: false,
isolate: false,
hookTimeout: 20_000,
testTimeout: 20_000,
},
}) })