From a104313c2ff11b984cd4b4651f54f7f98cc2290a Mon Sep 17 00:00:00 2001 From: Andrey Sitnik Date: Fri, 18 Oct 2024 23:55:41 +0000 Subject: [PATCH 01/20] Add new API for pages --- core/index.ts | 2 + core/page.ts | 75 ++++ core/pages/add.ts | 252 ++++++++++++++ core/pages/common.ts | 37 ++ core/pages/index.ts | 50 +++ core/preview.ts | 4 +- core/router.ts | 33 +- core/test/menu.test.ts | 10 +- core/test/page.test.ts | 108 ++++++ core/test/pages/add.test.ts | 380 +++++++++++++++++++++ core/test/preview.test.ts | 30 +- core/test/router.test.ts | 2 +- core/test/two-steps.test.ts | 12 +- web/stores/router.ts | 3 +- web/stories/pages/feeds/add.stories.svelte | 43 ++- web/stories/ui/navbar.stories.svelte | 2 +- web/ui/navbar/index.svelte | 7 +- web/ui/navbar/other.svelte | 5 +- 18 files changed, 1010 insertions(+), 45 deletions(-) create mode 100644 core/page.ts create mode 100644 core/pages/add.ts create mode 100644 core/pages/common.ts create mode 100644 core/pages/index.ts create mode 100644 core/test/page.test.ts create mode 100644 core/test/pages/add.test.ts diff --git a/core/index.ts b/core/index.ts index c4a0e80f..d44d9c3b 100644 --- a/core/index.ts +++ b/core/index.ts @@ -17,6 +17,8 @@ export * from './loader/index.ts' export * from './menu.ts' export * from './messages/index.ts' export * from './not-found.ts' +export * from './page.ts' +export * from './pages/index.ts' export * from './post.ts' export * from './posts-list.ts' export * from './preview.ts' diff --git a/core/page.ts b/core/page.ts new file mode 100644 index 00000000..7864d730 --- /dev/null +++ b/core/page.ts @@ -0,0 +1,75 @@ +import { computed, type ReadableAtom, type WritableStore } from 'nanostores' + +import { getEnvironment } from './environment.ts' +import { type Page, pages } from './pages/index.ts' +import { type Route, router } from './router.ts' + +function isStore(store: unknown): store is WritableStore { + return typeof store === 'object' && store !== null && 'listen' in store +} + +function eachParam( + page: Page, + route: SomeRoute, + iterator: ( + store: WritableStore, + name: Param, + value: SomeRoute['params'][Param] + ) => void +): void { + let params = route.params as SomeRoute['params'] + for (let i in params) { + let name = i as keyof SomeRoute['params'] + let value = params[name] + let store = page[name] + if (isStore(store)) { + iterator(store, name, value) + } + } +} + +function changeRouteParam( + route: Route, + change: Partial +): void { + getEnvironment().openRoute({ + ...route, + params: { + ...route.params, + ...change + } + } as Route) +} + +let prevPage: Page | undefined +let unbinds: (() => void)[] = [] + +export const page: ReadableAtom = computed(router, route => { + let currentPage = pages[route.route] + if (currentPage !== prevPage) { + if (prevPage) { + for (let unbind of unbinds) unbind() + prevPage.destroy() + } + prevPage = currentPage + + eachParam(currentPage, route, (store, param) => { + unbinds.push( + store.listen(newValue => { + let currentRoute = router.get() + if (currentRoute.route === currentPage.route) { + changeRouteParam(currentRoute, { [param]: newValue }) + } + }) + ) + }) + } + + eachParam(currentPage, route, (store, param, value) => { + if (store.get() !== value) { + store.set(value) + } + }) + + return currentPage +}) diff --git a/core/pages/add.ts b/core/pages/add.ts new file mode 100644 index 00000000..f3e6c5e0 --- /dev/null +++ b/core/pages/add.ts @@ -0,0 +1,252 @@ +import debounce from 'just-debounce-it' +import { atom, computed, map } from 'nanostores' + +import { + createDownloadTask, + type DownloadTask, + ignoreAbortError, + type TextResponse +} from '../download.ts' +import { type LoaderName, loaders } from '../loader/index.ts' +import { createPage } from './common.ts' + +const ALWAYS_HTTPS = [/^twitter\.com\//] + +export type AddLinksValue = Record< + string, + | { + error: 'invalidUrl' + state: 'invalid' + } + | { + state: 'loading' + } + | { + state: 'processed' + } + | { + state: 'unknown' + } + | { + state: 'unloadable' + } +> + +export interface AddCandidate { + loader: LoaderName + text?: TextResponse + title: string + url: string +} + +export const add = createPage('add', () => { + let $url = atom() + + let $links = map({}) + + let $candidates = atom([]) + + let $error = computed( + $links, + (links): 'invalidUrl' | 'unloadable' | undefined => { + let first = Object.keys(links)[0] + if (typeof first !== 'undefined') { + let link = links[first]! + if (link.state === 'invalid') { + return link.error + } else if (link.state === 'unloadable') { + return 'unloadable' + } + } + return undefined + } + ) + + let $sortedCandidates = computed($candidates, candidates => { + return candidates.sort((a, b) => { + return a.title.localeCompare(b.title) + }) + }) + + let $candidatesLoading = computed($links, links => { + return Object.keys(links).some(url => links[url]!.state === 'loading') + }) + + let $noResults = computed( + [$candidatesLoading, $url, $candidates, $error], + (loading, url, candidates, error) => { + return !loading && !!url && candidates.length === 0 && !error + } + ) + + function destroy(): void { + $links.set({}) + $candidates.set([]) + prevTask?.abortAll() + } + + let inputUrl = debounce((value: string) => { + if (value === '') { + destroy() + } else { + //TODO: currentCandidate.set(undefined) + setUrl(value) + } + }, 500) + + let prevTask: DownloadTask | undefined + async function setUrl(url: string): Promise { + if (prevTask) prevTask.abortAll() + if (url === $url.get()) return + inputUrl.cancel() + destroy() + prevTask = createDownloadTask() + await addLink(prevTask, url) + } + + function getLoaderForUrl(url: string): AddCandidate | false { + let names = Object.keys(loaders) as LoaderName[] + let parsed = new URL(url) + for (let name of names) { + let title = loaders[name].isMineUrl(parsed) + // Until we will have loader for specific domain + /* c8 ignore start */ + if (typeof title === 'string') { + return { loader: name, title, url } + } + /* c8 ignore end */ + } + return false + } + + function getLoaderForText(response: TextResponse): AddCandidate | false { + let names = Object.keys(loaders) as LoaderName[] + let parsed = new URL(response.url) + for (let name of names) { + if (loaders[name].isMineUrl(parsed) !== false) { + let title = loaders[name].isMineText(response) + if (title !== false) { + return { + loader: name, + text: response, + title: title.trim(), + url: response.url + } + } + } + } + return false + } + + function getLinksFromText(response: TextResponse): string[] { + let names = Object.keys(loaders) as LoaderName[] + return names.reduce((links, name) => { + return links.concat(loaders[name].getMineLinksFromText(response)) + }, []) + } + + function getSuggestedLinksFromText(response: TextResponse): string[] { + let names = Object.keys(loaders) as LoaderName[] + return names.reduce((links, name) => { + return links.concat(loaders[name].getSuggestedLinksFromText(response)) + }, []) + } + + function addCandidate(url: string, candidate: AddCandidate): void { + if ($candidates.get().some(i => i.url === url)) return + + $links.setKey(url, { state: 'processed' }) + $candidates.set([...$candidates.get(), candidate]) + } + + async function addLink( + task: DownloadTask, + url: string, + deep = false + ): Promise { + url = url.trim() + if (url === '') return + + if (url.startsWith('http://')) { + let methodLess = url.slice('http://'.length) + if (ALWAYS_HTTPS.some(i => i.test(methodLess))) { + url = 'https://' + methodLess + } + } else if (!url.startsWith('https://')) { + if (/^\w+:/.test(url)) { + $links.setKey(url, { error: 'invalidUrl', state: 'invalid' }) + return + } else if (ALWAYS_HTTPS.some(i => i.test(url))) { + url = 'https://' + url + } else { + url = 'http://' + url + } + } + + if ($links.get()[url]) return + + if (!URL.canParse(url)) { + $links.setKey(url, { error: 'invalidUrl', state: 'invalid' }) + return + } + + let byUrl = getLoaderForUrl(url) + + if (byUrl !== false) { + // Until we will have loader for specific domain + /* c8 ignore next */ + + addCandidate(url, byUrl) + } else { + $links.setKey(url, { state: 'loading' }) + try { + let response + try { + response = await task.text(url) + } catch { + $links.setKey(url, { state: 'unloadable' }) + return + } + if (!response.ok) { + $links.setKey(url, { state: 'unloadable' }) + } else { + let byText = getLoaderForText(response) + if (byText) { + addCandidate(url, byText) + } else { + $links.setKey(url, { state: 'unknown' }) + } + if (!deep) { + let links = getLinksFromText(response) + if (links.length > 0) { + await Promise.all(links.map(i => addLink(task, i, true))) + } else if ($candidates.get().length === 0) { + let suggested = getSuggestedLinksFromText(response) + await Promise.all(suggested.map(i => addLink(task, i, true))) + } + } + } + } catch (error) { + ignoreAbortError(error) + } + } + } + + $links.listen(links => { + $url.set(Object.keys(links)[0] ?? undefined) + }) + + return { + candidate: atom(), // TODO: Remove to popups + candidatesLoading: $candidatesLoading, + destroy, + error: $error, + inputUrl, + noResults: $noResults, + setUrl, + sortedCandidates: $sortedCandidates, + url: $url + } +}) + +export type AddPage = typeof add diff --git a/core/pages/common.ts b/core/pages/common.ts new file mode 100644 index 00000000..6f34f919 --- /dev/null +++ b/core/pages/common.ts @@ -0,0 +1,37 @@ +import { atom, type ReadableAtom } from 'nanostores' + +import type { ParamlessRouteName, RouteName, Routes } from '../router.ts' + +type Extra = { + destroy?: () => void +} + +type ParamStores = { + [Param in keyof Routes[Name]]-?: ReadableAtom +} + +export type BasePage = { + destroy(): void + readonly loading: ReadableAtom + readonly route: Name + underConstruction?: boolean +} & ParamStores + +export function createPage( + route: Name, + builder: () => ParamStores & Rest +): BasePage & Rest { + let rest = builder() + return { + destroy: rest.destroy ?? ((): void => {}), + loading: atom(false), + route, + ...rest + } +} + +export function createSimplePage( + route: Name +): BasePage { + return createPage(route, () => ({}) as ParamStores) +} diff --git a/core/pages/index.ts b/core/pages/index.ts new file mode 100644 index 00000000..23a24e4d --- /dev/null +++ b/core/pages/index.ts @@ -0,0 +1,50 @@ +import { atom } from 'nanostores' + +import type { RouteName, Routes } from '../router.ts' +import { add } from './add.ts' +import { type BasePage, createPage, createSimplePage } from './common.ts' + +export type { AddCandidate, AddPage } from './add.ts' +export * from './common.ts' + +// TODO: Remove after refactoring +export function underConstruction( + route: Name, + params: (keyof Routes[Name])[] +): BasePage { + return createPage(route, () => { + let result = {} as BasePage + for (let param of params) { + result[param] = atom() + } + result.underConstruction = true + return result + }) +} + +export const pages = { + about: underConstruction('about', []), + add, + categories: underConstruction('categories', ['feed']), + download: underConstruction('download', []), + export: underConstruction('export', []), + fast: underConstruction('fast', ['category', 'post', 'since']), + feeds: underConstruction('feeds', []), + home: underConstruction('home', []), + import: underConstruction('import', []), + interface: underConstruction('interface', []), + notFound: createSimplePage('notFound'), + profile: underConstruction('profile', []), + refresh: underConstruction('refresh', []), + settings: underConstruction('settings', []), + signin: underConstruction('signin', []), + slow: underConstruction('slow', ['feed', 'page', 'post']), + start: underConstruction('start', []), + welcome: underConstruction('welcome', []) +} satisfies { + [Name in RouteName]: BasePage +} + +export type Pages = typeof pages + +export type Page = Pages[Name] diff --git a/core/preview.ts b/core/preview.ts index 12deb273..67f3e4ed 100644 --- a/core/preview.ts +++ b/core/preview.ts @@ -348,7 +348,7 @@ onEnvironment(({ openRoute }) => { previewUrl.listen(link => { let page = router.get() if (page.route === 'add' && page.params.url !== link) { - openRoute({ params: { url: link }, route: 'add' }) + openRoute({ params: { candidate: undefined, url: link }, route: 'add' }) } }), router.subscribe(({ params, route }) => { @@ -367,7 +367,7 @@ onEnvironment(({ openRoute }) => { setPreviewCandidate(params.candidate) } else { openRoute({ - params: { url: params.url }, + params: { candidate: undefined, url: params.url }, route: 'add' }) } diff --git a/core/router.ts b/core/router.ts index 8684109b..541fe576 100644 --- a/core/router.ts +++ b/core/router.ts @@ -9,7 +9,7 @@ import { slowCategories } from './slow.ts' export interface Routes { about: {} - add: { candidate?: string; url?: string } + add: { candidate: string | undefined; url: string | undefined } categories: { feed?: string } download: {} export: { format?: string } @@ -70,13 +70,12 @@ const SETTINGS = new Set([ const ORGANIZE = new Set(['add', 'categories']) -function open(route: ParamlessRouteName | Route): Route { - if (typeof route === 'string') route = { params: {}, route } as Route - return route +function open(route: ParamlessRouteName): Route { + return { params: {}, route } } -function redirect(route: ParamlessRouteName | Route): Route { - return { ...open(route), redirect: true } +function redirect(route: Route): Route { + return { ...route, redirect: true } } function validateNumber( @@ -108,14 +107,17 @@ onEnvironment(({ baseRouter }) => { if (withFeeds) { return redirect({ params: {}, route: 'slow' }) } else { - return redirect('welcome') + return redirect(open('welcome')) } } else if (route.route === 'welcome' && withFeeds) { - return redirect('slow') + return redirect(open('slow')) } else if (route.route === 'settings') { - return redirect('interface') + return redirect(open('interface')) } else if (route.route === 'feeds') { - return redirect('add') + return redirect({ + params: { candidate: undefined, url: undefined }, + route: 'add' + }) } else if (route.route === 'fast') { if (!route.params.category && !fast.isLoading) { return redirect({ @@ -168,13 +170,13 @@ onEnvironment(({ baseRouter }) => { } }) } else { - return open({ + return { params: { ...route.params, page: 1 }, route: 'slow' - }) + } } } } else if (!GUEST.has(route.route)) { @@ -206,12 +208,13 @@ export function backToFirstStep(): void { } } +// TODO: Remove on moving to popups export const backRoute = computed( - router, + $router, ({ params, route }): Route | undefined => { if (route === 'add' && params.candidate) { return { - params: { url: params.url }, + params: { candidate: undefined, url: params.url }, route: 'add' } } else if (route === 'categories' && params.feed) { @@ -239,7 +242,7 @@ export const backRoute = computed( ) export function onNextRoute(cb: (route: Route) => void): void { - let unbind = router.listen(route => { + let unbind = $router.listen(route => { unbind() cb(route) }) diff --git a/core/test/menu.test.ts b/core/test/menu.test.ts index fdb32f91..12a3f51e 100644 --- a/core/test/menu.test.ts +++ b/core/test/menu.test.ts @@ -28,7 +28,10 @@ afterEach(async () => { }) test('do not open menu if fast has 1 category', async () => { - setBaseTestRoute({ params: {}, route: 'add' }) + setBaseTestRoute({ + params: { candidate: undefined, url: undefined }, + route: 'add' + }) let idA = await addCategory({ title: 'A' }) await addFeed(testFeed({ categoryId: idA, reading: 'fast' })) await addFeed(testFeed({ categoryId: idA, reading: 'fast' })) @@ -55,7 +58,10 @@ test('do not open menu if fast has 1 category', async () => { equal(isMenuOpened.get(), false) openMenu() - setBaseTestRoute({ params: {}, route: 'add' }) + setBaseTestRoute({ + params: { candidate: undefined, url: undefined }, + route: 'add' + }) await setTimeout(10) equal(isMenuOpened.get(), true) diff --git a/core/test/page.test.ts b/core/test/page.test.ts new file mode 100644 index 00000000..3d2c1370 --- /dev/null +++ b/core/test/page.test.ts @@ -0,0 +1,108 @@ +import { cleanStores, keepMount } from 'nanostores' +import { deepStrictEqual, equal } from 'node:assert' +import { afterEach, beforeEach, test } from 'node:test' +import { setTimeout } from 'node:timers/promises' + +import { page, pages, router, setBaseTestRoute } from '../index.ts' +import { cleanClientTest, enableClientTest } from './utils.ts' + +let addPage = pages.add + +beforeEach(() => { + enableClientTest() +}) + +afterEach(async () => { + pages.add = addPage + await cleanClientTest() + cleanStores(page) +}) + +test('synchronies router with page', () => { + keepMount(page) + + setBaseTestRoute({ params: {}, route: 'notFound' }) + equal(page.get(), pages.notFound) + + setBaseTestRoute({ + params: { candidate: undefined, url: undefined }, + route: 'add' + }) + equal(page.get(), pages.add) + + setBaseTestRoute({ params: {}, route: 'notFound' }) + equal(page.get(), pages.notFound) +}) + +test('calls events', () => { + keepMount(page) + let events = 0 + pages.add = { + ...pages.add, + destroy: () => { + events += 1 + } + } + + setBaseTestRoute({ params: {}, route: 'notFound' }) + equal(page.get(), pages.notFound) + equal(events, 0) + + setBaseTestRoute({ + params: { candidate: undefined, url: undefined }, + route: 'add' + }) + equal(page.get(), pages.add) + equal(events, 0) + + setBaseTestRoute({ params: {}, route: 'notFound' }) + equal(page.get(), pages.notFound) + equal(events, 1) + + setBaseTestRoute({ + params: { candidate: undefined, url: undefined }, + route: 'add' + }) + equal(page.get(), pages.add) + equal(events, 1) + + setBaseTestRoute({ params: {}, route: 'notFound' }) + equal(page.get(), pages.notFound) + equal(events, 2) +}) + +test('synchronizes params', async () => { + keepMount(page) + setBaseTestRoute({ + params: { candidate: undefined, url: undefined }, + route: 'add' + }) + equal(pages.add.url.get(), undefined) + equal(pages.add.candidate.get(), undefined) + + pages.add.url.set('https://example.com') + await setTimeout(1) + deepStrictEqual(router.get(), { + params: { candidate: undefined, url: 'https://example.com' }, + route: 'add' + }) + equal(pages.add.url.get(), 'https://example.com') + equal(pages.add.candidate.get(), undefined) + + setBaseTestRoute({ + params: { candidate: undefined, url: 'https://other.com' }, + route: 'add' + }) + await setTimeout(1) + deepStrictEqual(router.get(), { + params: { candidate: undefined, url: 'https://other.com' }, + route: 'add' + }) + equal(pages.add.url.get(), 'https://other.com') + equal(pages.add.candidate.get(), undefined) + + setBaseTestRoute({ params: {}, route: 'notFound' }) + pages.add.url.set('https://example.com') + await setTimeout(1) + deepStrictEqual(router.get(), { params: {}, route: 'notFound' }) +}) diff --git a/core/test/pages/add.test.ts b/core/test/pages/add.test.ts new file mode 100644 index 00000000..25c97578 --- /dev/null +++ b/core/test/pages/add.test.ts @@ -0,0 +1,380 @@ +import '../dom-parser.ts' + +import { restoreAll, spyOn } from 'nanospy' +import { keepMount } from 'nanostores' +import { deepStrictEqual, equal } from 'node:assert' +import { afterEach, beforeEach, test } from 'node:test' +import { setTimeout } from 'node:timers/promises' + +import { + type AddCandidate, + checkAndRemoveRequestMock, + expectRequest, + loaders, + mockRequest, + pages, + setBaseTestRoute +} from '../../index.ts' +import { cleanClientTest, enableClientTest } from '../utils.ts' + +beforeEach(() => { + enableClientTest() + mockRequest() + setBaseTestRoute({ + params: { candidate: undefined, url: undefined }, + route: 'add' + }) +}) + +afterEach(async () => { + await cleanClientTest() + restoreAll() + pages.add.destroy() + checkAndRemoveRequestMock() +}) + +function equalWithText(a: AddCandidate[], b: AddCandidate[]): void { + equal(a.length, b.length) + for (let i = 0; i < a.length; i++) { + let aFix = { ...a[i], text: undefined } + let bFix = { ...b[i], text: undefined } + deepStrictEqual(aFix, bFix) + } +} + +test('empty from beginning', () => { + keepMount(pages.add.error) + keepMount(pages.add.candidatesLoading) + keepMount(pages.add.sortedCandidates) + + equal(pages.add.error.get(), undefined) + equal(pages.add.candidatesLoading.get(), false) + deepStrictEqual(pages.add.sortedCandidates.get(), []) +}) + +test('validates URL', () => { + keepMount(pages.add.error) + + pages.add.setUrl('mailto:user@example.com') + equal(pages.add.error.get(), 'invalidUrl') + + pages.add.setUrl('http://a b') + equal(pages.add.error.get(), 'invalidUrl') + + pages.add.setUrl('not URL') + equal(pages.add.error.get(), 'invalidUrl') + + equal(pages.add.noResults.get(), false) +}) + +test('uses HTTPS for specific domains', async () => { + keepMount(pages.add.candidatesLoading) + keepMount(pages.add.sortedCandidates) + spyOn(loaders.rss, 'getMineLinksFromText', () => []) + spyOn(loaders.atom, 'getMineLinksFromText', () => []) + spyOn(loaders.jsonFeed, 'getMineLinksFromText', () => []) + spyOn(loaders.rss, 'getSuggestedLinksFromText', () => []) + spyOn(loaders.atom, 'getSuggestedLinksFromText', () => []) + spyOn(loaders.jsonFeed, 'getSuggestedLinksFromText', () => []) + + expectRequest('https://twitter.com/blog').andRespond(200, '') + await pages.add.setUrl('twitter.com/blog') + + expectRequest('https://twitter.com/blog').andRespond(200, '') + await pages.add.setUrl('http://twitter.com/blog') +}) + +test('cleans state', async () => { + keepMount(pages.add.error) + keepMount(pages.add.sortedCandidates) + + let reply = expectRequest('http://example.com').andWait() + pages.add.setUrl('example.com') + await setTimeout(10) + + pages.add.destroy() + equal(pages.add.error.get(), undefined) + deepStrictEqual(pages.add.sortedCandidates.get(), []) + equal(reply.aborted, true) + + pages.add.setUrl('not URL') + + pages.add.destroy() + equal(pages.add.error.get(), undefined) + deepStrictEqual(pages.add.sortedCandidates.get(), []) +}) + +test('is ready for network errors', async () => { + keepMount(pages.add.candidatesLoading) + keepMount(pages.add.error) + + let reply = expectRequest('http://example.com').andWait() + pages.add.setUrl('example.com') + + equal(pages.add.candidatesLoading.get(), true) + equal(pages.add.error.get(), undefined) + equal(pages.add.noResults.get(), false) + + await reply(404) + equal(pages.add.candidatesLoading.get(), false) + equal(pages.add.error.get(), 'unloadable') + equal(pages.add.noResults.get(), false) + + pages.add.setUrl('') + equal(pages.add.candidatesLoading.get(), false) + equal(pages.add.error.get(), undefined) + equal(pages.add.noResults.get(), false) +}) + +test('aborts all HTTP requests on URL change', async () => { + let reply1 = expectRequest('http://example.com').andWait() + pages.add.setUrl('example.com') + + pages.add.setUrl('') + await setTimeout(10) + equal(reply1.aborted, true) + + let reply2 = expectRequest('http://other.com').andWait() + pages.add.setUrl('other.com') + + pages.add.destroy() + await setTimeout(10) + equal(reply2.aborted, true) +}) + +test('detects RSS links', async () => { + keepMount(pages.add.candidatesLoading) + keepMount(pages.add.error) + keepMount(pages.add.sortedCandidates) + + let replyHtml = expectRequest('http://example.com').andWait() + pages.add.setUrl('example.com') + await setTimeout(10) + equal(pages.add.candidatesLoading.get(), true) + equal(pages.add.error.get(), undefined) + deepStrictEqual(pages.add.sortedCandidates.get(), []) + equal(pages.add.noResults.get(), false) + + let replyRss = expectRequest('http://example.com/news').andWait() + replyHtml( + 200, + '' + + '' + + '' + ) + await setTimeout(10) + equal(pages.add.candidatesLoading.get(), true) + equal(pages.add.error.get(), undefined) + deepStrictEqual(pages.add.sortedCandidates.get(), []) + equal(pages.add.noResults.get(), false) + + let rss = ' News ' + replyRss(200, rss, 'application/rss+xml') + await setTimeout(10) + equal(pages.add.candidatesLoading.get(), false) + equal(pages.add.error.get(), undefined) + equalWithText(pages.add.sortedCandidates.get(), [ + { + loader: 'rss', + title: 'News', + url: 'http://example.com/news' + } + ]) + equal(pages.add.noResults.get(), false) +}) + +test('is ready for empty title', async () => { + keepMount(pages.add.candidatesLoading) + keepMount(pages.add.error) + keepMount(pages.add.sortedCandidates) + + expectRequest('http://example.com').andRespond( + 200, + ` + + ` + ) + let rss = '' + expectRequest('http://other.com/atom').andRespond(200, rss, 'text/xml') + + await pages.add.setUrl('example.com') + equal(pages.add.candidatesLoading.get(), false) + equal(pages.add.error.get(), undefined) + equalWithText(pages.add.sortedCandidates.get(), [ + { + loader: 'atom', + title: '', + url: 'http://other.com/atom' + } + ]) +}) + +test('ignores duplicate links', async () => { + keepMount(pages.add.candidatesLoading) + keepMount(pages.add.error) + keepMount(pages.add.sortedCandidates) + + expectRequest('http://example.com').andRespond( + 200, + ` + + Feed + ` + ) + let rss = 'Feed' + expectRequest('http://other.com/atom').andRespond(200, rss, 'text/xml') + + pages.add.setUrl('example.com') + await setTimeout(10) + equal(pages.add.candidatesLoading.get(), false) + equal(pages.add.error.get(), undefined) + equalWithText(pages.add.sortedCandidates.get(), [ + { + loader: 'atom', + title: 'Feed', + url: 'http://other.com/atom' + } + ]) +}) + +test('looks for popular RSS, Atom and JsonFeed places', async () => { + keepMount(pages.add.candidatesLoading) + keepMount(pages.add.error) + keepMount(pages.add.sortedCandidates) + + expectRequest('http://example.com').andRespond(200, 'Nothing') + let atom = '' + expectRequest('http://example.com/feed').andRespond(404) + expectRequest('http://example.com/atom').andRespond(200, atom, 'text/xml') + expectRequest('http://example.com/feed.json').andRespond(404) + expectRequest('http://example.com/rss').andRespond(404) + + pages.add.setUrl('example.com') + + await setTimeout(10) + equal(pages.add.candidatesLoading.get(), false) + equal(pages.add.error.get(), undefined) + equalWithText(pages.add.sortedCandidates.get(), [ + { + loader: 'atom', + title: '', + url: 'http://example.com/atom' + } + ]) +}) + +test('shows if unknown URL', async () => { + keepMount(pages.add.candidatesLoading) + keepMount(pages.add.error) + keepMount(pages.add.sortedCandidates) + keepMount(pages.add.noResults) + + expectRequest('http://example.com').andRespond(200, 'Nothing') + expectRequest('http://example.com/feed').andRespond(404) + expectRequest('http://example.com/atom').andRespond(404) + expectRequest('http://example.com/feed.json').andRespond(404) + expectRequest('http://example.com/rss').andRespond(404) + + await pages.add.setUrl('example.com') + equal(pages.add.candidatesLoading.get(), false) + equal(pages.add.error.get(), undefined) + deepStrictEqual(pages.add.sortedCandidates.get(), []) + equal(pages.add.noResults.get(), true) +}) + +test('always keep the same order of candidates', async () => { + keepMount(pages.add.sortedCandidates) + expectRequest('http://example.com').andRespond(200, 'Nothing') + expectRequest('http://example.com/feed').andRespond(404) + expectRequest('http://example.com/atom').andRespond( + 200, + 'Atom', + 'application/rss+xml' + ) + expectRequest('http://example.com/feed.json').andRespond( + 200, + '{ "version": "https://jsonfeed.org/version/1.1", "title": "JsonFeed", "items": [] }', + 'application/json' + ) + expectRequest('http://example.com/rss').andRespond( + 200, + 'RSS', + 'application/rss+xml' + ) + await pages.add.setUrl('example.com') + + deepStrictEqual( + pages.add.sortedCandidates.get().map(i => i.title), + ['Atom', 'JsonFeed', 'RSS'] + ) + + pages.add.destroy() + expectRequest('http://example.com').andRespond(200, 'Nothing') + expectRequest('http://example.com/feed').andRespond(404) + let atom = expectRequest('http://example.com/atom').andWait() + let jsonFeed = expectRequest('http://example.com/feed.json').andWait() + expectRequest('http://example.com/rss').andRespond( + 200, + 'RSS', + 'application/rss+xml' + ) + pages.add.setUrl('example.com') + await setTimeout(10) + atom(200, 'Atom', 'application/rss+xml') + jsonFeed( + 200, + '{ "version": "https://jsonfeed.org/version/1.1", "title": "JsonFeed", "items": [] }', + 'application/json' + ) + await setTimeout(10) + + deepStrictEqual( + pages.add.sortedCandidates.get().map(i => i.title), + ['Atom', 'JsonFeed', 'RSS'] + ) +}) + +test('changes URL during typing in the field', async () => { + equal(pages.add.url.get(), undefined) + + pages.add.setUrl('') + equal(pages.add.url.get(), undefined) + + expectRequest('http://example.com').andRespond(200, 'Nothing') + expectRequest('http://example.com/feed').andRespond(404) + expectRequest('http://example.com/atom').andRespond(404) + expectRequest('http://example.com/feed.json').andRespond(404) + expectRequest('http://example.com/rss').andRespond(404) + pages.add.setUrl('example.com') + equal(pages.add.url.get(), 'http://example.com') + await setTimeout(10) + + pages.add.inputUrl('other') + equal(pages.add.url.get(), 'http://example.com') + + pages.add.inputUrl('other.') + equal(pages.add.url.get(), 'http://example.com') + + expectRequest('http://other.net').andRespond(200, 'Nothing') + expectRequest('http://other.net/feed').andRespond(404) + expectRequest('http://other.net/atom').andRespond(404) + expectRequest('http://other.net/feed.json').andRespond(404) + expectRequest('http://other.net/rss').andRespond(404) + pages.add.inputUrl('other.net') + await setTimeout(500) + equal(pages.add.url.get(), 'http://other.net') + + expectRequest('http://example.com').andRespond(200, 'Nothing') + expectRequest('http://example.com/feed').andRespond(404) + expectRequest('http://example.com/atom').andRespond(404) + expectRequest('http://example.com/feed.json').andRespond(404) + expectRequest('http://example.com/rss').andRespond(404) + pages.add.inputUrl('other.net/some') + pages.add.setUrl('example.com') + await setTimeout(500) + equal(pages.add.url.get(), 'http://example.com') + + pages.add.inputUrl('') + await setTimeout(500) + equal(pages.add.url.get(), undefined) +}) diff --git a/core/test/preview.test.ts b/core/test/preview.test.ts index 50a25223..2a21e144 100644 --- a/core/test/preview.test.ts +++ b/core/test/preview.test.ts @@ -41,7 +41,10 @@ import { cleanClientTest, enableClientTest } from './utils.ts' beforeEach(() => { enableClientTest() mockRequest() - setBaseTestRoute({ params: {}, route: 'add' }) + setBaseTestRoute({ + params: { candidate: undefined, url: undefined }, + route: 'add' + }) }) afterEach(async () => { @@ -626,28 +629,37 @@ test('changes URL during typing in the field', async () => { equal(previewUrl.get(), 'http://example.com') }) -test('syncs URL with router', () => { - deepStrictEqual(router.get(), { params: {}, route: 'add' }) +test('syncs URL with router', async () => { + deepStrictEqual(router.get(), { + params: { candidate: undefined, url: undefined }, + route: 'add' + }) expectRequest('http://example.com').andRespond(404) setPreviewUrl('example.com') deepStrictEqual(router.get(), { - params: { url: 'http://example.com' }, + params: { candidate: undefined, url: 'http://example.com' }, route: 'add' }) expectRequest('https://other.com').andRespond(404) setPreviewUrl('https://other.com') deepStrictEqual(router.get(), { - params: { url: 'https://other.com' }, + params: { candidate: undefined, url: 'https://other.com' }, route: 'add' }) expectRequest('http://example.com').andRespond(404) - setBaseTestRoute({ params: { url: 'http://example.com' }, route: 'add' }) + setBaseTestRoute({ + params: { candidate: undefined, url: 'http://example.com' }, + route: 'add' + }) deepStrictEqual(previewUrl.get(), 'http://example.com') - setBaseTestRoute({ params: {}, route: 'add' }) + setBaseTestRoute({ + params: { candidate: undefined, url: undefined }, + route: 'add' + }) deepStrictEqual(previewUrl.get(), '') expectRequest('https://new.com').andRespond(404) @@ -691,7 +703,7 @@ test('do not show candidate on mobile screen', async () => { await setTimeout(10) deepStrictEqual(router.get(), { - params: { url: 'https://a.com/atom' }, + params: { candidate: undefined, url: 'https://a.com/atom' }, route: 'add' }) equal(previewCandidate.get(), undefined) @@ -709,7 +721,7 @@ test('redirect to candidates list if no current candidate', async () => { await setTimeout(10) equal(previewCandidate.get(), undefined) deepStrictEqual(router.get(), { - params: { url: 'https://a.com/atom' }, + params: { candidate: undefined, url: 'https://a.com/atom' }, route: 'add' }) }) diff --git a/core/test/router.test.ts b/core/test/router.test.ts index 411421eb..49c38eb4 100644 --- a/core/test/router.test.ts +++ b/core/test/router.test.ts @@ -135,7 +135,7 @@ test('transforms section to first section page', () => { setBaseTestRoute({ params: {}, route: 'feeds' }) deepStrictEqual(router.get(), { - params: {}, + params: { candidate: undefined, url: undefined }, redirect: true, route: 'add' }) diff --git a/core/test/two-steps.test.ts b/core/test/two-steps.test.ts index 3e2cd4b7..7940d197 100644 --- a/core/test/two-steps.test.ts +++ b/core/test/two-steps.test.ts @@ -36,7 +36,10 @@ afterEach(async () => { test('works with adds route on wide screen', async () => { setIsMobile(false) - setBaseTestRoute({ params: {}, route: 'add' }) + setBaseTestRoute({ + params: { candidate: undefined, url: undefined }, + route: 'add' + }) expectRequest('https://a.com/atom').andRespond( 200, 'Atom' + @@ -50,14 +53,17 @@ test('works with adds route on wide screen', async () => { strictEqual(secondStep.get(), true) deepStrictEqual(backRoute.get(), { - params: { url: 'https://a.com/atom' }, + params: { candidate: undefined, url: 'https://a.com/atom' }, route: 'add' }) }) test('works with adds route on mobile screen', async () => { setIsMobile(true) - setBaseTestRoute({ params: {}, route: 'add' }) + setBaseTestRoute({ + params: { candidate: undefined, url: undefined }, + route: 'add' + }) expectRequest('https://a.com/atom').andRespond( 200, 'Atom' + diff --git a/web/stores/router.ts b/web/stores/router.ts index 80c6687c..4ef7a5cd 100644 --- a/web/stores/router.ts +++ b/web/stores/router.ts @@ -34,7 +34,8 @@ export const urlRouter = computed(pathRouter, path => { if (!path) { return undefined } else if (path.route === 'add') { - let params: Routes['add'] = path.params + let params: Routes['add'] = { candidate: undefined, url: undefined } + if ('url' in path.params) params.url = path.params.url if ('candidate' in path.search) params.candidate = path.search.candidate return { params, diff --git a/web/stories/pages/feeds/add.stories.svelte b/web/stories/pages/feeds/add.stories.svelte index 1a84a6f2..ef2877f1 100644 --- a/web/stories/pages/feeds/add.stories.svelte +++ b/web/stories/pages/feeds/add.stories.svelte @@ -82,7 +82,9 @@ - + @@ -90,14 +92,19 @@ - + @@ -105,7 +112,10 @@ @@ -114,7 +124,10 @@ ' }} - route={{ params: { url: 'https://example.com' }, route: 'add' }} + route={{ + params: { candidate: undefined, url: 'https://example.com' }, + route: 'add' + }} > @@ -128,7 +141,10 @@ 'https://example.com/long.atom': LONG_ATOM, 'https://example.com/news.atom': ATOM }} - route={{ params: { url: 'https://example.com' }, route: 'add' }} + route={{ + params: { candidate: undefined, url: 'https://example.com' }, + route: 'add' + }} > @@ -141,7 +157,10 @@ 'https://example.com': HTML_WITH_LINK, 'https://example.com/news.atom': ATOM }} - route={{ params: { url: 'https://example.com' }, route: 'add' }} + route={{ + params: { candidate: undefined, url: 'https://example.com' }, + route: 'add' + }} > @@ -160,7 +179,10 @@ 'https://example.com': HTML_WITH_LINK, 'https://example.com/news.atom': ATOM }} - route={{ params: { url: 'https://example.com' }, route: 'add' }} + route={{ + params: { candidate: undefined, url: 'https://example.com' }, + route: 'add' + }} > @@ -176,7 +198,10 @@ 'https://example.com': HTML_WITH_LINK, 'https://example.com/news.atom': ATOM }} - route={{ params: { url: 'https://example.com' }, route: 'add' }} + route={{ + params: { candidate: undefined, url: 'https://example.com' }, + route: 'add' + }} > diff --git a/web/stories/ui/navbar.stories.svelte b/web/stories/ui/navbar.stories.svelte index ac9fd202..7ef8b039 100644 --- a/web/stories/ui/navbar.stories.svelte +++ b/web/stories/ui/navbar.stories.svelte @@ -32,7 +32,7 @@
diff --git a/web/ui/navbar/index.svelte b/web/ui/navbar/index.svelte index d70b5d26..f86524d0 100644 --- a/web/ui/navbar/index.svelte +++ b/web/ui/navbar/index.svelte @@ -120,7 +120,12 @@ name={$t.menu} current={isOtherRoute($router)} hotkey="m" - href={isOtherRoute($router) ? undefined : getURL('add')} + href={isOtherRoute($router) + ? undefined + : getURL({ + params: { candidate: undefined, url: undefined }, + route: 'add' + })} icon={mdiMenu} onclick={openMenu} small diff --git a/web/ui/navbar/other.svelte b/web/ui/navbar/other.svelte index c16464e6..e877ea35 100644 --- a/web/ui/navbar/other.svelte +++ b/web/ui/navbar/other.svelte @@ -21,7 +21,10 @@ From 637e981918fb5dd24cf1096778ec0fc0775a1d31 Mon Sep 17 00:00:00 2001 From: Andrey Sitnik Date: Sat, 19 Oct 2024 21:50:37 +0000 Subject: [PATCH 02/20] Rename Page#destroy to Page#exit since we do not destroy page instance --- core/page.ts | 2 +- core/pages/add.ts | 8 ++++---- core/pages/common.ts | 6 +++--- core/test/page.test.ts | 2 +- core/test/pages/add.test.ts | 10 +++++----- 5 files changed, 14 insertions(+), 14 deletions(-) diff --git a/core/page.ts b/core/page.ts index 7864d730..2678b9c1 100644 --- a/core/page.ts +++ b/core/page.ts @@ -49,7 +49,7 @@ export const page: ReadableAtom = computed(router, route => { if (currentPage !== prevPage) { if (prevPage) { for (let unbind of unbinds) unbind() - prevPage.destroy() + prevPage.exit() } prevPage = currentPage diff --git a/core/pages/add.ts b/core/pages/add.ts index f3e6c5e0..e4fb021f 100644 --- a/core/pages/add.ts +++ b/core/pages/add.ts @@ -79,7 +79,7 @@ export const add = createPage('add', () => { } ) - function destroy(): void { + function exit(): void { $links.set({}) $candidates.set([]) prevTask?.abortAll() @@ -87,7 +87,7 @@ export const add = createPage('add', () => { let inputUrl = debounce((value: string) => { if (value === '') { - destroy() + exit() } else { //TODO: currentCandidate.set(undefined) setUrl(value) @@ -99,7 +99,7 @@ export const add = createPage('add', () => { if (prevTask) prevTask.abortAll() if (url === $url.get()) return inputUrl.cancel() - destroy() + exit() prevTask = createDownloadTask() await addLink(prevTask, url) } @@ -239,8 +239,8 @@ export const add = createPage('add', () => { return { candidate: atom(), // TODO: Remove to popups candidatesLoading: $candidatesLoading, - destroy, error: $error, + exit, inputUrl, noResults: $noResults, setUrl, diff --git a/core/pages/common.ts b/core/pages/common.ts index 6f34f919..fa760813 100644 --- a/core/pages/common.ts +++ b/core/pages/common.ts @@ -3,7 +3,7 @@ import { atom, type ReadableAtom } from 'nanostores' import type { ParamlessRouteName, RouteName, Routes } from '../router.ts' type Extra = { - destroy?: () => void + exit?: () => void } type ParamStores = { @@ -11,7 +11,7 @@ type ParamStores = { } export type BasePage = { - destroy(): void + exit(): void readonly loading: ReadableAtom readonly route: Name underConstruction?: boolean @@ -23,7 +23,7 @@ export function createPage( ): BasePage & Rest { let rest = builder() return { - destroy: rest.destroy ?? ((): void => {}), + exit: rest.exit ?? ((): void => {}), loading: atom(false), route, ...rest diff --git a/core/test/page.test.ts b/core/test/page.test.ts index 3d2c1370..7a031db4 100644 --- a/core/test/page.test.ts +++ b/core/test/page.test.ts @@ -39,7 +39,7 @@ test('calls events', () => { let events = 0 pages.add = { ...pages.add, - destroy: () => { + exit: () => { events += 1 } } diff --git a/core/test/pages/add.test.ts b/core/test/pages/add.test.ts index 25c97578..f3935e8d 100644 --- a/core/test/pages/add.test.ts +++ b/core/test/pages/add.test.ts @@ -29,7 +29,7 @@ beforeEach(() => { afterEach(async () => { await cleanClientTest() restoreAll() - pages.add.destroy() + pages.add.exit() checkAndRemoveRequestMock() }) @@ -92,14 +92,14 @@ test('cleans state', async () => { pages.add.setUrl('example.com') await setTimeout(10) - pages.add.destroy() + pages.add.exit() equal(pages.add.error.get(), undefined) deepStrictEqual(pages.add.sortedCandidates.get(), []) equal(reply.aborted, true) pages.add.setUrl('not URL') - pages.add.destroy() + pages.add.exit() equal(pages.add.error.get(), undefined) deepStrictEqual(pages.add.sortedCandidates.get(), []) }) @@ -137,7 +137,7 @@ test('aborts all HTTP requests on URL change', async () => { let reply2 = expectRequest('http://other.com').andWait() pages.add.setUrl('other.com') - pages.add.destroy() + pages.add.exit() await setTimeout(10) equal(reply2.aborted, true) }) @@ -308,7 +308,7 @@ test('always keep the same order of candidates', async () => { ['Atom', 'JsonFeed', 'RSS'] ) - pages.add.destroy() + pages.add.exit() expectRequest('http://example.com').andRespond(200, 'Nothing') expectRequest('http://example.com/feed').andRespond(404) let atom = expectRequest('http://example.com/atom').andWait() From 852fdf88a7bf6b1393c9b602c49229ec35a6c51d Mon Sep 17 00:00:00 2001 From: Andrey Sitnik Date: Sat, 19 Oct 2024 22:51:46 +0000 Subject: [PATCH 03/20] Lazy load page logic to save memory --- core/page.ts | 4 +- core/pages/common.ts | 37 ++++-- core/pages/index.ts | 13 +- core/test/page.test.ts | 65 ++++++---- core/test/pages/add.test.ts | 236 ++++++++++++++++++------------------ 5 files changed, 199 insertions(+), 156 deletions(-) diff --git a/core/page.ts b/core/page.ts index 2678b9c1..21c3acbd 100644 --- a/core/page.ts +++ b/core/page.ts @@ -45,11 +45,11 @@ let prevPage: Page | undefined let unbinds: (() => void)[] = [] export const page: ReadableAtom = computed(router, route => { - let currentPage = pages[route.route] + let currentPage = pages[route.route]() as Page if (currentPage !== prevPage) { if (prevPage) { for (let unbind of unbinds) unbind() - prevPage.exit() + prevPage.destroy() } prevPage = currentPage diff --git a/core/pages/common.ts b/core/pages/common.ts index fa760813..0c923410 100644 --- a/core/pages/common.ts +++ b/core/pages/common.ts @@ -11,27 +11,44 @@ type ParamStores = { } export type BasePage = { - exit(): void + destroy(): void readonly loading: ReadableAtom readonly route: Name - underConstruction?: boolean + underConstruction?: boolean // TODO: Remove after refactoring } & ParamStores +export interface PageCreator< + Name extends RouteName, + Rest extends Extra = Record +> { + (): BasePage & Rest + cache?: BasePage & Rest +} + export function createPage( route: Name, builder: () => ParamStores & Rest -): BasePage & Rest { - let rest = builder() - return { - exit: rest.exit ?? ((): void => {}), - loading: atom(false), - route, - ...rest +): PageCreator { + let creator: PageCreator = () => { + if (!creator.cache) { + let rest = builder() + creator.cache = { + destroy() { + creator.cache?.exit?.() + creator.cache = undefined + }, + loading: atom(false), + route, + ...rest + } + } + return creator.cache } + return creator } export function createSimplePage( route: Name -): BasePage { +): PageCreator { return createPage(route, () => ({}) as ParamStores) } diff --git a/core/pages/index.ts b/core/pages/index.ts index 23a24e4d..1054f897 100644 --- a/core/pages/index.ts +++ b/core/pages/index.ts @@ -2,7 +2,12 @@ import { atom } from 'nanostores' import type { RouteName, Routes } from '../router.ts' import { add } from './add.ts' -import { type BasePage, createPage, createSimplePage } from './common.ts' +import { + type BasePage, + createPage, + createSimplePage, + type PageCreator +} from './common.ts' export type { AddCandidate, AddPage } from './add.ts' export * from './common.ts' @@ -11,7 +16,7 @@ export * from './common.ts' export function underConstruction( route: Name, params: (keyof Routes[Name])[] -): BasePage { +): PageCreator { return createPage(route, () => { let result = {} as BasePage for (let param of params) { @@ -42,9 +47,9 @@ export const pages = { start: underConstruction('start', []), welcome: underConstruction('welcome', []) } satisfies { - [Name in RouteName]: BasePage + [Name in RouteName]: PageCreator } export type Pages = typeof pages -export type Page = Pages[Name] +export type Page = ReturnType diff --git a/core/test/page.test.ts b/core/test/page.test.ts index 7a031db4..9810d80b 100644 --- a/core/test/page.test.ts +++ b/core/test/page.test.ts @@ -22,53 +22,64 @@ test('synchronies router with page', () => { keepMount(page) setBaseTestRoute({ params: {}, route: 'notFound' }) - equal(page.get(), pages.notFound) + equal(page.get(), pages.notFound()) setBaseTestRoute({ params: { candidate: undefined, url: undefined }, route: 'add' }) - equal(page.get(), pages.add) + equal(page.get(), pages.add()) setBaseTestRoute({ params: {}, route: 'notFound' }) - equal(page.get(), pages.notFound) + equal(page.get(), pages.notFound()) }) test('calls events', () => { keepMount(page) - let events = 0 - pages.add = { - ...pages.add, - exit: () => { - events += 1 + let events = '' + let originAdd = pages.add + + pages.add = () => { + events += 'create ' + let add = originAdd() + let originExit = add.exit + add.exit = () => { + originExit() + events += 'exit ' } + let originDestroy = add.destroy + add.destroy = () => { + originDestroy() + events += 'destroy ' + } + return add } setBaseTestRoute({ params: {}, route: 'notFound' }) - equal(page.get(), pages.notFound) + equal(page.get().route, 'notFound') equal(events, 0) setBaseTestRoute({ params: { candidate: undefined, url: undefined }, route: 'add' }) - equal(page.get(), pages.add) - equal(events, 0) + equal(page.get().route, 'add') + equal(events, 'create ') setBaseTestRoute({ params: {}, route: 'notFound' }) - equal(page.get(), pages.notFound) - equal(events, 1) + equal(page.get().route, 'notFound') + equal(events, 'create exit destroy ') setBaseTestRoute({ params: { candidate: undefined, url: undefined }, route: 'add' }) - equal(page.get(), pages.add) - equal(events, 1) + equal(page.get().route, 'add') + equal(events, 'create exit destroy create ') setBaseTestRoute({ params: {}, route: 'notFound' }) - equal(page.get(), pages.notFound) - equal(events, 2) + equal(page.get().route, 'notFound') + equal(events, 'create exit destroy create exit destroy ') }) test('synchronizes params', async () => { @@ -77,17 +88,17 @@ test('synchronizes params', async () => { params: { candidate: undefined, url: undefined }, route: 'add' }) - equal(pages.add.url.get(), undefined) - equal(pages.add.candidate.get(), undefined) + equal(pages.add().url.get(), undefined) + equal(pages.add().candidate.get(), undefined) - pages.add.url.set('https://example.com') + pages.add().url.set('https://example.com') await setTimeout(1) deepStrictEqual(router.get(), { params: { candidate: undefined, url: 'https://example.com' }, route: 'add' }) - equal(pages.add.url.get(), 'https://example.com') - equal(pages.add.candidate.get(), undefined) + equal(pages.add().url.get(), 'https://example.com') + equal(pages.add().candidate.get(), undefined) setBaseTestRoute({ params: { candidate: undefined, url: 'https://other.com' }, @@ -98,11 +109,15 @@ test('synchronizes params', async () => { params: { candidate: undefined, url: 'https://other.com' }, route: 'add' }) - equal(pages.add.url.get(), 'https://other.com') - equal(pages.add.candidate.get(), undefined) + equal(pages.add().url.get(), 'https://other.com') + equal(pages.add().candidate.get(), undefined) setBaseTestRoute({ params: {}, route: 'notFound' }) - pages.add.url.set('https://example.com') + pages.add().url.set('https://example.com') await setTimeout(1) deepStrictEqual(router.get(), { params: {}, route: 'notFound' }) }) + +test('has under construction pages', () => { + equal(pages.slow().underConstruction, true) +}) diff --git a/core/test/pages/add.test.ts b/core/test/pages/add.test.ts index f3935e8d..c2202247 100644 --- a/core/test/pages/add.test.ts +++ b/core/test/pages/add.test.ts @@ -29,7 +29,7 @@ beforeEach(() => { afterEach(async () => { await cleanClientTest() restoreAll() - pages.add.exit() + pages.add().exit() checkAndRemoveRequestMock() }) @@ -43,33 +43,33 @@ function equalWithText(a: AddCandidate[], b: AddCandidate[]): void { } test('empty from beginning', () => { - keepMount(pages.add.error) - keepMount(pages.add.candidatesLoading) - keepMount(pages.add.sortedCandidates) + keepMount(pages.add().error) + keepMount(pages.add().candidatesLoading) + keepMount(pages.add().sortedCandidates) - equal(pages.add.error.get(), undefined) - equal(pages.add.candidatesLoading.get(), false) - deepStrictEqual(pages.add.sortedCandidates.get(), []) + equal(pages.add().error.get(), undefined) + equal(pages.add().candidatesLoading.get(), false) + deepStrictEqual(pages.add().sortedCandidates.get(), []) }) test('validates URL', () => { - keepMount(pages.add.error) + keepMount(pages.add().error) - pages.add.setUrl('mailto:user@example.com') - equal(pages.add.error.get(), 'invalidUrl') + pages.add().setUrl('mailto:user@example.com') + equal(pages.add().error.get(), 'invalidUrl') - pages.add.setUrl('http://a b') - equal(pages.add.error.get(), 'invalidUrl') + pages.add().setUrl('http://a b') + equal(pages.add().error.get(), 'invalidUrl') - pages.add.setUrl('not URL') - equal(pages.add.error.get(), 'invalidUrl') + pages.add().setUrl('not URL') + equal(pages.add().error.get(), 'invalidUrl') - equal(pages.add.noResults.get(), false) + equal(pages.add().noResults.get(), false) }) test('uses HTTPS for specific domains', async () => { - keepMount(pages.add.candidatesLoading) - keepMount(pages.add.sortedCandidates) + keepMount(pages.add().candidatesLoading) + keepMount(pages.add().sortedCandidates) spyOn(loaders.rss, 'getMineLinksFromText', () => []) spyOn(loaders.atom, 'getMineLinksFromText', () => []) spyOn(loaders.jsonFeed, 'getMineLinksFromText', () => []) @@ -78,82 +78,82 @@ test('uses HTTPS for specific domains', async () => { spyOn(loaders.jsonFeed, 'getSuggestedLinksFromText', () => []) expectRequest('https://twitter.com/blog').andRespond(200, '') - await pages.add.setUrl('twitter.com/blog') + await pages.add().setUrl('twitter.com/blog') expectRequest('https://twitter.com/blog').andRespond(200, '') - await pages.add.setUrl('http://twitter.com/blog') + await pages.add().setUrl('http://twitter.com/blog') }) test('cleans state', async () => { - keepMount(pages.add.error) - keepMount(pages.add.sortedCandidates) + keepMount(pages.add().error) + keepMount(pages.add().sortedCandidates) let reply = expectRequest('http://example.com').andWait() - pages.add.setUrl('example.com') + pages.add().setUrl('example.com') await setTimeout(10) - pages.add.exit() - equal(pages.add.error.get(), undefined) - deepStrictEqual(pages.add.sortedCandidates.get(), []) + pages.add().exit() + equal(pages.add().error.get(), undefined) + deepStrictEqual(pages.add().sortedCandidates.get(), []) equal(reply.aborted, true) - pages.add.setUrl('not URL') + pages.add().setUrl('not URL') - pages.add.exit() - equal(pages.add.error.get(), undefined) - deepStrictEqual(pages.add.sortedCandidates.get(), []) + pages.add().exit() + equal(pages.add().error.get(), undefined) + deepStrictEqual(pages.add().sortedCandidates.get(), []) }) test('is ready for network errors', async () => { - keepMount(pages.add.candidatesLoading) - keepMount(pages.add.error) + keepMount(pages.add().candidatesLoading) + keepMount(pages.add().error) let reply = expectRequest('http://example.com').andWait() - pages.add.setUrl('example.com') + pages.add().setUrl('example.com') - equal(pages.add.candidatesLoading.get(), true) - equal(pages.add.error.get(), undefined) - equal(pages.add.noResults.get(), false) + equal(pages.add().candidatesLoading.get(), true) + equal(pages.add().error.get(), undefined) + equal(pages.add().noResults.get(), false) await reply(404) - equal(pages.add.candidatesLoading.get(), false) - equal(pages.add.error.get(), 'unloadable') - equal(pages.add.noResults.get(), false) - - pages.add.setUrl('') - equal(pages.add.candidatesLoading.get(), false) - equal(pages.add.error.get(), undefined) - equal(pages.add.noResults.get(), false) + equal(pages.add().candidatesLoading.get(), false) + equal(pages.add().error.get(), 'unloadable') + equal(pages.add().noResults.get(), false) + + pages.add().setUrl('') + equal(pages.add().candidatesLoading.get(), false) + equal(pages.add().error.get(), undefined) + equal(pages.add().noResults.get(), false) }) test('aborts all HTTP requests on URL change', async () => { let reply1 = expectRequest('http://example.com').andWait() - pages.add.setUrl('example.com') + pages.add().setUrl('example.com') - pages.add.setUrl('') + pages.add().setUrl('') await setTimeout(10) equal(reply1.aborted, true) let reply2 = expectRequest('http://other.com').andWait() - pages.add.setUrl('other.com') + pages.add().setUrl('other.com') - pages.add.exit() + pages.add().exit() await setTimeout(10) equal(reply2.aborted, true) }) test('detects RSS links', async () => { - keepMount(pages.add.candidatesLoading) - keepMount(pages.add.error) - keepMount(pages.add.sortedCandidates) + keepMount(pages.add().candidatesLoading) + keepMount(pages.add().error) + keepMount(pages.add().sortedCandidates) let replyHtml = expectRequest('http://example.com').andWait() - pages.add.setUrl('example.com') + pages.add().setUrl('example.com') await setTimeout(10) - equal(pages.add.candidatesLoading.get(), true) - equal(pages.add.error.get(), undefined) - deepStrictEqual(pages.add.sortedCandidates.get(), []) - equal(pages.add.noResults.get(), false) + equal(pages.add().candidatesLoading.get(), true) + equal(pages.add().error.get(), undefined) + deepStrictEqual(pages.add().sortedCandidates.get(), []) + equal(pages.add().noResults.get(), false) let replyRss = expectRequest('http://example.com/news').andWait() replyHtml( @@ -163,30 +163,30 @@ test('detects RSS links', async () => { '' ) await setTimeout(10) - equal(pages.add.candidatesLoading.get(), true) - equal(pages.add.error.get(), undefined) - deepStrictEqual(pages.add.sortedCandidates.get(), []) - equal(pages.add.noResults.get(), false) + equal(pages.add().candidatesLoading.get(), true) + equal(pages.add().error.get(), undefined) + deepStrictEqual(pages.add().sortedCandidates.get(), []) + equal(pages.add().noResults.get(), false) let rss = ' News ' replyRss(200, rss, 'application/rss+xml') await setTimeout(10) - equal(pages.add.candidatesLoading.get(), false) - equal(pages.add.error.get(), undefined) - equalWithText(pages.add.sortedCandidates.get(), [ + equal(pages.add().candidatesLoading.get(), false) + equal(pages.add().error.get(), undefined) + equalWithText(pages.add().sortedCandidates.get(), [ { loader: 'rss', title: 'News', url: 'http://example.com/news' } ]) - equal(pages.add.noResults.get(), false) + equal(pages.add().noResults.get(), false) }) test('is ready for empty title', async () => { - keepMount(pages.add.candidatesLoading) - keepMount(pages.add.error) - keepMount(pages.add.sortedCandidates) + keepMount(pages.add().candidatesLoading) + keepMount(pages.add().error) + keepMount(pages.add().sortedCandidates) expectRequest('http://example.com').andRespond( 200, @@ -197,10 +197,10 @@ test('is ready for empty title', async () => { let rss = '' expectRequest('http://other.com/atom').andRespond(200, rss, 'text/xml') - await pages.add.setUrl('example.com') - equal(pages.add.candidatesLoading.get(), false) - equal(pages.add.error.get(), undefined) - equalWithText(pages.add.sortedCandidates.get(), [ + await pages.add().setUrl('example.com') + equal(pages.add().candidatesLoading.get(), false) + equal(pages.add().error.get(), undefined) + equalWithText(pages.add().sortedCandidates.get(), [ { loader: 'atom', title: '', @@ -210,9 +210,9 @@ test('is ready for empty title', async () => { }) test('ignores duplicate links', async () => { - keepMount(pages.add.candidatesLoading) - keepMount(pages.add.error) - keepMount(pages.add.sortedCandidates) + keepMount(pages.add().candidatesLoading) + keepMount(pages.add().error) + keepMount(pages.add().sortedCandidates) expectRequest('http://example.com').andRespond( 200, @@ -224,11 +224,11 @@ test('ignores duplicate links', async () => { let rss = 'Feed' expectRequest('http://other.com/atom').andRespond(200, rss, 'text/xml') - pages.add.setUrl('example.com') + pages.add().setUrl('example.com') await setTimeout(10) - equal(pages.add.candidatesLoading.get(), false) - equal(pages.add.error.get(), undefined) - equalWithText(pages.add.sortedCandidates.get(), [ + equal(pages.add().candidatesLoading.get(), false) + equal(pages.add().error.get(), undefined) + equalWithText(pages.add().sortedCandidates.get(), [ { loader: 'atom', title: 'Feed', @@ -238,9 +238,9 @@ test('ignores duplicate links', async () => { }) test('looks for popular RSS, Atom and JsonFeed places', async () => { - keepMount(pages.add.candidatesLoading) - keepMount(pages.add.error) - keepMount(pages.add.sortedCandidates) + keepMount(pages.add().candidatesLoading) + keepMount(pages.add().error) + keepMount(pages.add().sortedCandidates) expectRequest('http://example.com').andRespond(200, 'Nothing') let atom = '' @@ -249,12 +249,12 @@ test('looks for popular RSS, Atom and JsonFeed places', async () => { expectRequest('http://example.com/feed.json').andRespond(404) expectRequest('http://example.com/rss').andRespond(404) - pages.add.setUrl('example.com') + pages.add().setUrl('example.com') await setTimeout(10) - equal(pages.add.candidatesLoading.get(), false) - equal(pages.add.error.get(), undefined) - equalWithText(pages.add.sortedCandidates.get(), [ + equal(pages.add().candidatesLoading.get(), false) + equal(pages.add().error.get(), undefined) + equalWithText(pages.add().sortedCandidates.get(), [ { loader: 'atom', title: '', @@ -264,10 +264,10 @@ test('looks for popular RSS, Atom and JsonFeed places', async () => { }) test('shows if unknown URL', async () => { - keepMount(pages.add.candidatesLoading) - keepMount(pages.add.error) - keepMount(pages.add.sortedCandidates) - keepMount(pages.add.noResults) + keepMount(pages.add().candidatesLoading) + keepMount(pages.add().error) + keepMount(pages.add().sortedCandidates) + keepMount(pages.add().noResults) expectRequest('http://example.com').andRespond(200, 'Nothing') expectRequest('http://example.com/feed').andRespond(404) @@ -275,15 +275,15 @@ test('shows if unknown URL', async () => { expectRequest('http://example.com/feed.json').andRespond(404) expectRequest('http://example.com/rss').andRespond(404) - await pages.add.setUrl('example.com') - equal(pages.add.candidatesLoading.get(), false) - equal(pages.add.error.get(), undefined) - deepStrictEqual(pages.add.sortedCandidates.get(), []) - equal(pages.add.noResults.get(), true) + await pages.add().setUrl('example.com') + equal(pages.add().candidatesLoading.get(), false) + equal(pages.add().error.get(), undefined) + deepStrictEqual(pages.add().sortedCandidates.get(), []) + equal(pages.add().noResults.get(), true) }) test('always keep the same order of candidates', async () => { - keepMount(pages.add.sortedCandidates) + keepMount(pages.add().sortedCandidates) expectRequest('http://example.com').andRespond(200, 'Nothing') expectRequest('http://example.com/feed').andRespond(404) expectRequest('http://example.com/atom').andRespond( @@ -301,14 +301,17 @@ test('always keep the same order of candidates', async () => { 'RSS', 'application/rss+xml' ) - await pages.add.setUrl('example.com') + await pages.add().setUrl('example.com') deepStrictEqual( - pages.add.sortedCandidates.get().map(i => i.title), + pages + .add() + .sortedCandidates.get() + .map(i => i.title), ['Atom', 'JsonFeed', 'RSS'] ) - pages.add.exit() + pages.add().exit() expectRequest('http://example.com').andRespond(200, 'Nothing') expectRequest('http://example.com/feed').andRespond(404) let atom = expectRequest('http://example.com/atom').andWait() @@ -318,7 +321,7 @@ test('always keep the same order of candidates', async () => { 'RSS', 'application/rss+xml' ) - pages.add.setUrl('example.com') + pages.add().setUrl('example.com') await setTimeout(10) atom(200, 'Atom', 'application/rss+xml') jsonFeed( @@ -329,52 +332,55 @@ test('always keep the same order of candidates', async () => { await setTimeout(10) deepStrictEqual( - pages.add.sortedCandidates.get().map(i => i.title), + pages + .add() + .sortedCandidates.get() + .map(i => i.title), ['Atom', 'JsonFeed', 'RSS'] ) }) test('changes URL during typing in the field', async () => { - equal(pages.add.url.get(), undefined) + equal(pages.add().url.get(), undefined) - pages.add.setUrl('') - equal(pages.add.url.get(), undefined) + pages.add().setUrl('') + equal(pages.add().url.get(), undefined) expectRequest('http://example.com').andRespond(200, 'Nothing') expectRequest('http://example.com/feed').andRespond(404) expectRequest('http://example.com/atom').andRespond(404) expectRequest('http://example.com/feed.json').andRespond(404) expectRequest('http://example.com/rss').andRespond(404) - pages.add.setUrl('example.com') - equal(pages.add.url.get(), 'http://example.com') + pages.add().setUrl('example.com') + equal(pages.add().url.get(), 'http://example.com') await setTimeout(10) - pages.add.inputUrl('other') - equal(pages.add.url.get(), 'http://example.com') + pages.add().inputUrl('other') + equal(pages.add().url.get(), 'http://example.com') - pages.add.inputUrl('other.') - equal(pages.add.url.get(), 'http://example.com') + pages.add().inputUrl('other.') + equal(pages.add().url.get(), 'http://example.com') expectRequest('http://other.net').andRespond(200, 'Nothing') expectRequest('http://other.net/feed').andRespond(404) expectRequest('http://other.net/atom').andRespond(404) expectRequest('http://other.net/feed.json').andRespond(404) expectRequest('http://other.net/rss').andRespond(404) - pages.add.inputUrl('other.net') + pages.add().inputUrl('other.net') await setTimeout(500) - equal(pages.add.url.get(), 'http://other.net') + equal(pages.add().url.get(), 'http://other.net') expectRequest('http://example.com').andRespond(200, 'Nothing') expectRequest('http://example.com/feed').andRespond(404) expectRequest('http://example.com/atom').andRespond(404) expectRequest('http://example.com/feed.json').andRespond(404) expectRequest('http://example.com/rss').andRespond(404) - pages.add.inputUrl('other.net/some') - pages.add.setUrl('example.com') + pages.add().inputUrl('other.net/some') + pages.add().setUrl('example.com') await setTimeout(500) - equal(pages.add.url.get(), 'http://example.com') + equal(pages.add().url.get(), 'http://example.com') - pages.add.inputUrl('') + pages.add().inputUrl('') await setTimeout(500) - equal(pages.add.url.get(), undefined) + equal(pages.add().url.get(), undefined) }) From 68298d21bd141a9a0bce46ea3a605f386fc99de5 Mon Sep 17 00:00:00 2001 From: Andrey Sitnik Date: Tue, 22 Oct 2024 21:08:08 +0000 Subject: [PATCH 04/20] Fix code format --- web/ui/button.svelte | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/web/ui/button.svelte b/web/ui/button.svelte index d5956ac5..7b9ed293 100644 --- a/web/ui/button.svelte +++ b/web/ui/button.svelte @@ -20,8 +20,7 @@ onclick?: (event: MouseEvent) => void secondary?: boolean wide?: boolean - } & - ({ href: string } | HTMLButtonAttributes) = $props() + } & ({ href: string } | HTMLButtonAttributes) = $props() {#snippet content()} From de6ca3f687138b3e996154bc0d85528d36354290 Mon Sep 17 00:00:00 2001 From: Andrey Sitnik Date: Sat, 26 Oct 2024 23:29:56 +0000 Subject: [PATCH 05/20] Add popups to router --- core/environment.ts | 17 +++++-- core/fast.ts | 1 + core/preview.ts | 8 +++- core/router.ts | 72 +++++++++++++++++++++++------ core/slow.ts | 1 + core/test/fast.test.ts | 1 + core/test/page.test.ts | 4 +- core/test/preview.test.ts | 6 +++ core/test/router.test.ts | 92 ++++++++++++++++++++++++++----------- core/test/two-steps.test.ts | 7 +++ web/pages/feeds/edit.svelte | 1 + web/stores/router.ts | 11 ++++- web/stories/environment.ts | 3 +- web/stories/scene.svelte | 14 ++++-- 14 files changed, 183 insertions(+), 55 deletions(-) diff --git a/core/environment.ts b/core/environment.ts index b754e9c2..b802a1ab 100644 --- a/core/environment.ts +++ b/core/environment.ts @@ -126,8 +126,17 @@ export function setIsMobile(isSmallScreen: boolean): void { const testRouter = atom() -export function setBaseTestRoute(route: BaseRoute | undefined): void { - testRouter.set(route) +export function addHashToBaseRoute( + route: BaseRoute | Omit | undefined +): BaseRoute | undefined { + if (!route) return undefined + return { hash: '', ...route } as BaseRoute +} + +export function setBaseTestRoute( + route: BaseRoute | Omit | undefined +): void { + testRouter.set(addHashToBaseRoute(route)) } export function getTestEnvironment(): EnvironmentAndStore { @@ -137,7 +146,9 @@ export function getTestEnvironment(): EnvironmentAndStore { locale: atom('en'), logStoreCreator: () => new MemoryStore(), networkType: () => ({ saveData: undefined, type: undefined }), - openRoute: setBaseTestRoute, + openRoute: route => { + setBaseTestRoute({ ...route, hash: '' }) + }, persistentEvents: { addEventListener() {}, removeEventListener() {} }, persistentStore: {}, restartApp: () => {}, diff --git a/core/fast.ts b/core/fast.ts index 320e91dc..f62cca20 100644 --- a/core/fast.ts +++ b/core/fast.ts @@ -228,6 +228,7 @@ onEnvironment(({ openRoute }) => { if (notSynced(router.get())) { openRoute({ params: { category, since }, + popups: [], route: 'fast' }) } diff --git a/core/preview.ts b/core/preview.ts index 67f3e4ed..f98f6226 100644 --- a/core/preview.ts +++ b/core/preview.ts @@ -348,7 +348,11 @@ onEnvironment(({ openRoute }) => { previewUrl.listen(link => { let page = router.get() if (page.route === 'add' && page.params.url !== link) { - openRoute({ params: { candidate: undefined, url: link }, route: 'add' }) + openRoute({ + params: { candidate: undefined, url: link }, + popups: [], + route: 'add' + }) } }), router.subscribe(({ params, route }) => { @@ -368,6 +372,7 @@ onEnvironment(({ openRoute }) => { } else { openRoute({ params: { candidate: undefined, url: params.url }, + popups: [], route: 'add' }) } @@ -398,6 +403,7 @@ onEnvironment(({ openRoute }) => { candidate: candidateUrl, url: page.params.url }, + popups: [], route: 'add' }) } diff --git a/core/router.ts b/core/router.ts index 541fe576..52b4eaab 100644 --- a/core/router.ts +++ b/core/router.ts @@ -28,6 +28,10 @@ export interface Routes { welcome: {} } +export const popupNames = { feed: true, feedUrl: true, post: true } + +export type PopupRoute = { param: string; popup: keyof typeof popupNames } + export type RouteName = keyof Routes type EmptyObject = Record @@ -44,7 +48,12 @@ export type ParamlessRouteName = { }[RouteName] export type Route = Name extends string - ? { params: Routes[Name]; redirect?: boolean; route: Name } + ? { + params: Routes[Name] + popups: PopupRoute[] + redirect?: boolean + route: Name + } : never type StringParams = { @@ -52,7 +61,7 @@ type StringParams = { } export type BaseRoute = Name extends string - ? { params: StringParams; route: Name } + ? { hash: string; params: StringParams; route: Name } : never export type BaseRouter = ReadableAtom @@ -71,7 +80,7 @@ const SETTINGS = new Set([ const ORGANIZE = new Set(['add', 'categories']) function open(route: ParamlessRouteName): Route { - return { params: {}, route } + return { params: {}, popups: [], route } } function redirect(route: Route): Route { @@ -91,7 +100,25 @@ function validateNumber( } } -let $router = atom({ params: {}, route: 'home' }) +let $router = atom({ params: {}, popups: [], route: 'home' }) + +function checkPopupName( + popup: string | undefined +): popup is keyof typeof popupNames { + return !!popup && popup in popupNames +} + +function parsePopups(hash: string): PopupRoute[] { + let popups: PopupRoute[] = [] + let parts = hash.split(',') + for (let part of parts) { + let [popup, param] = part.split('=', 2) + if (checkPopupName(popup) && param) { + popups.push({ param, popup }) + } + } + return popups +} export const router = readonlyExport($router) @@ -102,10 +129,17 @@ onEnvironment(({ baseRouter }) => { (route, user, withFeeds, fast, slowUnread) => { if (!route) { return open('notFound') - } else if (user) { + } else if (!user) { + if (!GUEST.has(route.route)) { + return open('start') + } else { + return { params: route.params, popups: [], route: route.route } + } + } else { + let popups = parsePopups(route.hash) if (GUEST.has(route.route) || route.route === 'home') { if (withFeeds) { - return redirect({ params: {}, route: 'slow' }) + return redirect({ params: {}, popups, route: 'slow' }) } else { return redirect(open('welcome')) } @@ -116,12 +150,14 @@ onEnvironment(({ baseRouter }) => { } else if (route.route === 'feeds') { return redirect({ params: { candidate: undefined, url: undefined }, + popups, route: 'add' }) } else if (route.route === 'fast') { if (!route.params.category && !fast.isLoading) { return redirect({ params: { category: fast.categories[0].id }, + popups, route: 'fast' }) } @@ -136,11 +172,12 @@ onEnvironment(({ baseRouter }) => { if (route.params.since) { return validateNumber(route.params.since, since => { return { - ...route, params: { ...route.params, since - } + }, + popups, + route: route.route } }) } @@ -153,6 +190,7 @@ onEnvironment(({ baseRouter }) => { if (feedData) { return redirect({ params: { feed: feedData[0].id || '' }, + popups, route: 'slow' }) } @@ -162,11 +200,12 @@ onEnvironment(({ baseRouter }) => { if (route.params.page) { return validateNumber(route.params.page, page => { return { - ...route, params: { ...route.params, page - } + }, + popups, + route: route.route } }) } else { @@ -175,19 +214,19 @@ onEnvironment(({ baseRouter }) => { ...route.params, page: 1 }, + popups, route: 'slow' } } } - } else if (!GUEST.has(route.route)) { - return open('start') + return { params: route.params, popups, route: route.route } } - return route }, (oldRoute, newRoute) => { return ( oldRoute.route === newRoute.route && - JSON.stringify(oldRoute.params) === JSON.stringify(newRoute.params) + JSON.stringify(oldRoute.params) === JSON.stringify(newRoute.params) && + JSON.stringify(oldRoute.popups) === JSON.stringify(newRoute.popups) ) } ) @@ -215,26 +254,31 @@ export const backRoute = computed( if (route === 'add' && params.candidate) { return { params: { candidate: undefined, url: params.url }, + popups: [], route: 'add' } } else if (route === 'categories' && params.feed) { return { params: {}, + popups: [], route: 'categories' } } else if (route === 'fast' && params.post) { return { params: { category: params.category }, + popups: [], route: 'fast' } } else if (route === 'slow' && params.post) { return { params: { feed: params.feed }, + popups: [], route: 'slow' } } else if (route === 'export' && params.format) { return { params: { format: undefined }, + popups: [], route: 'export' } } diff --git a/core/slow.ts b/core/slow.ts index 3e6abc48..6a5306c1 100644 --- a/core/slow.ts +++ b/core/slow.ts @@ -193,6 +193,7 @@ onEnvironment(({ openRoute }) => { if (notSynced(router.get())) { openRoute({ params: { feed, page }, + popups: [], route: 'slow' }) } diff --git a/core/test/fast.test.ts b/core/test/fast.test.ts index 738c50c2..7978ccca 100644 --- a/core/test/fast.test.ts +++ b/core/test/fast.test.ts @@ -412,6 +412,7 @@ test('syncs fast category and since with URL', async () => { await markReadAndLoadNextFastPosts() deepStrictEqual(router.get(), { params: { category: category1, since: 5000 }, + popups: [], route: 'fast' }) diff --git a/core/test/page.test.ts b/core/test/page.test.ts index 9810d80b..023e7ef9 100644 --- a/core/test/page.test.ts +++ b/core/test/page.test.ts @@ -95,6 +95,7 @@ test('synchronizes params', async () => { await setTimeout(1) deepStrictEqual(router.get(), { params: { candidate: undefined, url: 'https://example.com' }, + popups: [], route: 'add' }) equal(pages.add().url.get(), 'https://example.com') @@ -107,6 +108,7 @@ test('synchronizes params', async () => { await setTimeout(1) deepStrictEqual(router.get(), { params: { candidate: undefined, url: 'https://other.com' }, + popups: [], route: 'add' }) equal(pages.add().url.get(), 'https://other.com') @@ -115,7 +117,7 @@ test('synchronizes params', async () => { setBaseTestRoute({ params: {}, route: 'notFound' }) pages.add().url.set('https://example.com') await setTimeout(1) - deepStrictEqual(router.get(), { params: {}, route: 'notFound' }) + deepStrictEqual(router.get(), { params: {}, popups: [], route: 'notFound' }) }) test('has under construction pages', () => { diff --git a/core/test/preview.test.ts b/core/test/preview.test.ts index 2a21e144..733e2bd7 100644 --- a/core/test/preview.test.ts +++ b/core/test/preview.test.ts @@ -632,6 +632,7 @@ test('changes URL during typing in the field', async () => { test('syncs URL with router', async () => { deepStrictEqual(router.get(), { params: { candidate: undefined, url: undefined }, + popups: [], route: 'add' }) @@ -639,6 +640,7 @@ test('syncs URL with router', async () => { setPreviewUrl('example.com') deepStrictEqual(router.get(), { params: { candidate: undefined, url: 'http://example.com' }, + popups: [], route: 'add' }) @@ -646,6 +648,7 @@ test('syncs URL with router', async () => { setPreviewUrl('https://other.com') deepStrictEqual(router.get(), { params: { candidate: undefined, url: 'https://other.com' }, + popups: [], route: 'add' }) @@ -683,6 +686,7 @@ test('show candidate on wide screen', async () => { deepStrictEqual(router.get(), { params: { candidate: 'https://a.com/atom', url: 'https://a.com/atom' }, + popups: [], route: 'add' }) equal(currentCandidate.get(), undefined) @@ -704,6 +708,7 @@ test('do not show candidate on mobile screen', async () => { deepStrictEqual(router.get(), { params: { candidate: undefined, url: 'https://a.com/atom' }, + popups: [], route: 'add' }) equal(previewCandidate.get(), undefined) @@ -722,6 +727,7 @@ test('redirect to candidates list if no current candidate', async () => { equal(previewCandidate.get(), undefined) deepStrictEqual(router.get(), { params: { candidate: undefined, url: 'https://a.com/atom' }, + popups: [], route: 'add' }) }) diff --git a/core/test/router.test.ts b/core/test/router.test.ts index 49c38eb4..2401fd64 100644 --- a/core/test/router.test.ts +++ b/core/test/router.test.ts @@ -28,31 +28,19 @@ afterEach(async () => { test('opens 404', () => { setBaseTestRoute(undefined) - deepStrictEqual(router.get(), { - params: {}, - route: 'notFound' - }) + deepStrictEqual(router.get(), { params: {}, popups: [], route: 'notFound' }) }) test('transforms routers for guest', () => { userId.set(undefined) setBaseTestRoute({ params: {}, route: 'home' }) - deepStrictEqual(router.get(), { - params: {}, - route: 'start' - }) + deepStrictEqual(router.get(), { params: {}, popups: [], route: 'start' }) setBaseTestRoute({ params: {}, route: 'slow' }) - deepStrictEqual(router.get(), { - params: {}, - route: 'start' - }) + deepStrictEqual(router.get(), { params: {}, popups: [], route: 'start' }) setBaseTestRoute({ params: {}, route: 'signin' }) - deepStrictEqual(router.get(), { - params: {}, - route: 'signin' - }) + deepStrictEqual(router.get(), { params: {}, popups: [], route: 'signin' }) }) test('transforms routers for users', () => { @@ -60,6 +48,7 @@ test('transforms routers for users', () => { setBaseTestRoute({ params: {}, route: 'home' }) deepStrictEqual(router.get(), { params: {}, + popups: [], redirect: true, route: 'welcome' }) @@ -67,12 +56,14 @@ test('transforms routers for users', () => { setBaseTestRoute({ params: { category: 'general' }, route: 'fast' }) deepStrictEqual(router.get(), { params: { category: 'general' }, + popups: [], route: 'fast' }) setBaseTestRoute({ params: {}, route: 'home' }) deepStrictEqual(router.get(), { params: {}, + popups: [], redirect: true, route: 'welcome' }) @@ -80,15 +71,13 @@ test('transforms routers for users', () => { setBaseTestRoute({ params: {}, route: 'signin' }) deepStrictEqual(router.get(), { params: {}, + popups: [], redirect: true, route: 'welcome' }) userId.set(undefined) - deepStrictEqual(router.get(), { - params: {}, - route: 'signin' - }) + deepStrictEqual(router.get(), { params: {}, popups: [], route: 'signin' }) }) test('transforms routers for users with feeds', async () => { @@ -96,6 +85,7 @@ test('transforms routers for users with feeds', async () => { setBaseTestRoute({ params: {}, route: 'home' }) deepStrictEqual(router.get(), { params: {}, + popups: [], redirect: true, route: 'welcome' }) @@ -103,6 +93,7 @@ test('transforms routers for users with feeds', async () => { let id = await addFeed(testFeed()) deepStrictEqual(router.get(), { params: {}, + popups: [], redirect: true, route: 'slow' }) @@ -110,6 +101,7 @@ test('transforms routers for users with feeds', async () => { setBaseTestRoute({ params: {}, route: 'welcome' }) deepStrictEqual(router.get(), { params: {}, + popups: [], redirect: true, route: 'slow' }) @@ -118,6 +110,7 @@ test('transforms routers for users with feeds', async () => { await deleteFeed(id) deepStrictEqual(router.get(), { params: {}, + popups: [], redirect: true, route: 'welcome' }) @@ -129,6 +122,7 @@ test('transforms section to first section page', () => { setBaseTestRoute({ params: {}, route: 'settings' }) deepStrictEqual(router.get(), { params: {}, + popups: [], redirect: true, route: 'interface' }) @@ -136,6 +130,7 @@ test('transforms section to first section page', () => { setBaseTestRoute({ params: {}, route: 'feeds' }) deepStrictEqual(router.get(), { params: { candidate: undefined, url: undefined }, + popups: [], redirect: true, route: 'add' }) @@ -152,6 +147,7 @@ test('transforms routers to first fast category', async () => { await setTimeout(100) deepStrictEqual(router.get(), { params: { category: idA }, + popups: [], redirect: true, route: 'fast' }) @@ -195,12 +191,14 @@ test('converts since to number', async () => { setBaseTestRoute({ params: { category: idA, since: 1000 }, route: 'fast' }) deepStrictEqual(router.get(), { params: { category: idA, since: 1000 }, + popups: [], route: 'fast' }) setBaseTestRoute({ params: { category: idA, since: '1000' }, route: 'fast' }) deepStrictEqual(router.get(), { params: { category: idA, since: 1000 }, + popups: [], route: 'fast' }) @@ -210,15 +208,13 @@ test('converts since to number', async () => { }) deepStrictEqual(router.get(), { params: { category: idA, post, since: 1000 }, + popups: [], route: 'fast' }) await setTimeout(10) setBaseTestRoute({ params: { category: idA, since: '1000k' }, route: 'fast' }) - deepStrictEqual(router.get(), { - params: {}, - route: 'notFound' - }) + deepStrictEqual(router.get(), { params: {}, popups: [], route: 'notFound' }) }) test('checks that category exists', async () => { @@ -231,15 +227,13 @@ test('checks that category exists', async () => { route: 'fast' }) await setTimeout(100) - deepStrictEqual(router.get(), { - params: {}, - route: 'notFound' - }) + deepStrictEqual(router.get(), { params: {}, popups: [], route: 'notFound' }) setBaseTestRoute({ params: { category: idA, since: 100 }, route: 'fast' }) await setTimeout(100) deepStrictEqual(router.get(), { params: { category: idA, since: 100 }, + popups: [], route: 'fast' }) }) @@ -252,6 +246,48 @@ test('backRoute handles export with format', () => { deepStrictEqual(router.get(), { params: { format: undefined }, + popups: [], route: 'export' }) }) + +test('parses popups', () => { + userId.set('10') + setBaseTestRoute({ hash: 'feed=id1,post=id2', params: {}, route: 'profile' }) + deepStrictEqual(router.get(), { + params: {}, + popups: [ + { param: 'id1', popup: 'feed' }, + { param: 'id2', popup: 'post' } + ], + route: 'profile' + }) + + setBaseTestRoute({ hash: 'broken,post=id', params: {}, route: 'profile' }) + deepStrictEqual(router.get(), { + params: {}, + popups: [{ param: 'id', popup: 'post' }], + route: 'profile' + }) + + setBaseTestRoute({ + hash: 'unknown=id1,post=id', + params: {}, + route: 'profile' + }) + deepStrictEqual(router.get(), { + params: {}, + popups: [{ param: 'id', popup: 'post' }], + route: 'profile' + }) +}) + +test('hides popups for guest', () => { + userId.set(undefined) + setBaseTestRoute({ hash: 'feed=id1,post=id2', params: {}, route: 'profile' }) + deepStrictEqual(router.get(), { + params: {}, + popups: [], + route: 'start' + }) +}) diff --git a/core/test/two-steps.test.ts b/core/test/two-steps.test.ts index 7940d197..99992ca5 100644 --- a/core/test/two-steps.test.ts +++ b/core/test/two-steps.test.ts @@ -54,6 +54,7 @@ test('works with adds route on wide screen', async () => { strictEqual(secondStep.get(), true) deepStrictEqual(backRoute.get(), { params: { candidate: undefined, url: 'https://a.com/atom' }, + popups: [], route: 'add' }) }) @@ -91,6 +92,7 @@ test('works with categories route', async () => { strictEqual(secondStep.get(), true) deepStrictEqual(backRoute.get(), { params: {}, + popups: [], route: 'categories' }) }) @@ -111,6 +113,7 @@ test('works with fast route', async () => { strictEqual(secondStep.get(), true) deepStrictEqual(backRoute.get(), { params: { category: idA }, + popups: [], route: 'fast' }) }) @@ -131,6 +134,7 @@ test('works with slow route', async () => { strictEqual(secondStep.get(), true) deepStrictEqual(backRoute.get(), { params: { feed }, + popups: [], route: 'slow' }) }) @@ -143,6 +147,7 @@ test('goes back to first step', async () => { deepStrictEqual(router.get(), { params: { feed }, + popups: [], route: 'categories' }) @@ -150,6 +155,7 @@ test('goes back to first step', async () => { deepStrictEqual(router.get(), { params: {}, + popups: [], route: 'categories' }) @@ -157,6 +163,7 @@ test('goes back to first step', async () => { deepStrictEqual(router.get(), { params: {}, + popups: [], route: 'categories' }) }) diff --git a/web/pages/feeds/edit.svelte b/web/pages/feeds/edit.svelte index 7100fa3e..d048fe8a 100644 --- a/web/pages/feeds/edit.svelte +++ b/web/pages/feeds/edit.svelte @@ -72,6 +72,7 @@ if (page.route === 'categories') { openRoute({ params: {}, + popups: [], route: page.route }) } diff --git a/web/stores/router.ts b/web/stores/router.ts index 4ef7a5cd..b2d06992 100644 --- a/web/stores/router.ts +++ b/web/stores/router.ts @@ -38,6 +38,7 @@ export const urlRouter = computed(pathRouter, path => { if ('url' in path.params) params.url = path.params.url if ('candidate' in path.search) params.candidate = path.search.candidate return { + hash: path.hash, params, route: path.route } @@ -46,6 +47,7 @@ export const urlRouter = computed(pathRouter, path => { if ('since' in path.search) params.since = Number(path.search.since) if ('post' in path.search) params.post = path.search.post return { + hash: path.hash, params, route: path.route } @@ -56,6 +58,7 @@ export const urlRouter = computed(pathRouter, path => { } if ('post' in path.search) params.post = path.search.post return { + hash: path.hash, params, route: path.route } @@ -91,10 +94,14 @@ function moveToSearch( return getPagePath(pathRouter, page.route, rest, search) } -export function getURL(to: ParamlessRouteName | Route): string { +export function getURL( + to: Omit | ParamlessRouteName | Route +): string { let page: Route if (typeof to === 'string') { - page = { params: {}, route: to } + page = { params: {}, popups: [], route: to } + } else if (!('popups' in to)) { + page = { ...to, popups: [] } as Route } else { page = to } diff --git a/web/stories/environment.ts b/web/stories/environment.ts index c427bc4a..59a1d8e8 100644 --- a/web/stories/environment.ts +++ b/web/stories/environment.ts @@ -9,6 +9,7 @@ import { import { atom } from 'nanostores' export const baseRouter = atom({ + hash: '', params: { category: 'general' }, route: 'fast' }) @@ -37,7 +38,7 @@ setupEnvironment({ return networkType }, openRoute(page) { - baseRouter.set(page) + baseRouter.set({ ...page, hash: '' }) }, persistentEvents: { addEventListener() {}, diff --git a/web/stories/scene.svelte b/web/stories/scene.svelte index 0339b6b2..7258b3fb 100644 --- a/web/stories/scene.svelte +++ b/web/stories/scene.svelte @@ -2,6 +2,8 @@ import { addCategory, addFeed, + addHashToBaseRoute, + type BaseRoute, Category, type CategoryValue, clearPreview, @@ -32,7 +34,6 @@ type PostValue, refreshStatistics, type RefreshStatistics, - type Route, selectAllImportedFeeds, slowPosts, type SlowPostsValue, @@ -74,7 +75,7 @@ openedPost, refreshing = false, responses = {}, - route = { params: {}, route: 'slow' }, + route, showPagination = false, slowState = INITIAL_SLOW, unloadedFeeds = [] @@ -91,7 +92,7 @@ openedPost?: PostValue | undefined refreshing?: false | Partial responses?: Record - route?: Route + route?: BaseRoute | Omit showPagination?: boolean slowState?: SlowPostsValue unloadedFeeds?: string[] @@ -125,11 +126,14 @@ if (fast) { baseRouter.set({ + hash: '', params: { category: 'general' }, route: 'fast' }) } else { - baseRouter.set(route) + baseRouter.set( + addHashToBaseRoute(route) ?? { hash: '', params: {}, route: 'slow' } + ) } }) @@ -177,7 +181,7 @@ return () => { forceSet(isRefreshing, false) - baseRouter.set({ params: {}, route: 'slow' }) + baseRouter.set({ hash: '', params: {}, route: 'slow' }) setNetworkType(DEFAULT_NETWORK) cleanLogux() forceSet(slowPosts, INITIAL_SLOW) From ff5c78ceb3af3efbb8eb2939b75d21a0a2056808 Mon Sep 17 00:00:00 2001 From: Andrey Sitnik Date: Tue, 29 Oct 2024 18:30:53 +0100 Subject: [PATCH 06/20] Add cache to download task to connect add page and feedUrl popup --- core/download.ts | 26 +++++++++++++++++++++----- core/pages/add.ts | 4 ++-- core/preview.ts | 2 +- core/refresh.ts | 2 +- core/test/download.test.ts | 33 +++++++++++++++++++++++++++++---- 5 files changed, 54 insertions(+), 13 deletions(-) diff --git a/core/download.ts b/core/download.ts index 94cf673d..0e77204b 100644 --- a/core/download.ts +++ b/core/download.ts @@ -1,6 +1,8 @@ import { warning } from './devtools.ts' import { request } from './request.ts' +let cache = new Map() + export interface TextResponse { readonly contentType: string readonly headers: Headers @@ -14,7 +16,7 @@ export interface TextResponse { } export interface DownloadTask { - abortAll(): void + destroy(): void request: typeof request text(...args: Parameters): Promise } @@ -107,18 +109,32 @@ export function createTextResponse( } } -export function createDownloadTask(): DownloadTask { +export function createDownloadTask( + taskOpts: { cache?: 'read' | 'write' | false } = {} +): DownloadTask { let controller = new AbortController() + let cached: string[] = [] return { - abortAll() { + destroy() { + for (let url of cached) cache.delete(url) controller.abort() }, - request(url, opts = {}) { - return request(url, { + async request(url, opts = {}) { + if (taskOpts.cache) { + let fromCache = cache.get(url) + if (fromCache) return fromCache.clone() + } + + let response = await request(url, { redirect: 'follow', signal: controller.signal, ...opts }) + if (taskOpts.cache === 'write') { + cached.push(url) + cache.set(url, response.clone()) + } + return response }, async text(url, opts) { let response = await this.request(url, opts) diff --git a/core/pages/add.ts b/core/pages/add.ts index e4fb021f..3ffb6f03 100644 --- a/core/pages/add.ts +++ b/core/pages/add.ts @@ -82,7 +82,7 @@ export const add = createPage('add', () => { function exit(): void { $links.set({}) $candidates.set([]) - prevTask?.abortAll() + prevTask?.destroy() } let inputUrl = debounce((value: string) => { @@ -96,7 +96,7 @@ export const add = createPage('add', () => { let prevTask: DownloadTask | undefined async function setUrl(url: string): Promise { - if (prevTask) prevTask.abortAll() + if (prevTask) prevTask.destroy() if (url === $url.get()) return inputUrl.cancel() exit() diff --git a/core/preview.ts b/core/preview.ts index f98f6226..1bdc5e0f 100644 --- a/core/preview.ts +++ b/core/preview.ts @@ -108,7 +108,7 @@ export function clearPreview(): void { $added.set(undefined) $posts.set(undefined) postsCache.clear() - task.abortAll() + task.destroy() task = createDownloadTask() } diff --git a/core/refresh.ts b/core/refresh.ts index 0c1359cd..85ece621 100644 --- a/core/refresh.ts +++ b/core/refresh.ts @@ -136,5 +136,5 @@ export function stopRefreshing(): void { if (!$isRefreshing.get()) return $isRefreshing.set(false) queue.stop() - task.abortAll() + task.destroy() } diff --git a/core/test/download.test.ts b/core/test/download.test.ts index c6b6ffc6..f7fdea7f 100644 --- a/core/test/download.test.ts +++ b/core/test/download.test.ts @@ -83,7 +83,7 @@ test('aborts requests', async () => { await reply1(200) equal(calls, '1:ok ') - task1.abortAll() + task1.destroy() await setTimeout(10) equal(calls, '1:ok 2:AbortError 3:AbortError ') @@ -119,7 +119,7 @@ test('can download text by keeping eyes on abort signal', async () => { let response2 = task.text('https://example.com') await setTimeout(10) - task.abortAll() + task.destroy() sendText?.('Done') await rejects(response2, (e: Error) => e.name === 'AbortError') }) @@ -179,7 +179,7 @@ test('parses JSON content', () => { test('has helper to ignore abort errors', async () => { let task = createDownloadTask() - task.abortAll() + task.destroy() let error1 = new Error('Test') throws(() => { @@ -207,7 +207,7 @@ test('has helper to ignore abort errors', async () => { task.text('https://example.com').catch(e => { error3 = e }) - task.abortAll() + task.destroy() await setTimeout(10) ignoreAbortError(error3) @@ -254,3 +254,28 @@ test('detects content type', () => { 'application/rss+xml' ) }) + +test('has cache by request', async () => { + let write = createDownloadTask({ cache: 'write' }) + let read = createDownloadTask({ cache: 'read' }) + let none = createDownloadTask() + + expectRequest('https://example.com/1').andRespond(200, '1') + equal((await read.text('https://example.com/1')).text, '1') + + expectRequest('https://example.com/1').andRespond(200, '1') + equal((await read.text('https://example.com/1')).text, '1') + + expectRequest('https://example.com/1').andRespond(200, '1') + equal((await write.text('https://example.com/1')).text, '1') + equal((await write.text('https://example.com/1')).text, '1') + equal((await read.text('https://example.com/1')).text, '1') + + expectRequest('https://example.com/1').andRespond(200, '1') + equal((await none.text('https://example.com/1')).text, '1') + + write.destroy() + + expectRequest('https://example.com/1').andRespond(200, '1') + equal((await read.text('https://example.com/1')).text, '1') +}) From 3e0c65193f9eb4704ee9ca8f5850629e08b93b12 Mon Sep 17 00:00:00 2001 From: Andrey Sitnik Date: Tue, 29 Oct 2024 18:31:29 +0100 Subject: [PATCH 07/20] Copy object type limits from pages to loaders --- core/loader/index.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/core/loader/index.ts b/core/loader/index.ts index 5947f31b..e59d3a13 100644 --- a/core/loader/index.ts +++ b/core/loader/index.ts @@ -16,6 +16,8 @@ export const loaders = { atom, jsonFeed, rss +} satisfies { + [Name in string]: Loader } export type LoaderName = keyof typeof loaders From 2ff4ada70f5077ab28f610bb2d978bda8193018a Mon Sep 17 00:00:00 2001 From: Andrey Sitnik Date: Fri, 22 Nov 2024 12:49:11 +0000 Subject: [PATCH 08/20] Rename page to currentPage to avoid using too generic words --- core/{page.ts => current-page.ts} | 16 ++++++------ core/index.ts | 2 +- .../{page.test.ts => current-page.test.ts} | 26 +++++++++---------- 3 files changed, 22 insertions(+), 22 deletions(-) rename core/{page.ts => current-page.ts} (80%) rename core/test/{page.test.ts => current-page.test.ts} (84%) diff --git a/core/page.ts b/core/current-page.ts similarity index 80% rename from core/page.ts rename to core/current-page.ts index 21c3acbd..2f8a582b 100644 --- a/core/page.ts +++ b/core/current-page.ts @@ -44,20 +44,20 @@ function changeRouteParam( let prevPage: Page | undefined let unbinds: (() => void)[] = [] -export const page: ReadableAtom = computed(router, route => { - let currentPage = pages[route.route]() as Page - if (currentPage !== prevPage) { +export const currentPage: ReadableAtom = computed(router, route => { + let page = pages[route.route]() as Page + if (page !== prevPage) { if (prevPage) { for (let unbind of unbinds) unbind() prevPage.destroy() } - prevPage = currentPage + prevPage = page - eachParam(currentPage, route, (store, param) => { + eachParam(page, route, (store, param) => { unbinds.push( store.listen(newValue => { let currentRoute = router.get() - if (currentRoute.route === currentPage.route) { + if (currentRoute.route === page.route) { changeRouteParam(currentRoute, { [param]: newValue }) } }) @@ -65,11 +65,11 @@ export const page: ReadableAtom = computed(router, route => { }) } - eachParam(currentPage, route, (store, param, value) => { + eachParam(page, route, (store, param, value) => { if (store.get() !== value) { store.set(value) } }) - return currentPage + return page }) diff --git a/core/index.ts b/core/index.ts index d44d9c3b..289f3f95 100644 --- a/core/index.ts +++ b/core/index.ts @@ -2,6 +2,7 @@ export * from './busy.ts' export * from './category.ts' export * from './client.ts' export * from './comfort.ts' +export * from './current-page.ts' export * from './devtools.ts' export * from './download.ts' export * from './environment.ts' @@ -17,7 +18,6 @@ export * from './loader/index.ts' export * from './menu.ts' export * from './messages/index.ts' export * from './not-found.ts' -export * from './page.ts' export * from './pages/index.ts' export * from './post.ts' export * from './posts-list.ts' diff --git a/core/test/page.test.ts b/core/test/current-page.test.ts similarity index 84% rename from core/test/page.test.ts rename to core/test/current-page.test.ts index 023e7ef9..9abb0731 100644 --- a/core/test/page.test.ts +++ b/core/test/current-page.test.ts @@ -3,7 +3,7 @@ import { deepStrictEqual, equal } from 'node:assert' import { afterEach, beforeEach, test } from 'node:test' import { setTimeout } from 'node:timers/promises' -import { page, pages, router, setBaseTestRoute } from '../index.ts' +import { currentPage, pages, router, setBaseTestRoute } from '../index.ts' import { cleanClientTest, enableClientTest } from './utils.ts' let addPage = pages.add @@ -15,27 +15,27 @@ beforeEach(() => { afterEach(async () => { pages.add = addPage await cleanClientTest() - cleanStores(page) + cleanStores(currentPage) }) test('synchronies router with page', () => { - keepMount(page) + keepMount(currentPage) setBaseTestRoute({ params: {}, route: 'notFound' }) - equal(page.get(), pages.notFound()) + equal(currentPage.get(), pages.notFound()) setBaseTestRoute({ params: { candidate: undefined, url: undefined }, route: 'add' }) - equal(page.get(), pages.add()) + equal(currentPage.get(), pages.add()) setBaseTestRoute({ params: {}, route: 'notFound' }) - equal(page.get(), pages.notFound()) + equal(currentPage.get(), pages.notFound()) }) test('calls events', () => { - keepMount(page) + keepMount(currentPage) let events = '' let originAdd = pages.add @@ -56,34 +56,34 @@ test('calls events', () => { } setBaseTestRoute({ params: {}, route: 'notFound' }) - equal(page.get().route, 'notFound') + equal(currentPage.get().route, 'notFound') equal(events, 0) setBaseTestRoute({ params: { candidate: undefined, url: undefined }, route: 'add' }) - equal(page.get().route, 'add') + equal(currentPage.get().route, 'add') equal(events, 'create ') setBaseTestRoute({ params: {}, route: 'notFound' }) - equal(page.get().route, 'notFound') + equal(currentPage.get().route, 'notFound') equal(events, 'create exit destroy ') setBaseTestRoute({ params: { candidate: undefined, url: undefined }, route: 'add' }) - equal(page.get().route, 'add') + equal(currentPage.get().route, 'add') equal(events, 'create exit destroy create ') setBaseTestRoute({ params: {}, route: 'notFound' }) - equal(page.get().route, 'notFound') + equal(currentPage.get().route, 'notFound') equal(events, 'create exit destroy create exit destroy ') }) test('synchronizes params', async () => { - keepMount(page) + keepMount(currentPage) setBaseTestRoute({ params: { candidate: undefined, url: undefined }, route: 'add' From 7da6196d4a09f139b1eb7e93efb369e160d2ecee Mon Sep 17 00:00:00 2001 From: Andrey Sitnik Date: Fri, 22 Nov 2024 12:52:14 +0000 Subject: [PATCH 09/20] Move some add pages types/functions to loader file to re-use them in feed URL popup later --- core/loader/index.ts | 28 +++++++++ core/pages/add.ts | 113 +++++++++++------------------------- core/pages/index.ts | 2 +- core/preview.ts | 4 +- core/test/pages/add.test.ts | 16 +++-- 5 files changed, 74 insertions(+), 89 deletions(-) diff --git a/core/loader/index.ts b/core/loader/index.ts index e59d3a13..f0572732 100644 --- a/core/loader/index.ts +++ b/core/loader/index.ts @@ -21,3 +21,31 @@ export const loaders = { } export type LoaderName = keyof typeof loaders + +export interface FeedLoader { + loader: Loader + name: LoaderName + title: string + url: string +} + +export function getLoaderForText(response: TextResponse): false | FeedLoader { + if (response.ok) { + let names = Object.keys(loaders) as LoaderName[] + let parsed = new URL(response.url) + for (let name of names) { + if (loaders[name].isMineUrl(parsed) !== false) { + let title = loaders[name].isMineText(response) + if (title !== false) { + return { + loader: loaders[name], + name, + title: title.trim(), + url: response.url + } + } + } + } + } + return false +} diff --git a/core/pages/add.ts b/core/pages/add.ts index 3ffb6f03..36ee702c 100644 --- a/core/pages/add.ts +++ b/core/pages/add.ts @@ -7,7 +7,12 @@ import { ignoreAbortError, type TextResponse } from '../download.ts' -import { type LoaderName, loaders } from '../loader/index.ts' +import { + type FeedLoader, + getLoaderForText, + type LoaderName, + loaders +} from '../loader/index.ts' import { createPage } from './common.ts' const ALWAYS_HTTPS = [/^twitter\.com\//] @@ -32,19 +37,12 @@ export type AddLinksValue = Record< } > -export interface AddCandidate { - loader: LoaderName - text?: TextResponse - title: string - url: string -} - export const add = createPage('add', () => { let $url = atom() let $links = map({}) - let $candidates = atom([]) + let $candidates = atom([]) let $error = computed( $links, @@ -100,44 +98,10 @@ export const add = createPage('add', () => { if (url === $url.get()) return inputUrl.cancel() exit() - prevTask = createDownloadTask() + prevTask = createDownloadTask({ cache: 'write' }) await addLink(prevTask, url) } - function getLoaderForUrl(url: string): AddCandidate | false { - let names = Object.keys(loaders) as LoaderName[] - let parsed = new URL(url) - for (let name of names) { - let title = loaders[name].isMineUrl(parsed) - // Until we will have loader for specific domain - /* c8 ignore start */ - if (typeof title === 'string') { - return { loader: name, title, url } - } - /* c8 ignore end */ - } - return false - } - - function getLoaderForText(response: TextResponse): AddCandidate | false { - let names = Object.keys(loaders) as LoaderName[] - let parsed = new URL(response.url) - for (let name of names) { - if (loaders[name].isMineUrl(parsed) !== false) { - let title = loaders[name].isMineText(response) - if (title !== false) { - return { - loader: name, - text: response, - title: title.trim(), - url: response.url - } - } - } - } - return false - } - function getLinksFromText(response: TextResponse): string[] { let names = Object.keys(loaders) as LoaderName[] return names.reduce((links, name) => { @@ -152,7 +116,7 @@ export const add = createPage('add', () => { }, []) } - function addCandidate(url: string, candidate: AddCandidate): void { + function addCandidate(url: string, candidate: FeedLoader): void { if ($candidates.get().some(i => i.url === url)) return $links.setKey(url, { state: 'processed' }) @@ -190,45 +154,36 @@ export const add = createPage('add', () => { return } - let byUrl = getLoaderForUrl(url) - - if (byUrl !== false) { - // Until we will have loader for specific domain - /* c8 ignore next */ - - addCandidate(url, byUrl) - } else { - $links.setKey(url, { state: 'loading' }) + $links.setKey(url, { state: 'loading' }) + try { + let response try { - let response - try { - response = await task.text(url) - } catch { - $links.setKey(url, { state: 'unloadable' }) - return - } - if (!response.ok) { - $links.setKey(url, { state: 'unloadable' }) + response = await task.text(url) + } catch { + $links.setKey(url, { state: 'unloadable' }) + return + } + if (!response.ok) { + $links.setKey(url, { state: 'unloadable' }) + } else { + let byText = getLoaderForText(response) + if (byText) { + addCandidate(url, byText) } else { - let byText = getLoaderForText(response) - if (byText) { - addCandidate(url, byText) - } else { - $links.setKey(url, { state: 'unknown' }) - } - if (!deep) { - let links = getLinksFromText(response) - if (links.length > 0) { - await Promise.all(links.map(i => addLink(task, i, true))) - } else if ($candidates.get().length === 0) { - let suggested = getSuggestedLinksFromText(response) - await Promise.all(suggested.map(i => addLink(task, i, true))) - } + $links.setKey(url, { state: 'unknown' }) + } + if (!deep) { + let links = getLinksFromText(response) + if (links.length > 0) { + await Promise.all(links.map(i => addLink(task, i, true))) + } else if ($candidates.get().length === 0) { + let suggested = getSuggestedLinksFromText(response) + await Promise.all(suggested.map(i => addLink(task, i, true))) } } - } catch (error) { - ignoreAbortError(error) } + } catch (error) { + ignoreAbortError(error) } } diff --git a/core/pages/index.ts b/core/pages/index.ts index 1054f897..0ad2dc1c 100644 --- a/core/pages/index.ts +++ b/core/pages/index.ts @@ -9,7 +9,7 @@ import { type PageCreator } from './common.ts' -export type { AddCandidate, AddPage } from './add.ts' +export type { AddPage } from './add.ts' export * from './common.ts' // TODO: Remove after refactoring diff --git a/core/preview.ts b/core/preview.ts index 1bdc5e0f..56ea7431 100644 --- a/core/preview.ts +++ b/core/preview.ts @@ -134,9 +134,7 @@ function getLoaderForUrl(url: string): false | PreviewCandidate { return false } -export function getLoaderForText( - response: TextResponse -): false | PreviewCandidate { +function getLoaderForText(response: TextResponse): false | PreviewCandidate { let names = Object.keys(loaders) as LoaderName[] let parsed = new URL(response.url) for (let name of names) { diff --git a/core/test/pages/add.test.ts b/core/test/pages/add.test.ts index c2202247..e652e0c0 100644 --- a/core/test/pages/add.test.ts +++ b/core/test/pages/add.test.ts @@ -7,9 +7,9 @@ import { afterEach, beforeEach, test } from 'node:test' import { setTimeout } from 'node:timers/promises' import { - type AddCandidate, checkAndRemoveRequestMock, expectRequest, + type FeedLoader, loaders, mockRequest, pages, @@ -33,7 +33,7 @@ afterEach(async () => { checkAndRemoveRequestMock() }) -function equalWithText(a: AddCandidate[], b: AddCandidate[]): void { +function equalWithText(a: FeedLoader[], b: FeedLoader[]): void { equal(a.length, b.length) for (let i = 0; i < a.length; i++) { let aFix = { ...a[i], text: undefined } @@ -175,7 +175,8 @@ test('detects RSS links', async () => { equal(pages.add().error.get(), undefined) equalWithText(pages.add().sortedCandidates.get(), [ { - loader: 'rss', + loader: loaders.rss, + name: 'rss', title: 'News', url: 'http://example.com/news' } @@ -202,7 +203,8 @@ test('is ready for empty title', async () => { equal(pages.add().error.get(), undefined) equalWithText(pages.add().sortedCandidates.get(), [ { - loader: 'atom', + loader: loaders.atom, + name: 'atom', title: '', url: 'http://other.com/atom' } @@ -230,7 +232,8 @@ test('ignores duplicate links', async () => { equal(pages.add().error.get(), undefined) equalWithText(pages.add().sortedCandidates.get(), [ { - loader: 'atom', + loader: loaders.atom, + name: 'atom', title: 'Feed', url: 'http://other.com/atom' } @@ -256,7 +259,8 @@ test('looks for popular RSS, Atom and JsonFeed places', async () => { equal(pages.add().error.get(), undefined) equalWithText(pages.add().sortedCandidates.get(), [ { - loader: 'atom', + loader: loaders.atom, + name: 'atom', title: '', url: 'http://example.com/atom' } From 718689e498b0c46463ebf4b9360706b9d912c2c9 Mon Sep 17 00:00:00 2001 From: Andrey Sitnik Date: Fri, 22 Nov 2024 12:52:37 +0000 Subject: [PATCH 10/20] Move loader tests to new add page --- loader-tests/utils.ts | 27 +++++++++++++-------------- 1 file changed, 13 insertions(+), 14 deletions(-) diff --git a/loader-tests/utils.ts b/loader-tests/utils.ts index c2ceaeed..bfa9ddcc 100644 --- a/loader-tests/utils.ts +++ b/loader-tests/utils.ts @@ -5,12 +5,8 @@ import { enableTestTime, getLoaderForText, getTestEnvironment, - loaders, - type PreviewCandidate, - previewCandidates, - previewCandidatesLoading, + pages, setBaseTestRoute, - setPreviewUrl, setRequestMethod, setupEnvironment, userId @@ -200,13 +196,12 @@ export async function fetchAndParsePosts( semiSuccess(url, `redirect to HTML`) return } - let candidate: false | PreviewCandidate = getLoaderForText(response) + let candidate = getLoaderForText(response) if (!candidate) { error(`Can not found loader for feed ${url}`) return } - let loader = loaders[candidate.loader] - let { list } = loader.getPosts(task, url, response).get() + let { list } = candidate.loader.getPosts(task, url, response).get() if (list.length === 0) { if (badSource) { semiSuccess(url, '0 posts') @@ -233,12 +228,14 @@ export async function findRSSfromHome( feed: LoaderTestFeed, tries = 0 ): Promise { - let unbindPreview = previewCandidates.listen(() => {}) + setBaseTestRoute({ params: {}, route: 'add' }) + let addPage = pages.add() + let unbindPreview = addPage.sortedCandidates.listen(() => {}) try { let homeUrl = feed.homeUrl || getHomeUrl(feed.url) - setPreviewUrl(homeUrl) + addPage.setUrl(homeUrl) try { - await timeout(10_000, waitFor(previewCandidatesLoading, false)) + await timeout(10_000, waitFor(addPage.candidatesLoading, false)) } catch (e) { if (e instanceof Error && e.message === 'Timeout' && tries > 0) { return await findRSSfromHome(feed, tries - 1) @@ -246,11 +243,13 @@ export async function findRSSfromHome( throw e } } - let normalizedUrls = previewCandidates.get().map(i => normalizeUrl(i.url)) + let normalizedUrls = addPage.sortedCandidates + .get() + .map(i => normalizeUrl(i.url)) if (normalizedUrls.includes(normalizeUrl(feed.url))) { success(`Feed ${feed.title} has feed URL at home`) return true - } else if (previewCandidates.get().length === 0) { + } else if (addPage.sortedCandidates.get().length === 0) { error( `Can’t find any feed from home URL or ${feed.title}`, `Home URL: ${homeUrl}\nFeed URL: ${feed.url}` @@ -260,7 +259,7 @@ export async function findRSSfromHome( error( `Can’t find ${feed.title} feed from home URL`, `Home URL: ${homeUrl}\n` + - `Found: ${previewCandidates + `Found: ${addPage.sortedCandidates .get() .map(i => i.url) .join('\n ')}\n` + From e8ce759cf325d49ff8801f09dc50c8ef1e1da021 Mon Sep 17 00:00:00 2001 From: Andrey Sitnik Date: Sat, 29 Mar 2025 23:52:00 +0000 Subject: [PATCH 11/20] Fix isse after rebase because of linters updates --- core/pages/add.ts | 1 + core/test/preview.test.ts | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/core/pages/add.ts b/core/pages/add.ts index 36ee702c..c7417372 100644 --- a/core/pages/add.ts +++ b/core/pages/add.ts @@ -183,6 +183,7 @@ export const add = createPage('add', () => { } } } catch (error) { + /* c8 ignore next 2 */ ignoreAbortError(error) } } diff --git a/core/test/preview.test.ts b/core/test/preview.test.ts index 733e2bd7..a19095bf 100644 --- a/core/test/preview.test.ts +++ b/core/test/preview.test.ts @@ -629,7 +629,7 @@ test('changes URL during typing in the field', async () => { equal(previewUrl.get(), 'http://example.com') }) -test('syncs URL with router', async () => { +test('syncs URL with router', () => { deepStrictEqual(router.get(), { params: { candidate: undefined, url: undefined }, popups: [], From 7bbd8c7a342208757881f6c9d2bf726632938d3e Mon Sep 17 00:00:00 2001 From: Andrey Sitnik Date: Sun, 30 Mar 2025 00:18:28 +0000 Subject: [PATCH 12/20] Increase size limit after rebase --- web/.size-limit.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/web/.size-limit.json b/web/.size-limit.json index 0d4da2d3..d23352ff 100644 --- a/web/.size-limit.json +++ b/web/.size-limit.json @@ -9,12 +9,12 @@ "!dist/500.html", "!dist/error.avif" ], - "limit": "70 KB" + "limit": "75 KB" }, { "name": "Core scripts to execute", "path": "dist/assets/index-*.js", "brotli": false, - "limit": "195 KB" + "limit": "200 KB" } ] From 08efbec6fcdfc34af2fad7b373b164c7924e6e73 Mon Sep 17 00:00:00 2001 From: Andrey Sitnik Date: Sun, 30 Mar 2025 00:27:54 +0000 Subject: [PATCH 13/20] Keep loading state until the end of feed search --- core/pages/add.ts | 10 +++++----- core/test/pages/add.test.ts | 6 ++++++ 2 files changed, 11 insertions(+), 5 deletions(-) diff --git a/core/pages/add.ts b/core/pages/add.ts index c7417372..3d827210 100644 --- a/core/pages/add.ts +++ b/core/pages/add.ts @@ -167,11 +167,6 @@ export const add = createPage('add', () => { $links.setKey(url, { state: 'unloadable' }) } else { let byText = getLoaderForText(response) - if (byText) { - addCandidate(url, byText) - } else { - $links.setKey(url, { state: 'unknown' }) - } if (!deep) { let links = getLinksFromText(response) if (links.length > 0) { @@ -181,6 +176,11 @@ export const add = createPage('add', () => { await Promise.all(suggested.map(i => addLink(task, i, true))) } } + if (byText) { + addCandidate(url, byText) + } else { + $links.setKey(url, { state: 'unknown' }) + } } } catch (error) { /* c8 ignore next 2 */ diff --git a/core/test/pages/add.test.ts b/core/test/pages/add.test.ts index e652e0c0..dabed0cb 100644 --- a/core/test/pages/add.test.ts +++ b/core/test/pages/add.test.ts @@ -147,6 +147,11 @@ test('detects RSS links', async () => { keepMount(pages.add().error) keepMount(pages.add().sortedCandidates) + let loadingChanges: boolean[] = [] + pages.add().candidatesLoading.subscribe(() => { + loadingChanges.push(pages.add().candidatesLoading.get()) + }) + let replyHtml = expectRequest('http://example.com').andWait() pages.add().setUrl('example.com') await setTimeout(10) @@ -173,6 +178,7 @@ test('detects RSS links', async () => { await setTimeout(10) equal(pages.add().candidatesLoading.get(), false) equal(pages.add().error.get(), undefined) + deepStrictEqual(loadingChanges, [false, true, false]) equalWithText(pages.add().sortedCandidates.get(), [ { loader: loaders.rss, From d23530ba6fbc933ab5ae0d76ba17650a47d86703 Mon Sep 17 00:00:00 2001 From: Andrey Sitnik Date: Sun, 30 Mar 2025 20:34:20 +0000 Subject: [PATCH 14/20] Add new popup API --- core/index.ts | 2 + core/opened-popups.ts | 29 ++++++ core/package.json | 2 +- core/popups/common.ts | 92 ++++++++++++++++++ core/popups/feed-url.ts | 53 +++++++++++ core/popups/feed.ts | 23 +++++ core/popups/index.ts | 22 +++++ core/popups/post.ts | 22 +++++ core/preview.ts | 7 +- core/router.ts | 4 +- core/test/popups/feed-url.test.ts | 149 ++++++++++++++++++++++++++++++ core/test/popups/feed.test.ts | 68 ++++++++++++++ core/test/popups/post.test.ts | 90 ++++++++++++++++++ pnpm-lock.yaml | 19 ++-- web/package.json | 2 +- 15 files changed, 570 insertions(+), 14 deletions(-) create mode 100644 core/opened-popups.ts create mode 100644 core/popups/common.ts create mode 100644 core/popups/feed-url.ts create mode 100644 core/popups/feed.ts create mode 100644 core/popups/index.ts create mode 100644 core/popups/post.ts create mode 100644 core/test/popups/feed-url.test.ts create mode 100644 core/test/popups/feed.test.ts create mode 100644 core/test/popups/post.test.ts diff --git a/core/index.ts b/core/index.ts index 289f3f95..53b6ed08 100644 --- a/core/index.ts +++ b/core/index.ts @@ -18,7 +18,9 @@ export * from './loader/index.ts' export * from './menu.ts' export * from './messages/index.ts' export * from './not-found.ts' +export * from './opened-popups.ts' export * from './pages/index.ts' +export * from './popups/index.ts' export * from './post.ts' export * from './posts-list.ts' export * from './preview.ts' diff --git a/core/opened-popups.ts b/core/opened-popups.ts new file mode 100644 index 00000000..9273d0cc --- /dev/null +++ b/core/opened-popups.ts @@ -0,0 +1,29 @@ +import { computed, type ReadableAtom } from 'nanostores' + +import { type Popup, popups } from './popups/index.ts' +import { router } from './router.ts' + +let prevPopups: Popup[] = [] + +export const openedPopups: ReadableAtom = computed(router, route => { + let lastIndex = 0 + let nextPopups = route.popups.map((popup, index) => { + lastIndex = index + let prevPopup = prevPopups[index] + if ( + prevPopup && + prevPopup.name === popup.popup && + prevPopup.param === popup.param + ) { + return prevPopup + } else { + if (prevPopup) prevPopup.destroy() + return popups[popup.popup](popup.param) + } + }) + for (let closedPopup of prevPopups.slice(lastIndex + 1)) { + closedPopup.destroy() + } + prevPopups = nextPopups + return nextPopups +}) diff --git a/core/package.json b/core/package.json index 588842b1..57424017 100644 --- a/core/package.json +++ b/core/package.json @@ -16,7 +16,7 @@ "./package.json": "./package.json" }, "dependencies": { - "@logux/client": "0.21.1", + "@logux/client": "github:logux/client#next", "@logux/core": "0.9.0", "@nanostores/i18n": "0.12.2", "@nanostores/persistent": "0.10.2", diff --git a/core/popups/common.ts b/core/popups/common.ts new file mode 100644 index 00000000..54560508 --- /dev/null +++ b/core/popups/common.ts @@ -0,0 +1,92 @@ +import { atom, type ReadableAtom } from 'nanostores' + +import type { PopupName } from '../router.ts' + +type Extra = { + destroy?: () => void + notFound: boolean +} + +export type BasePopup< + Name extends PopupName = PopupName, + Loading extends boolean = boolean, + NotFound extends boolean = boolean +> = { + destroy(): void + readonly loading: ReadableAtom + readonly name: Name + readonly notFound: NotFound + readonly param: string +} + +export interface PopupCreator< + Name extends PopupName, + Rest extends Extra = + | ({ notFound: false } & Record) + | { notFound: true } +> { + ( + param: string + ): + | (BasePopup & Rest) + | BasePopup + | BasePopup +} + +export type LoadedPopup> = Extract< + ReturnType, + { loading: ReadableAtom; notFound: false } +> + +export interface PopupHelpers { + startTask(): () => void +} + +export function definePopup( + name: Name, + builder: (param: string, popup: PopupHelpers) => Promise +): PopupCreator { + let creator: PopupCreator = param => { + let destroyed = false + let rest: Rest | undefined + let loading = atom(true) + let popup = { + destroy() { + destroyed = true + rest?.destroy?.() + }, + loading, + name, + notFound: false, + param + } + + let tasks = 0 + let helpers: PopupHelpers = { + startTask() { + tasks += 1 + loading.set(true) + return () => { + tasks -= 1 + if (tasks <= 0) loading.set(false) + } + } + } + + let stop = helpers.startTask() + builder(param, helpers).then(extra => { + rest = extra + if (destroyed) extra.destroy?.() + for (let i in rest) { + // @ts-expect-error Too complex case for TypeScript + popup[i] = extra[i] + } + stop() + }) + return popup as + | (BasePopup & Rest) + | BasePopup + | BasePopup + } + return creator +} diff --git a/core/popups/feed-url.ts b/core/popups/feed-url.ts new file mode 100644 index 00000000..cb9492f1 --- /dev/null +++ b/core/popups/feed-url.ts @@ -0,0 +1,53 @@ +import { atom } from 'nanostores' + +import { createDownloadTask } from '../download.ts' +import { type FeedValue, getFeeds } from '../feed.ts' +import { getLoaderForText } from '../loader/index.ts' +import { definePopup, type LoadedPopup } from './common.ts' + +export const feedUrl = definePopup('feedUrl', async url => { + let task = createDownloadTask({ cache: 'read' }) + let response = await task.text(url) + + let candidate = getLoaderForText(response) + if (candidate) { + let posts = candidate.loader.getPosts(task, url, response) + + let $feed = atom() + + let feedsFilter = getFeeds({ url }) + let unbindFeed = (): void => {} + let unbindFeeds = feedsFilter.subscribe(feeds => { + if (!feeds.isLoading) { + let needed = feeds.list.find(feed => feed.url === url) + if (needed) { + let $needed = feeds.stores.get(needed.id)! + unbindFeed = $needed.subscribe(feed => { + if (!feed.isLoading) { + $feed.set(feed) + } + }) + } else { + $feed.set(undefined) + } + } + }) + + return { + destroy() { + task.destroy() + unbindFeeds() + unbindFeed() + }, + feed: $feed, + notFound: false, + posts + } + } else { + return { + notFound: true + } + } +}) + +export type FeedUrlPopup = LoadedPopup diff --git a/core/popups/feed.ts b/core/popups/feed.ts new file mode 100644 index 00000000..e88a3be9 --- /dev/null +++ b/core/popups/feed.ts @@ -0,0 +1,23 @@ +import { loadValue } from '@logux/client' + +import { getFeed, getFeedLatestPosts } from '../feed.ts' +import { definePopup, type LoadedPopup } from './common.ts' + +export const feed = definePopup('feed', async id => { + let $feed = getFeed(id) + let value = await loadValue($feed) + + if (value) { + return { + feed: $feed, + notFound: false, + posts: getFeedLatestPosts(value) + } + } else { + return { + notFound: true + } + } +}) + +export type FeedPopup = LoadedPopup diff --git a/core/popups/index.ts b/core/popups/index.ts new file mode 100644 index 00000000..72eee698 --- /dev/null +++ b/core/popups/index.ts @@ -0,0 +1,22 @@ +import type { PopupName } from '../router.ts' +import type { PopupCreator } from './common.ts' +import { feedUrl } from './feed-url.ts' +import { feed } from './feed.ts' +import { post } from './post.ts' + +export * from './common.ts' +export type { FeedUrlPopup } from './feed-url.ts' +export type { FeedPopup } from './feed.ts' +export type { PostPopup } from './post.ts' + +export const popups = { + feed, + feedUrl, + post +} satisfies { + [Name in PopupName]: PopupCreator +} + +export type Popups = typeof popups + +export type Popup = ReturnType diff --git a/core/popups/post.ts b/core/popups/post.ts new file mode 100644 index 00000000..b9822277 --- /dev/null +++ b/core/popups/post.ts @@ -0,0 +1,22 @@ +import { loadValue } from '@logux/client' + +import { getPost } from '../post.ts' +import { definePopup, type LoadedPopup } from './common.ts' + +export const post = definePopup('post', async id => { + let $post = getPost(id) + let found = await loadValue($post) + + if (found) { + return { + notFound: false, + post: $post + } as const + } else { + return { + notFound: true + } as const + } +}) + +export type PostPopup = LoadedPopup diff --git a/core/preview.ts b/core/preview.ts index 56ea7431..e8a00816 100644 --- a/core/preview.ts +++ b/core/preview.ts @@ -1,3 +1,4 @@ +/* eslint-disable @typescript-eslint/no-unsafe-member-access */ import { loadValue } from '@logux/client' import debounce from 'just-debounce-it' import { nanoid } from 'nanoid' @@ -12,7 +13,7 @@ import { isMobile, onEnvironment } from './environment.ts' import { addFeed, type FeedValue, getFeeds } from './feed.ts' import { readonlyExport } from './lib/stores.ts' import { type LoaderName, loaders } from './loader/index.ts' -import { addPost, processOriginPost } from './post.ts' +import { addPost, type OriginPost, processOriginPost } from './post.ts' import type { PostsList } from './posts-list.ts' import { router } from './router.ts' @@ -255,6 +256,7 @@ export async function createFeedFromUrl( let posts = loaders[candidate.loader].getPosts(task, url, candidate.text) $posts.set(posts) + // @ts-expect-error Legacy file which we will delete soon let page = await loadValue(posts) let lastPost = page.list[0] @@ -317,6 +319,7 @@ export function setPreviewCandidate(url: string): void { export async function addPreviewCandidate(): Promise { let url = $candidate.get() if (url) { + // @ts-expect-error Legacy file which we will delete soon let page = await loadValue($posts.get()!) let lastPost = page.list[0] let candidate = $candidates.get().find(i => i.url === url)! @@ -331,7 +334,7 @@ export async function addPreviewCandidate(): Promise { url }) if (lastPost) { - await addPost(processOriginPost(lastPost, feedId, 'fast')) + await addPost(processOriginPost(lastPost as OriginPost, feedId, 'fast')) } } } diff --git a/core/router.ts b/core/router.ts index 52b4eaab..c46704ec 100644 --- a/core/router.ts +++ b/core/router.ts @@ -30,7 +30,9 @@ export interface Routes { export const popupNames = { feed: true, feedUrl: true, post: true } -export type PopupRoute = { param: string; popup: keyof typeof popupNames } +export type PopupName = keyof typeof popupNames + +export type PopupRoute = { param: string; popup: PopupName } export type RouteName = keyof Routes diff --git a/core/test/popups/feed-url.test.ts b/core/test/popups/feed-url.test.ts new file mode 100644 index 00000000..7944d129 --- /dev/null +++ b/core/test/popups/feed-url.test.ts @@ -0,0 +1,149 @@ +import '../dom-parser.ts' + +import { cleanStores, keepMount } from 'nanostores' +import { deepStrictEqual, equal } from 'node:assert' +import { afterEach, beforeEach, test } from 'node:test' +import { setTimeout } from 'node:timers/promises' + +import { + addFeed, + changeFeed, + checkAndRemoveRequestMock, + deleteFeed, + expectRequest, + type FeedUrlPopup, + mockRequest, + openedPopups, + setBaseTestRoute, + testFeed +} from '../../index.ts' +import { cleanClientTest, enableClientTest } from '../utils.ts' + +beforeEach(() => { + enableClientTest() + mockRequest() +}) + +afterEach(async () => { + await cleanClientTest() + cleanStores(openedPopups) + checkAndRemoveRequestMock() +}) + +test('loads 404 for feeds by URL popup', async () => { + keepMount(openedPopups) + expectRequest('http://a.com/one').andRespond(404) + setBaseTestRoute({ + hash: `feedUrl=http://a.com/one`, + params: {}, + route: 'add' + }) + equal(openedPopups.get().length, 1) + equal(openedPopups.get()[0]?.name, 'feedUrl') + equal(openedPopups.get()[0]?.param, 'http://a.com/one') + equal(openedPopups.get()[0]?.loading.get(), true) + + await setTimeout(100) + equal(openedPopups.get()[0]?.loading.get(), false) + equal(openedPopups.get()[0]?.notFound, true) + + expectRequest('http://a.com/two').andRespond(200, 'Nothing') + setBaseTestRoute({ + hash: `feedUrl=http://a.com/two`, + params: {}, + route: 'add' + }) + equal(openedPopups.get()[0]?.param, 'http://a.com/two') + equal(openedPopups.get()[0]?.loading.get(), true) + + await setTimeout(100) + equal(openedPopups.get()[0]?.loading.get(), false) + equal(openedPopups.get()[0]?.notFound, true) +}) + +test('loads feeds by URL popup', async () => { + keepMount(openedPopups) + expectRequest('https://a.com/atom').andRespond( + 200, + 'Atom' + + '22023-07-01T00:00:00Z' + + '12023-06-01T00:00:00Z' + + '', + 'text/xml' + ) + setBaseTestRoute({ + hash: `feedUrl=https://a.com/atom`, + params: {}, + route: 'add' + }) + equal(openedPopups.get().length, 1) + equal(openedPopups.get()[0]?.loading.get(), true) + + await setTimeout(100) + equal(openedPopups.get()[0]?.loading.get(), false) + equal(openedPopups.get()[0]?.notFound, false) + let feedPopup = openedPopups.get()[0] as FeedUrlPopup + equal(feedPopup.feed.get(), undefined) + deepStrictEqual(feedPopup.posts.get().isLoading, false) + deepStrictEqual(feedPopup.posts.get().list.length, 2) + deepStrictEqual(feedPopup.posts.get().list[0]?.originId, '2') + + let feedId = await addFeed(testFeed({ url: 'https://a.com/atom' })) + equal(feedPopup.feed.get()!.url, 'https://a.com/atom') + equal(feedPopup.feed.get()!.id, feedId) + equal(feedPopup.feed.get()!.title, 'Test 1') + + await changeFeed(feedId, { title: 'New Test 1' }) + equal(feedPopup.feed.get()!.title, 'New Test 1') + + await deleteFeed(feedId) + equal(feedPopup.feed.get(), undefined) +}) + +test('destroys replaced popups and keep unchanged', async () => { + keepMount(openedPopups) + expectRequest('https://a.com/atom').andRespond( + 200, + 'Atom' + + '22023-07-01T00:00:00Z' + + '12023-06-01T00:00:00Z' + + '', + 'text/xml' + ) + expectRequest('https://a.com/atom').andRespond( + 200, + 'Atom' + + '22023-07-01T00:00:00Z' + + '12023-06-01T00:00:00Z' + + '', + 'text/xml' + ) + + setBaseTestRoute({ + hash: `feedUrl=https://a.com/atom,feedUrl=https://a.com/atom`, + params: {}, + route: 'add' + }) + await setTimeout(100) + equal(openedPopups.get().length, 2) + let feedPopup1 = openedPopups.get()[0] as FeedUrlPopup + let feedPopup2 = openedPopups.get()[1] as FeedUrlPopup + equal(feedPopup1.feed.get(), undefined) + equal(feedPopup2.feed.get(), undefined) + + let feedId = await addFeed(testFeed({ url: 'https://a.com/atom' })) + equal(feedPopup1.feed.get()!.url, 'https://a.com/atom') + equal(feedPopup1.feed.get()?.id, feedId) + equal(feedPopup2.feed.get()?.id, feedId) + + setBaseTestRoute({ + hash: `feedUrl=https://a.com/atom`, + params: {}, + route: 'add' + }) + deepStrictEqual(openedPopups.get(), [feedPopup1]) + + await deleteFeed(feedId) + equal(feedPopup1.feed.get(), undefined) + equal(feedPopup2.feed.get()?.id, feedId) +}) diff --git a/core/test/popups/feed.test.ts b/core/test/popups/feed.test.ts new file mode 100644 index 00000000..46671055 --- /dev/null +++ b/core/test/popups/feed.test.ts @@ -0,0 +1,68 @@ +import { cleanStores, keepMount } from 'nanostores' +import { equal } from 'node:assert' +import { afterEach, beforeEach, test } from 'node:test' +import { setTimeout } from 'node:timers/promises' + +import { + addFeed, + addPost, + type FeedPopup, + openedPopups, + type PostPopup, + setBaseTestRoute, + testFeed, + testPost +} from '../../index.ts' +import { cleanClientTest, enableClientTest } from '../utils.ts' + +beforeEach(() => { + enableClientTest() +}) + +afterEach(async () => { + await cleanClientTest() + cleanStores(openedPopups) +}) + +test('opens feed', async () => { + keepMount(openedPopups) + equal(openedPopups.get().length, 0) + let feed = await addFeed(testFeed({ categoryId: 'general' })) + let post = await addPost(testPost({ feedId: feed })) + + setBaseTestRoute({ hash: `feed=${feed}`, params: {}, route: 'fast' }) + equal(openedPopups.get().length, 1) + equal(openedPopups.get()[0]?.name, 'feed') + equal(openedPopups.get()[0]?.param, feed) + equal(openedPopups.get()[0]?.loading.get(), true) + + await setTimeout(100) + equal(openedPopups.get()[0]?.loading.get(), false) + equal(openedPopups.get()[0]?.notFound, false) + equal((openedPopups.get()[0] as FeedPopup).feed.get().id, feed) + + setBaseTestRoute({ hash: `feed=unknown`, params: {}, route: 'fast' }) + equal(openedPopups.get().length, 1) + equal(openedPopups.get()[0]?.loading.get(), true) + + await setTimeout(100) + equal(openedPopups.get()[0]?.notFound, true) + + setBaseTestRoute({ hash: `feed=${feed}`, params: {}, route: 'fast' }) + await setTimeout(100) + + setBaseTestRoute({ + hash: `feed=${feed},post=${post}`, + params: {}, + route: 'fast' + }) + equal(openedPopups.get().length, 2) + equal(openedPopups.get()[0]?.loading.get(), false) + equal(openedPopups.get()[1]?.loading.get(), true) + + await setTimeout(100) + equal(openedPopups.get()[0]?.loading.get(), false) + equal(openedPopups.get()[1]?.loading.get(), false) + equal((openedPopups.get()[0] as FeedPopup).feed.get().id, feed) + equal((openedPopups.get()[1] as PostPopup).post.get().id, post) +}) diff --git a/core/test/popups/post.test.ts b/core/test/popups/post.test.ts new file mode 100644 index 00000000..b689d093 --- /dev/null +++ b/core/test/popups/post.test.ts @@ -0,0 +1,90 @@ +import { cleanStores, keepMount } from 'nanostores' +import { equal } from 'node:assert' +import { afterEach, beforeEach, test } from 'node:test' +import { setTimeout } from 'node:timers/promises' + +import { + addFeed, + addPost, + openedPopups, + type PostPopup, + setBaseTestRoute, + testFeed, + testPost +} from '../../index.ts' +import { cleanClientTest, enableClientTest } from '../utils.ts' + +beforeEach(() => { + enableClientTest() +}) + +afterEach(async () => { + await cleanClientTest() + cleanStores(openedPopups) +}) + +test('reacts on unknown popups', () => { + keepMount(openedPopups) + equal(openedPopups.get().length, 0) + + setBaseTestRoute({ hash: `unknown=id`, params: {}, route: 'fast' }) + equal(openedPopups.get().length, 0) + + setBaseTestRoute({ hash: `popup:id`, params: {}, route: 'fast' }) + equal(openedPopups.get().length, 0) +}) + +test('opens post', async () => { + keepMount(openedPopups) + let feed = await addFeed(testFeed({ categoryId: 'general' })) + let post1 = await addPost(testPost({ feedId: feed })) + let post2 = await addPost(testPost({ feedId: feed })) + + setBaseTestRoute({ hash: `post=${post1}`, params: {}, route: 'fast' }) + equal(openedPopups.get().length, 1) + equal(openedPopups.get()[0]?.name, 'post') + equal(openedPopups.get()[0]?.param, post1) + equal(openedPopups.get()[0]?.loading.get(), true) + + await setTimeout(100) + equal(openedPopups.get()[0]?.loading.get(), false) + equal(openedPopups.get()[0]?.notFound, false) + equal((openedPopups.get()[0] as PostPopup).post.get().id, post1) + + setBaseTestRoute({ + hash: `post=${post1},post=${post1}`, + params: {}, + route: 'fast' + }) + equal(openedPopups.get().length, 2) + equal(openedPopups.get()[0]?.loading.get(), false) + equal(openedPopups.get()[1]?.loading.get(), true) + + await setTimeout(100) + equal(openedPopups.get()[0]?.loading.get(), false) + equal(openedPopups.get()[1]?.loading.get(), false) + + setBaseTestRoute({ + hash: `post=${post2},post=${post1}`, + params: {}, + route: 'fast' + }) + equal(openedPopups.get()[0]?.loading.get(), true) + equal(openedPopups.get()[1]?.loading.get(), false) + + await setTimeout(100) + equal(openedPopups.get()[0]?.loading.get(), false) + equal(openedPopups.get()[0]?.notFound, false) + equal((openedPopups.get()[0] as PostPopup).post.get().id, post2) + + setBaseTestRoute({ + hash: `post=unknown`, + params: {}, + route: 'fast' + }) + equal(openedPopups.get()[0]?.loading.get(), true) + + await setTimeout(100) + equal(openedPopups.get()[0]?.loading.get(), false) + equal(openedPopups.get()[0]?.notFound, true) +}) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8b048b1e..53f7c319 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -112,8 +112,8 @@ importers: core: dependencies: '@logux/client': - specifier: 0.21.1 - version: 0.21.1(@logux/core@0.9.0)(nanostores@0.11.4)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + specifier: github:logux/client#next + version: https://codeload.github.com/logux/client/tar.gz/98505de89240a653df7e1b0d8850b6780114cf57(@logux/core@0.9.0)(nanostores@0.11.4)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@logux/core': specifier: 0.9.0 version: 0.9.0 @@ -240,8 +240,8 @@ importers: web: dependencies: '@logux/client': - specifier: 0.21.1 - version: 0.21.1(@logux/core@0.9.0)(nanostores@0.11.4)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + specifier: github:logux/client#next + version: https://codeload.github.com/logux/client/tar.gz/98505de89240a653df7e1b0d8850b6780114cf57(@logux/core@0.9.0)(nanostores@0.11.4)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@logux/core': specifier: 0.9.0 version: 0.9.0 @@ -1055,11 +1055,12 @@ packages: peerDependencies: '@logux/core': ^0.9.0 - '@logux/client@0.21.1': - resolution: {integrity: sha512-MJJ5yRrMoT2R/BelVGl7P1Bc7kIsERLaI7cs8XZIeVWQixSYwLs7Ma+SBTjE13EyPPABqZzJNdmArTwufh85vw==} - engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} + '@logux/client@https://codeload.github.com/logux/client/tar.gz/98505de89240a653df7e1b0d8850b6780114cf57': + resolution: {tarball: https://codeload.github.com/logux/client/tar.gz/98505de89240a653df7e1b0d8850b6780114cf57} + version: 0.20.1 + engines: {node: ^20.0.0 || >=22.0.0} peerDependencies: - '@logux/core': ^0.9.0 + '@logux/core': ^0.8.0 '@nanostores/preact': '>=0.0.0' '@nanostores/react': '>=0.0.0' '@nanostores/vue': '>=0.0.0' @@ -5248,7 +5249,7 @@ snapshots: dependencies: '@logux/core': 0.9.0 - '@logux/client@0.21.1(@logux/core@0.9.0)(nanostores@0.11.4)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + '@logux/client@https://codeload.github.com/logux/client/tar.gz/98505de89240a653df7e1b0d8850b6780114cf57(@logux/core@0.9.0)(nanostores@0.11.4)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: '@logux/actions': 0.4.0(@logux/core@0.9.0) '@logux/core': 0.9.0 diff --git a/web/package.json b/web/package.json index 12e36b36..dd999010 100644 --- a/web/package.json +++ b/web/package.json @@ -24,7 +24,7 @@ "clean:build": "rm -rf dist" }, "dependencies": { - "@logux/client": "0.21.1", + "@logux/client": "github:logux/client#next", "@logux/core": "0.9.0", "@mdi/js": "7.4.47", "@nanostores/i18n": "0.12.2", From 50dcc22622d568f1f400771bd516b35fe66240e7 Mon Sep 17 00:00:00 2001 From: Andrey Sitnik Date: Mon, 31 Mar 2025 14:42:00 +0000 Subject: [PATCH 15/20] Wait for loading become false, instead of using setTimneout for more stable tests --- core/index.ts | 1 + core/lib/stores.ts | 14 ++++++++++++++ core/test/popups/feed-url.test.ts | 17 +++++++---------- core/test/popups/feed.test.ts | 12 ++++++------ core/test/popups/post.test.ts | 14 ++++++-------- loader-tests/utils.ts | 20 +++----------------- 6 files changed, 37 insertions(+), 41 deletions(-) diff --git a/core/index.ts b/core/index.ts index 53b6ed08..f518fbac 100644 --- a/core/index.ts +++ b/core/index.ts @@ -14,6 +14,7 @@ export * from './filter.ts' export * from './html.ts' export * from './i18n.ts' export * from './import.ts' +export { waitForStore } from './lib/stores.ts' export * from './loader/index.ts' export * from './menu.ts' export * from './messages/index.ts' diff --git a/core/lib/stores.ts b/core/lib/stores.ts index 2f2ed578..1907e7e3 100644 --- a/core/lib/stores.ts +++ b/core/lib/stores.ts @@ -67,3 +67,17 @@ export function computeFrom( } }) } + +export function waitForStore( + store: ReadableAtom, + value: Value +): Promise { + return new Promise(resolve => { + let unbind = store.listen(state => { + if (state === value) { + unbind() + resolve() + } + }) + }) +} diff --git a/core/test/popups/feed-url.test.ts b/core/test/popups/feed-url.test.ts index 7944d129..f9437a6b 100644 --- a/core/test/popups/feed-url.test.ts +++ b/core/test/popups/feed-url.test.ts @@ -3,7 +3,6 @@ import '../dom-parser.ts' import { cleanStores, keepMount } from 'nanostores' import { deepStrictEqual, equal } from 'node:assert' import { afterEach, beforeEach, test } from 'node:test' -import { setTimeout } from 'node:timers/promises' import { addFeed, @@ -15,7 +14,8 @@ import { mockRequest, openedPopups, setBaseTestRoute, - testFeed + testFeed, + waitForStore } from '../../index.ts' import { cleanClientTest, enableClientTest } from '../utils.ts' @@ -43,8 +43,7 @@ test('loads 404 for feeds by URL popup', async () => { equal(openedPopups.get()[0]?.param, 'http://a.com/one') equal(openedPopups.get()[0]?.loading.get(), true) - await setTimeout(100) - equal(openedPopups.get()[0]?.loading.get(), false) + await waitForStore((openedPopups.get()[0] as FeedUrlPopup).loading, false) equal(openedPopups.get()[0]?.notFound, true) expectRequest('http://a.com/two').andRespond(200, 'Nothing') @@ -56,8 +55,7 @@ test('loads 404 for feeds by URL popup', async () => { equal(openedPopups.get()[0]?.param, 'http://a.com/two') equal(openedPopups.get()[0]?.loading.get(), true) - await setTimeout(100) - equal(openedPopups.get()[0]?.loading.get(), false) + await waitForStore((openedPopups.get()[0] as FeedUrlPopup).loading, false) equal(openedPopups.get()[0]?.notFound, true) }) @@ -79,10 +77,9 @@ test('loads feeds by URL popup', async () => { equal(openedPopups.get().length, 1) equal(openedPopups.get()[0]?.loading.get(), true) - await setTimeout(100) - equal(openedPopups.get()[0]?.loading.get(), false) - equal(openedPopups.get()[0]?.notFound, false) let feedPopup = openedPopups.get()[0] as FeedUrlPopup + await waitForStore(feedPopup.loading, false) + equal(feedPopup.notFound, false) equal(feedPopup.feed.get(), undefined) deepStrictEqual(feedPopup.posts.get().isLoading, false) deepStrictEqual(feedPopup.posts.get().list.length, 2) @@ -124,10 +121,10 @@ test('destroys replaced popups and keep unchanged', async () => { params: {}, route: 'add' }) - await setTimeout(100) equal(openedPopups.get().length, 2) let feedPopup1 = openedPopups.get()[0] as FeedUrlPopup let feedPopup2 = openedPopups.get()[1] as FeedUrlPopup + await waitForStore(feedPopup1.loading, false) equal(feedPopup1.feed.get(), undefined) equal(feedPopup2.feed.get(), undefined) diff --git a/core/test/popups/feed.test.ts b/core/test/popups/feed.test.ts index 46671055..735540bf 100644 --- a/core/test/popups/feed.test.ts +++ b/core/test/popups/feed.test.ts @@ -1,7 +1,6 @@ import { cleanStores, keepMount } from 'nanostores' import { equal } from 'node:assert' import { afterEach, beforeEach, test } from 'node:test' -import { setTimeout } from 'node:timers/promises' import { addFeed, @@ -11,7 +10,8 @@ import { type PostPopup, setBaseTestRoute, testFeed, - testPost + testPost, + waitForStore } from '../../index.ts' import { cleanClientTest, enableClientTest } from '../utils.ts' @@ -36,7 +36,7 @@ test('opens feed', async () => { equal(openedPopups.get()[0]?.param, feed) equal(openedPopups.get()[0]?.loading.get(), true) - await setTimeout(100) + await waitForStore((openedPopups.get()[0] as FeedPopup).loading, false) equal(openedPopups.get()[0]?.loading.get(), false) equal(openedPopups.get()[0]?.notFound, false) equal((openedPopups.get()[0] as FeedPopup).feed.get().id, feed) @@ -45,11 +45,11 @@ test('opens feed', async () => { equal(openedPopups.get().length, 1) equal(openedPopups.get()[0]?.loading.get(), true) - await setTimeout(100) + await waitForStore((openedPopups.get()[0] as FeedPopup).loading, false) equal(openedPopups.get()[0]?.notFound, true) setBaseTestRoute({ hash: `feed=${feed}`, params: {}, route: 'fast' }) - await setTimeout(100) + await waitForStore((openedPopups.get()[0] as FeedPopup).loading, false) setBaseTestRoute({ hash: `feed=${feed},post=${post}`, @@ -60,7 +60,7 @@ test('opens feed', async () => { equal(openedPopups.get()[0]?.loading.get(), false) equal(openedPopups.get()[1]?.loading.get(), true) - await setTimeout(100) + await waitForStore((openedPopups.get()[1] as FeedPopup).loading, false) equal(openedPopups.get()[0]?.loading.get(), false) equal(openedPopups.get()[1]?.loading.get(), false) equal((openedPopups.get()[0] as FeedPopup).feed.get().id, feed) diff --git a/core/test/popups/post.test.ts b/core/test/popups/post.test.ts index b689d093..869915b5 100644 --- a/core/test/popups/post.test.ts +++ b/core/test/popups/post.test.ts @@ -1,7 +1,6 @@ import { cleanStores, keepMount } from 'nanostores' import { equal } from 'node:assert' import { afterEach, beforeEach, test } from 'node:test' -import { setTimeout } from 'node:timers/promises' import { addFeed, @@ -10,7 +9,8 @@ import { type PostPopup, setBaseTestRoute, testFeed, - testPost + testPost, + waitForStore } from '../../index.ts' import { cleanClientTest, enableClientTest } from '../utils.ts' @@ -46,7 +46,7 @@ test('opens post', async () => { equal(openedPopups.get()[0]?.param, post1) equal(openedPopups.get()[0]?.loading.get(), true) - await setTimeout(100) + await waitForStore((openedPopups.get()[0] as PostPopup).loading, false) equal(openedPopups.get()[0]?.loading.get(), false) equal(openedPopups.get()[0]?.notFound, false) equal((openedPopups.get()[0] as PostPopup).post.get().id, post1) @@ -60,7 +60,7 @@ test('opens post', async () => { equal(openedPopups.get()[0]?.loading.get(), false) equal(openedPopups.get()[1]?.loading.get(), true) - await setTimeout(100) + await waitForStore((openedPopups.get()[1] as PostPopup).loading, false) equal(openedPopups.get()[0]?.loading.get(), false) equal(openedPopups.get()[1]?.loading.get(), false) @@ -72,8 +72,7 @@ test('opens post', async () => { equal(openedPopups.get()[0]?.loading.get(), true) equal(openedPopups.get()[1]?.loading.get(), false) - await setTimeout(100) - equal(openedPopups.get()[0]?.loading.get(), false) + await waitForStore((openedPopups.get()[0] as PostPopup).loading, false) equal(openedPopups.get()[0]?.notFound, false) equal((openedPopups.get()[0] as PostPopup).post.get().id, post2) @@ -84,7 +83,6 @@ test('opens post', async () => { }) equal(openedPopups.get()[0]?.loading.get(), true) - await setTimeout(100) - equal(openedPopups.get()[0]?.loading.get(), false) + await waitForStore((openedPopups.get()[0] as PostPopup).loading, false) equal(openedPopups.get()[0]?.notFound, true) }) diff --git a/loader-tests/utils.ts b/loader-tests/utils.ts index bfa9ddcc..c7e66ae5 100644 --- a/loader-tests/utils.ts +++ b/loader-tests/utils.ts @@ -9,9 +9,9 @@ import { setBaseTestRoute, setRequestMethod, setupEnvironment, - userId + userId, + waitForStore } from '@slowreader/core' -import type { ReadableAtom } from 'nanostores' import { readFile } from 'node:fs/promises' import { isAbsolute, join } from 'node:path' import readline from 'node:readline' @@ -59,20 +59,6 @@ export function timeout( ]) } -export function waitFor( - store: ReadableAtom, - value: Value -): Promise { - return new Promise(resolve => { - let unbind = store.listen(state => { - if (state === value) { - unbind() - resolve() - } - }) - }) -} - interface NoFileError extends Error { code: string path: string @@ -235,7 +221,7 @@ export async function findRSSfromHome( let homeUrl = feed.homeUrl || getHomeUrl(feed.url) addPage.setUrl(homeUrl) try { - await timeout(10_000, waitFor(addPage.candidatesLoading, false)) + await timeout(10_000, waitForStore(addPage.candidatesLoading, false)) } catch (e) { if (e instanceof Error && e.message === 'Timeout' && tries > 0) { return await findRSSfromHome(feed, tries - 1) From 8e81ebd55a97051971a57f75aef0e84b6f32ef1d Mon Sep 17 00:00:00 2001 From: Andrey Sitnik Date: Tue, 1 Apr 2025 17:57:05 +0000 Subject: [PATCH 16/20] Simplify loading waiting syntax --- core/index.ts | 2 +- core/lib/stores.ts | 7 +++---- core/test/popups/feed-url.test.ts | 10 +++++----- core/test/popups/feed.test.ts | 10 +++++----- core/test/popups/post.test.ts | 23 ++++++++++++----------- loader-tests/utils.ts | 4 ++-- 6 files changed, 28 insertions(+), 28 deletions(-) diff --git a/core/index.ts b/core/index.ts index f518fbac..7d11a57f 100644 --- a/core/index.ts +++ b/core/index.ts @@ -14,7 +14,7 @@ export * from './filter.ts' export * from './html.ts' export * from './i18n.ts' export * from './import.ts' -export { waitForStore } from './lib/stores.ts' +export { waitLoading } from './lib/stores.ts' export * from './loader/index.ts' export * from './menu.ts' export * from './messages/index.ts' diff --git a/core/lib/stores.ts b/core/lib/stores.ts index 1907e7e3..e07736c9 100644 --- a/core/lib/stores.ts +++ b/core/lib/stores.ts @@ -68,13 +68,12 @@ export function computeFrom( }) } -export function waitForStore( - store: ReadableAtom, - value: Value +export function waitLoading( + store: ReadableAtom ): Promise { return new Promise(resolve => { let unbind = store.listen(state => { - if (state === value) { + if (state === false) { unbind() resolve() } diff --git a/core/test/popups/feed-url.test.ts b/core/test/popups/feed-url.test.ts index f9437a6b..3f1257b6 100644 --- a/core/test/popups/feed-url.test.ts +++ b/core/test/popups/feed-url.test.ts @@ -15,7 +15,7 @@ import { openedPopups, setBaseTestRoute, testFeed, - waitForStore + waitLoading } from '../../index.ts' import { cleanClientTest, enableClientTest } from '../utils.ts' @@ -43,7 +43,7 @@ test('loads 404 for feeds by URL popup', async () => { equal(openedPopups.get()[0]?.param, 'http://a.com/one') equal(openedPopups.get()[0]?.loading.get(), true) - await waitForStore((openedPopups.get()[0] as FeedUrlPopup).loading, false) + await waitLoading((openedPopups.get()[0] as FeedUrlPopup).loading) equal(openedPopups.get()[0]?.notFound, true) expectRequest('http://a.com/two').andRespond(200, 'Nothing') @@ -55,7 +55,7 @@ test('loads 404 for feeds by URL popup', async () => { equal(openedPopups.get()[0]?.param, 'http://a.com/two') equal(openedPopups.get()[0]?.loading.get(), true) - await waitForStore((openedPopups.get()[0] as FeedUrlPopup).loading, false) + await waitLoading((openedPopups.get()[0] as FeedUrlPopup).loading) equal(openedPopups.get()[0]?.notFound, true) }) @@ -78,7 +78,7 @@ test('loads feeds by URL popup', async () => { equal(openedPopups.get()[0]?.loading.get(), true) let feedPopup = openedPopups.get()[0] as FeedUrlPopup - await waitForStore(feedPopup.loading, false) + await waitLoading(feedPopup.loading) equal(feedPopup.notFound, false) equal(feedPopup.feed.get(), undefined) deepStrictEqual(feedPopup.posts.get().isLoading, false) @@ -124,7 +124,7 @@ test('destroys replaced popups and keep unchanged', async () => { equal(openedPopups.get().length, 2) let feedPopup1 = openedPopups.get()[0] as FeedUrlPopup let feedPopup2 = openedPopups.get()[1] as FeedUrlPopup - await waitForStore(feedPopup1.loading, false) + await waitLoading(feedPopup1.loading) equal(feedPopup1.feed.get(), undefined) equal(feedPopup2.feed.get(), undefined) diff --git a/core/test/popups/feed.test.ts b/core/test/popups/feed.test.ts index 735540bf..6eb3d4e7 100644 --- a/core/test/popups/feed.test.ts +++ b/core/test/popups/feed.test.ts @@ -11,7 +11,7 @@ import { setBaseTestRoute, testFeed, testPost, - waitForStore + waitLoading } from '../../index.ts' import { cleanClientTest, enableClientTest } from '../utils.ts' @@ -36,7 +36,7 @@ test('opens feed', async () => { equal(openedPopups.get()[0]?.param, feed) equal(openedPopups.get()[0]?.loading.get(), true) - await waitForStore((openedPopups.get()[0] as FeedPopup).loading, false) + await waitLoading((openedPopups.get()[0] as FeedPopup).loading) equal(openedPopups.get()[0]?.loading.get(), false) equal(openedPopups.get()[0]?.notFound, false) equal((openedPopups.get()[0] as FeedPopup).feed.get().id, feed) @@ -45,11 +45,11 @@ test('opens feed', async () => { equal(openedPopups.get().length, 1) equal(openedPopups.get()[0]?.loading.get(), true) - await waitForStore((openedPopups.get()[0] as FeedPopup).loading, false) + await waitLoading((openedPopups.get()[0] as FeedPopup).loading) equal(openedPopups.get()[0]?.notFound, true) setBaseTestRoute({ hash: `feed=${feed}`, params: {}, route: 'fast' }) - await waitForStore((openedPopups.get()[0] as FeedPopup).loading, false) + await waitLoading((openedPopups.get()[0] as FeedPopup).loading) setBaseTestRoute({ hash: `feed=${feed},post=${post}`, @@ -60,7 +60,7 @@ test('opens feed', async () => { equal(openedPopups.get()[0]?.loading.get(), false) equal(openedPopups.get()[1]?.loading.get(), true) - await waitForStore((openedPopups.get()[1] as FeedPopup).loading, false) + await waitLoading((openedPopups.get()[1] as FeedPopup).loading) equal(openedPopups.get()[0]?.loading.get(), false) equal(openedPopups.get()[1]?.loading.get(), false) equal((openedPopups.get()[0] as FeedPopup).feed.get().id, feed) diff --git a/core/test/popups/post.test.ts b/core/test/popups/post.test.ts index 869915b5..e15540ed 100644 --- a/core/test/popups/post.test.ts +++ b/core/test/popups/post.test.ts @@ -10,7 +10,7 @@ import { setBaseTestRoute, testFeed, testPost, - waitForStore + waitLoading } from '../../index.ts' import { cleanClientTest, enableClientTest } from '../utils.ts' @@ -42,14 +42,15 @@ test('opens post', async () => { setBaseTestRoute({ hash: `post=${post1}`, params: {}, route: 'fast' }) equal(openedPopups.get().length, 1) - equal(openedPopups.get()[0]?.name, 'post') - equal(openedPopups.get()[0]?.param, post1) - equal(openedPopups.get()[0]?.loading.get(), true) + let postPopup = openedPopups.get()[0] as PostPopup + equal(postPopup.name, 'post') + equal(postPopup.param, post1) + equal(postPopup.loading.get(), true) - await waitForStore((openedPopups.get()[0] as PostPopup).loading, false) - equal(openedPopups.get()[0]?.loading.get(), false) - equal(openedPopups.get()[0]?.notFound, false) - equal((openedPopups.get()[0] as PostPopup).post.get().id, post1) + await waitLoading(postPopup.loading) + equal(postPopup.loading.get(), false) + equal(postPopup.notFound, false) + equal(postPopup.post.get().id, post1) setBaseTestRoute({ hash: `post=${post1},post=${post1}`, @@ -60,7 +61,7 @@ test('opens post', async () => { equal(openedPopups.get()[0]?.loading.get(), false) equal(openedPopups.get()[1]?.loading.get(), true) - await waitForStore((openedPopups.get()[1] as PostPopup).loading, false) + await waitLoading((openedPopups.get()[1] as PostPopup).loading) equal(openedPopups.get()[0]?.loading.get(), false) equal(openedPopups.get()[1]?.loading.get(), false) @@ -72,7 +73,7 @@ test('opens post', async () => { equal(openedPopups.get()[0]?.loading.get(), true) equal(openedPopups.get()[1]?.loading.get(), false) - await waitForStore((openedPopups.get()[0] as PostPopup).loading, false) + await waitLoading((openedPopups.get()[0] as PostPopup).loading) equal(openedPopups.get()[0]?.notFound, false) equal((openedPopups.get()[0] as PostPopup).post.get().id, post2) @@ -83,6 +84,6 @@ test('opens post', async () => { }) equal(openedPopups.get()[0]?.loading.get(), true) - await waitForStore((openedPopups.get()[0] as PostPopup).loading, false) + await waitLoading((openedPopups.get()[0] as PostPopup).loading) equal(openedPopups.get()[0]?.notFound, true) }) diff --git a/loader-tests/utils.ts b/loader-tests/utils.ts index c7e66ae5..bb290407 100644 --- a/loader-tests/utils.ts +++ b/loader-tests/utils.ts @@ -10,7 +10,7 @@ import { setRequestMethod, setupEnvironment, userId, - waitForStore + waitLoading } from '@slowreader/core' import { readFile } from 'node:fs/promises' import { isAbsolute, join } from 'node:path' @@ -221,7 +221,7 @@ export async function findRSSfromHome( let homeUrl = feed.homeUrl || getHomeUrl(feed.url) addPage.setUrl(homeUrl) try { - await timeout(10_000, waitForStore(addPage.candidatesLoading, false)) + await timeout(10_000, waitLoading(addPage.candidatesLoading)) } catch (e) { if (e instanceof Error && e.message === 'Timeout' && tries > 0) { return await findRSSfromHome(feed, tries - 1) From 9e6f4d0485f384f25fff9bcefeed75742c8a49ed Mon Sep 17 00:00:00 2001 From: Andrey Sitnik Date: Wed, 2 Apr 2025 21:25:29 +0000 Subject: [PATCH 17/20] Add helpers to test popups URL --- core/router.ts | 39 ++++++++++++++++++++++++++++++- core/test/popups/feed-url.test.ts | 30 ++++++++---------------- core/test/popups/feed.test.ts | 21 ++++++++++------- core/test/popups/post.test.ts | 22 ++++++++--------- core/test/router.test.ts | 9 +++++++ 5 files changed, 80 insertions(+), 41 deletions(-) diff --git a/core/router.ts b/core/router.ts index c46704ec..21b097f7 100644 --- a/core/router.ts +++ b/core/router.ts @@ -1,6 +1,10 @@ import { atom, computed, type ReadableAtom } from 'nanostores' -import { getEnvironment, onEnvironment } from './environment.ts' +import { + getEnvironment, + onEnvironment, + setBaseTestRoute +} from './environment.ts' import { fastCategories } from './fast.ts' import { hasFeeds } from './feed.ts' import { computeFrom, readonlyExport } from './lib/stores.ts' @@ -293,3 +297,36 @@ export function onNextRoute(cb: (route: Route) => void): void { cb(route) }) } + +export function addPopup( + hash: string, + popup: PopupName, + param: string +): string { + let add = `${popup}=${param}` + return hash === '' ? add : `${hash},${add}` +} + +export function removeLastPopup(hash: string): string { + return hash.split(',').slice(0, -1).join(',') +} + +export function openTestPopup(popup: PopupName, param: string): void { + let route = getEnvironment().baseRouter.get() ?? { hash: '' } + setBaseTestRoute({ + params: {}, + route: 'start', + ...route, + hash: addPopup(route.hash, popup, param) + }) +} + +export function closeLastTestPopup(): void { + let route = getEnvironment().baseRouter.get() ?? { hash: '' } + setBaseTestRoute({ + params: {}, + route: 'start', + ...route, + hash: removeLastPopup(route.hash) + }) +} diff --git a/core/test/popups/feed-url.test.ts b/core/test/popups/feed-url.test.ts index 3f1257b6..93b7fe78 100644 --- a/core/test/popups/feed-url.test.ts +++ b/core/test/popups/feed-url.test.ts @@ -8,11 +8,13 @@ import { addFeed, changeFeed, checkAndRemoveRequestMock, + closeLastTestPopup, deleteFeed, expectRequest, type FeedUrlPopup, mockRequest, openedPopups, + openTestPopup, setBaseTestRoute, testFeed, waitLoading @@ -21,6 +23,7 @@ import { cleanClientTest, enableClientTest } from '../utils.ts' beforeEach(() => { enableClientTest() + setBaseTestRoute({ params: {}, route: 'add' }) mockRequest() }) @@ -33,11 +36,7 @@ afterEach(async () => { test('loads 404 for feeds by URL popup', async () => { keepMount(openedPopups) expectRequest('http://a.com/one').andRespond(404) - setBaseTestRoute({ - hash: `feedUrl=http://a.com/one`, - params: {}, - route: 'add' - }) + openTestPopup('feedUrl', 'http://a.com/one') equal(openedPopups.get().length, 1) equal(openedPopups.get()[0]?.name, 'feedUrl') equal(openedPopups.get()[0]?.param, 'http://a.com/one') @@ -46,12 +45,11 @@ test('loads 404 for feeds by URL popup', async () => { await waitLoading((openedPopups.get()[0] as FeedUrlPopup).loading) equal(openedPopups.get()[0]?.notFound, true) + closeLastTestPopup() + equal(openedPopups.get().length, 0) + expectRequest('http://a.com/two').andRespond(200, 'Nothing') - setBaseTestRoute({ - hash: `feedUrl=http://a.com/two`, - params: {}, - route: 'add' - }) + openTestPopup('feedUrl', 'http://a.com/two') equal(openedPopups.get()[0]?.param, 'http://a.com/two') equal(openedPopups.get()[0]?.loading.get(), true) @@ -69,11 +67,7 @@ test('loads feeds by URL popup', async () => { '', 'text/xml' ) - setBaseTestRoute({ - hash: `feedUrl=https://a.com/atom`, - params: {}, - route: 'add' - }) + openTestPopup('feedUrl', 'https://a.com/atom') equal(openedPopups.get().length, 1) equal(openedPopups.get()[0]?.loading.get(), true) @@ -133,11 +127,7 @@ test('destroys replaced popups and keep unchanged', async () => { equal(feedPopup1.feed.get()?.id, feedId) equal(feedPopup2.feed.get()?.id, feedId) - setBaseTestRoute({ - hash: `feedUrl=https://a.com/atom`, - params: {}, - route: 'add' - }) + closeLastTestPopup() deepStrictEqual(openedPopups.get(), [feedPopup1]) await deleteFeed(feedId) diff --git a/core/test/popups/feed.test.ts b/core/test/popups/feed.test.ts index 6eb3d4e7..ae4e45b0 100644 --- a/core/test/popups/feed.test.ts +++ b/core/test/popups/feed.test.ts @@ -5,10 +5,11 @@ import { afterEach, beforeEach, test } from 'node:test' import { addFeed, addPost, + closeLastTestPopup, type FeedPopup, openedPopups, + openTestPopup, type PostPopup, - setBaseTestRoute, testFeed, testPost, waitLoading @@ -30,7 +31,7 @@ test('opens feed', async () => { let feed = await addFeed(testFeed({ categoryId: 'general' })) let post = await addPost(testPost({ feedId: feed })) - setBaseTestRoute({ hash: `feed=${feed}`, params: {}, route: 'fast' }) + openTestPopup('feed', feed) equal(openedPopups.get().length, 1) equal(openedPopups.get()[0]?.name, 'feed') equal(openedPopups.get()[0]?.param, feed) @@ -41,21 +42,23 @@ test('opens feed', async () => { equal(openedPopups.get()[0]?.notFound, false) equal((openedPopups.get()[0] as FeedPopup).feed.get().id, feed) - setBaseTestRoute({ hash: `feed=unknown`, params: {}, route: 'fast' }) + closeLastTestPopup() + equal(openedPopups.get().length, 0) + + openTestPopup('feed', 'unknown') equal(openedPopups.get().length, 1) equal(openedPopups.get()[0]?.loading.get(), true) await waitLoading((openedPopups.get()[0] as FeedPopup).loading) equal(openedPopups.get()[0]?.notFound, true) - setBaseTestRoute({ hash: `feed=${feed}`, params: {}, route: 'fast' }) + closeLastTestPopup() + equal(openedPopups.get().length, 0) + + openTestPopup('feed', feed) await waitLoading((openedPopups.get()[0] as FeedPopup).loading) - setBaseTestRoute({ - hash: `feed=${feed},post=${post}`, - params: {}, - route: 'fast' - }) + openTestPopup('post', post) equal(openedPopups.get().length, 2) equal(openedPopups.get()[0]?.loading.get(), false) equal(openedPopups.get()[1]?.loading.get(), true) diff --git a/core/test/popups/post.test.ts b/core/test/popups/post.test.ts index e15540ed..0d19cced 100644 --- a/core/test/popups/post.test.ts +++ b/core/test/popups/post.test.ts @@ -5,7 +5,9 @@ import { afterEach, beforeEach, test } from 'node:test' import { addFeed, addPost, + closeLastTestPopup, openedPopups, + openTestPopup, type PostPopup, setBaseTestRoute, testFeed, @@ -40,7 +42,7 @@ test('opens post', async () => { let post1 = await addPost(testPost({ feedId: feed })) let post2 = await addPost(testPost({ feedId: feed })) - setBaseTestRoute({ hash: `post=${post1}`, params: {}, route: 'fast' }) + openTestPopup('post', post1) equal(openedPopups.get().length, 1) let postPopup = openedPopups.get()[0] as PostPopup equal(postPopup.name, 'post') @@ -52,11 +54,7 @@ test('opens post', async () => { equal(postPopup.notFound, false) equal(postPopup.post.get().id, post1) - setBaseTestRoute({ - hash: `post=${post1},post=${post1}`, - params: {}, - route: 'fast' - }) + openTestPopup('post', post1) equal(openedPopups.get().length, 2) equal(openedPopups.get()[0]?.loading.get(), false) equal(openedPopups.get()[1]?.loading.get(), true) @@ -77,11 +75,13 @@ test('opens post', async () => { equal(openedPopups.get()[0]?.notFound, false) equal((openedPopups.get()[0] as PostPopup).post.get().id, post2) - setBaseTestRoute({ - hash: `post=unknown`, - params: {}, - route: 'fast' - }) + closeLastTestPopup() + equal(openedPopups.get().length, 1) + + closeLastTestPopup() + equal(openedPopups.get().length, 0) + + openTestPopup('post', 'unknown') equal(openedPopups.get()[0]?.loading.get(), true) await waitLoading((openedPopups.get()[0] as PostPopup).loading) diff --git a/core/test/router.test.ts b/core/test/router.test.ts index 2401fd64..fe6df958 100644 --- a/core/test/router.test.ts +++ b/core/test/router.test.ts @@ -5,11 +5,13 @@ import { setTimeout } from 'node:timers/promises' import { addCategory, addFeed, + addPopup, addPost, backToFirstStep, deleteFeed, isGuestRoute, isOtherRoute, + removeLastPopup, router, setBaseTestRoute, testFeed, @@ -291,3 +293,10 @@ test('hides popups for guest', () => { route: 'start' }) }) + +test('has helpers for popups', () => { + equal(addPopup('', 'feed', 'id1'), 'feed=id1') + equal(addPopup('feed=id1', 'post', 'id2'), 'feed=id1,post=id2') + equal(removeLastPopup('feed=id1,post=id2'), 'feed=id1') + equal(removeLastPopup('feed=id1'), '') +}) From 9328c727a6ec463d3b9d6ba4516bff6db8e4ec11 Mon Sep 17 00:00:00 2001 From: Andrey Sitnik Date: Wed, 2 Apr 2025 21:55:55 +0000 Subject: [PATCH 18/20] Add more popups helpers --- core/popups/index.ts | 6 ++- core/router.ts | 26 +--------- core/test/popups/feed-url.test.ts | 83 ++++++++++++++++--------------- core/test/popups/feed.test.ts | 52 +++++++++---------- core/test/popups/post.test.ts | 59 +++++++++++----------- core/test/utils.ts | 69 ++++++++++++++++++++++++- 6 files changed, 173 insertions(+), 122 deletions(-) diff --git a/core/popups/index.ts b/core/popups/index.ts index 72eee698..3654d1a2 100644 --- a/core/popups/index.ts +++ b/core/popups/index.ts @@ -17,6 +17,8 @@ export const popups = { [Name in PopupName]: PopupCreator } -export type Popups = typeof popups +export type PopupCreators = typeof popups -export type Popup = ReturnType +export type Popup = ReturnType< + PopupCreators[Name] +> diff --git a/core/router.ts b/core/router.ts index 21b097f7..e1b14a12 100644 --- a/core/router.ts +++ b/core/router.ts @@ -1,10 +1,6 @@ import { atom, computed, type ReadableAtom } from 'nanostores' -import { - getEnvironment, - onEnvironment, - setBaseTestRoute -} from './environment.ts' +import { getEnvironment, onEnvironment } from './environment.ts' import { fastCategories } from './fast.ts' import { hasFeeds } from './feed.ts' import { computeFrom, readonlyExport } from './lib/stores.ts' @@ -310,23 +306,3 @@ export function addPopup( export function removeLastPopup(hash: string): string { return hash.split(',').slice(0, -1).join(',') } - -export function openTestPopup(popup: PopupName, param: string): void { - let route = getEnvironment().baseRouter.get() ?? { hash: '' } - setBaseTestRoute({ - params: {}, - route: 'start', - ...route, - hash: addPopup(route.hash, popup, param) - }) -} - -export function closeLastTestPopup(): void { - let route = getEnvironment().baseRouter.get() ?? { hash: '' } - setBaseTestRoute({ - params: {}, - route: 'start', - ...route, - hash: removeLastPopup(route.hash) - }) -} diff --git a/core/test/popups/feed-url.test.ts b/core/test/popups/feed-url.test.ts index 93b7fe78..ade9f4dd 100644 --- a/core/test/popups/feed-url.test.ts +++ b/core/test/popups/feed-url.test.ts @@ -8,18 +8,22 @@ import { addFeed, changeFeed, checkAndRemoveRequestMock, - closeLastTestPopup, deleteFeed, expectRequest, - type FeedUrlPopup, mockRequest, openedPopups, - openTestPopup, setBaseTestRoute, testFeed, waitLoading } from '../../index.ts' -import { cleanClientTest, enableClientTest } from '../utils.ts' +import { + checkLoadedPopup, + cleanClientTest, + closeLastTestPopup, + enableClientTest, + getPopup, + openTestPopup +} from '../utils.ts' beforeEach(() => { enableClientTest() @@ -36,25 +40,26 @@ afterEach(async () => { test('loads 404 for feeds by URL popup', async () => { keepMount(openedPopups) expectRequest('http://a.com/one').andRespond(404) - openTestPopup('feedUrl', 'http://a.com/one') + let feed1Popup = openTestPopup('feedUrl', 'http://a.com/one') equal(openedPopups.get().length, 1) - equal(openedPopups.get()[0]?.name, 'feedUrl') - equal(openedPopups.get()[0]?.param, 'http://a.com/one') - equal(openedPopups.get()[0]?.loading.get(), true) + equal(feed1Popup.name, 'feedUrl') + equal(feed1Popup.param, 'http://a.com/one') + equal(feed1Popup.loading.get(), true) - await waitLoading((openedPopups.get()[0] as FeedUrlPopup).loading) - equal(openedPopups.get()[0]?.notFound, true) + await waitLoading(feed1Popup.loading) + equal(feed1Popup.notFound, true) closeLastTestPopup() equal(openedPopups.get().length, 0) expectRequest('http://a.com/two').andRespond(200, 'Nothing') - openTestPopup('feedUrl', 'http://a.com/two') - equal(openedPopups.get()[0]?.param, 'http://a.com/two') - equal(openedPopups.get()[0]?.loading.get(), true) + let feed2Popup = openTestPopup('feedUrl', 'http://a.com/two') + equal(openedPopups.get().length, 1) + equal(feed2Popup.param, 'http://a.com/two') + equal(feed2Popup.loading.get(), true) - await waitLoading((openedPopups.get()[0] as FeedUrlPopup).loading) - equal(openedPopups.get()[0]?.notFound, true) + await waitLoading(feed2Popup.loading) + equal(feed2Popup.notFound, true) }) test('loads feeds by URL popup', async () => { @@ -67,28 +72,26 @@ test('loads feeds by URL popup', async () => { '', 'text/xml' ) - openTestPopup('feedUrl', 'https://a.com/atom') + let popup = openTestPopup('feedUrl', 'https://a.com/atom') equal(openedPopups.get().length, 1) - equal(openedPopups.get()[0]?.loading.get(), true) + equal(popup.loading.get(), true) - let feedPopup = openedPopups.get()[0] as FeedUrlPopup - await waitLoading(feedPopup.loading) - equal(feedPopup.notFound, false) - equal(feedPopup.feed.get(), undefined) - deepStrictEqual(feedPopup.posts.get().isLoading, false) - deepStrictEqual(feedPopup.posts.get().list.length, 2) - deepStrictEqual(feedPopup.posts.get().list[0]?.originId, '2') + await waitLoading(popup.loading) + equal(checkLoadedPopup(popup).feed.get(), undefined) + deepStrictEqual(checkLoadedPopup(popup).posts.get().isLoading, false) + deepStrictEqual(checkLoadedPopup(popup).posts.get().list.length, 2) + deepStrictEqual(checkLoadedPopup(popup).posts.get().list[0]?.originId, '2') let feedId = await addFeed(testFeed({ url: 'https://a.com/atom' })) - equal(feedPopup.feed.get()!.url, 'https://a.com/atom') - equal(feedPopup.feed.get()!.id, feedId) - equal(feedPopup.feed.get()!.title, 'Test 1') + equal(checkLoadedPopup(popup).feed.get()!.url, 'https://a.com/atom') + equal(checkLoadedPopup(popup).feed.get()!.id, feedId) + equal(checkLoadedPopup(popup).feed.get()!.title, 'Test 1') await changeFeed(feedId, { title: 'New Test 1' }) - equal(feedPopup.feed.get()!.title, 'New Test 1') + equal(checkLoadedPopup(popup).feed.get()!.title, 'New Test 1') await deleteFeed(feedId) - equal(feedPopup.feed.get(), undefined) + equal(checkLoadedPopup(popup).feed.get(), undefined) }) test('destroys replaced popups and keep unchanged', async () => { @@ -116,21 +119,21 @@ test('destroys replaced popups and keep unchanged', async () => { route: 'add' }) equal(openedPopups.get().length, 2) - let feedPopup1 = openedPopups.get()[0] as FeedUrlPopup - let feedPopup2 = openedPopups.get()[1] as FeedUrlPopup - await waitLoading(feedPopup1.loading) - equal(feedPopup1.feed.get(), undefined) - equal(feedPopup2.feed.get(), undefined) + let popup1 = getPopup('feedUrl', 0) + let popup2 = getPopup('feedUrl', 1) + await waitLoading(popup1.loading) + equal(checkLoadedPopup(popup1).feed.get(), undefined) + equal(checkLoadedPopup(popup2).feed.get(), undefined) let feedId = await addFeed(testFeed({ url: 'https://a.com/atom' })) - equal(feedPopup1.feed.get()!.url, 'https://a.com/atom') - equal(feedPopup1.feed.get()?.id, feedId) - equal(feedPopup2.feed.get()?.id, feedId) + equal(checkLoadedPopup(popup1).feed.get()!.url, 'https://a.com/atom') + equal(checkLoadedPopup(popup1).feed.get()?.id, feedId) + equal(checkLoadedPopup(popup2).feed.get()?.id, feedId) closeLastTestPopup() - deepStrictEqual(openedPopups.get(), [feedPopup1]) + deepStrictEqual(openedPopups.get(), [popup1]) await deleteFeed(feedId) - equal(feedPopup1.feed.get(), undefined) - equal(feedPopup2.feed.get()?.id, feedId) + equal(checkLoadedPopup(popup1).feed.get(), undefined) + equal(checkLoadedPopup(popup2).feed.get()?.id, feedId) }) diff --git a/core/test/popups/feed.test.ts b/core/test/popups/feed.test.ts index ae4e45b0..58a53df8 100644 --- a/core/test/popups/feed.test.ts +++ b/core/test/popups/feed.test.ts @@ -5,16 +5,19 @@ import { afterEach, beforeEach, test } from 'node:test' import { addFeed, addPost, - closeLastTestPopup, type FeedPopup, openedPopups, - openTestPopup, - type PostPopup, testFeed, testPost, waitLoading } from '../../index.ts' -import { cleanClientTest, enableClientTest } from '../utils.ts' +import { + checkLoadedPopup, + cleanClientTest, + closeLastTestPopup, + enableClientTest, + openTestPopup +} from '../utils.ts' beforeEach(() => { enableClientTest() @@ -31,41 +34,38 @@ test('opens feed', async () => { let feed = await addFeed(testFeed({ categoryId: 'general' })) let post = await addPost(testPost({ feedId: feed })) - openTestPopup('feed', feed) + let popup1 = openTestPopup('feed', feed) equal(openedPopups.get().length, 1) - equal(openedPopups.get()[0]?.name, 'feed') - equal(openedPopups.get()[0]?.param, feed) - equal(openedPopups.get()[0]?.loading.get(), true) + equal(popup1.name, 'feed') + equal(popup1.param, feed) + equal(popup1.loading.get(), true) - await waitLoading((openedPopups.get()[0] as FeedPopup).loading) - equal(openedPopups.get()[0]?.loading.get(), false) - equal(openedPopups.get()[0]?.notFound, false) + await waitLoading(popup1.loading) + equal(popup1.loading.get(), false) + equal(popup1.notFound, false) equal((openedPopups.get()[0] as FeedPopup).feed.get().id, feed) closeLastTestPopup() equal(openedPopups.get().length, 0) - openTestPopup('feed', 'unknown') - equal(openedPopups.get().length, 1) - equal(openedPopups.get()[0]?.loading.get(), true) + let unknown = openTestPopup('feed', 'unknown') + equal(unknown.loading.get(), true) - await waitLoading((openedPopups.get()[0] as FeedPopup).loading) - equal(openedPopups.get()[0]?.notFound, true) + await waitLoading(unknown.loading) + equal(unknown.notFound, true) closeLastTestPopup() equal(openedPopups.get().length, 0) - openTestPopup('feed', feed) - await waitLoading((openedPopups.get()[0] as FeedPopup).loading) + let feedPopup = openTestPopup('feed', feed) + await waitLoading(feedPopup.loading) - openTestPopup('post', post) + let postPopup = openTestPopup('post', post) equal(openedPopups.get().length, 2) - equal(openedPopups.get()[0]?.loading.get(), false) - equal(openedPopups.get()[1]?.loading.get(), true) + equal(feedPopup.loading.get(), false) + equal(postPopup.loading.get(), true) - await waitLoading((openedPopups.get()[1] as FeedPopup).loading) - equal(openedPopups.get()[0]?.loading.get(), false) - equal(openedPopups.get()[1]?.loading.get(), false) - equal((openedPopups.get()[0] as FeedPopup).feed.get().id, feed) - equal((openedPopups.get()[1] as PostPopup).post.get().id, post) + await waitLoading(postPopup.loading) + equal(checkLoadedPopup(feedPopup).feed.get().id, feed) + equal(checkLoadedPopup(postPopup).post.get().id, post) }) diff --git a/core/test/popups/post.test.ts b/core/test/popups/post.test.ts index 0d19cced..0e3a42b1 100644 --- a/core/test/popups/post.test.ts +++ b/core/test/popups/post.test.ts @@ -5,16 +5,20 @@ import { afterEach, beforeEach, test } from 'node:test' import { addFeed, addPost, - closeLastTestPopup, openedPopups, - openTestPopup, - type PostPopup, setBaseTestRoute, testFeed, testPost, waitLoading } from '../../index.ts' -import { cleanClientTest, enableClientTest } from '../utils.ts' +import { + checkLoadedPopup, + cleanClientTest, + closeLastTestPopup, + enableClientTest, + getPopup, + openTestPopup +} from '../utils.ts' beforeEach(() => { enableClientTest() @@ -42,38 +46,37 @@ test('opens post', async () => { let post1 = await addPost(testPost({ feedId: feed })) let post2 = await addPost(testPost({ feedId: feed })) - openTestPopup('post', post1) + let popup1 = openTestPopup('post', post1) equal(openedPopups.get().length, 1) - let postPopup = openedPopups.get()[0] as PostPopup - equal(postPopup.name, 'post') - equal(postPopup.param, post1) - equal(postPopup.loading.get(), true) + equal(openedPopups.get()[0], popup1) + equal(popup1.name, 'post') + equal(popup1.param, post1) + equal(popup1.loading.get(), true) - await waitLoading(postPopup.loading) - equal(postPopup.loading.get(), false) - equal(postPopup.notFound, false) - equal(postPopup.post.get().id, post1) + await waitLoading(popup1.loading) + equal(checkLoadedPopup(popup1).post.get().id, post1) - openTestPopup('post', post1) + let popup2 = openTestPopup('post', post1) equal(openedPopups.get().length, 2) - equal(openedPopups.get()[0]?.loading.get(), false) - equal(openedPopups.get()[1]?.loading.get(), true) + equal(popup1.loading.get(), false) + equal(popup2.loading.get(), true) - await waitLoading((openedPopups.get()[1] as PostPopup).loading) - equal(openedPopups.get()[0]?.loading.get(), false) - equal(openedPopups.get()[1]?.loading.get(), false) + await waitLoading(popup2.loading) + equal(popup1.loading.get(), false) + equal(popup2.loading.get(), false) setBaseTestRoute({ hash: `post=${post2},post=${post1}`, params: {}, route: 'fast' }) - equal(openedPopups.get()[0]?.loading.get(), true) - equal(openedPopups.get()[1]?.loading.get(), false) + let popup3 = getPopup('post', 0) + let popup4 = getPopup('post', 1) + equal(popup3.loading.get(), true) + equal(popup4.loading.get(), false) - await waitLoading((openedPopups.get()[0] as PostPopup).loading) - equal(openedPopups.get()[0]?.notFound, false) - equal((openedPopups.get()[0] as PostPopup).post.get().id, post2) + await waitLoading(popup3.loading) + equal(checkLoadedPopup(popup3).post.get().id, post2) closeLastTestPopup() equal(openedPopups.get().length, 1) @@ -81,9 +84,9 @@ test('opens post', async () => { closeLastTestPopup() equal(openedPopups.get().length, 0) - openTestPopup('post', 'unknown') - equal(openedPopups.get()[0]?.loading.get(), true) + let popup5 = openTestPopup('post', 'unknown') + equal(popup5.loading.get(), true) - await waitLoading((openedPopups.get()[0] as PostPopup).loading) - equal(openedPopups.get()[0]?.notFound, true) + await waitLoading(popup5.loading) + equal(popup5.notFound, true) }) diff --git a/core/test/utils.ts b/core/test/utils.ts index bc865646..6ef10108 100644 --- a/core/test/utils.ts +++ b/core/test/utils.ts @@ -1,7 +1,9 @@ -import { cleanStores } from 'nanostores' +import { cleanStores, type ReadableAtom } from 'nanostores' import { fail } from 'node:assert' import { + addPopup, + type BasePopup, Category, client, enableTestTime, @@ -9,8 +11,13 @@ import { fastCategories, Feed, Filter, + getEnvironment, getTestEnvironment, + openedPopups, + type Popup, + type PopupName, Post, + removeLastPopup, setBaseTestRoute, setupEnvironment, slowCategories, @@ -51,3 +58,63 @@ export function createPromise(): PromiseMock { } return result } + +export function openTestPopup( + popup: Name, + param: string +): Popup { + let route = getEnvironment().baseRouter.get() ?? { hash: '' } + setBaseTestRoute({ + params: {}, + route: 'start', + ...route, + hash: addPopup(route.hash, popup, param) + }) + return getPopup(popup, openedPopups.get().length - 1) +} + +export function closeLastTestPopup(): void { + let route = getEnvironment().baseRouter.get() ?? { hash: '' } + setBaseTestRoute({ + params: {}, + route: 'start', + ...route, + hash: removeLastPopup(route.hash) + }) +} + +export function getPopup( + name: Name, + at = 0 +): Popup { + let popups = openedPopups.get() + if (popups.length <= at) { + throw new Error( + `openedPopups has only ${popups.length} popups, but ${at} was requested` + ) + } + let popup = popups[at]! + if (popup.name !== name) { + throw new Error( + `openedPopups[${at}] has name ${popup.name}, but ${name} was requested` + ) + } + return popup as Popup +} + +export type Loaded = Extract< + SomePopup, + { loading: ReadableAtom; notFound: false } +> + +export function checkLoadedPopup( + popup: SomePopup +): Loaded { + if (popup.loading.get()) { + throw new Error('Popup is still loading') + } + if (popup.notFound) { + throw new Error('Popup data was not found') + } + return popup as Loaded +} From 7c975ffd92dd5ed2d4f01ed974eab99e76d66205 Mon Sep 17 00:00:00 2001 From: Andrey Sitnik Date: Wed, 2 Apr 2025 22:48:18 +0000 Subject: [PATCH 19/20] Use errors to track 404 in popups like we do in pages --- core/lib/stores.ts | 17 +++++++++++ core/not-found.ts | 27 ++++++++++++++---- core/popups/common.ts | 62 +++++++++++++++++----------------------- core/popups/feed-url.ts | 63 ++++++++++++++++++++--------------------- core/popups/feed.ts | 21 ++++---------- core/popups/post.ts | 17 +++-------- pnpm-lock.yaml | 10 +++---- 7 files changed, 110 insertions(+), 107 deletions(-) diff --git a/core/lib/stores.ts b/core/lib/stores.ts index e07736c9..9cc5a378 100644 --- a/core/lib/stores.ts +++ b/core/lib/stores.ts @@ -1,3 +1,5 @@ +import type { SyncMapValues } from '@logux/actions' +import type { LoadedSyncMap, SyncMapStore } from '@logux/client' import type { MapStore, ReadableAtom, @@ -80,3 +82,18 @@ export function waitLoading( }) }) } + +export async function waitSyncLoading( + store: SyncMapStore +): Promise>> { + let value = store.get() + if (value.isLoading) { + let unbind = store.listen(() => {}) + try { + await store.loading + } finally { + unbind() + } + } + return store as LoadedSyncMap> +} diff --git a/core/not-found.ts b/core/not-found.ts index 9a6ccb36..2d1ae0b5 100644 --- a/core/not-found.ts +++ b/core/not-found.ts @@ -1,4 +1,4 @@ -import { LoguxUndoError } from '@logux/client' +import type { LoguxUndoError } from '@logux/client' import { atom } from 'nanostores' import { onEnvironment } from './environment.ts' @@ -6,12 +6,29 @@ import { router } from './router.ts' export const notFound = atom(false) +export class NotFoundError extends Error { + constructor() { + super('Not found') + this.name = 'NotFoundError' + Error.captureStackTrace(this, NotFoundError) + } +} + +export function isNotFoundError( + error: unknown +): error is LoguxUndoError | NotFoundError { + if (error instanceof Error) { + return ( + error.name === 'NotFoundError' || + (error.name === 'LoguxUndoError' && error.message.includes('notFound')) + ) + } + return false +} + onEnvironment(({ errorEvents }) => { errorEvents.addEventListener('unhandledrejection', event => { - if ( - event.reason instanceof LoguxUndoError && - event.reason.message.includes('notFound') - ) { + if (isNotFoundError(event.reason)) { notFound.set(true) } diff --git a/core/popups/common.ts b/core/popups/common.ts index 54560508..c4b6af65 100644 --- a/core/popups/common.ts +++ b/core/popups/common.ts @@ -1,10 +1,10 @@ import { atom, type ReadableAtom } from 'nanostores' +import { isNotFoundError } from '../not-found.ts' import type { PopupName } from '../router.ts' type Extra = { - destroy?: () => void - notFound: boolean + destroy: () => void } export type BasePopup< @@ -21,9 +21,7 @@ export type BasePopup< export interface PopupCreator< Name extends PopupName, - Rest extends Extra = - | ({ notFound: false } & Record) - | { notFound: true } + Rest extends Extra = Extra > { ( param: string @@ -38,13 +36,9 @@ export type LoadedPopup> = Extract< { loading: ReadableAtom; notFound: false } > -export interface PopupHelpers { - startTask(): () => void -} - export function definePopup( name: Name, - builder: (param: string, popup: PopupHelpers) => Promise + builder: (param: string) => Promise ): PopupCreator { let creator: PopupCreator = param => { let destroyed = false @@ -53,7 +47,7 @@ export function definePopup( let popup = { destroy() { destroyed = true - rest?.destroy?.() + rest?.destroy() }, loading, name, @@ -61,32 +55,28 @@ export function definePopup( param } - let tasks = 0 - let helpers: PopupHelpers = { - startTask() { - tasks += 1 - loading.set(true) - return () => { - tasks -= 1 - if (tasks <= 0) loading.set(false) + loading.set(true) + builder(param) + .then(extra => { + rest = extra + if (destroyed) extra.destroy() + for (let i in rest) { + // @ts-expect-error Too complex case for TypeScript + popup[i] = extra[i] } - } - } - - let stop = helpers.startTask() - builder(param, helpers).then(extra => { - rest = extra - if (destroyed) extra.destroy?.() - for (let i in rest) { - // @ts-expect-error Too complex case for TypeScript - popup[i] = extra[i] - } - stop() - }) - return popup as - | (BasePopup & Rest) - | BasePopup - | BasePopup + loading.set(false) + }) + .catch((e: unknown) => { + if (isNotFoundError(e)) { + popup.notFound = true + popup.destroy() + loading.set(false) + } else { + /* c8 ignore next 2 */ + throw e + } + }) + return popup as ReturnType> } return creator } diff --git a/core/popups/feed-url.ts b/core/popups/feed-url.ts index cb9492f1..fe38d6fe 100644 --- a/core/popups/feed-url.ts +++ b/core/popups/feed-url.ts @@ -3,6 +3,7 @@ import { atom } from 'nanostores' import { createDownloadTask } from '../download.ts' import { type FeedValue, getFeeds } from '../feed.ts' import { getLoaderForText } from '../loader/index.ts' +import { NotFoundError } from '../not-found.ts' import { definePopup, type LoadedPopup } from './common.ts' export const feedUrl = definePopup('feedUrl', async url => { @@ -10,43 +11,39 @@ export const feedUrl = definePopup('feedUrl', async url => { let response = await task.text(url) let candidate = getLoaderForText(response) - if (candidate) { - let posts = candidate.loader.getPosts(task, url, response) + if (!candidate) { + throw new NotFoundError() + } - let $feed = atom() + let posts = candidate.loader.getPosts(task, url, response) + let $feed = atom() - let feedsFilter = getFeeds({ url }) - let unbindFeed = (): void => {} - let unbindFeeds = feedsFilter.subscribe(feeds => { - if (!feeds.isLoading) { - let needed = feeds.list.find(feed => feed.url === url) - if (needed) { - let $needed = feeds.stores.get(needed.id)! - unbindFeed = $needed.subscribe(feed => { - if (!feed.isLoading) { - $feed.set(feed) - } - }) - } else { - $feed.set(undefined) - } + let feedsFilter = getFeeds({ url }) + let unbindFeed = (): void => {} + let unbindFeeds = feedsFilter.subscribe(feeds => { + if (!feeds.isLoading) { + let needed = feeds.list.find(feed => feed.url === url) + if (needed) { + let $needed = feeds.stores.get(needed.id)! + unbindFeed = $needed.subscribe(feed => { + if (!feed.isLoading) { + $feed.set(feed) + } + }) + } else { + $feed.set(undefined) } - }) - - return { - destroy() { - task.destroy() - unbindFeeds() - unbindFeed() - }, - feed: $feed, - notFound: false, - posts - } - } else { - return { - notFound: true } + }) + + return { + destroy() { + task.destroy() + unbindFeeds() + unbindFeed() + }, + feed: $feed, + posts } }) diff --git a/core/popups/feed.ts b/core/popups/feed.ts index e88a3be9..186e0e53 100644 --- a/core/popups/feed.ts +++ b/core/popups/feed.ts @@ -1,22 +1,13 @@ -import { loadValue } from '@logux/client' - import { getFeed, getFeedLatestPosts } from '../feed.ts' +import { waitSyncLoading } from '../lib/stores.ts' import { definePopup, type LoadedPopup } from './common.ts' export const feed = definePopup('feed', async id => { - let $feed = getFeed(id) - let value = await loadValue($feed) - - if (value) { - return { - feed: $feed, - notFound: false, - posts: getFeedLatestPosts(value) - } - } else { - return { - notFound: true - } + let $feed = await waitSyncLoading(getFeed(id)) + return { + destroy() {}, + feed: $feed, + posts: getFeedLatestPosts($feed.get()) } }) diff --git a/core/popups/post.ts b/core/popups/post.ts index b9822277..6dd38145 100644 --- a/core/popups/post.ts +++ b/core/popups/post.ts @@ -1,21 +1,12 @@ -import { loadValue } from '@logux/client' - +import { waitSyncLoading } from '../lib/stores.ts' import { getPost } from '../post.ts' import { definePopup, type LoadedPopup } from './common.ts' export const post = definePopup('post', async id => { let $post = getPost(id) - let found = await loadValue($post) - - if (found) { - return { - notFound: false, - post: $post - } as const - } else { - return { - notFound: true - } as const + return { + destroy() {}, + post: await waitSyncLoading($post) } }) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 53f7c319..2b5d2dc2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -113,7 +113,7 @@ importers: dependencies: '@logux/client': specifier: github:logux/client#next - version: https://codeload.github.com/logux/client/tar.gz/98505de89240a653df7e1b0d8850b6780114cf57(@logux/core@0.9.0)(nanostores@0.11.4)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + version: https://codeload.github.com/logux/client/tar.gz/a9850caf600b3a8d047e7d32ed390714e36bacab(@logux/core@0.9.0)(nanostores@0.11.4)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@logux/core': specifier: 0.9.0 version: 0.9.0 @@ -241,7 +241,7 @@ importers: dependencies: '@logux/client': specifier: github:logux/client#next - version: https://codeload.github.com/logux/client/tar.gz/98505de89240a653df7e1b0d8850b6780114cf57(@logux/core@0.9.0)(nanostores@0.11.4)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + version: https://codeload.github.com/logux/client/tar.gz/a9850caf600b3a8d047e7d32ed390714e36bacab(@logux/core@0.9.0)(nanostores@0.11.4)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@logux/core': specifier: 0.9.0 version: 0.9.0 @@ -1055,8 +1055,8 @@ packages: peerDependencies: '@logux/core': ^0.9.0 - '@logux/client@https://codeload.github.com/logux/client/tar.gz/98505de89240a653df7e1b0d8850b6780114cf57': - resolution: {tarball: https://codeload.github.com/logux/client/tar.gz/98505de89240a653df7e1b0d8850b6780114cf57} + '@logux/client@https://codeload.github.com/logux/client/tar.gz/a9850caf600b3a8d047e7d32ed390714e36bacab': + resolution: {tarball: https://codeload.github.com/logux/client/tar.gz/a9850caf600b3a8d047e7d32ed390714e36bacab} version: 0.20.1 engines: {node: ^20.0.0 || >=22.0.0} peerDependencies: @@ -5249,7 +5249,7 @@ snapshots: dependencies: '@logux/core': 0.9.0 - '@logux/client@https://codeload.github.com/logux/client/tar.gz/98505de89240a653df7e1b0d8850b6780114cf57(@logux/core@0.9.0)(nanostores@0.11.4)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + '@logux/client@https://codeload.github.com/logux/client/tar.gz/a9850caf600b3a8d047e7d32ed390714e36bacab(@logux/core@0.9.0)(nanostores@0.11.4)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: '@logux/actions': 0.4.0(@logux/core@0.9.0) '@logux/core': 0.9.0 From 02974db8c1b928077d491e03bb99de613e4a886b Mon Sep 17 00:00:00 2001 From: Andrey Sitnik Date: Wed, 2 Apr 2025 23:52:41 +0000 Subject: [PATCH 20/20] Clean up tests --- core/client.ts | 1 + core/environment.ts | 2 + core/i18n.ts | 3 + core/request.ts | 2 + core/router.ts | 3 + core/test/client.test.ts | 42 ---------- core/test/environment.test.ts | 59 ------------- core/test/feed.test.ts | 151 ---------------------------------- core/test/i18n.test.ts | 25 ------ core/test/not-found.test.ts | 7 +- core/test/popups/post.test.ts | 11 --- core/test/post.test.ts | 63 +------------- core/test/posts-list.test.ts | 136 ------------------------------ core/test/request.test.ts | 103 ----------------------- core/test/router.test.ts | 57 ++++--------- 15 files changed, 37 insertions(+), 628 deletions(-) delete mode 100644 core/test/client.test.ts delete mode 100644 core/test/environment.test.ts delete mode 100644 core/test/feed.test.ts delete mode 100644 core/test/i18n.test.ts delete mode 100644 core/test/posts-list.test.ts delete mode 100644 core/test/request.test.ts diff --git a/core/client.ts b/core/client.ts index 61ffeb16..0d40307f 100644 --- a/core/client.ts +++ b/core/client.ts @@ -51,6 +51,7 @@ onEnvironment(({ logStoreCreator }) => { export function getClient(): CrossTabClient { let logux = client.get() if (!logux) { + /* c8 ignore next 2 */ throw new Error('No Slow Reader client') } return logux diff --git a/core/environment.ts b/core/environment.ts index b802a1ab..2aa34e3c 100644 --- a/core/environment.ts +++ b/core/environment.ts @@ -82,6 +82,7 @@ function runEnvListener(listener: EnvironmentListener): void { export function onEnvironment(cb: EnvironmentListener): void { if (currentEnvironment) { + /* c8 ignore next 2 */ runEnvListener(cb) } listeners.push(cb) @@ -113,6 +114,7 @@ export function setupEnvironment( export function getEnvironment(): Environment { if (!currentEnvironment) { + /* c8 ignore next 2 */ throw new Error('No Slow Reader environment') } return currentEnvironment diff --git a/core/i18n.ts b/core/i18n.ts index 77e6b6c9..0d936343 100644 --- a/core/i18n.ts +++ b/core/i18n.ts @@ -7,6 +7,8 @@ let $locale = atom('en') let loader: TranslationLoader +/* c8 ignore start */ +// TODO: Until we will have real translations export const i18n = createI18n($locale, { get(...args) { return loader(...args) @@ -19,3 +21,4 @@ onEnvironment(({ locale, translationLoader }) => { $locale.set(value) }) }) +/* c8 ignore stop */ diff --git a/core/request.ts b/core/request.ts index 98f60b78..e17c4a19 100644 --- a/core/request.ts +++ b/core/request.ts @@ -29,6 +29,7 @@ interface RequestExpect { let requestExpects: RequestExpect[] = [] +/* c8 ignore start */ let fetchMock: RequestMethod = async (url, opts = {}) => { let expect = requestExpects.shift() if (!expect) { @@ -106,3 +107,4 @@ export function checkAndRemoveRequestMock(): void { } setRequestMethod(fetch) } +/* c8 ignore stop */ diff --git a/core/router.ts b/core/router.ts index e1b14a12..bb38e5e2 100644 --- a/core/router.ts +++ b/core/router.ts @@ -156,6 +156,7 @@ onEnvironment(({ baseRouter }) => { route: 'add' }) } else if (route.route === 'fast') { + // TODO: move to new fast/slow migration to pages if (!route.params.category && !fast.isLoading) { return redirect({ params: { category: fast.categories[0].id }, @@ -163,6 +164,7 @@ onEnvironment(({ baseRouter }) => { route: 'fast' }) } + // TODO: remove check from loader on fast/slow migration to pages if (route.params.category && !fast.isLoading) { let category = fast.categories.find( i => i.id === route.params.category @@ -184,6 +186,7 @@ onEnvironment(({ baseRouter }) => { }) } } else if (route.route === 'slow') { + // TODO: move to new fast/slow migration to pages if (!route.params.feed && !slowUnread.isLoading) { let firstCategory = slowUnread.tree[0] if (firstCategory) { diff --git a/core/test/client.test.ts b/core/test/client.test.ts deleted file mode 100644 index 00681ee3..00000000 --- a/core/test/client.test.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { spyOn } from 'nanospy' -import { cleanStores, keepMount } from 'nanostores' -import { equal, match, throws } from 'node:assert' -import { afterEach, test } from 'node:test' - -import { client, getClient, userId } from '../index.ts' -import { enableClientTest } from './utils.ts' - -enableClientTest() - -afterEach(() => { - cleanStores(client) -}) - -test('re-create client on user ID changes', () => { - userId.set(undefined) - keepMount(client) - equal(client.get(), undefined) - - userId.set('10') - match(client.get()!.clientId, /^10:/) - - let destroy10 = spyOn(client.get()!, 'destroy') - userId.set('11') - match(client.get()!.clientId, /^11:/) - equal(destroy10.callCount, 1) - - let destroy11 = spyOn(client.get()!, 'destroy') - userId.set(undefined) - equal(client.get(), undefined) - equal(destroy11.callCount, 1) -}) - -test('has helper for client area', () => { - userId.set(undefined) - throws(() => { - getClient() - }, 'Error: No Slow Reader client') - - userId.set('10') - match(getClient().clientId, /^10:/) -}) diff --git a/core/test/environment.test.ts b/core/test/environment.test.ts deleted file mode 100644 index 2f701e55..00000000 --- a/core/test/environment.test.ts +++ /dev/null @@ -1,59 +0,0 @@ -import { atom } from 'nanostores' -import { deepStrictEqual, equal, throws } from 'node:assert' -import { test } from 'node:test' - -import { - getEnvironment, - getTestEnvironment, - onEnvironment, - setupEnvironment -} from '../index.ts' - -test('throws on current environment if it is not set', () => { - throws(() => { - getEnvironment() - }, 'Error: No Slow Reader environment') -}) - -test('runs callback when environment will be set', () => { - let calls: string[] = [] - let cleans = '' - onEnvironment(env => { - calls.push(env.locale.get()) - return () => { - cleans += '1' - } - }) - onEnvironment(() => { - return [ - () => { - cleans += '2' - }, - () => { - cleans += '3' - } - ] - }) - deepStrictEqual(calls, []) - - setupEnvironment({ ...getTestEnvironment(), locale: atom('fr') }) - deepStrictEqual(calls, ['fr']) - equal(cleans, '') - - setupEnvironment({ ...getTestEnvironment(), locale: atom('ru') }) - deepStrictEqual(calls, ['fr', 'ru']) - equal(cleans, '123') - - let after: string[] = [] - onEnvironment(env => { - after.push(env.locale.get()) - }) - deepStrictEqual(after, ['ru']) - equal(cleans, '123') -}) - -test('returns current environment', () => { - let locale = atom('fr') - setupEnvironment({ ...getTestEnvironment(), locale }) - equal(getEnvironment().locale, locale) -}) diff --git a/core/test/feed.test.ts b/core/test/feed.test.ts deleted file mode 100644 index c7ed5f2c..00000000 --- a/core/test/feed.test.ts +++ /dev/null @@ -1,151 +0,0 @@ -import { ensureLoaded } from '@logux/client' -import { restoreAll, spyOn } from 'nanospy' -import { keepMount } from 'nanostores' -import { deepStrictEqual, equal } from 'node:assert' -import { afterEach, beforeEach, test } from 'node:test' -import { setTimeout } from 'node:timers/promises' - -import { - addFeed, - addFilterForFeed, - addPost, - changeFeed, - changeFilter, - createPostsList, - deleteFeed, - deleteFilter, - type FilterValue, - getFeed, - getFeedLatestPosts, - getPosts, - hasFeeds, - loaders, - loadFeed, - loadFeeds, - testFeed, - testPost -} from '../index.ts' -import { cleanClientTest, enableClientTest } from './utils.ts' - -beforeEach(() => { - enableClientTest() -}) - -afterEach(async () => { - restoreAll() - await cleanClientTest() -}) - -test('adds, loads, changes and removes feed', async () => { - deepStrictEqual(await loadFeeds(), []) - - let id = await addFeed({ - categoryId: 'general', - loader: 'rss', - reading: 'fast', - title: 'RSS', - url: 'https://example.com/' - }) - equal(typeof id, 'string') - let added = await loadFeeds() - equal(added.length, 1) - equal(added[0]!.title, 'RSS') - - equal(await loadFeed(id), added[0]) - - let feed = getFeed(id) - keepMount(feed) - equal(feed.get(), added[0]) - - await changeFeed(id, { title: 'New title' }) - equal(ensureLoaded(feed.get()).title, 'New title') - - await deleteFeed(id) - deepStrictEqual(await loadFeeds(), []) - - equal(await loadFeed('unknown'), undefined) -}) - -test('removes feed posts too', async () => { - let posts = getPosts() - keepMount(posts) - - let feed1 = await addFeed(testFeed()) - let feed2 = await addFeed(testFeed()) - await addPost(testPost({ feedId: feed1 })) - await addPost(testPost({ feedId: feed1 })) - let post3 = await addPost(testPost({ feedId: feed2 })) - let store1 = getFeed(feed1) - - await deleteFeed(feed1) - equal(store1.deleted, true) - deepStrictEqual( - ensureLoaded(posts.get()).list.map(i => i.id), - [post3] - ) -}) - -test('loads latest posts', async () => { - let page = createPostsList([], undefined) - let getPage = spyOn(loaders.rss, 'getPosts', () => page) - - let id = await addFeed( - testFeed({ - loader: 'rss', - url: 'https://example.com/' - }) - ) - let feed = await loadFeed(id) - - equal(getFeedLatestPosts(feed!), page) - equal(getPage.calls.length, 1) - equal(getPage.calls[0]![1], 'https://example.com/') -}) - -test('shows that user has any feeds', async () => { - await cleanClientTest() - enableClientTest() - equal(hasFeeds.get(), undefined) - await setTimeout(10) - equal(hasFeeds.get(), false) - - let id = await addFeed(testFeed()) - equal(hasFeeds.get(), true) - - await deleteFeed(id) - equal(hasFeeds.get(), false) -}) - -test('change feed and post reading status', async () => { - let feedId = await addFeed(testFeed()) - let feed = getFeed(feedId) - let posts = getPosts() - keepMount(feed) - keepMount(posts) - await addPost(testPost({ feedId, title: 'Feed post' })) - await addPost(testPost({ feedId, title: 'Filter post' })) - - let filter: FilterValue = { - action: 'fast', - feedId, - id: '1', - priority: 100, - query: 'include(Filter)' - } - - let filterId = await addFilterForFeed((await loadFeed(feedId))!) - await changeFilter(filterId, filter) - - equal(ensureLoaded(feed.get()).reading, 'fast') - equal(ensureLoaded(posts.get()).list[0]?.reading, 'fast') - - await changeFeed(feedId, { reading: 'slow' }) - - equal(ensureLoaded(feed.get()).reading, 'slow') - equal(ensureLoaded(posts.get()).list[0]?.reading, 'slow') - equal(ensureLoaded(posts.get()).list[1]?.reading, 'fast') - - await deleteFilter(filterId) - - equal(ensureLoaded(posts.get()).list[1]?.reading, 'slow') -}) diff --git a/core/test/i18n.test.ts b/core/test/i18n.test.ts deleted file mode 100644 index 87fe1b5f..00000000 --- a/core/test/i18n.test.ts +++ /dev/null @@ -1,25 +0,0 @@ -import './environment.ts' - -import { atom } from 'nanostores' -import { deepStrictEqual, equal } from 'node:assert' -import { test } from 'node:test' - -import { i18n, settingsMessages } from '../index.ts' -import { enableClientTest } from './utils.ts' - -test('has i18n', () => { - equal(typeof i18n, 'function') - equal(typeof settingsMessages.get().theme, 'string') - - let locale = atom('en') - let loading: string[] = [] - enableClientTest({ - locale, - translationLoader(lang) { - loading.push(lang) - return Promise.resolve({}) - } - }) - locale.set('es') - deepStrictEqual(loading, ['es']) -}) diff --git a/core/test/not-found.test.ts b/core/test/not-found.test.ts index 64338179..472f26c0 100644 --- a/core/test/not-found.test.ts +++ b/core/test/not-found.test.ts @@ -2,7 +2,7 @@ import { LoguxUndoError } from '@logux/client' import { equal } from 'node:assert' import { afterEach, beforeEach, test } from 'node:test' -import { notFound, setBaseTestRoute } from '../index.ts' +import { notFound, NotFoundError, setBaseTestRoute } from '../index.ts' import { cleanClientTest, enableClientTest } from './utils.ts' let listener: (e: { reason: Error }) => void @@ -37,4 +37,9 @@ test('listens for not found error', () => { setBaseTestRoute({ params: { feed: 'another' }, route: 'categories' }) equal(notFound.get(), false) + + listener({ + reason: new NotFoundError() + }) + equal(notFound.get(), true) }) diff --git a/core/test/popups/post.test.ts b/core/test/popups/post.test.ts index 0e3a42b1..22a35896 100644 --- a/core/test/popups/post.test.ts +++ b/core/test/popups/post.test.ts @@ -29,17 +29,6 @@ afterEach(async () => { cleanStores(openedPopups) }) -test('reacts on unknown popups', () => { - keepMount(openedPopups) - equal(openedPopups.get().length, 0) - - setBaseTestRoute({ hash: `unknown=id`, params: {}, route: 'fast' }) - equal(openedPopups.get().length, 0) - - setBaseTestRoute({ hash: `popup:id`, params: {}, route: 'fast' }) - equal(openedPopups.get().length, 0) -}) - test('opens post', async () => { keepMount(openedPopups) let feed = await addFeed(testFeed({ categoryId: 'general' })) diff --git a/core/test/post.test.ts b/core/test/post.test.ts index dc180f9e..2ede17bb 100644 --- a/core/test/post.test.ts +++ b/core/test/post.test.ts @@ -1,19 +1,7 @@ -import { keepMount } from 'nanostores' -import { deepStrictEqual, equal, ok } from 'node:assert' +import { equal } from 'node:assert' import { afterEach, beforeEach, test } from 'node:test' -import { - addPost, - changePost, - deletePost, - getPost, - getPostContent, - getPostIntro, - loadPost, - loadPosts, - type OriginPost, - processOriginPost -} from '../index.ts' +import { getPostContent, getPostIntro, type OriginPost } from '../index.ts' import { cleanClientTest, enableClientTest } from './utils.ts' beforeEach(() => { @@ -28,53 +16,6 @@ function longText(): string { return 'a'.repeat(5000) } -test('adds, loads, changes and removes posts', async () => { - deepStrictEqual(await loadPosts(), []) - - let id = await addPost({ - feedId: '1', - media: [], - originId: '1', - publishedAt: 0, - reading: 'fast' - }) - equal(typeof id, 'string') - let added = await loadPosts() - equal(added.length, 1) - equal(added[0]!.reading, 'fast') - - deepStrictEqual(await loadPost(id), added[0]) - - let post = getPost(id) - keepMount(post) - equal(post.get(), added[0]) - - await changePost(id, { reading: 'slow' }) - let changed = await loadPost(id) - equal(changed!.reading, 'slow') - - await deletePost(id) - deepStrictEqual(await loadPosts(), []) -}) - -test('processes origin post', () => { - let origin = { - media: [], - originId: '1' - } satisfies OriginPost - - ok(processOriginPost(origin, 'feed', 'fast').publishedAt >= Date.now() - 100) - equal(processOriginPost(origin, 'feed', 'fast').feedId, 'feed') - equal(processOriginPost(origin, 'feed', 'fast').reading, 'fast') - equal(typeof processOriginPost(origin, 'feed', 'fast').id, 'string') - - equal( - processOriginPost({ ...origin, publishedAt: 200 }, 'feed', 'fast') - .publishedAt, - 200 - ) -}) - test('loads post content', () => { let origin = { media: [], originId: '' } satisfies OriginPost diff --git a/core/test/posts-list.test.ts b/core/test/posts-list.test.ts deleted file mode 100644 index c1e302b0..00000000 --- a/core/test/posts-list.test.ts +++ /dev/null @@ -1,136 +0,0 @@ -import { deepStrictEqual } from 'node:assert' -import { test } from 'node:test' - -import { createPostsList, type OriginPost } from '../index.ts' - -const POST1: OriginPost = { - full: '1', - media: [], - originId: '1' -} - -const POST2: OriginPost = { - full: '2', - media: [], - originId: '2' -} - -test('works with cached posts without next page', async () => { - let posts = createPostsList([POST1], undefined) - deepStrictEqual(posts.get(), { - hasNext: false, - isLoading: false, - list: [POST1] - }) - - let promise = posts.next() - deepStrictEqual(posts.get(), { - hasNext: false, - isLoading: false, - list: [POST1] - }) - deepStrictEqual(await promise, []) -}) - -test('works without posts', async () => { - let posts = createPostsList(undefined, () => { - return Promise.resolve([ - [POST1], - () => Promise.resolve([[POST2], undefined]) - ]) - }) - deepStrictEqual(posts.get(), { - hasNext: true, - isLoading: true, - list: [] - }) - - let next1 = await posts.loading - deepStrictEqual(posts.get(), { - hasNext: true, - isLoading: false, - list: [POST1] - }) - deepStrictEqual(next1, [POST1]) - - let promise2 = posts.next() - deepStrictEqual(posts.get(), { - hasNext: true, - isLoading: true, - list: [POST1] - }) - - await posts.loading - deepStrictEqual(posts.get(), { - hasNext: false, - isLoading: false, - list: [POST1, POST2] - }) - deepStrictEqual(await promise2, [POST2]) - - let promise3 = posts.next() - deepStrictEqual(posts.get(), { - hasNext: false, - isLoading: false, - list: [POST1, POST2] - }) - deepStrictEqual(await promise3, []) -}) - -test('is ready for double calls', async () => { - let posts = createPostsList(undefined, () => { - return Promise.resolve([ - [POST1], - () => Promise.resolve([[POST2], undefined]) - ]) - }) - posts.next() - await posts.loading - deepStrictEqual(posts.get(), { - hasNext: true, - isLoading: false, - list: [POST1] - }) - - let promise1 = posts.next() - let promise2 = posts.next() - deepStrictEqual(posts.get(), { - hasNext: true, - isLoading: true, - list: [POST1] - }) - - await posts.loading - deepStrictEqual(posts.get(), { - hasNext: false, - isLoading: false, - list: [POST1, POST2] - }) - deepStrictEqual(await promise1, [POST2]) - deepStrictEqual(await promise2, [POST2]) -}) - -test('works with cached posts with next page loader', async () => { - let posts = createPostsList([POST1], () => { - return Promise.resolve([[POST2], undefined]) - }) - deepStrictEqual(posts.get(), { - hasNext: true, - isLoading: false, - list: [POST1] - }) - - posts.next() - deepStrictEqual(posts.get(), { - hasNext: true, - isLoading: true, - list: [POST1] - }) - - await posts.loading - deepStrictEqual(posts.get(), { - hasNext: false, - isLoading: false, - list: [POST1, POST2] - }) -}) diff --git a/core/test/request.test.ts b/core/test/request.test.ts deleted file mode 100644 index 67fc3ac7..00000000 --- a/core/test/request.test.ts +++ /dev/null @@ -1,103 +0,0 @@ -import { deepStrictEqual, equal, rejects, throws } from 'node:assert' -import { afterEach, test } from 'node:test' -import { setTimeout } from 'node:timers/promises' - -import { - checkAndRemoveRequestMock, - expectRequest, - mockRequest, - request, - setRequestMethod -} from '../index.ts' - -afterEach(() => { - setRequestMethod(fetch) -}) - -test('replaces request method', () => { - let result = new Promise(resolve => { - resolve(new Response()) - }) - - let calls: string[] = [] - setRequestMethod(url => { - if (typeof url === 'string') { - calls.push(url) - } - return result - }) - - equal(request('https://example.com'), result) - deepStrictEqual(calls, ['https://example.com']) -}) - -test('checks that all mock requests was called', async () => { - mockRequest() - - expectRequest('https://one.com').andRespond(200) - expectRequest('https://two.com').andRespond(200) - - equal((await request('https://one.com')).status, 200) - throws(() => { - checkAndRemoveRequestMock() - }, /didn’t send requests: https:\/\/two.com/) -}) - -test('checks mocks order', async () => { - mockRequest() - - expectRequest('https://one.com').andRespond(200) - expectRequest('https://two.com').andRespond(200) - - await rejects( - request('https://two.com'), - /https:\/\/one\.com instead of https:\/\/two\.com/ - ) -}) - -test('is ready for unexpected requests', async () => { - mockRequest() - - expectRequest('https://one.com').andRespond(200) - - equal((await request('https://one.com')).status, 200) - await rejects( - request('https://one.com'), - /Unexpected request https:\/\/one.com/ - ) -}) - -test('marks requests as aborted', async () => { - mockRequest() - let reply = expectRequest('https://one.com').andWait() - - let aborted = '' - let controller = new AbortController() - request('https://one.com', { signal: controller.signal }).catch( - (e: unknown) => { - if (e instanceof Error) aborted = e.name - } - ) - - controller.abort() - await setTimeout(10) - equal(aborted, 'AbortError') - equal(reply.aborted, true) -}) - -test('sets content type', async () => { - mockRequest() - - expectRequest('https://one.com').andRespond(200, 'Hi') - let response1 = await request('https://one.com') - equal(response1.headers.get('content-type'), 'text/html') - - expectRequest('https://two.com').andRespond(200, 'Hi', 'text/plain') - let response2 = await request('https://two.com') - equal(response2.headers.get('content-type'), 'text/plain') - - let reply3 = expectRequest('https://two.com').andWait() - reply3(200, 'Hi', 'text/html; charset=utf-8') - let response3 = await request('https://two.com') - equal(response3.headers.get('content-type'), 'text/html; charset=utf-8') -}) diff --git a/core/test/router.test.ts b/core/test/router.test.ts index fe6df958..d24a369f 100644 --- a/core/test/router.test.ts +++ b/core/test/router.test.ts @@ -1,3 +1,4 @@ +import { keepMount } from 'nanostores' import { deepStrictEqual, equal } from 'node:assert' import { afterEach, beforeEach, test } from 'node:test' import { setTimeout } from 'node:timers/promises' @@ -11,6 +12,7 @@ import { deleteFeed, isGuestRoute, isOtherRoute, + openedPopups, removeLastPopup, router, setBaseTestRoute, @@ -253,50 +255,27 @@ test('backRoute handles export with format', () => { }) }) -test('parses popups', () => { +test('has helpers for popups', () => { + equal(addPopup('', 'feed', 'id1'), 'feed=id1') + equal(addPopup('feed=id1', 'post', 'id2'), 'feed=id1,post=id2') + equal(removeLastPopup('feed=id1,post=id2'), 'feed=id1') + equal(removeLastPopup('feed=id1'), '') +}) + +test('reacts on unknown popups', () => { userId.set('10') - setBaseTestRoute({ hash: 'feed=id1,post=id2', params: {}, route: 'profile' }) - deepStrictEqual(router.get(), { - params: {}, - popups: [ - { param: 'id1', popup: 'feed' }, - { param: 'id2', popup: 'post' } - ], - route: 'profile' - }) + keepMount(openedPopups) + equal(openedPopups.get().length, 0) - setBaseTestRoute({ hash: 'broken,post=id', params: {}, route: 'profile' }) - deepStrictEqual(router.get(), { - params: {}, - popups: [{ param: 'id', popup: 'post' }], - route: 'profile' - }) + setBaseTestRoute({ hash: `unknown=id`, params: {}, route: 'fast' }) + equal(openedPopups.get().length, 0) - setBaseTestRoute({ - hash: 'unknown=id1,post=id', - params: {}, - route: 'profile' - }) - deepStrictEqual(router.get(), { - params: {}, - popups: [{ param: 'id', popup: 'post' }], - route: 'profile' - }) + setBaseTestRoute({ hash: `popup:id`, params: {}, route: 'fast' }) + equal(openedPopups.get().length, 0) }) test('hides popups for guest', () => { userId.set(undefined) - setBaseTestRoute({ hash: 'feed=id1,post=id2', params: {}, route: 'profile' }) - deepStrictEqual(router.get(), { - params: {}, - popups: [], - route: 'start' - }) -}) - -test('has helpers for popups', () => { - equal(addPopup('', 'feed', 'id1'), 'feed=id1') - equal(addPopup('feed=id1', 'post', 'id2'), 'feed=id1,post=id2') - equal(removeLastPopup('feed=id1,post=id2'), 'feed=id1') - equal(removeLastPopup('feed=id1'), '') + setBaseTestRoute({ hash: 'feed=id1,post=id2', params: {}, route: 'signin' }) + equal(openedPopups.get().length, 0) })