-
Notifications
You must be signed in to change notification settings - Fork 280
test: basic test suite #441
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,21 @@ | ||
| name: ci | ||
|
|
||
| on: | ||
| push: | ||
| branches: | ||
| - main | ||
| pull_request: | ||
|
|
||
| jobs: | ||
| test: | ||
| runs-on: ubuntu-latest | ||
| steps: | ||
| - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 | ||
| - run: corepack enable | ||
| - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7 | ||
| with: | ||
| node-version: 22 | ||
| cache: pnpm | ||
| - run: pnpm install | ||
| - run: pnpm lint | ||
| - run: pnpm test | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1 @@ | ||
| setups.@nuxt/test-utils="4.1.0" |
Large diffs are not rendered by default.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1 +1 @@ | ||
| export const BASE_URL = 'https://hacker-news.firebaseio.com/v0' | ||
| export const BASE_URL = process.env.HN_API_BASE || 'https://hacker-news.firebaseio.com/v0' |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,90 @@ | ||
| import { fileURLToPath } from 'node:url' | ||
| import { afterAll, describe, expect, it } from 'vitest' | ||
| import { $fetch, fetch, setup } from '@nuxt/test-utils/e2e' | ||
| import { startMockHackerNews } from './mock-hn' | ||
|
|
||
| const mock = await startMockHackerNews() | ||
| process.env.HN_API_BASE = mock.url | ||
|
|
||
| await setup({ | ||
| rootDir: fileURLToPath(new URL('../..', import.meta.url)), | ||
| server: true, | ||
| env: { | ||
| HN_API_BASE: mock.url, | ||
| }, | ||
| }) | ||
|
|
||
| afterAll(() => mock.close()) | ||
|
|
||
| describe('server api', () => { | ||
| it('returns a feed', async () => { | ||
| const feed = await $fetch<{ id: number, title: string }[]>('/api/hn/feeds') | ||
| expect(feed).toHaveLength(2) | ||
| expect(feed[0]).toMatchObject({ | ||
| id: 100, | ||
| title: 'A top story', | ||
| user: 'daniel', | ||
| points: 42, | ||
| comments_count: 2, | ||
| }) | ||
| }) | ||
|
|
||
| it('rejects unknown feeds and invalid pages', async () => { | ||
| for (const query of ['?feed=nope', '?page=abc']) { | ||
| const res = await fetch(`/api/hn/feeds${query}`) | ||
| expect(res.status).toBe(422) | ||
| } | ||
| }) | ||
|
|
||
| it('returns an item with nested comments', async () => { | ||
| const item = await $fetch<{ comments: { id: number, comments: { id: number }[] }[] }>('/api/hn/item?id=100') | ||
| expect(item.comments.map(c => c.id)).toEqual([200, 201]) | ||
| expect(item.comments[0]!.comments.map(c => c.id)).toEqual([202]) | ||
| }) | ||
|
|
||
| it('validates item ids', async () => { | ||
| expect((await fetch('/api/hn/item')).status).toBe(422) | ||
| expect((await fetch('/api/hn/item?id=abc')).status).toBe(400) | ||
| }) | ||
|
|
||
| it('returns a user', async () => { | ||
| const user = await $fetch<{ id: string, karma: number }>('/api/hn/user?id=daniel') | ||
| expect(user).toMatchObject({ id: 'daniel', karma: 1234 }) | ||
| }) | ||
|
|
||
| it('validates user ids', async () => { | ||
| expect((await fetch('/api/hn/user')).status).toBe(422) | ||
| }) | ||
| }) | ||
|
|
||
| describe('pages', () => { | ||
| it('server-renders the news feed on the home page', async () => { | ||
| const html = await $fetch<string>('/') | ||
| expect(html).toContain('A top story') | ||
| expect(html).toContain('(example.com)') | ||
| expect(html).toContain('Ask HN: A question') | ||
| }) | ||
|
|
||
| it('server-renders feed pages', async () => { | ||
| const html = await $fetch<string>('/ask/1') | ||
| expect(html).toContain('A top story') | ||
| }) | ||
|
|
||
| it('redirects unknown feeds', async () => { | ||
| const res = await fetch('/nope/1', { redirect: 'manual' }) | ||
| expect([301, 302, 307, 308]).toContain(res.status) | ||
| }) | ||
|
|
||
| it('server-renders an item page with comments', async () => { | ||
| const html = await $fetch<string>('/item/100') | ||
| expect(html).toContain('A top story') | ||
| expect(html).toContain('A comment') | ||
| expect(html).toContain('A nested reply') | ||
| }) | ||
|
|
||
| it('server-renders a user page', async () => { | ||
| const html = await $fetch<string>('/user/daniel') | ||
| expect(html).toContain('daniel') | ||
| expect(html).toContain('1234') | ||
| }) | ||
| }) |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,86 @@ | ||
| import { createServer } from 'node:http' | ||
| import type { AddressInfo } from 'node:net' | ||
|
|
||
| const items: Record<number, Record<string, unknown>> = { | ||
| 100: { | ||
| id: 100, | ||
| by: 'daniel', | ||
| score: 42, | ||
| time: Math.floor(Date.now() / 1000) - 3600, | ||
| title: 'A top story', | ||
| url: 'https://example.com/story', | ||
| type: 'story', | ||
| kids: [200, 201], | ||
| }, | ||
| 101: { | ||
| id: 101, | ||
| by: 'someone', | ||
| score: 10, | ||
| time: Math.floor(Date.now() / 1000) - 7200, | ||
| title: 'Ask HN: A question', | ||
| text: '<p>The question body</p>', | ||
| type: 'story', | ||
| }, | ||
| 200: { | ||
| id: 200, | ||
| by: 'commenter', | ||
| time: Math.floor(Date.now() / 1000) - 1800, | ||
| text: '<p>A comment</p>', | ||
| type: 'comment', | ||
| kids: [202], | ||
| }, | ||
| 201: { | ||
| id: 201, | ||
| by: 'other', | ||
| time: Math.floor(Date.now() / 1000) - 900, | ||
| text: '<p>Another comment</p>', | ||
| type: 'comment', | ||
| }, | ||
| 202: { | ||
| id: 202, | ||
| by: 'nested', | ||
| time: Math.floor(Date.now() / 1000) - 600, | ||
| text: '<p>A nested reply</p>', | ||
| type: 'comment', | ||
| }, | ||
| } | ||
|
|
||
| const users: Record<string, Record<string, unknown>> = { | ||
| daniel: { | ||
| id: 'daniel', | ||
| karma: 1234, | ||
| created: Math.floor(Date.now() / 1000) - 86400 * 365, | ||
| about: 'Test user', | ||
| }, | ||
| } | ||
|
|
||
| export async function startMockHackerNews() { | ||
| const server = createServer((req, res) => { | ||
| const url = req.url || '' | ||
| const json = (body: unknown) => { | ||
| res.setHeader('content-type', 'application/json') | ||
| res.end(JSON.stringify(body)) | ||
| } | ||
| if (/^\/(top|new|ask|show|job)stories\.json/.test(url)) { | ||
| return json([100, 101]) | ||
|
Comment on lines
+64
to
+65
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win Return distinct fixture data for each feed category. Every feed route returns the same IDs. Therefore, Return a category-specific ID list. Update the 🤖 Prompt for AI Agents |
||
| } | ||
| const item = url.match(/^\/item\/(\d+)\.json/) | ||
| if (item) { | ||
| return json(items[Number(item[1])] ?? null) | ||
| } | ||
| const user = url.match(/^\/user\/([^.]+)\.json/) | ||
| if (user) { | ||
| return json(users[user[1]!] ?? null) | ||
| } | ||
| res.statusCode = 404 | ||
| res.end('not found') | ||
| }) | ||
| await new Promise<void>(resolve => server.listen(0, '127.0.0.1', resolve)) | ||
| const { port } = server.address() as AddressInfo | ||
| return { | ||
| url: `http://127.0.0.1:${port}`, | ||
| close: () => new Promise<void>((resolve, reject) => | ||
| server.close(err => err ? reject(err) : resolve()), | ||
| ), | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,62 @@ | ||
| import { describe, expect, it } from 'vitest' | ||
| import { mountSuspended } from '@nuxt/test-utils/runtime' | ||
| import type { Item } from '~~/types' | ||
| import PostItem from '~/components/PostItem.vue' | ||
| import ItemListNav from '~/components/ItemListNav.vue' | ||
|
|
||
| const story: Item = { | ||
| id: 1, | ||
| title: 'A story', | ||
| url: 'https://www.example.com/story', | ||
| type: 'story', | ||
| points: 42, | ||
| user: 'daniel', | ||
| time: String(Math.floor(Date.now() / 1000) - 120), | ||
| comments_count: 7, | ||
|
Comment on lines
+7
to
+15
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win Freeze the clock for the relative-time assertion.
Proposed fix-import { describe, expect, it } from 'vitest'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+const fixedNow = new Date('2024-01-10T12:00:00Z')
const story: Item = {
id: 1,
title: 'A story',
url: 'https://www.example.com/story',
type: 'story',
points: 42,
user: 'daniel',
- time: String(Math.floor(Date.now() / 1000) - 120),
+ time: String(Math.floor(fixedNow.getTime() / 1000) - 120),
comments_count: 7,
}
describe('PostItem', () => {
+ beforeEach(() => {
+ vi.useFakeTimers()
+ vi.setSystemTime(fixedNow)
+ })
+
+ afterEach(() => {
+ vi.useRealTimers()
+ })
+🤖 Prompt for AI Agents |
||
| } | ||
|
|
||
| describe('PostItem', () => { | ||
| it('renders an external story with host and comments', async () => { | ||
| const wrapper = await mountSuspended(PostItem, { props: { item: story } }) | ||
| const link = wrapper.get('.title a') | ||
| expect(link.attributes('href')).toBe(story.url) | ||
| expect(link.text()).toBe('A story') | ||
| expect(wrapper.get('.host').text()).toBe('(example.com)') | ||
| expect(wrapper.get('.score').text()).toBe('42') | ||
| expect(wrapper.get('.comments-link').text()).toContain('7 comments') | ||
| expect(wrapper.get('.time').text()).toContain('2 minutes ago') | ||
| }) | ||
|
|
||
| it('links internally for items without a url', async () => { | ||
| const ask: Item = { ...story, url: undefined as never, id: 2 } | ||
| const wrapper = await mountSuspended(PostItem, { props: { item: ask } }) | ||
| expect(wrapper.get('.title a').attributes('href')).toBe('/item/2') | ||
| }) | ||
|
|
||
| it('hides author and comments for jobs', async () => { | ||
| const job: Item = { ...story, type: 'job' } | ||
| const wrapper = await mountSuspended(PostItem, { props: { item: job } }) | ||
| expect(wrapper.find('.by').exists()).toBe(false) | ||
| expect(wrapper.find('.comments-link').exists()).toBe(false) | ||
| }) | ||
| }) | ||
|
|
||
| describe('ItemListNav', () => { | ||
| it('renders pagination links', async () => { | ||
| const wrapper = await mountSuspended(ItemListNav, { | ||
| props: { feed: 'news', page: 2, maxPage: 10 }, | ||
| }) | ||
| expect(wrapper.get('.page').text()).toBe('2 / 10') | ||
| const links = wrapper.findAll('a').map(a => a.attributes('href')) | ||
| expect(links).toContain('/news/1') | ||
| expect(links).toContain('/news/3') | ||
| }) | ||
|
|
||
| it('disables prev on the first page and more on the last', async () => { | ||
| const wrapper = await mountSuspended(ItemListNav, { | ||
| props: { feed: 'ask', page: 1, maxPage: 1 }, | ||
| }) | ||
| expect(wrapper.findAll('a')).toHaveLength(0) | ||
| expect(wrapper.findAll('.disabled')).toHaveLength(2) | ||
| }) | ||
| }) | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🔒 Security & Privacy | 🟡 Minor | ⚡ Quick win
🧩 Analysis chain
🏁 Script executed:
Repository: nuxt/hackernews
Length of output: 1092
🏁 Script executed:
Repository: nuxt/hackernews
Length of output: 682
Do not persist the checkout token.
Set
permissions: contents: readandpersist-credentials: false. No later step requires authenticated Git commands, andpnpm installcan run lifecycle scripts.🧰 Tools
🪛 zizmor (1.29.0)
[warning] 13-13: credential persistence through GitHub Actions artifacts (artipacked): does not set persist-credentials: false
(artipacked)
🤖 Prompt for AI Agents
Source: Linters/SAST tools