Skip to content

Commit fba1b1c

Browse files
committed
Added github action for CI
1 parent ae8efec commit fba1b1c

5 files changed

Lines changed: 123 additions & 85 deletions

File tree

‎.github/workflows/ci.yml‎

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
name: CI
2+
3+
on:
4+
push:
5+
pull_request:
6+
7+
jobs:
8+
check:
9+
runs-on: ubuntu-latest
10+
steps:
11+
- uses: actions/checkout@v4
12+
- uses: pnpm/action-setup@v4
13+
- uses: actions/setup-node@v4
14+
with:
15+
node-version: 22
16+
cache: pnpm
17+
- run: pnpm install --frozen-lockfile
18+
- run: pnpm test
19+
- run: pnpm typecheck

‎README.md‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ Key files:
4949
### Installation
5050

5151
1. Clone the repository:
52+
5253
```
5354
git clone <repository-url>
5455
cd react-express-socketio
@@ -62,11 +63,13 @@ Key files:
6263
### Running the Application
6364

6465
1. Start the backend server:
66+
6567
```
6668
pnpm dev
6769
```
6870

6971
2. In a new terminal, start the frontend development server:
72+
7073
```
7174
pnpm --filter frontend dev
7275
```
@@ -91,6 +94,7 @@ The header shows **Connected** while the Socket.io connection is open.
9194
- Prettier formats files on save in this workspace. The Prettier extension is recommended in `.vscode/extensions.json`.
9295
- `pnpm typecheck` checks the server and the frontend.
9396
- `pnpm --filter frontend lint` runs ESLint on the frontend.
97+
- GitHub Actions runs `pnpm test` and `pnpm typecheck` on every push and pull request.
9498

9599
### Tests
96100

@@ -105,11 +109,13 @@ Server tests use Vitest and a real Socket.io server. Frontend tests use Vitest,
105109
## Building for Production
106110

107111
1. Typecheck and build the frontend:
112+
108113
```
109114
pnpm build
110115
```
111116

112117
2. Start the production server:
118+
113119
```
114120
pnpm start
115121
```

‎frontend/README.md‎

Lines changed: 3 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,5 @@
1-
# React + Vite
1+
# Frontend
22

3-
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
3+
React chat client for this repository, built with Vite and Tailwind CSS.
44

5-
Currently, two official plugins are available:
6-
7-
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react/README.md) uses [Babel](https://babeljs.io/) for Fast Refresh
8-
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
5+
Run and test it from the repository root. The root [README](../README.md) covers install, development, tests, and production.

‎frontend/src/App.test.tsx‎

Lines changed: 53 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -4,38 +4,40 @@ import { beforeEach, describe, expect, it, vi } from "vitest"
44
import type { ChatMessage as ServerMessage } from "../../shared/events.ts"
55
import App from "./App.tsx"
66

7-
const { socket, resetSocket, receiveMessage, receiveHistory } = vi.hoisted(() => {
8-
const handlers = new Map<string, Set<(...args: unknown[]) => void>>()
9-
const socket = {
10-
connected: true,
11-
id: "abcdefghijklmnop",
12-
on(event: string, handler: (...args: unknown[]) => void) {
13-
const current = handlers.get(event) ?? new Set()
14-
current.add(handler)
15-
handlers.set(event, current)
16-
return socket
17-
},
18-
off(event: string, handler: (...args: unknown[]) => void) {
19-
handlers.get(event)?.delete(handler)
20-
return socket
21-
},
22-
emit: vi.fn(),
23-
}
24-
25-
return {
26-
socket,
27-
resetSocket() {
28-
handlers.clear()
29-
socket.emit.mockClear()
30-
},
31-
receiveMessage(message: ServerMessage) {
32-
handlers.get("message")?.forEach((handler) => handler(message))
33-
},
34-
receiveHistory(messages: ServerMessage[]) {
35-
handlers.get("history")?.forEach((handler) => handler(messages))
36-
},
37-
}
38-
})
7+
const { socket, resetSocket, receiveMessage, receiveHistory } = vi.hoisted(
8+
() => {
9+
const handlers = new Map<string, Set<(...args: unknown[]) => void>>()
10+
const socket = {
11+
connected: true,
12+
id: "abcdefghijklmnop",
13+
on(event: string, handler: (...args: unknown[]) => void) {
14+
const current = handlers.get(event) ?? new Set()
15+
current.add(handler)
16+
handlers.set(event, current)
17+
return socket
18+
},
19+
off(event: string, handler: (...args: unknown[]) => void) {
20+
handlers.get(event)?.delete(handler)
21+
return socket
22+
},
23+
emit: vi.fn(),
24+
}
25+
26+
return {
27+
socket,
28+
resetSocket() {
29+
handlers.clear()
30+
socket.emit.mockClear()
31+
},
32+
receiveMessage(message: ServerMessage) {
33+
handlers.get("message")?.forEach((handler) => handler(message))
34+
},
35+
receiveHistory(messages: ServerMessage[]) {
36+
handlers.get("history")?.forEach((handler) => handler(messages))
37+
},
38+
}
39+
},
40+
)
3941

4042
vi.mock("socket.io-client", () => ({
4143
io: () => socket,
@@ -51,8 +53,14 @@ describe("chat app", () => {
5153
render(<App />)
5254

5355
expect(await screen.findByRole("status")).toHaveTextContent("Connected")
54-
expect(screen.getByRole("textbox", { name: "Your name" })).toBeInTheDocument()
56+
expect(
57+
screen.getByRole("textbox", { name: "Your name" }),
58+
).toBeInTheDocument()
5559
expect(screen.getByText("No messages yet. Say hello.")).toBeInTheDocument()
60+
expect(screen.getByRole("log", { name: "Messages" })).toHaveAttribute(
61+
"aria-live",
62+
"polite",
63+
)
5664
expect(screen.getByRole("button", { name: "Send" })).toBeDisabled()
5765
})
5866

@@ -64,7 +72,9 @@ describe("chat app", () => {
6472
await user.click(screen.getByRole("button", { name: "Save" }))
6573

6674
expect(screen.getByText("Ada")).toBeInTheDocument()
67-
expect(screen.queryByRole("textbox", { name: "Your name" })).not.toBeInTheDocument()
75+
expect(
76+
screen.queryByRole("textbox", { name: "Your name" }),
77+
).not.toBeInTheDocument()
6878
expect(socket.emit).toHaveBeenCalledWith("name", "Ada")
6979
expect(screen.getByText("Ada").previousElementSibling).toHaveStyle({
7080
backgroundColor: expect.any(String),
@@ -93,10 +103,9 @@ describe("chat app", () => {
93103
})
94104

95105
expect(screen.getByText("Hello")).toBeInTheDocument()
96-
expect(screen.getByText("Hello").closest("li")?.querySelector("time")).toHaveAttribute(
97-
"dateTime",
98-
new Date(sentAt).toISOString(),
99-
)
106+
expect(
107+
screen.getByText("Hello").closest("li")?.querySelector("time"),
108+
).toHaveAttribute("dateTime", new Date(sentAt).toISOString())
100109
})
101110

102111
it("shows another person's message with a different color", async () => {
@@ -130,7 +139,9 @@ describe("chat app", () => {
130139
const graceDot = messages.getByText("Grace").querySelector("[aria-hidden]")
131140
expect(adaDot?.getAttribute("style")).toContain("background-color")
132141
expect(graceDot?.getAttribute("style")).toContain("background-color")
133-
expect(graceDot?.getAttribute("style")).not.toBe(adaDot?.getAttribute("style"))
142+
expect(graceDot?.getAttribute("style")).not.toBe(
143+
adaDot?.getAttribute("style"),
144+
)
134145
})
135146

136147
it("shows recent messages from the server when joining", async () => {
@@ -151,7 +162,9 @@ describe("chat app", () => {
151162
})
152163

153164
expect(screen.getByText("Already here")).toBeInTheDocument()
154-
expect(screen.queryByText("No messages yet. Say hello.")).not.toBeInTheDocument()
165+
expect(
166+
screen.queryByText("No messages yet. Say hello."),
167+
).not.toBeInTheDocument()
155168
expect(socket.emit).toHaveBeenCalledWith("history")
156169
})
157170
})

‎frontend/src/App.tsx‎

Lines changed: 42 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -104,7 +104,9 @@ const App = () => {
104104
setMessages((current) => {
105105
const byId = new Map(current.map((item) => [item.id, item]))
106106
for (const item of incoming) byId.set(item.id, toMessage(item))
107-
return [...byId.values()].sort((a, b) => a.at.getTime() - b.at.getTime())
107+
return [...byId.values()].sort(
108+
(a, b) => a.at.getTime() - b.at.getTime(),
109+
)
108110
})
109111
}
110112
const onMessage = (incoming: ServerMessage) => {
@@ -237,50 +239,51 @@ const App = () => {
237239
</p>
238240
</header>
239241

240-
<div
241-
className="min-h-0 flex-1 overflow-y-auto p-5"
242-
ref={listRef}
243-
aria-label="Messages"
244-
>
242+
<div className="min-h-0 flex-1 overflow-y-auto p-5" ref={listRef}>
245243
{messages.length === 0 ? (
246244
<p className="grid h-full place-items-center text-center text-muted">
247245
No messages yet. Say hello.
248246
</p>
249-
) : (
250-
<ol className="m-0 flex list-none flex-col gap-3.5 p-0">
251-
{messages.map((item) => {
252-
const author = item.mine
253-
? item.name || "You"
254-
: item.name || `Guest ${shortId(item.from)}`
247+
) : null}
248+
<ol
249+
className="m-0 flex list-none flex-col gap-3.5 p-0"
250+
role="log"
251+
aria-live="polite"
252+
aria-relevant="additions"
253+
aria-label="Messages"
254+
>
255+
{messages.map((item) => {
256+
const author = item.mine
257+
? item.name || "You"
258+
: item.name || `Guest ${shortId(item.from)}`
255259

256-
return (
257-
<li
258-
key={item.id}
259-
className={`flex max-w-[85%] flex-col ${item.mine ? "items-end self-end" : "items-start"}`}
260+
return (
261+
<li
262+
key={item.id}
263+
className={`flex max-w-[85%] flex-col ${item.mine ? "items-end self-end" : "items-start"}`}
264+
>
265+
<p className="mb-1 flex items-center gap-2 px-0.5 text-xs text-muted">
266+
<span className="inline-flex items-center gap-1.5 font-semibold text-ink">
267+
<span
268+
className="size-2.5 shrink-0 rounded-full"
269+
style={{ backgroundColor: colorFor(item.from) }}
270+
aria-hidden="true"
271+
/>
272+
{author}
273+
</span>
274+
<time dateTime={item.at.toISOString()}>
275+
{formatTime(item.at)}
276+
</time>
277+
</p>
278+
<p
279+
className={`m-0 px-3.5 py-2.5 leading-snug wrap-anywhere whitespace-pre-wrap ${item.mine ? "rounded-2xl rounded-br-md bg-mine text-mine-ink" : "rounded-2xl rounded-bl-md bg-other"}`}
260280
>
261-
<p className="mb-1 flex items-center gap-2 px-0.5 text-xs text-muted">
262-
<span className="inline-flex items-center gap-1.5 font-semibold text-ink">
263-
<span
264-
className="size-2.5 shrink-0 rounded-full"
265-
style={{ backgroundColor: colorFor(item.from) }}
266-
aria-hidden="true"
267-
/>
268-
{author}
269-
</span>
270-
<time dateTime={item.at.toISOString()}>
271-
{formatTime(item.at)}
272-
</time>
273-
</p>
274-
<p
275-
className={`m-0 px-3.5 py-2.5 leading-snug wrap-anywhere whitespace-pre-wrap ${item.mine ? "rounded-2xl rounded-br-md bg-mine text-mine-ink" : "rounded-2xl rounded-bl-md bg-other"}`}
276-
>
277-
{item.body}
278-
</p>
279-
</li>
280-
)
281-
})}
282-
</ol>
283-
)}
281+
{item.body}
282+
</p>
283+
</li>
284+
)
285+
})}
286+
</ol>
284287
</div>
285288

286289
<form

0 commit comments

Comments
 (0)