Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
160 changes: 156 additions & 4 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -45,10 +45,13 @@ jobs:
if: matrix.node == 22
uses: ./.github/actions/setup-tex
- run: npm run typecheck
# The runner image already has the libraries Chromium needs. Installing
# them (--with-deps) needs Ubuntu's package mirror, which the runner
# sometimes cannot resolve for an hour or more.
- name: Install browser for PDF review tests
if: matrix.node == 22
working-directory: engine
run: npx playwright install --with-deps chromium
run: npx playwright install chromium
- run: npm test
env:
# The viewer's lock test drives the built review viewer in Chromium.
Expand All @@ -68,8 +71,11 @@ jobs:
# The static pages do not depend on the server's Node version.
if: matrix.node == 22
run: |
engine/node_modules/.bin/playwright install --with-deps chromium
engine/node_modules/.bin/playwright install chromium
node scripts/check-site-blog.mjs
node scripts/check-site.mjs
node scripts/check-site-analytics.mjs
node scripts/check-site-privacy.mjs
node scripts/check-playground.mjs
env:
PAPILOT_BROWSER_CHANNEL: chromium
Expand Down Expand Up @@ -98,7 +104,7 @@ jobs:
uses: ./.github/actions/setup-tex
- name: Install browser
working-directory: engine
run: npx playwright install --with-deps chromium
run: npx playwright install chromium
- run: npm run build
# One step each, so one failure does not hide the others' results.
- name: Review batching
Expand Down Expand Up @@ -134,6 +140,151 @@ jobs:
if-no-files-found: ignore
retention-days: 7

# Every other interface check, against the real built app with mocked or
# real services, real LaTeX where a check compiles. One step each, so one
# failure does not hide the others.
interface:
name: Interface checks
runs-on: ubuntu-latest
timeout-minutes: 45
env:
PAPILOT_BROWSER_CHANNEL: chromium
steps:
- uses: actions/checkout@v5
- uses: actions/setup-node@v5
with:
node-version: 22
cache: npm
cache-dependency-path: |
package-lock.json
engine/package-lock.json
- run: npm ci
- run: npm run setup:review
- name: Install TeX
uses: ./.github/actions/setup-tex
- name: Install browser
working-directory: engine
run: npx playwright install chromium
- run: npm run build
- name: ai-review
run: node --import tsx scripts/check-ai-review.mjs
- name: appearance
if: ${{ !cancelled() }}
run: node --import tsx scripts/check-appearance.mjs
- name: attachments
if: ${{ !cancelled() }}
run: node --import tsx scripts/check-attachments.mjs
- name: build-problems
if: ${{ !cancelled() }}
run: node --import tsx scripts/check-build-problems.mjs
- name: comparison-progress
if: ${{ !cancelled() }}
run: node --import tsx scripts/check-comparison-progress.mjs
- name: compilation
if: ${{ !cancelled() }}
run: node --import tsx scripts/check-compilation.mjs
- name: fast-decisions
if: ${{ !cancelled() }}
run: node --import tsx scripts/check-fast-decisions.mjs
- name: git-panel
if: ${{ !cancelled() }}
run: node --import tsx scripts/check-git-panel.mjs
- name: git-workspace
if: ${{ !cancelled() }}
run: node --import tsx scripts/check-git-workspace.mjs
- name: imports
if: ${{ !cancelled() }}
run: node --import tsx scripts/check-imports.mjs
- name: locales
if: ${{ !cancelled() }}
run: node --import tsx scripts/check-locales.mjs
- name: manuscript-source
if: ${{ !cancelled() }}
run: node --import tsx scripts/check-manuscript-source.mjs
- name: note-groups
if: ${{ !cancelled() }}
run: node --import tsx scripts/check-note-groups.mjs
- name: note-new-pass
if: ${{ !cancelled() }}
run: node --import tsx scripts/check-note-new-pass.mjs
- name: note-selection
if: ${{ !cancelled() }}
run: node --import tsx scripts/check-note-selection.mjs
- name: papers
if: ${{ !cancelled() }}
run: node --import tsx scripts/check-papers.mjs
- name: pdf-tools
if: ${{ !cancelled() }}
run: node --import tsx scripts/check-pdf-tools.mjs
- name: pdf-words
if: ${{ !cancelled() }}
run: node --import tsx scripts/check-pdf-words.mjs
- name: persistent-pdf
if: ${{ !cancelled() }}
run: node --import tsx scripts/check-persistent-pdf.mjs
- name: places
if: ${{ !cancelled() }}
run: node --import tsx scripts/check-places.mjs
- name: reply-duplicates
if: ${{ !cancelled() }}
run: node --import tsx scripts/check-reply-duplicates.mjs
- name: review-notes
if: ${{ !cancelled() }}
run: node --import tsx scripts/check-review-notes.mjs
- name: review-pdf-appearance
if: ${{ !cancelled() }}
run: node --import tsx scripts/check-review-pdf-appearance.mjs
- name: review-tools
if: ${{ !cancelled() }}
run: node --import tsx scripts/check-review-tools.mjs
- name: settings-layout
if: ${{ !cancelled() }}
run: node --import tsx scripts/check-settings-layout.mjs
- name: skill-library
if: ${{ !cancelled() }}
run: node --import tsx scripts/check-skill-library.mjs
- name: skills
if: ${{ !cancelled() }}
run: node --import tsx scripts/check-skills.mjs
- name: source-edit
if: ${{ !cancelled() }}
run: node --import tsx scripts/check-source-edit.mjs
- name: system-requirements
if: ${{ !cancelled() }}
run: node --import tsx scripts/check-system-requirements.mjs
- name: task-model
if: ${{ !cancelled() }}
run: node --import tsx scripts/check-task-model.mjs
- name: task-permissions
if: ${{ !cancelled() }}
run: node --import tsx scripts/check-task-permissions.mjs
- name: task-preparation
if: ${{ !cancelled() }}
run: node --import tsx scripts/check-task-preparation.mjs
- name: ui
if: ${{ !cancelled() }}
run: node --import tsx scripts/check-ui.mjs
- name: unified-review
if: ${{ !cancelled() }}
run: node --import tsx scripts/check-unified-review.mjs
- name: updates
if: ${{ !cancelled() }}
run: node --import tsx scripts/check-updates.mjs
- name: workspace-switcher-notes
if: ${{ !cancelled() }}
run: node --import tsx scripts/check-workspace-switcher-notes.mjs
- name: workspace-switcher
if: ${{ !cancelled() }}
run: node --import tsx scripts/check-workspace-switcher.mjs
- name: Keep screenshots of a failed check
if: failure()
uses: actions/upload-artifact@v4
with:
name: interface-results
path: test-results/
if-no-files-found: ignore
retention-days: 7

# The same checks on a native Windows host: the CLI, server and review engine
# run there too, and the tests that need POSIX signals skip themselves.
windows:
Expand Down Expand Up @@ -196,14 +347,15 @@ jobs:
passed:
name: CI passed
if: always()
needs: [check, review, windows, changes, desktop]
needs: [check, review, interface, windows, changes, desktop]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- name: Require explicit success from every required job
env:
CHECK_RESULT: ${{ needs.check.result }}
REVIEW_RESULT: ${{ needs.review.result }}
INTERFACE_RESULT: ${{ needs.interface.result }}
WINDOWS_RESULT: ${{ needs.windows.result }}
CHANGES_RESULT: ${{ needs.changes.result }}
DESKTOP_SCOPE: ${{ needs.changes.outputs.desktop }}
Expand Down
49 changes: 46 additions & 3 deletions .github/workflows/desktop.yml
Original file line number Diff line number Diff line change
Expand Up @@ -66,11 +66,54 @@ jobs:
~/AppData/Local/electron-builder/Cache
key: electron-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('package-lock.json') }}
restore-keys: electron-${{ runner.os }}-${{ runner.arch }}-
- name: Install Linux desktop dependencies
# The image has most of the packages the Linux smoke test needs. The rest
# come from a cache of their .deb files per image, like TeX's, so a run
# needs Ubuntu's mirror only on a new image: the runners sometimes cannot
# resolve it for an hour or more.
- name: Find missing Linux desktop packages
id: desktop-packages
if: runner.os == 'Linux'
run: |
sudo apt-get update -qq
sudo apt-get install -y libgtk-3-0 libnss3 libxss1 libxtst6 libasound2 libgbm1 libnotify4 libsecret-1-0 libfuse2 xvfb xauth openbox
missing=()
for package in libgtk-3-0 libnss3 libxss1 libxtst6 libasound2 libgbm1 libnotify4 libsecret-1-0 libfuse2 xvfb xauth openbox; do
dpkg -s "$package" >/dev/null 2>&1 || missing+=("$package")
done
echo "Missing: ${missing[*]:-none}"
echo "list=${missing[*]}" >> "$GITHUB_OUTPUT"
echo "dir=/tmp/papilot-desktop-debs" >> "$GITHUB_OUTPUT"
echo "key=desktop-debs-v1-$ImageOS-$ImageVersion-$(uname -m)-$(sha256sum <<< "${missing[*]}" | cut -c1-16)" >> "$GITHUB_OUTPUT"
- id: desktop-cache
if: runner.os == 'Linux' && steps.desktop-packages.outputs.list != ''
uses: actions/cache/restore@v5
with:
path: ${{ steps.desktop-packages.outputs.dir }}
key: ${{ steps.desktop-packages.outputs.key }}
- name: Install Linux desktop packages from the cache
id: desktop-cached
if: steps.desktop-cache.outputs.cache-hit == 'true'
continue-on-error: true
env:
DIR: ${{ steps.desktop-packages.outputs.dir }}
run: sudo dpkg -i "$DIR"/*.deb
- name: Install Linux desktop packages from the mirror
id: desktop-download
if: steps.desktop-packages.outputs.list != '' && (steps.desktop-cache.outputs.cache-hit != 'true' || steps.desktop-cached.outcome == 'failure')
env:
DIR: ${{ steps.desktop-packages.outputs.dir }}
PACKAGES: ${{ steps.desktop-packages.outputs.list }}
run: |
apt=(-o Acquire::Retries=3 -o Acquire::http::Timeout=30 -o Dir::Cache::archives="$DIR" -o APT::Keep-Downloaded-Packages=true)
mkdir -p "$DIR/partial" && sudo chown _apt "$DIR/partial"
sudo apt-get "${apt[@]}" update -qq
sudo apt-get "${apt[@]}" install -y $PACKAGES
sudo rm -rf "$DIR/partial" "$DIR/lock"
sudo chown -R "$(id -u):$(id -g)" "$DIR"
# Only main saves, as for TeX: pull requests restore its cache.
- if: github.ref == 'refs/heads/main' && steps.desktop-download.outcome == 'success'
uses: actions/cache/save@v5
with:
path: ${{ steps.desktop-packages.outputs.dir }}
key: ${{ steps.desktop-packages.outputs.key }}
- run: npm ci
# Making the installers takes most of a full build. The smoke tests
# launch the unpacked app, which --dir builds by itself.
Expand Down
8 changes: 6 additions & 2 deletions scripts/check-fast-decisions.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import fs from 'node:fs';
import path from 'node:path';
import Fastify from 'fastify';
import staticFiles from '@fastify/static';
import { chromium, webkit, expect } from './playwright.mjs';
import { afterRender, chromium, webkit, expect } from './playwright.mjs';
import { annotateReviewState } from '../src/server/review.ts';

fs.mkdirSync('test-results', { recursive: true });
Expand Down Expand Up @@ -66,7 +66,11 @@ async function open(page) {
return route.fulfill({ json: view(extra) });
});
await page.goto(`${origin}/?task=flow`);
await ready(page);
}
async function ready(page) {
await expect(page.locator('.change.current')).toHaveAttribute('data-stop', '0');
await afterRender(page);
}
const decides = () => posts.filter(post => post.route === 'decide');
const current = page => page.locator('.change.current');
Expand Down Expand Up @@ -288,5 +292,5 @@ async function reloadFresh(page, status) {
reset(status);
await page.addInitScript(() => localStorage.clear());
await page.reload();
await expect(current(page)).toHaveAttribute('data-stop', '0');
await ready(page);
}
3 changes: 2 additions & 1 deletion scripts/check-review-batching.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import assert from 'node:assert/strict';
import fs from 'node:fs';
import path from 'node:path';
import { EventEmitter } from 'node:events';
import { chromium, expect } from './playwright.mjs';
import { afterRender, chromium, expect } from './playwright.mjs';
import { Fixture, METHOD } from '../engine/tests/ts/helpers.ts';
import { createServer } from '../engine/src/server.ts';
import { Builder } from '../engine/src/core/build.ts';
Expand Down Expand Up @@ -53,6 +53,7 @@ try {
page.on('pageerror', error => errors.push(error.message));
await page.goto(`http://127.0.0.1:${listener.port}/review/paper/?task=batch`);
await expect(page.locator('.change.current')).toHaveAttribute('data-stop', '0');
await afterRender(page);
await page.keyboard.press('a');
await expect.poll(() => app.review.pageState(task.name).decisions.c001).toBe('accepted');
await page.keyboard.press(process.platform === 'darwin' ? 'Meta+z' : 'Control+z');
Expand Down
10 changes: 6 additions & 4 deletions scripts/check-review-pdf-appearance.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -147,10 +147,12 @@ try {
await page.reload();
await page.getByRole('button', { name: /^Review(?:,|$)/ }).click();
await expect(frame.locator('html')).toHaveAttribute('data-papilot-phone', '');
await expect.poll(() => frame.locator('.change.current .dock').evaluate(dock => {
const box = dock.getBoundingClientRect();
return box.bottom <= innerHeight && box.bottom >= innerHeight - 16 && box.left >= 0 && box.right <= innerWidth;
})).toBe(true);
// The decision dock floats just above the marking tools, fully on screen.
const dockPlacement = async () => {
const dock = await frame.locator('.change.current .dock').boundingBox(), tools = await page.locator('.review-marking-rail .pdf-rail').boundingBox();
return Boolean(dock && tools) && dock.x >= 0 && dock.x + dock.width <= 390 && dock.y >= 0 && dock.y + dock.height <= tools.y && tools.y - (dock.y + dock.height) <= 24;
};
await expect.poll(dockPlacement).toBe(true);
assert.deepEqual(errors, []);
console.log('PASS: all 12 UI/PDF/review appearance combinations, inline and standalone changes, preview filters, controls, LaTeX colors, per-edit action hover states, pending/decided toolbars at 320/500/700 px, localized two-digit numbers, pointer target sizes, and phone dock positioning.');
} catch (error) {
Expand Down
10 changes: 6 additions & 4 deletions scripts/check-skills.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ const skills = [
{ id: 'proofread', name: 'Proofread', description: 'Fix typos, grammar and punctuation without rewording', hint: 'Which part? Leave empty for the whole paper', source: 'builtin' },
{ id: 'venue', name: 'Venue style', description: 'Follow the venue style guide', hint: '', icon: 'book', source: 'paper' },
];
const task = { id: 'skill-fixture-0001', projectId: 'paper', name: 'p-skill', title: 'Polish: the introduction', instructions: 'x', worktree: '/tmp/paper-tasks/p-skill', threadId: 't', turnId: null, model: null, status: 'reviewing', createdAt: 1, updatedAt: 1, feedbackCursor: 0, error: null,
const task = { id: 'skill-fixture-0001', projectId: 'paper', name: 'p-skill', title: 'Polish: the introduction', instructions: 'x', worktree: '/tmp/paper-tasks/p-skill', threadId: 't', turnId: null, model: null, status: 'reviewing', deferred: true, createdAt: 1, updatedAt: 1, feedbackCursor: 0, error: null,
review: { state: 'done', round: 2, counts: { pending: 0, accepted: 2, rejected: 1, commented: 0 }, unsent: 3, comments: 0, files: ['sections/intro.tex'] } };
const snapshot = { version: 'test', csrf: 'fixture', projects: [{ id: 'paper', name: 'Paper', path: '/tmp/paper', createdAt: 1 }], tasks: [task], requests: [], runtime: { connected: true, authenticated: true, account: null, error: null, models: [] } };
const git = { remote: true, branch: 'main', upstream: 'origin/main', ahead: 0, behind: 0, head: { hash: 'abc1234', subject: 'Draft', date: 1 }, files: [{ path: 'sections/intro.tex', status: 'M', staged: false, lines: [3, 2] }] };
Expand Down Expand Up @@ -51,6 +51,8 @@ await page.route('**/api/**', async route => {
}
if (url.pathname.endsWith('/items')) return json({ items: taskItems.get(url.pathname.split('/')[3]) || items });
if (url.pathname.endsWith('/note-results')) return json({ notes: [] });
// A decided task compiles what applying it writes, ahead of time.
if (url.pathname.endsWith('/predict')) return json({ ok: true });
if (url.pathname.endsWith('/git')) return json(git);
// A finished review writes its commit message as soon as it opens.
if (url.pathname.endsWith('/git/message') || url.pathname.endsWith('/commit-message')) return json({ message: 'Polish the introduction', agent: 'codex' });
Expand Down Expand Up @@ -107,10 +109,10 @@ try {
assert.equal(messages[0].feedback, true);
task.review = { ...task.review, unsent: 0, comments: 0 }; await sync();
await page.getByRole('button', { name: 'Chat', exact: true }).click();
// The accepted change is in the manuscript; committing it is offered, not required.
// The accepted changes are saved when the author chooses: applied or committed.
const commitCard = page.locator('.thread .commit-card');
await expect(commitCard.getByRole('textbox', { name: 'Commit message', exact: true })).toHaveValue('Polish the introduction');
await expect(commitCard.getByRole('button', { name: 'Commit', exact: true })).toBeEnabled();
await expect(commitCard.getByRole('button', { name: 'Apply to workspace', exact: true })).toBeEnabled();
await expect(commitCard.getByRole('textbox', { name: 'Commit message', exact: true })).toBeVisible();
await page.getByRole('button', { name: 'Split', exact: true }).click();

// "/" lists skills in the follow-up composer; the chosen one runs without text.
Expand Down
1 change: 1 addition & 0 deletions scripts/ci-passed.sh
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
set -euo pipefail
[[ "${CHECK_RESULT:-}" == success ]] || exit 1
[[ "${REVIEW_RESULT:-}" == success ]] || exit 1
[[ "${INTERFACE_RESULT:-}" == success ]] || exit 1
[[ "${WINDOWS_RESULT:-}" == success ]] || exit 1
event=${GITHUB_EVENT_NAME:-}
if [[ "$event" == push && "${GITHUB_REF:-}" != refs/heads/main ]]; then event=workflow_call; fi
Expand Down
3 changes: 3 additions & 0 deletions scripts/playwright.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,6 @@
import { expect as playwrightExpect } from '../engine/node_modules/@playwright/test/index.mjs';
export { chromium, webkit, _electron } from '../engine/node_modules/@playwright/test/index.mjs';
export const expect = playwrightExpect.configure({ timeout: 15_000 });
// The review viewer listens for keys from an effect that runs after it shows
// the current change; a key pressed before then is not a decision.
export const afterRender = page => page.evaluate(() => new Promise(resolve => requestAnimationFrame(() => setTimeout(resolve))));
2 changes: 1 addition & 1 deletion src/web/note-option-settings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ export function NoteOptionSettings() {
void (choice === 'off' ? saveNoteAlternatives(false) : saveNoteAlternatives(true, choice));
}}/>
</div>
<div className="effort-scale note-count-scale" aria-hidden="true">{choices.map((choice, step) => <span key={choice.id} className={step === index ? 'active' : undefined} style={step > 0 && step < choices.length - 1 ? { left: centre(step) } : undefined}>{choice.label}</span>)}</div>
<div className="effort-scale note-count-scale" aria-hidden="true">{choices.map((choice, step) => <span key={choice.id} className={step === index ? 'active' : undefined}>{choice.label}</span>)}</div>
<span className="sr-only" id={description}>{help}</span>
</div>
</div>;
Expand Down
Loading
Loading