kitchen sink test
This commit is contained in:
13
jest.config.js
Normal file
13
jest.config.js
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
/** @type {import('@jest/types').Config.InitialOptions} */
|
||||||
|
const config = {
|
||||||
|
transform: {
|
||||||
|
"^.+\\.[jt]sx?$": ["esbuild-jest", { format: "esm", sourcemap: true }],
|
||||||
|
},
|
||||||
|
extensionsToTreatAsEsm: [".ts", ".tsx"],
|
||||||
|
moduleNameMapper: {
|
||||||
|
"^(\\.\\.?/.+)\\.jsx?$": "$1",
|
||||||
|
},
|
||||||
|
verbose: true,
|
||||||
|
cache: false,
|
||||||
|
}
|
||||||
|
export default config
|
||||||
17
package.json
17
package.json
@@ -17,9 +17,9 @@
|
|||||||
"lint": "eslint --ext js,ts,tsx .",
|
"lint": "eslint --ext js,ts,tsx .",
|
||||||
"lint-fix": "pnpm lint -- --fix",
|
"lint-fix": "pnpm lint -- --fix",
|
||||||
"format": "prettier --write .",
|
"format": "prettier --write .",
|
||||||
"test": "vitest",
|
"test": "node --experimental-vm-modules ./node_modules/jest/bin/jest.js",
|
||||||
"test-watch": "vitest --watch",
|
"test-watch": "pnpm test -- --watch",
|
||||||
"coverage": "vitest --coverage",
|
"coverage": "pnpm test -- --coverage",
|
||||||
"typecheck": "tsc --noEmit",
|
"typecheck": "tsc --noEmit",
|
||||||
"playground": "nodemon --exec esmo --ext ts,tsx ./playground/main.tsx"
|
"playground": "nodemon --exec esmo --ext ts,tsx ./playground/main.tsx"
|
||||||
},
|
},
|
||||||
@@ -42,11 +42,15 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@itsmapleleaf/configs": "^1.1.2",
|
"@itsmapleleaf/configs": "^1.1.2",
|
||||||
|
"@types/jest": "^27.0.3",
|
||||||
|
"@types/lodash-es": "^4.17.5",
|
||||||
"@typescript-eslint/eslint-plugin": "^5.8.0",
|
"@typescript-eslint/eslint-plugin": "^5.8.0",
|
||||||
"@typescript-eslint/parser": "^5.8.0",
|
"@typescript-eslint/parser": "^5.8.0",
|
||||||
"c8": "^7.10.0",
|
"c8": "^7.10.0",
|
||||||
"discord.js": "^13.4.0",
|
"discord.js": "^13.4.0",
|
||||||
"dotenv": "^10.0.0",
|
"dotenv": "^10.0.0",
|
||||||
|
"esbuild": "latest",
|
||||||
|
"esbuild-jest": "^0.5.0",
|
||||||
"eslint": "^8.5.0",
|
"eslint": "^8.5.0",
|
||||||
"eslint-config-prettier": "^8.3.0",
|
"eslint-config-prettier": "^8.3.0",
|
||||||
"eslint-import-resolver-typescript": "^2.5.0",
|
"eslint-import-resolver-typescript": "^2.5.0",
|
||||||
@@ -56,13 +60,14 @@
|
|||||||
"eslint-plugin-react-hooks": "^4.3.0",
|
"eslint-plugin-react-hooks": "^4.3.0",
|
||||||
"eslint-plugin-unicorn": "^39.0.0",
|
"eslint-plugin-unicorn": "^39.0.0",
|
||||||
"esmo": "^0.13.0",
|
"esmo": "^0.13.0",
|
||||||
|
"jest": "^27.4.5",
|
||||||
|
"lodash-es": "^4.17.21",
|
||||||
"nodemon": "^2.0.15",
|
"nodemon": "^2.0.15",
|
||||||
"prettier": "^2.5.1",
|
"prettier": "^2.5.1",
|
||||||
"react": "^17.0.2",
|
"react": "^17.0.2",
|
||||||
"tsup": "^5.11.9",
|
"tsup": "^5.11.9",
|
||||||
"typescript": "^4.5.4",
|
"type-fest": "^2.8.0",
|
||||||
"vite": "^2.7.6",
|
"typescript": "^4.5.4"
|
||||||
"vitest": "^0.0.108"
|
|
||||||
},
|
},
|
||||||
"resolutions": {
|
"resolutions": {
|
||||||
"esbuild": "latest"
|
"esbuild": "latest"
|
||||||
|
|||||||
1974
pnpm-lock.yaml
generated
1974
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
21
src/helpers/retry-with-timeout.ts
Normal file
21
src/helpers/retry-with-timeout.ts
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import { setTimeout } from "node:timers/promises"
|
||||||
|
|
||||||
|
const maxTime = 500
|
||||||
|
const waitPeriod = 50
|
||||||
|
|
||||||
|
export async function retryWithTimeout<T>(
|
||||||
|
callback: () => Promise<T> | T,
|
||||||
|
): Promise<T> {
|
||||||
|
const startTime = Date.now()
|
||||||
|
// eslint-disable-next-line no-constant-condition
|
||||||
|
while (true) {
|
||||||
|
try {
|
||||||
|
return await callback()
|
||||||
|
} catch (error) {
|
||||||
|
if (Date.now() - startTime > maxTime) {
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
await setTimeout(waitPeriod)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
import { rejectAfter } from "./reject-after.js"
|
|
||||||
import type { MaybePromise } from "./types.js"
|
|
||||||
import { waitFor } from "./wait-for.js"
|
|
||||||
|
|
||||||
// eslint-disable-next-line import/no-unused-modules
|
|
||||||
export function waitForWithTimeout(
|
|
||||||
condition: () => MaybePromise<boolean>,
|
|
||||||
timeout = 1000,
|
|
||||||
errorMessage = `timed out after ${timeout}ms`,
|
|
||||||
) {
|
|
||||||
return Promise.race([waitFor(condition), rejectAfter(timeout, errorMessage)])
|
|
||||||
}
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
import { setTimeout } from "node:timers/promises"
|
|
||||||
import type { MaybePromise } from "./types.js"
|
|
||||||
|
|
||||||
export async function waitFor(condition: () => MaybePromise<boolean>) {
|
|
||||||
while (!(await condition())) {
|
|
||||||
await setTimeout(100)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
import "dotenv/config"
|
|
||||||
import { test } from "vitest"
|
|
||||||
import { getEnvironmentValue } from "./helpers/get-environment-value.js"
|
|
||||||
|
|
||||||
const testBotToken = getEnvironmentValue("TEST_BOT_TOKEN")
|
|
||||||
const testChannelId = getEnvironmentValue("TEST_CHANNEL_ID")
|
|
||||||
|
|
||||||
test.todo("reacord")
|
|
||||||
107
src/test-adapter.ts
Normal file
107
src/test-adapter.ts
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
import { nanoid } from "nanoid"
|
||||||
|
import type { Adapter } from "./adapter"
|
||||||
|
import { raise } from "./helpers/raise"
|
||||||
|
import type {
|
||||||
|
ButtonInteraction,
|
||||||
|
CommandInteraction,
|
||||||
|
ComponentInteraction,
|
||||||
|
} from "./interaction"
|
||||||
|
import type { Message, MessageButtonOptions, MessageOptions } from "./message"
|
||||||
|
|
||||||
|
export class TestAdapter implements Adapter<{}> {
|
||||||
|
readonly messages: TestMessage[] = []
|
||||||
|
|
||||||
|
// eslint-disable-next-line class-methods-use-this
|
||||||
|
private componentInteractionListener: (
|
||||||
|
interaction: ComponentInteraction,
|
||||||
|
) => void = () => {}
|
||||||
|
|
||||||
|
addComponentInteractionListener(
|
||||||
|
listener: (interaction: ComponentInteraction) => void,
|
||||||
|
): void {
|
||||||
|
this.componentInteractionListener = listener
|
||||||
|
}
|
||||||
|
|
||||||
|
// eslint-disable-next-line class-methods-use-this
|
||||||
|
createCommandInteraction(
|
||||||
|
interaction: CommandInteraction,
|
||||||
|
): CommandInteraction {
|
||||||
|
return interaction
|
||||||
|
}
|
||||||
|
|
||||||
|
findButtonByLabel(label: string) {
|
||||||
|
for (const message of this.messages) {
|
||||||
|
for (const component of message.options.actionRows.flat()) {
|
||||||
|
if (component.type === "button" && component.label === label) {
|
||||||
|
return this.createButtonActions(component, message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
raise(`Couldn't find button with label "${label}"`)
|
||||||
|
}
|
||||||
|
|
||||||
|
private createButtonActions(
|
||||||
|
button: MessageButtonOptions,
|
||||||
|
message: TestMessage,
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
click: () => {
|
||||||
|
this.componentInteractionListener(
|
||||||
|
new TestButtonInteraction(button.customId, message),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class TestMessage implements Message {
|
||||||
|
constructor(public options: MessageOptions) {}
|
||||||
|
|
||||||
|
async edit(options: MessageOptions): Promise<void> {
|
||||||
|
this.options = options
|
||||||
|
}
|
||||||
|
|
||||||
|
async disableComponents(): Promise<void> {
|
||||||
|
for (const row of this.options.actionRows) {
|
||||||
|
for (const action of row) {
|
||||||
|
if (action.type === "button") {
|
||||||
|
action.disabled = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class TestCommandInteraction implements CommandInteraction {
|
||||||
|
readonly type = "command"
|
||||||
|
readonly id = "test-command-interaction"
|
||||||
|
readonly channelId = "test-channel-id"
|
||||||
|
|
||||||
|
constructor(private adapter: TestAdapter) {}
|
||||||
|
|
||||||
|
private createMesssage(messageOptions: MessageOptions): Message {
|
||||||
|
const message = new TestMessage(messageOptions)
|
||||||
|
this.adapter.messages.push(message)
|
||||||
|
return message
|
||||||
|
}
|
||||||
|
|
||||||
|
reply(messageOptions: MessageOptions): Promise<Message> {
|
||||||
|
return Promise.resolve(this.createMesssage(messageOptions))
|
||||||
|
}
|
||||||
|
|
||||||
|
followUp(messageOptions: MessageOptions): Promise<Message> {
|
||||||
|
return Promise.resolve(this.createMesssage(messageOptions))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class TestButtonInteraction implements ButtonInteraction {
|
||||||
|
readonly type = "button"
|
||||||
|
readonly id = nanoid()
|
||||||
|
readonly channelId = "test-channel-id"
|
||||||
|
|
||||||
|
constructor(readonly customId: string, readonly message: TestMessage) {}
|
||||||
|
|
||||||
|
async update(options: MessageOptions): Promise<void> {
|
||||||
|
this.message.options = options
|
||||||
|
}
|
||||||
|
}
|
||||||
297
test/kitchen-sink.test.tsx
Normal file
297
test/kitchen-sink.test.tsx
Normal file
@@ -0,0 +1,297 @@
|
|||||||
|
import { nextTick } from "node:process"
|
||||||
|
import { promisify } from "node:util"
|
||||||
|
import * as React from "react"
|
||||||
|
import { omit } from "../src/helpers/omit"
|
||||||
|
import { Button, Embed, EmbedField, EmbedTitle, Reacord } from "../src/main"
|
||||||
|
import { TestAdapter, TestCommandInteraction } from "../src/test-adapter"
|
||||||
|
|
||||||
|
const nextTickPromise = promisify(nextTick)
|
||||||
|
|
||||||
|
test("kitchen-sink", async () => {
|
||||||
|
const adapter = new TestAdapter()
|
||||||
|
const reacord = new Reacord({ adapter })
|
||||||
|
|
||||||
|
const reply = reacord.createCommandReply(new TestCommandInteraction(adapter))
|
||||||
|
reply.render(<KitchenSinkCounter onDeactivate={() => reply.deactivate()} />)
|
||||||
|
|
||||||
|
await assertMessages(adapter, [
|
||||||
|
{
|
||||||
|
content: "count: 0",
|
||||||
|
embeds: [],
|
||||||
|
actionRows: [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
type: "button",
|
||||||
|
style: "primary",
|
||||||
|
label: "clicc",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "button",
|
||||||
|
style: "secondary",
|
||||||
|
label: "show embed",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "button",
|
||||||
|
style: "danger",
|
||||||
|
label: "deactivate",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
],
|
||||||
|
},
|
||||||
|
])
|
||||||
|
|
||||||
|
adapter.findButtonByLabel("show embed").click()
|
||||||
|
await assertMessages(adapter, [
|
||||||
|
{
|
||||||
|
content: "count: 0",
|
||||||
|
embeds: [{ title: "the counter" }],
|
||||||
|
actionRows: [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
type: "button",
|
||||||
|
style: "secondary",
|
||||||
|
label: "hide embed",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "button",
|
||||||
|
style: "primary",
|
||||||
|
label: "clicc",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "button",
|
||||||
|
style: "danger",
|
||||||
|
label: "deactivate",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
],
|
||||||
|
},
|
||||||
|
])
|
||||||
|
|
||||||
|
adapter.findButtonByLabel("clicc").click()
|
||||||
|
await assertMessages(adapter, [
|
||||||
|
{
|
||||||
|
content: "count: 1",
|
||||||
|
embeds: [
|
||||||
|
{
|
||||||
|
title: "the counter",
|
||||||
|
fields: [{ name: "is it even?", value: "no" }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
actionRows: [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
type: "button",
|
||||||
|
style: "secondary",
|
||||||
|
label: "hide embed",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "button",
|
||||||
|
style: "primary",
|
||||||
|
label: "clicc",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "button",
|
||||||
|
style: "danger",
|
||||||
|
label: "deactivate",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
],
|
||||||
|
},
|
||||||
|
])
|
||||||
|
|
||||||
|
adapter.findButtonByLabel("clicc").click()
|
||||||
|
await assertMessages(adapter, [
|
||||||
|
{
|
||||||
|
content: "count: 2",
|
||||||
|
embeds: [
|
||||||
|
{
|
||||||
|
title: "the counter",
|
||||||
|
fields: [{ name: "is it even?", value: "yes" }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
actionRows: [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
type: "button",
|
||||||
|
style: "secondary",
|
||||||
|
label: "hide embed",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "button",
|
||||||
|
style: "primary",
|
||||||
|
label: "clicc",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "button",
|
||||||
|
style: "danger",
|
||||||
|
label: "deactivate",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
],
|
||||||
|
},
|
||||||
|
])
|
||||||
|
|
||||||
|
adapter.findButtonByLabel("hide embed").click()
|
||||||
|
await assertMessages(adapter, [
|
||||||
|
{
|
||||||
|
content: "count: 2",
|
||||||
|
embeds: [],
|
||||||
|
actionRows: [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
type: "button",
|
||||||
|
style: "primary",
|
||||||
|
label: "clicc",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "button",
|
||||||
|
style: "secondary",
|
||||||
|
label: "show embed",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "button",
|
||||||
|
style: "danger",
|
||||||
|
label: "deactivate",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
],
|
||||||
|
},
|
||||||
|
])
|
||||||
|
|
||||||
|
adapter.findButtonByLabel("clicc").click()
|
||||||
|
await assertMessages(adapter, [
|
||||||
|
{
|
||||||
|
content: "count: 3",
|
||||||
|
embeds: [],
|
||||||
|
actionRows: [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
type: "button",
|
||||||
|
style: "primary",
|
||||||
|
label: "clicc",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "button",
|
||||||
|
style: "secondary",
|
||||||
|
label: "show embed",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "button",
|
||||||
|
style: "danger",
|
||||||
|
label: "deactivate",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
],
|
||||||
|
},
|
||||||
|
])
|
||||||
|
|
||||||
|
adapter.findButtonByLabel("deactivate").click()
|
||||||
|
await assertMessages(adapter, [
|
||||||
|
{
|
||||||
|
content: "count: 3",
|
||||||
|
embeds: [],
|
||||||
|
actionRows: [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
type: "button",
|
||||||
|
style: "primary",
|
||||||
|
label: "clicc",
|
||||||
|
disabled: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "button",
|
||||||
|
style: "secondary",
|
||||||
|
label: "show embed",
|
||||||
|
disabled: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "button",
|
||||||
|
style: "danger",
|
||||||
|
label: "deactivate",
|
||||||
|
disabled: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
],
|
||||||
|
},
|
||||||
|
])
|
||||||
|
|
||||||
|
adapter.findButtonByLabel("clicc").click()
|
||||||
|
await assertMessages(adapter, [
|
||||||
|
{
|
||||||
|
content: "count: 3",
|
||||||
|
embeds: [],
|
||||||
|
actionRows: [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
type: "button",
|
||||||
|
style: "primary",
|
||||||
|
label: "clicc",
|
||||||
|
disabled: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "button",
|
||||||
|
style: "secondary",
|
||||||
|
label: "show embed",
|
||||||
|
disabled: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "button",
|
||||||
|
style: "danger",
|
||||||
|
label: "deactivate",
|
||||||
|
disabled: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
],
|
||||||
|
},
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
function KitchenSinkCounter(props: { onDeactivate: () => void }) {
|
||||||
|
const [count, setCount] = React.useState(0)
|
||||||
|
const [embedVisible, setEmbedVisible] = React.useState(false)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
count: {count}
|
||||||
|
{embedVisible && (
|
||||||
|
<Embed>
|
||||||
|
<EmbedTitle>the counter</EmbedTitle>
|
||||||
|
{count > 0 && (
|
||||||
|
<EmbedField name="is it even?">
|
||||||
|
{count % 2 === 0 ? "yes" : "no"}
|
||||||
|
</EmbedField>
|
||||||
|
)}
|
||||||
|
</Embed>
|
||||||
|
)}
|
||||||
|
{embedVisible && (
|
||||||
|
<Button label="hide embed" onClick={() => setEmbedVisible(false)} />
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
style="primary"
|
||||||
|
label="clicc"
|
||||||
|
onClick={() => setCount(count + 1)}
|
||||||
|
/>
|
||||||
|
{!embedVisible && (
|
||||||
|
<Button label="show embed" onClick={() => setEmbedVisible(true)} />
|
||||||
|
)}
|
||||||
|
<Button style="danger" label="deactivate" onClick={props.onDeactivate} />
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractMessageDataSample(adapter: TestAdapter) {
|
||||||
|
return adapter.messages.map((message) => ({
|
||||||
|
...message.options,
|
||||||
|
actionRows: message.options.actionRows.map((row) =>
|
||||||
|
row.map((component) => omit(component, ["customId"])),
|
||||||
|
),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
async function assertMessages(
|
||||||
|
adapter: TestAdapter,
|
||||||
|
expected: ReturnType<typeof extractMessageDataSample>,
|
||||||
|
) {
|
||||||
|
await nextTickPromise()
|
||||||
|
expect(extractMessageDataSample(adapter)).toEqual(expected)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user