feat: publish CodexPi contest snapshot
Some checks are pending
Qualité / tests (push) Waiting to run
Some checks are pending
Qualité / tests (push) Waiting to run
This commit is contained in:
15
frontend/tests/apiAuth.test.ts
Normal file
15
frontend/tests/apiAuth.test.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import {readFile} from 'node:fs/promises'
|
||||
import test from 'node:test'
|
||||
|
||||
import {csrfTokenFromBootstrap} from '../src/csrfRefresh.ts'
|
||||
|
||||
test('a fresh CSRF token is required before submitting login', async () => {
|
||||
assert.equal(csrfTokenFromBootstrap({csrf_token: 'fresh-token'}), 'fresh-token')
|
||||
assert.throws(() => csrfTokenFromBootstrap({}), /indisponible/i)
|
||||
|
||||
const app = await readFile(new URL('../src/App.tsx', import.meta.url), 'utf8')
|
||||
const refresh = app.indexOf('await refreshCsrf()')
|
||||
const login = app.indexOf("const endpoint=setupRequired?'/api/auth/setup':'/api/auth/login'")
|
||||
assert.ok(refresh >= 0 && refresh < login, 'login must refresh CSRF before selecting the POST endpoint')
|
||||
})
|
||||
22
frontend/tests/apiPayload.test.ts
Normal file
22
frontend/tests/apiPayload.test.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
|
||||
import {expectArrayPayload, readJsonPayload} from '../src/apiPayload.ts'
|
||||
|
||||
test('a truncated successful JSON response is rejected instead of becoming an object', async () => {
|
||||
const response = new Response('{"messages":[', {
|
||||
status: 200,
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
})
|
||||
await assert.rejects(() => readJsonPayload(response), /incomplète/i)
|
||||
})
|
||||
|
||||
test('a valid JSON response is decoded', async () => {
|
||||
const response = new Response('[{"id":"message-1"}]', {status: 200})
|
||||
assert.deepEqual(await readJsonPayload(response), [{id: 'message-1'}])
|
||||
})
|
||||
|
||||
test('array endpoints reject a valid but malformed payload', () => {
|
||||
assert.throws(() => expectArrayPayload({}, 'messages'), /messages.*invalide/i)
|
||||
assert.deepEqual(expectArrayPayload([{id: 'message-1'}], 'messages'), [{id: 'message-1'}])
|
||||
})
|
||||
18
frontend/tests/appRoutes.test.ts
Normal file
18
frontend/tests/appRoutes.test.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
|
||||
import { routeHref, routeView } from '../src/appRoutes.ts'
|
||||
|
||||
test('deep links restore the requested view',()=>{
|
||||
assert.equal(routeView('?view=activity'),'activity')
|
||||
assert.equal(routeView('?view=workspace&project=p1&conversation=c1'),'workspace')
|
||||
assert.equal(routeView('?view=unknown'),'company')
|
||||
})
|
||||
|
||||
test('workspace links retain their project, conversation and tab',()=>{
|
||||
assert.equal(routeHref('workspace',{project:{id:'p1'},conversationId:'c1',tab:'changes'}),'/?view=workspace&project=p1&conversation=c1&tab=changes')
|
||||
})
|
||||
|
||||
test('global machine links do not depend on a selected project',()=>{
|
||||
assert.equal(routeHref('remote',{project:{id:'p1'}}),'/?view=remote')
|
||||
})
|
||||
25
frontend/tests/appearance.test.ts
Normal file
25
frontend/tests/appearance.test.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
|
||||
import {interfaceLanding,rememberAppearance,storedInterfaceType,storedTheme} from '../src/appearance.ts'
|
||||
|
||||
function memory(initial:Record<string,string>={}){
|
||||
const values=new Map(Object.entries(initial))
|
||||
return{getItem:(key:string)=>values.get(key)??null,setItem:(key:string,value:string)=>values.set(key,value),values}
|
||||
}
|
||||
|
||||
test('each interface type owns a distinct landing structure',()=>{
|
||||
assert.deepEqual(interfaceLanding('dashboard'),{view:'activity',tab:'chat'})
|
||||
assert.deepEqual(interfaceLanding('atelier'),{view:'workspace',tab:'chat'})
|
||||
assert.deepEqual(interfaceLanding('review'),{view:'workspace',tab:'changes'})
|
||||
})
|
||||
|
||||
test('interface and theme persist independently with safe defaults',()=>{
|
||||
const storage=memory()
|
||||
rememberAppearance('review','light',storage)
|
||||
assert.equal(storedInterfaceType(storage),'review')
|
||||
assert.equal(storedTheme(storage),'light')
|
||||
const invalid=memory({'codexpi-interface-type':'palette','codexpi-theme':'neon'})
|
||||
assert.equal(storedInterfaceType(invalid),'dashboard')
|
||||
assert.equal(storedTheme(invalid),'system')
|
||||
})
|
||||
46
frontend/tests/attachments.test.ts
Normal file
46
frontend/tests/attachments.test.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
|
||||
import {
|
||||
MAX_ATTACHMENT_BYTES,
|
||||
MAX_COMPOSER_TEXT_LENGTH,
|
||||
MAX_EDITABLE_TEXT_BYTES,
|
||||
prepareComposerSubmission,
|
||||
} from '../src/attachments.ts'
|
||||
|
||||
test('files up to one hundred megabytes are accepted', () => {
|
||||
assert.equal(MAX_ATTACHMENT_BYTES, 100 * 1024 * 1024)
|
||||
})
|
||||
|
||||
test('regular composer text remains inline', () => {
|
||||
const result = prepareComposerSubmission('short request', [], new Date('2026-09-18T12:34:56Z'))
|
||||
assert.equal(result.content, 'short request')
|
||||
assert.deepEqual(result.files, [])
|
||||
assert.equal(result.converted, false)
|
||||
})
|
||||
|
||||
test('oversized composer text becomes a named text attachment', async () => {
|
||||
const content = 'x'.repeat(MAX_COMPOSER_TEXT_LENGTH + 1)
|
||||
const result = prepareComposerSubmission(content, [], new Date('2026-09-18T12:34:56Z'))
|
||||
assert.equal(result.converted, true)
|
||||
assert.equal(result.files.length, 1)
|
||||
assert.equal(result.files[0].name, 'message-long-2026-09-18-123456.txt')
|
||||
assert.equal(result.files[0].type, 'text/plain')
|
||||
assert.equal(await result.files[0].text(), content)
|
||||
assert.match(result.content, /message-long-2026-09-18-123456\.txt/)
|
||||
})
|
||||
|
||||
test('conversion refuses to exceed the four attachment limit', () => {
|
||||
const files = Array.from({length:4}, (_, index) => new File(['x'], `file-${index}.txt`, {type:'text/plain'}))
|
||||
assert.throws(
|
||||
() => prepareComposerSubmission('x'.repeat(MAX_COMPOSER_TEXT_LENGTH + 1), files, new Date()),
|
||||
/quatre fichiers/i,
|
||||
)
|
||||
})
|
||||
|
||||
test('conversion refuses text too large for the integrated editor', () => {
|
||||
assert.throws(
|
||||
() => prepareComposerSubmission('x'.repeat(MAX_EDITABLE_TEXT_BYTES + 1), [], new Date()),
|
||||
/10 Mo maximum/i,
|
||||
)
|
||||
})
|
||||
18
frontend/tests/browserTouchpad.test.ts
Normal file
18
frontend/tests/browserTouchpad.test.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
|
||||
import { relativePointerPosition } from '../public/browser-touchpad-math.js'
|
||||
|
||||
test('trackpad movement is relative to the current pointer position',()=>{
|
||||
assert.deepEqual(
|
||||
relativePointerPosition({x:320,y:180},40,-20,2,2,1.35,1440,900),
|
||||
{x:428,y:126},
|
||||
)
|
||||
})
|
||||
|
||||
test('relative trackpad movement stays inside the remote screen',()=>{
|
||||
assert.deepEqual(
|
||||
relativePointerPosition({x:1430,y:5},100,-100,1,1,1.35,1440,900),
|
||||
{x:1439,y:0},
|
||||
)
|
||||
})
|
||||
20
frontend/tests/commandLine.test.ts
Normal file
20
frontend/tests/commandLine.test.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
|
||||
import { parseCommandLine } from '../src/commandLine.ts'
|
||||
|
||||
test('quoted arguments retain their spaces',()=>{
|
||||
assert.deepEqual(parseCommandLine('pytest "tests/my feature.py" -k \'works here\''),['pytest','tests/my feature.py','-k','works here'])
|
||||
})
|
||||
|
||||
test('escaped spaces and empty quoted arguments are retained',()=>{
|
||||
assert.deepEqual(parseCommandLine('tool one\\ two ""'),['tool','one two',''])
|
||||
})
|
||||
|
||||
test('windows paths are not damaged by parsing',()=>{
|
||||
assert.deepEqual(parseCommandLine('py "C:\\Mes Tests\\test.py"'),['py','C:\\Mes Tests\\test.py'])
|
||||
})
|
||||
|
||||
test('an unclosed quote is rejected',()=>{
|
||||
assert.throws(()=>parseCommandLine('pytest "tests/file.py'),/Guillemet non fermé/)
|
||||
})
|
||||
22
frontend/tests/conversationRequests.test.ts
Normal file
22
frontend/tests/conversationRequests.test.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
|
||||
import {isCurrentConversationResponse,reuseInFlightRequest} from '../src/conversationRequests.ts'
|
||||
|
||||
test('a late response from the previously selected conversation is ignored', () => {
|
||||
assert.equal(isCurrentConversationResponse('gameflix','codexpi-self'),false)
|
||||
assert.equal(isCurrentConversationResponse('gameflix','gameflix'),true)
|
||||
})
|
||||
|
||||
test('simultaneous refreshes reuse one request and release it after completion', async () => {
|
||||
const requests=new Map<string,Promise<number>>()
|
||||
let calls=0
|
||||
const load=()=>reuseInFlightRequest(requests,'conversation',async()=>++calls)
|
||||
|
||||
const first=load()
|
||||
const second=load()
|
||||
|
||||
assert.equal(first,second)
|
||||
assert.equal(await first,1)
|
||||
assert.equal(await load(),2)
|
||||
})
|
||||
17
frontend/tests/inlineImages.test.ts
Normal file
17
frontend/tests/inlineImages.test.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import {readFileSync} from 'node:fs'
|
||||
import test from 'node:test'
|
||||
|
||||
const common=readFileSync(new URL('../src/Common.tsx',import.meta.url),'utf8')
|
||||
const styles=readFileSync(new URL('../src/styles.css',import.meta.url),'utf8')
|
||||
|
||||
test('inline chat images load eagerly inside the iOS scroll container',()=>{
|
||||
assert.match(common,/className="markdown-image"[\s\S]*loading="eager"/)
|
||||
assert.doesNotMatch(common,/className="markdown-image"[\s\S]*loading="lazy"/)
|
||||
})
|
||||
|
||||
test('inline chat images reserve a visible panoramic area before decoding',()=>{
|
||||
const rule=styles.match(/\.markdown-image>img\{([^}]*)\}/)?.[1]||''
|
||||
assert.match(rule,/aspect-ratio:auto 16\/9/)
|
||||
assert.match(rule,/min-height:160px/)
|
||||
})
|
||||
80
frontend/tests/markdownImages.test.ts
Normal file
80
frontend/tests/markdownImages.test.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
|
||||
import {embeddedAttachmentIds, resolveMarkdownImage, resolveMarkdownLink, stableAttachments} from '../src/markdownImages.ts'
|
||||
|
||||
const project={id:'project-1',name:'Demo',path:'/home/config/Demo',full_access:0}
|
||||
const attachments=[
|
||||
{id:'shot-1',original_name:'capture navigateur.png',content_type:'image/png',size:42,url:'/api/browser/screenshots/shot-1'},
|
||||
]
|
||||
|
||||
test('messages without attachments reuse one stable empty collection',()=>{
|
||||
assert.equal(stableAttachments(undefined),stableAttachments(undefined))
|
||||
})
|
||||
|
||||
test('an attachment reference is resolved inside markdown text',()=>{
|
||||
assert.deepEqual(resolveMarkdownImage('attachment://shot-1',attachments,project),{
|
||||
url:'/api/browser/screenshots/shot-1',
|
||||
name:'capture navigateur.png',
|
||||
attachmentId:'shot-1',
|
||||
reference:'attachment://shot-1',
|
||||
})
|
||||
assert.deepEqual(
|
||||
embeddedAttachmentIds('Avant\n\n\n\nAprès',attachments),
|
||||
new Set(['shot-1']),
|
||||
)
|
||||
})
|
||||
|
||||
test('an attachment can be referenced by its encoded file name',()=>{
|
||||
assert.equal(
|
||||
resolveMarkdownImage('attachment://capture%20navigateur.png',attachments,project)?.url,
|
||||
'/api/browser/screenshots/shot-1',
|
||||
)
|
||||
})
|
||||
|
||||
test('an absolute project image is served through the authenticated download route',()=>{
|
||||
const image=resolveMarkdownImage('/home/config/Demo/docs/schema.png',[],project)
|
||||
assert.equal(image?.url,'/api/projects/project-1/files/download?path=docs%2Fschema.png&inline=true')
|
||||
assert.equal(image?.name,'schema.png')
|
||||
})
|
||||
|
||||
test('an image created in a conversation worktree uses its authorized workspace route',()=>{
|
||||
const conversation={id:'conversation-1',title:'Images',status:'idle' as const,updated_at:'',worktree_path:'/app/data/worktrees/conversation-1'}
|
||||
const image=resolveMarkdownImage('/app/data/worktrees/conversation-1/output/result.webp',[],project,conversation)
|
||||
assert.equal(image?.url,'/api/conversations/conversation-1/files/image?path=output%2Fresult.webp')
|
||||
})
|
||||
|
||||
test('external images remain external and unrelated absolute paths are rejected',()=>{
|
||||
assert.equal(resolveMarkdownImage('https://example.com/image.png',[],project)?.url,'https://example.com/image.png')
|
||||
assert.equal(resolveMarkdownImage('/etc/passwd',[],project),null)
|
||||
})
|
||||
|
||||
test('a project archive link becomes an authenticated download opened outside the PWA',()=>{
|
||||
const link=resolveMarkdownLink('/home/config/Demo/output/boards.zip',project)
|
||||
assert.deepEqual(link,{
|
||||
url:'/api/projects/project-1/files/download?path=output%2Fboards.zip',
|
||||
name:'boards.zip',
|
||||
download:true,
|
||||
external:false,
|
||||
presentation:'dialog',
|
||||
})
|
||||
})
|
||||
|
||||
test('a relative artifact created in a conversation uses its private worktree route',()=>{
|
||||
const conversation={id:'conversation-1',title:'Images',status:'idle' as const,updated_at:'',worktree_path:'/app/data/worktrees/conversation-1'}
|
||||
const link=resolveMarkdownLink('output/boards.zip',project,conversation)
|
||||
assert.equal(link.url,'/api/conversations/conversation-1/files/download?path=output%2Fboards.zip')
|
||||
assert.equal(link.name,'boards.zip')
|
||||
assert.equal(link.download,true)
|
||||
assert.equal(link.presentation,'dialog')
|
||||
})
|
||||
|
||||
test('external links are opened separately without pretending to be local downloads',()=>{
|
||||
assert.deepEqual(resolveMarkdownLink('https://example.com/help',project),{
|
||||
url:'https://example.com/help',name:'help',download:false,external:true,presentation:'external',
|
||||
})
|
||||
})
|
||||
|
||||
test('ordinary links keep navigating inside the application',()=>{
|
||||
assert.equal(resolveMarkdownLink('#details',project).presentation,'navigate')
|
||||
})
|
||||
23
frontend/tests/retryMessage.test.ts
Normal file
23
frontend/tests/retryMessage.test.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
|
||||
import {retryPayload} from '../src/retryMessage.ts'
|
||||
|
||||
test('retry preserves the original message and attachment identifiers', () => {
|
||||
const payload=retryPayload({
|
||||
id:'message-1',
|
||||
role:'user',
|
||||
content:'Analyse cette capture',
|
||||
metadata:{attachments:[
|
||||
{id:'attachment-1',original_name:'capture.png',content_type:'image/png',size:42,url:'/attachment-1'},
|
||||
]},
|
||||
})
|
||||
assert.deepEqual(payload,{content:'Analyse cette capture',attachment_ids:['attachment-1']})
|
||||
})
|
||||
|
||||
test('retry keeps a text-only message valid', () => {
|
||||
assert.deepEqual(retryPayload({id:'message-2',role:'user',content:'Réessaie',metadata:{}}),{
|
||||
content:'Réessaie',
|
||||
attachment_ids:[],
|
||||
})
|
||||
})
|
||||
26
frontend/tests/slashCommands.test.ts
Normal file
26
frontend/tests/slashCommands.test.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
|
||||
import { SLASH_COMMANDS, filterSlashCommands, slashCommandValue } from '../src/slashCommands.ts'
|
||||
|
||||
test('a lone slash exposes the available command catalog', () => {
|
||||
assert.ok(SLASH_COMMANDS.length >= 8)
|
||||
assert.deepEqual(filterSlashCommands('/'), SLASH_COMMANDS)
|
||||
})
|
||||
|
||||
test('a command prefix filters names case-insensitively', () => {
|
||||
assert.deepEqual(filterSlashCommands('/plu').map(item => item.name), ['/plugins'])
|
||||
assert.deepEqual(filterSlashCommands('/PLU').map(item => item.name), ['/plugins'])
|
||||
})
|
||||
|
||||
test('suggestions only appear for a single command token at the start', () => {
|
||||
assert.deepEqual(filterSlashCommands('bonjour /'), [])
|
||||
assert.deepEqual(filterSlashCommands('/plugins maintenant'), [])
|
||||
assert.deepEqual(filterSlashCommands('/plugins\ntexte'), [])
|
||||
})
|
||||
|
||||
test('selecting a command leaves a space ready for optional arguments', () => {
|
||||
const plugins = SLASH_COMMANDS.find(item => item.name === '/plugins')
|
||||
assert.ok(plugins)
|
||||
assert.equal(slashCommandValue(plugins), '/plugins ')
|
||||
})
|
||||
35
frontend/tests/speech.test.ts
Normal file
35
frontend/tests/speech.test.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
|
||||
import { speechTextFromMarkdown, splitSpeechText } from '../src/speech.ts'
|
||||
|
||||
test('markdown is converted to natural speech without fenced code or URLs', () => {
|
||||
const markdown = `# Résultat
|
||||
|
||||
Voici **la réponse** avec [la documentation](https://example.com/docs) et \`npm test\`.
|
||||
|
||||
\`\`\`ts
|
||||
const secret = 'not read aloud'
|
||||
\`\`\`
|
||||
|
||||
- Premier point
|
||||
- Deuxième point`
|
||||
|
||||
const spoken = speechTextFromMarkdown(markdown)
|
||||
|
||||
assert.match(spoken, /Résultat/)
|
||||
assert.match(spoken, /la réponse/)
|
||||
assert.match(spoken, /la documentation/)
|
||||
assert.match(spoken, /npm test/)
|
||||
assert.match(spoken, /Bloc de code omis/)
|
||||
assert.doesNotMatch(spoken, /https|secret|\*\*|```/)
|
||||
})
|
||||
|
||||
test('long speech is split into short non-empty chunks without losing text', () => {
|
||||
const text = `${'Une phrase assez longue. '.repeat(18)}Dernière phrase.`
|
||||
const chunks = splitSpeechText(text, 180)
|
||||
|
||||
assert.ok(chunks.length > 1)
|
||||
assert.ok(chunks.every(chunk => chunk.length > 0 && chunk.length <= 180))
|
||||
assert.equal(chunks.join(' '), text.replace(/\s+/g, ' ').trim())
|
||||
})
|
||||
Reference in New Issue
Block a user