diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 000000000..825afde3a --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,45 @@ +name: CI + +on: + pull_request: + branches: + - develop + push: + branches: + - develop + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + build-and-test: + name: Build and test + runs-on: ubuntu-latest + + steps: + - name: Check out repository + uses: actions/checkout@v4 + with: + persist-credentials: false + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + + - name: Install dependencies + run: npm ci --legacy-peer-deps + + - name: Run server tests + run: npm test + + - name: Build maxun-core + run: npm ci --prefix maxun-core && npm run build --prefix maxun-core + + - name: Build frontend + run: npm run build diff --git a/package.json b/package.json index 76f1abfe8..8670750ae 100644 --- a/package.json +++ b/package.json @@ -98,6 +98,8 @@ "build": "vite build", "build:server": "tsc -p server/tsconfig.json", "preview": "vite preview", + "test": "npm run test:server", + "test:server": "npm run build:server && node --test \"tests/server/**/*.test.js\"", "lint": "./node_modules/.bin/eslint .", "migrate": "sequelize-cli db:migrate", "migrate:undo": "sequelize-cli db:migrate:undo", diff --git a/tests/server/utils/output-validation.test.js b/tests/server/utils/output-validation.test.js new file mode 100644 index 000000000..acfa88a06 --- /dev/null +++ b/tests/server/utils/output-validation.test.js @@ -0,0 +1,102 @@ +const assert = require('node:assert/strict'); +const { describe, it } = require('node:test'); + +const { + getInterpretationFailureReason, + hasExpectedRobotOutput, +} = require('../../../server/dist/server/src/utils/output-validation'); + +describe('getInterpretationFailureReason', () => { + it('returns the first search or crawl error from interpretation logs', () => { + const reason = getInterpretationFailureReason( + [ + 'Starting search', + ' Search execution error: DuckDuckGo returned no result container ', + 'Search action failed: fallback detail', + ], + 'Run completed without output' + ); + + assert.equal(reason, 'Search execution error: DuckDuckGo returned no result container'); + }); + + it('returns the fallback when logs are missing or do not contain a known marker', () => { + assert.equal( + getInterpretationFailureReason('not-an-array', 'Run completed without output'), + 'Run completed without output' + ); + + assert.equal( + getInterpretationFailureReason(['Run started', 'Run ended'], 'Run completed without output'), + 'Run completed without output' + ); + }); +}); + +describe('hasExpectedRobotOutput', () => { + it('accepts default search output when search results exist', () => { + assert.equal( + hasExpectedRobotOutput('search', { + search: { + 'Search Results': { + results: [{ title: 'Maxun', url: 'https://www.maxun.dev' }], + }, + }, + }), + true + ); + }); + + it('rejects search output when requested formats are absent', () => { + assert.equal( + hasExpectedRobotOutput( + 'search', + { + search: { + 'Search Results': { + results: [{ title: 'Maxun', url: 'https://www.maxun.dev' }], + }, + }, + }, + ['markdown'] + ), + false + ); + }); + + it('accepts search output when a requested serializable format is present', () => { + assert.equal( + hasExpectedRobotOutput( + 'search', + { + search: { + 'Search Results': { + results: [{ markdown: '# Maxun', url: 'https://www.maxun.dev' }], + }, + }, + }, + ['markdown'] + ), + true + ); + }); + + it('rejects crawl output when every result is an error', () => { + assert.equal( + hasExpectedRobotOutput( + 'crawl', + { + crawl: { + 'Crawl Results': [{ error: 'Navigation failed' }], + }, + }, + ['text'] + ), + false + ); + }); + + it('continues to accept non-search and non-crawl robots', () => { + assert.equal(hasExpectedRobotOutput('extract', {}), true); + }); +});