diff --git a/package.json b/package.json index a771d3799..22f65a444 100644 --- a/package.json +++ b/package.json @@ -106,6 +106,7 @@ "@vitest/coverage-v8": "4.0.18", "@vue/test-utils": "2.4.6", "axe-core": "4.11.1", + "fast-check": "4.5.3", "fast-npm-meta": "1.0.0", "knip": "5.83.0", "lint-staged": "16.2.7", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7fc507471..877d8d183 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -225,6 +225,9 @@ importers: axe-core: specifier: 4.11.1 version: 4.11.1 + fast-check: + specifier: 4.5.3 + version: 4.5.3 fast-npm-meta: specifier: 1.0.0 version: 1.0.0 @@ -5685,6 +5688,10 @@ packages: resolution: {integrity: sha512-CGnyrvbhPlWYMngksqrSSUT1BAVP49dZocrHuK0SvtR0D5TMs5wP0o3j7jexDJW01KSadjBp1M/71o/KR3nD1w==} engines: {node: '>=18'} + fast-check@4.5.3: + resolution: {integrity: sha512-IE9csY7lnhxBnA8g/WI5eg/hygA6MGWJMSNfFRrBlXUciADEhS1EDB0SIsMSvzubzIlOBbVITSsypCsW717poA==} + engines: {node: '>=12.17.0'} + fast-deep-equal@3.1.3: resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} @@ -7905,6 +7912,9 @@ packages: resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} engines: {node: '>=6'} + pure-rand@7.0.1: + resolution: {integrity: sha512-oTUZM/NAZS8p7ANR3SHh30kXB+zK2r2BPcEn/awJIbOvq82WoMN4p62AWWp3Hhw50G0xMsw1mhIBLqHw64EcNQ==} + qs@6.14.1: resolution: {integrity: sha512-4EK3+xJl8Ts67nLYNwqw/dsFVnCf+qR7RgXSK9jEEm9unao3njwMDdmsdvoKBKHzxd7tCYz5e5M+SnMjdtXGQQ==} engines: {node: '>=0.6'} @@ -15807,6 +15817,10 @@ snapshots: fake-indexeddb@6.2.5: {} + fast-check@4.5.3: + dependencies: + pure-rand: 7.0.1 + fast-deep-equal@3.1.3: {} fast-fifo@1.3.2: {} @@ -19006,6 +19020,8 @@ snapshots: punycode@2.3.1: {} + pure-rand@7.0.1: {} + qs@6.14.1: dependencies: side-channel: 1.1.0 diff --git a/test/unit/server/utils/docs/text.spec.ts b/test/unit/server/utils/docs/text.spec.ts index 6b77bb774..a928d45fe 100644 --- a/test/unit/server/utils/docs/text.spec.ts +++ b/test/unit/server/utils/docs/text.spec.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from 'vitest' +import * as fc from 'fast-check' import { escapeHtml, parseJsDocLinks, @@ -40,6 +41,15 @@ describe('stripAnsi', () => { const input = `object is ReactElement${ESC}[0m${ESC}[38;5;12m<${ESC}[0mP${ESC}[38;5;12m>${ESC}[0m` expect(stripAnsi(input)).toBe('object is ReactElement
') }) + + it('should strip everything in one pass', () => { + fc.assert( + fc.property(fc.string(), input => { + const stripped = stripAnsi(input) + expect(stripAnsi(stripped)).toBe(stripped) + }), + ) + }) }) describe('escapeHtml', () => { @@ -124,6 +134,65 @@ describe('parseJsDocLinks', () => { const result = parseJsDocLinks('{@link http://example.com}', emptyLookup) expect(result).toContain('href="http://example.com"') }) + + it('should convert external URLs using {@link url} to links', () => { + fc.assert( + fc.property(fc.webUrl(), url => { + const result = parseJsDocLinks(`{@link ${url}}`, emptyLookup) + expect(result).toContain(`href="${escapeHtml(url)}"`) + expect(result).toContain('target="_blank"') + expect(result).toContain('rel="noreferrer"') + expect(result).toContain(escapeHtml(url)) + }), + ) + }) + + it('should convert external URLs using {@link url text} to links', () => { + fc.assert( + fc.property(fc.webUrl(), fc.stringMatching(/^[^}\s][^}]+[^}\s]$/), (url, text) => { + const result = parseJsDocLinks(`{@link ${url} ${text}}`, emptyLookup) + expect(result).toContain(`href="${escapeHtml(url)}"`) + expect(result).toContain('target="_blank"') + expect(result).toContain('rel="noreferrer"') + expect(result).toContain(escapeHtml(text)) + }), + ) + }) + + it('should be able to treat correctly several external URLs at the middle of a text', () => { + const surrounding = fc.stringMatching(/^[^{]*$/) + const link = fc.record({ + url: fc.webUrl(), + label: fc.option(fc.stringMatching(/^[^}\s][^}]+[^}\s]$/)), + before: surrounding, + after: surrounding, + }) + fc.assert( + fc.property(fc.array(link, { minLength: 1 }), content => { + let docString = '' + const expectedUrls = [] + for (const chunk of content) { + if (chunk.before.length !== 0 || docString.length !== 0) { + docString += `${chunk.before} ` + } + if (chunk.label === null) { + docString += `{@link ${chunk.url}}` + expectedUrls.push(chunk.url) + } else { + docString += `{@link ${chunk.url} ${chunk.label}}` + expectedUrls.push(chunk.url) + } + if (chunk.after.length !== 0) { + docString += ` ${chunk.after}` + } + } + const result = parseJsDocLinks(docString, emptyLookup) + for (const url of expectedUrls) { + expect(result).toContain(`href="${escapeHtml(url)}"`) + } + }), + ) + }) }) describe('renderMarkdown', () => { diff --git a/test/unit/shared/utils/async.spec.ts b/test/unit/shared/utils/async.spec.ts index 227460781..8c2104e4c 100644 --- a/test/unit/shared/utils/async.spec.ts +++ b/test/unit/shared/utils/async.spec.ts @@ -1,4 +1,5 @@ import { describe, expect, it, vi } from 'vitest' +import * as fc from 'fast-check' import { mapWithConcurrency } from '../../../../shared/utils/async' describe('mapWithConcurrency', () => { @@ -92,4 +93,44 @@ describe('mapWithConcurrency', () => { // Should only have 3 concurrent since we only have 3 items expect(maxConcurrent).toBe(3) }) + + it('waits for all tasks to succeed and return them in order whatever their count and the concurrency', async () => { + await fc.assert( + fc.asyncProperty( + fc.array(fc.anything()), + fc.integer({ min: 1 }), + fc.scheduler(), + async (items, concurrency, s) => { + const fn = s.scheduleFunction(async item => item) + const results = await s.waitFor(mapWithConcurrency(items, fn, concurrency)) + expect(results).toEqual(items) + }, + ), + ) + }) + + it('not run more than concurrency tasks in parallel', async () => { + await fc.assert( + fc.asyncProperty( + fc.array(fc.anything()), + fc.integer({ min: 1 }), + fc.scheduler(), + async (items, concurrency, s) => { + let tooManyRunningTasksEncountered = false + let currentlyRunning = 0 + const fn = async (item: (typeof items)[number]) => { + currentlyRunning++ + if (currentlyRunning > concurrency) { + tooManyRunningTasksEncountered = true + } + const task = s.schedule(Promise.resolve(item)) + task.then(() => currentlyRunning--) // this task always succeeds by construct + return task + } + await s.waitFor(mapWithConcurrency(items, fn, concurrency)) + expect(tooManyRunningTasksEncountered).toBe(false) + }, + ), + ) + }) })