- These are jobs at startups that were funded by Y Combinator. - You can also get a job at a YC startup through Triplebyte. -
--
-
-
-
- -
-
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6ba349a9c..f6630c524 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -6,20 +6,35 @@ on: pull_request: jobs: - web: - name: Lint, test and build the React app + build: + name: Lint, test and build runs-on: ubuntu-latest - defaults: - run: - working-directory: web steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: 20 cache: npm - cache-dependency-path: web/package-lock.json - run: npm ci - run: npm run lint - run: npm test - run: npm run build + + e2e: + name: End-to-end tests + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 20 + cache: npm + - run: npm ci + - run: npx playwright install --with-deps chromium + - run: npm run test:e2e + - uses: actions/upload-artifact@v4 + if: failure() + with: + name: playwright-report + path: playwright-report/ + retention-days: 7 diff --git a/.gitignore b/.gitignore index f4f46a5fe..baad8e4d6 100644 --- a/.gitignore +++ b/.gitignore @@ -2,17 +2,18 @@ # compiled output /dist +/dev-dist /tmp -/out-tsc -# Only exists if Bazel was run -/bazel-out # dependencies /node_modules -# profiling files -chrome-profiler-events.json -speed-measure-plugin.json +# tests +/coverage +/test-results +/playwright-report +/blob-report +/playwright/.cache # IDEs and editors /.idea @@ -32,14 +33,9 @@ speed-measure-plugin.json .history/* # misc -/.sass-cache -/connect.lock -/coverage -/libpeerconnection.log npm-debug.log yarn-error.log -testem.log -/typings +*.local # System Files .DS_Store diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index ff155b5fa..000000000 --- a/.travis.yml +++ /dev/null @@ -1,23 +0,0 @@ -language: node_js -node_js: - - "6.9" - -branches: - only: - - master - -before_script: - - npm install -g firebase-tools - - npm install -g @angular/cli - -script: - - npm run build - -after_success: - - firebase use default - - firebase deploy --token $FIREBASE_TOKEN - -notifications: - email: - on_failure: change - on_success: change diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 840cf8244..7d862ed94 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,27 +1,27 @@ # Contributing Thank you for your interest in contributing! Please feel free to put up a PR for any issue or feature request. -Even if you have little to no experience with Angular, I'll be more than happy to help. :) +Even if you have little to no experience with React, I'll be more than happy to help. :) ## Setup 1. Fork the repo 2. Clone your fork 3. Make a branch for your feature or bug fix -4. If you don't have Angular CLI installed: `npm install -g angular-cli@latest` -5. `ng init` -6. Type `n` for each file to not overwrite any file changes -7. Run `npm start` and open `localhost:4200` in a browser -8. Work your magic -9. Run `npm run build` or `npm run static-serve` to kick off a production build and make sure nothing is broken -10. To test service worker changes: - * `npm run build` to kick off a fresh build and update the `dist/` directory - * `npm run precache` to generate the service worker file - * `npm run static-serve` to load the application along with the service worker asset using [live-server](https://github.com/tapio/live-server) -11. Add yourself to the [contributor's list](https://github.com/hdjirdeh/angular2-hn#contributors) in the README! -12. Commit your changes and reference the issue you're addressing (for example: `git commit -am 'Commit message. Closes #5'`) -13. Push your branch to your fork -14. Create a pull request from your branch on your fork to `master` on this repo -15. Have your branch get merged in! :star2: +4. `npm ci` +5. `npm run dev` and open `localhost:5173` in a browser +6. Work your magic +7. Before pushing, make sure nothing is broken: + * `npm run lint` + * `npm test` (add or update Vitest/React Testing Library tests for your change) + * `npm run build` + * `npm run test:e2e` for the Playwright suite (run `npx playwright install chromium` once beforehand) +8. To test service worker changes, run `npm run build` followed by `npm run preview` — the service worker is only + generated for production builds +9. Add yourself to the [contributor's list](https://github.com/hdjirdeh/angular2-hn#contributors) in the README! +10. Commit your changes and reference the issue you're addressing (for example: `git commit -am 'Commit message. Closes #5'`) +11. Push your branch to your fork +12. Create a pull request from your branch on your fork to `master` on this repo +13. Have your branch get merged in! :star2: If you experience a problem at any point, please don't hesitate to file an issue or send me a message! diff --git a/README.md b/README.md index 8fca3ce71..3fa3c0ea1 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@
- A progressive Hacker News client built with Angular + A progressive Hacker News client built with React
@@ -14,52 +14,80 @@
--- -## React migration (in progress) - -The app is being migrated from Angular to React 18 + TypeScript + Vite. The React app lives in [`web/`](/web) while the -Angular app in `src/` stays buildable until parity is reached. - -```bash -cd web -npm ci -npm run dev # dev server on http://localhost:5173 -npm run lint -npm test -npm run build -``` - ---- - :zap: **Fast:** Service Worker App Shell + Dynamic Content model to achieve faster load times with and without a network. :iphone: **Responsive:** Completely responsive UI that can be installed to your mobile home screen to provide a native feel. -:rocket: **Progressive:** [Lighthouse](https://github.com/GoogleChrome/lighthouse) score of 87/100. +:rocket: **Progressive:** installable, offline-capable PWA.
-
-
-
-
+
+
+
+
Item body
', + comments_count: 2, + comments: [ + { + id: 1, + level: 0, + user: 'commenter', + time: 1600000001, + time_ago: '2 hours ago', + content: 'A top level comment
', + comments: [ + { + id: 2, + level: 1, + user: 'replier', + time: 1600000002, + time_ago: '1 hour ago', + content: 'A nested reply
', + comments: [], + }, + ], + }, + ], +}; + +export const user = { + id: 'author', + created: 'October 22, 2010', + karma: 4321, + about: 'All about the author
', +}; + +/** + * Serves deterministic fixtures for every Hacker News API call so the e2e run + * never depends on the live API. + */ +export async function mockHackerNewsApi(page: Page) { + await page.route(`${API}/**`, async (route) => { + const url = new URL(route.request().url()); + const [, resource, id] = url.pathname.split('/'); + const pageNumber = Number(url.searchParams.get('page') ?? '1'); + + if (resource === 'item') { + await route.fulfill({ json: { ...itemWithComments, id: Number(id) } }); + return; + } + + if (resource === 'user') { + await route.fulfill({ json: { ...user, id: String(id) } }); + return; + } + + await route.fulfill({ json: feedStories(resource, pageNumber) }); + }); +} diff --git a/e2e/protractor.conf.js b/e2e/protractor.conf.js deleted file mode 100644 index 73e4e6806..000000000 --- a/e2e/protractor.conf.js +++ /dev/null @@ -1,32 +0,0 @@ -// @ts-check -// Protractor configuration file, see link for more information -// https://github.com/angular/protractor/blob/master/lib/config.ts - -const { SpecReporter } = require('jasmine-spec-reporter'); - -/** - * @type { import("protractor").Config } - */ -exports.config = { - allScriptsTimeout: 11000, - specs: [ - './src/**/*.e2e-spec.ts' - ], - capabilities: { - 'browserName': 'chrome' - }, - directConnect: true, - baseUrl: 'http://localhost:4200/', - framework: 'jasmine', - jasmineNodeOpts: { - showColors: true, - defaultTimeoutInterval: 30000, - print: function() {} - }, - onPrepare() { - require('ts-node').register({ - project: require('path').join(__dirname, './tsconfig.json') - }); - jasmine.getEnv().addReporter(new SpecReporter({ spec: { displayStacktrace: true } })); - } -}; \ No newline at end of file diff --git a/e2e/src/app.e2e-spec.ts b/e2e/src/app.e2e-spec.ts deleted file mode 100644 index 0897abab0..000000000 --- a/e2e/src/app.e2e-spec.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { AppPage } from './app.po'; -import { browser, logging } from 'protractor'; - -describe('workspace-project App', () => { - let page: AppPage; - - beforeEach(() => { - page = new AppPage(); - }); - - it('should display welcome message', () => { - page.navigateTo(); - expect(page.getTitleText()).toEqual('Welcome to angular-hnpwa!'); - }); - - afterEach(async () => { - // Assert that there are no errors emitted from the browser - const logs = await browser.manage().logs().get(logging.Type.BROWSER); - expect(logs).not.toContain(jasmine.objectContaining({ - level: logging.Level.SEVERE, - } as logging.Entry)); - }); -}); diff --git a/e2e/src/app.po.ts b/e2e/src/app.po.ts deleted file mode 100644 index 5776aa9eb..000000000 --- a/e2e/src/app.po.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { browser, by, element } from 'protractor'; - -export class AppPage { - navigateTo() { - return browser.get(browser.baseUrl) as Promise- These are jobs at startups that were funded by Y Combinator. - You can also get a job at a YC startup through Triplebyte. -
-- - {{item.title}} - - ({{item.domain}}) -
- -- - - {{item.title}} - - - {{item.title}} - -
-- - {{item.title}} - - ({{item.domain}}) -
- -{{ message }}
-If you are offline viewing, you'll need to visit this page with a network connection first before it can work offline.
-- - Profile: {{ user.id }} -
-Created {{ user.created }}
-{`theme: ${settings.theme}`}
+ {settings.showSettings &&+ + {item.title} + + {item.domain && ({item.domain})} +
+ ) : ( +
+
Top level comment
', + comments: [], + ...overrides, + }; +} + +function renderComment(comment: CommentModel) { + return render( +Top level comment
'); + expect(container.querySelector('.meta')).not.toHaveClass('meta-collapse'); + }); + + it('renders nested comments recursively', () => { + const comment = buildComment({ + content: 'level one', + comments: [ + buildComment({ + id: 2, + level: 1, + user: 'bob', + content: 'level two', + comments: [buildComment({ id: 3, level: 2, user: 'carol', content: 'level three' })], + }), + ], + }); + + const { container } = renderComment(comment); + + expect(screen.getByText('level one')).toBeInTheDocument(); + expect(screen.getByText('level two')).toBeInTheDocument(); + expect(screen.getByText('level three')).toBeInTheDocument(); + expect(container.querySelectorAll('.subtree')).toHaveLength(3); + + const firstSubtree = container.querySelector('.subtree'); + expect(within(firstSubtree as HTMLElement).getByRole('link', { name: 'bob' })).toBeInTheDocument(); + expect(within(firstSubtree as HTMLElement).getByRole('link', { name: 'carol' })).toBeInTheDocument(); + }); + + it('collapses and expands the comment content and its children', async () => { + const user = userEvent.setup(); + const comment = buildComment({ + content: 'parent', + comments: [buildComment({ id: 2, user: 'bob', content: 'child' })], + }); + + const { container } = renderComment(comment); + + expect(screen.getByText('parent')).toBeVisible(); + expect(screen.getByText('child')).toBeVisible(); + + await user.click(screen.getAllByText('[-]')[0]); + + expect(screen.getByText('[+]')).toBeInTheDocument(); + expect(container.querySelector('.meta')).toHaveClass('meta-collapse'); + expect(screen.getByText('parent')).not.toBeVisible(); + expect(screen.getByText('child')).not.toBeVisible(); + expect(screen.getByRole('link', { name: 'kate' })).toBeVisible(); + + await user.click(screen.getByText('[+]')); + + expect(screen.getAllByText('[-]')[0]).toBeInTheDocument(); + expect(container.querySelector('.meta')).not.toHaveClass('meta-collapse'); + expect(screen.getByText('parent')).toBeVisible(); + }); + + it('collapses a child independently of its parent', async () => { + const user = userEvent.setup(); + const comment = buildComment({ + content: 'parent', + comments: [buildComment({ id: 2, user: 'bob', content: 'child' })], + }); + + renderComment(comment); + + await user.click(screen.getAllByText('[-]')[1]); + + expect(screen.getByText('parent')).toBeVisible(); + expect(screen.getByText('child')).not.toBeVisible(); + }); + + it('renders the deleted state instead of the comment body', () => { + const { container } = renderComment( + buildComment({ deleted: true, content: 'should not be rendered', user: 'ghost' }) + ); + + expect(screen.getByText('[deleted]')).toHaveClass('collapse'); + expect(container.querySelector('.deleted-meta')?.textContent).toBe('[deleted] | Comment Deleted'); + expect(screen.queryByText('should not be rendered')).not.toBeInTheDocument(); + expect(screen.queryByRole('link', { name: 'ghost' })).not.toBeInTheDocument(); + expect(container.querySelector('.comment-tree')).toBeNull(); + }); + + it('renders deleted children inside a live comment tree', () => { + const comment = buildComment({ + content: 'parent', + comments: [buildComment({ id: 2, user: 'ghost', deleted: true, content: 'hidden' })], + }); + + renderComment(comment); + + expect(screen.getByText('parent')).toBeInTheDocument(); + expect(screen.getByText('[deleted]')).toBeInTheDocument(); + expect(screen.queryByText('hidden')).not.toBeInTheDocument(); + }); +}); diff --git a/src/item-details/Comment.tsx b/src/item-details/Comment.tsx new file mode 100644 index 000000000..77e6e03d4 --- /dev/null +++ b/src/item-details/Comment.tsx @@ -0,0 +1,49 @@ +import { useState } from 'react'; +import { NavLink } from 'react-router-dom'; + +import { Comment as CommentModel } from '../models/comment'; +import './comment.scss'; + +export interface CommentProps { + comment: CommentModel; +} + +export function Comment({ comment }: CommentProps) { + const [collapse, setCollapse] = useState(false); + + if (comment.deleted) { + return ( ++ These are jobs at startups that were funded by Y Combinator. You can also get a job at a YC + startup through Triplebyte. +
+ )} + {feedType !== 'new' && ( +story body
' })); + + const { container } = renderPage(); + await screen.findAllByText('A React story'); + + const mobileHeader = container.querySelector('.mobile.item-header') as HTMLElement; + + const mobileTitle = mobileHeader.querySelector('a.title'); + expect(mobileTitle).toHaveAttribute('href', 'https://example.com/story'); + expect(mobileTitle).not.toHaveAttribute('target'); + expect(mobileTitle).not.toHaveAttribute('rel'); + expect(mobileHeader.querySelector('.title-block .back-button')).not.toBeNull(); + + const laptopHeader = container.querySelector('.laptop') as HTMLElement; + expect(laptopHeader).toHaveClass('item-header'); + expect(laptopHeader).toHaveClass('head-margin'); + expect(laptopHeader.querySelector('a.title')).toHaveAttribute('href', 'https://example.com/story'); + expect(laptopHeader.querySelector('.domain')?.textContent).toBe('(example.com)'); + + const subtext = laptopHeader.querySelector('.subtext') as HTMLElement; + expect(subtext.textContent).toContain('120 points by'); + expect(subtext.querySelector('a[href="/user/alice"]')).not.toBeNull(); + expect(subtext.querySelector('.item-details')?.textContent).toContain('3 hours ago'); + expect(subtext.querySelector('a[href="/item/42"]')?.textContent).toBe('2 comments'); + + expect(container.querySelector('.subject')?.innerHTML).toBe('story body
'); + }); + + it('opens the story link in a new tab when the setting is enabled', async () => { + localStorage.setItem('openLinkInNewTab', 'true'); + fetchItemContentMock.mockResolvedValue(buildStory()); + + const { container } = renderPage(); + await screen.findAllByText('A React story'); + + container.querySelectorAll('a.title').forEach((title) => { + expect(title).toHaveAttribute('target', '_blank'); + expect(title).toHaveAttribute('rel', 'noopener'); + }); + }); + + it('links the title to the item itself when the story has no external url', async () => { + fetchItemContentMock.mockResolvedValue( + buildStory({ url: 'item?id=42', domain: undefined, comments_count: 0, content: undefined }) + ); + + const { container } = renderPage(); + await screen.findAllByText('A React story'); + + container.querySelectorAll('a.title').forEach((title) => { + expect(title).toHaveAttribute('href', '/item/42'); + }); + expect(container.querySelector('.domain')).toBeNull(); + expect(container.querySelector('.laptop')).not.toHaveClass('item-header'); + expect(container.querySelector('.laptop')).not.toHaveClass('head-margin'); + expect(container.querySelector('.subtext a[href="/item/42"]')?.textContent).toBe('discuss'); + expect(container.querySelector('.subject')?.innerHTML).toBe(''); + }); + + it('hides the points and comment count for job postings', async () => { + fetchItemContentMock.mockResolvedValue(buildStory({ type: 'job', comments_count: 0 })); + + const { container } = renderPage(); + await screen.findAllByText('A React story'); + + const subtext = container.querySelector('.laptop .subtext') as HTMLElement; + expect(subtext.textContent?.trim()).toBe('3 hours ago'); + expect(subtext.querySelector('.item-details')).toBeNull(); + expect(subtext.querySelector('a')).toBeNull(); + expect(container.querySelector('.laptop')).toHaveClass('item-header'); + }); + + it('renders poll results with bars sized from the vote share', async () => { + fetchItemContentMock.mockResolvedValue( + buildStory({ + type: 'poll', + poll: [ + { points: 30, content: 'Option A
' }, + { points: 10, content: 'Option B
' }, + ], + poll_votes_count: 40, + }) + ); + + const { container } = renderPage(); + await screen.findAllByText('A React story'); + + const pollContents = container.querySelectorAll('.pollResults .pollContent'); + expect(pollContents).toHaveLength(2); + expect(pollContents[0].textContent).toContain('Option A'); + expect(pollContents[0].querySelector('.subtext')?.textContent).toBe('30 points'); + expect(pollContents[0].querySelector
+ navigate(-1)}>
+ {hasUrl ? (
+
+ {item.title}
+
+ ) : (
+
+ + {item.title} + + {item.domain && ({item.domain})} +
+ ) : ( +
+
+ + Profile: {user.id} +
+Created {user.created}
+{`feed ${feedType} page ${page}`}
; + }; + return { FeedPage, default: FeedPage }; +}); + +vi.mock('./pages/ItemDetailsPage', () => { + const ItemDetailsPage = () => { + const { id } = useParams(); + return{`item ${id}`}
; + }; + return { ItemDetailsPage, default: ItemDetailsPage }; +}); + +vi.mock('./pages/UserPage', () => { + const UserPage = () => { + const { id } = useParams(); + return{`user ${id}`}
; + }; + return { UserPage, default: UserPage }; +}); + +function renderRoutes(initialEntry: string) { + stubMatchMedia(false); + vi.stubGlobal('scrollTo', vi.fn()); + + return render( +Y Combinator
indented', +}; + +function renderUserPage(entries: string[] = ['/user/pg'], initialIndex = 0) { + return render( +
Y Combinator
indented'); + }); + + it('omits the about section for a user without an about text', async () => { + fetchUserMock.mockResolvedValue({ id: 'lurker', created: '2 days ago', karma: 1 }); + + const { container } = renderUserPage(['/user/lurker']); + + await screen.findByText('Created 2 days ago'); + + expect(container.querySelector('.other-details')).toBeNull(); + }); + + it('shows an error message when the user could not be loaded', async () => { + fetchUserMock.mockRejectedValue(new Error('offline')); + + const { container } = renderUserPage(['/user/ghost']); + + expect(await screen.findByText('Could not load user ghost.')).toBeInTheDocument(); + expect(container.querySelector('.profile')).toBeNull(); + expect(container.querySelector('.loading-section')).toBeNull(); + }); + + it('goes back in the history when the back button is clicked', async () => { + fetchUserMock.mockResolvedValue(user); + + const { container } = renderUserPage(['/news/1', '/user/pg'], 1); + + await screen.findByText('Created 4230 days ago'); + + await userEvent.click(container.querySelector('.back-button') as HTMLElement); + + await waitFor(() => expect(screen.getByText('news feed')).toBeInTheDocument()); + }); + + it('does not fetch anything when the route carries no user id', () => { + const { container } = render( +
content
-
--
-
-
-
-