sync: update from private repo (5b6df2f)
CI / build-and-test (push) Has been cancelled

This commit is contained in:
oss-sync
2026-06-10 08:40:41 +00:00
parent dfc5950117
commit 5502478636
43 changed files with 6452 additions and 18 deletions
+616
View File
@@ -0,0 +1,616 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { mkdtempSync, rmSync, mkdirSync, writeFileSync, readdirSync, existsSync, readFileSync } from 'fs';
import { join } from 'path';
import { tmpdir } from 'os';
import AdmZip from 'adm-zip';
import {
DEFAULT_PET_SETTINGS,
PetConflictError,
PetValidationError,
slugifyPetId,
readPetSettings,
writePetSettings,
listPets,
getPet,
importPetZip,
deletePet,
resolvePetAsset,
} from './pets.js';
import { ensureUserFolder, userRoot } from './paths.js';
// ── Fixtures ──────────────────────────────────────────────────────────────────
let root: string;
const USER = 'test-user';
function petsDir(): string {
return join(userRoot(root, USER), 'pets');
}
function settingsFile(): string {
return join(userRoot(root, USER), 'pet-settings.json');
}
function trashDir(): string {
return join(userRoot(root, USER), 'trash');
}
/** Create a pet directory on disk with a manifest and optional extra files. */
function makePetDir(
petId: string,
manifest: Record<string, unknown>,
files: Record<string, Buffer | string> = {},
): void {
const dir = join(petsDir(), petId);
mkdirSync(dir, { recursive: true });
writeFileSync(join(dir, 'pet.json'), JSON.stringify(manifest));
for (const [name, content] of Object.entries(files)) {
writeFileSync(join(dir, name), content);
}
}
/** Build an in-memory zip from a name → content map. */
function makeZip(files: Record<string, Buffer | string>): Buffer {
const zip = new AdmZip();
for (const [name, content] of Object.entries(files)) {
zip.addFile(name, Buffer.isBuffer(content) ? content : Buffer.from(content));
}
return zip.toBuffer();
}
beforeEach(() => {
root = mkdtempSync(join(tmpdir(), 'pets-test-'));
ensureUserFolder(root, USER);
});
afterEach(() => {
rmSync(root, { recursive: true, force: true });
});
// ── slugifyPetId ──────────────────────────────────────────────────────────────
describe('slugifyPetId', () => {
it('lowercases and replaces invalid characters with hyphens', () => {
expect(slugifyPetId('My Cool Pet!')).toBe('my-cool-pet');
});
it('strips a trailing file extension', () => {
expect(slugifyPetId('Sprite Cat.zip')).toBe('sprite-cat');
});
it('keeps already-valid ids unchanged', () => {
expect(slugifyPetId('cat_01-x')).toBe('cat_01-x');
});
it('trims leading/trailing hyphens produced by replacement', () => {
expect(slugifyPetId(' ---hello--- ')).toBe('hello');
});
it('truncates to 64 characters', () => {
const out = slugifyPetId('a'.repeat(100));
expect(out).toBe('a'.repeat(64));
});
it('falls back to a generated id for empty input', () => {
expect(slugifyPetId('')).toMatch(/^pet-[a-z0-9]+$/);
expect(slugifyPetId(null)).toMatch(/^pet-[a-z0-9]+$/);
expect(slugifyPetId(undefined)).toMatch(/^pet-[a-z0-9]+$/);
});
it('falls back when input reduces to nothing', () => {
expect(slugifyPetId('!!!')).toMatch(/^pet-[a-z0-9]+$/);
});
});
// ── readPetSettings / writePetSettings ────────────────────────────────────────
describe('readPetSettings', () => {
it('returns defaults when the settings file is missing', () => {
const s = readPetSettings(root, USER);
expect(s).toEqual(DEFAULT_PET_SETTINGS);
// and it must be a copy, not the shared default object
expect(s).not.toBe(DEFAULT_PET_SETTINGS);
});
it('reads back persisted settings', () => {
writePetSettings(root, USER, { enabled: false, size: 48, activePetId: 'cat' });
const s = readPetSettings(root, USER);
expect(s.enabled).toBe(false);
expect(s.size).toBe(48);
expect(s.activePetId).toBe('cat');
});
it('quarantines a corrupted settings file to trash and returns defaults', () => {
writeFileSync(settingsFile(), 'not json at all{');
const s = readPetSettings(root, USER);
expect(s).toEqual(DEFAULT_PET_SETTINGS);
expect(existsSync(settingsFile())).toBe(false);
const trashed = readdirSync(trashDir());
expect(trashed.some(f => f.endsWith('-pet-settings.json'))).toBe(true);
});
it('quarantines a settings file with invalid values and returns defaults', () => {
writeFileSync(settingsFile(), JSON.stringify({ size: 999 }));
const s = readPetSettings(root, USER);
expect(s).toEqual(DEFAULT_PET_SETTINGS);
expect(existsSync(settingsFile())).toBe(false);
});
});
describe('writePetSettings', () => {
it('merges a patch over the previous settings', () => {
writePetSettings(root, USER, { enabled: false });
const s = writePetSettings(root, USER, { size: 32 });
expect(s.enabled).toBe(false); // preserved from earlier write
expect(s.size).toBe(32);
expect(s.position).toBe('bottom-right');
});
it('persists valid JSON to disk', () => {
writePetSettings(root, USER, { sound: true });
const onDisk = JSON.parse(readFileSync(settingsFile(), 'utf-8'));
expect(onDisk.sound).toBe(true);
});
it('rejects a non-object patch', () => {
expect(() => writePetSettings(root, USER, null)).toThrow(PetValidationError);
expect(() => writePetSettings(root, USER, [])).toThrow(PetValidationError);
expect(() => writePetSettings(root, USER, 'x')).toThrow(PetValidationError);
});
it('rejects non-boolean values for boolean fields', () => {
expect(() => writePetSettings(root, USER, { enabled: 1 })).toThrow(/enabled must be boolean/);
expect(() => writePetSettings(root, USER, { sound: 'yes' })).toThrow(/sound must be boolean/);
expect(() => writePetSettings(root, USER, { reducedMotion: 0 })).toThrow(/reducedMotion must be boolean/);
expect(() => writePetSettings(root, USER, { toolSparkEnabled: 'on' })).toThrow(/toolSparkEnabled must be boolean/);
});
it('rejects an invalid size', () => {
expect(() => writePetSettings(root, USER, { size: 100 })).toThrow(/size must be one of/);
expect(() => writePetSettings(root, USER, { size: '64' })).toThrow(/size must be one of/);
});
it('accepts each allowed size', () => {
for (const size of [32, 48, 64, 80] as const) {
expect(writePetSettings(root, USER, { size }).size).toBe(size);
}
});
it('rejects any position other than bottom-right', () => {
expect(() => writePetSettings(root, USER, { position: 'top-left' })).toThrow(/position must be bottom-right/);
expect(writePetSettings(root, USER, { position: 'bottom-right' }).position).toBe('bottom-right');
});
it('rejects an invalid activePetId', () => {
expect(() => writePetSettings(root, USER, { activePetId: 'UPPER' })).toThrow(/activePetId is invalid/);
expect(() => writePetSettings(root, USER, { activePetId: 42 })).toThrow(/activePetId must be string or null/);
});
it('normalizes empty-string activePetId to null', () => {
writePetSettings(root, USER, { activePetId: 'cat' });
const s = writePetSettings(root, USER, { activePetId: '' });
expect(s.activePetId).toBeNull();
});
it('accepts null activePetId', () => {
expect(writePetSettings(root, USER, { activePetId: null }).activePetId).toBeNull();
});
it('validates workerPets keys and values', () => {
expect(() => writePetSettings(root, USER, { workerPets: { 'bad key!': 'cat' } }))
.toThrow(/workerPets key is invalid/);
expect(() => writePetSettings(root, USER, { workerPets: { worker1: 'BAD ID' } }))
.toThrow(/must be a valid pet id/);
expect(() => writePetSettings(root, USER, { workerPets: [] }))
.toThrow(/workerPets must be an object/);
expect(() => writePetSettings(root, USER, { workerPets: null }))
.toThrow(/workerPets must be an object/);
});
it('drops workerPets entries with empty or null values (explicit removal)', () => {
const s = writePetSettings(root, USER, {
workerPets: { keep: 'cat', dropEmpty: '', dropNull: null },
});
expect(s.workerPets).toEqual({ keep: 'cat' });
});
it('replaces (not merges) the whole workerPets map', () => {
writePetSettings(root, USER, { workerPets: { a: 'cat' } });
const s = writePetSettings(root, USER, { workerPets: { b: 'dog' } });
expect(s.workerPets).toEqual({ b: 'dog' });
});
it('rejects workerPets with too many entries', () => {
const wp: Record<string, string> = {};
for (let i = 0; i < 65; i++) wp[`worker-${i}`] = 'cat';
expect(() => writePetSettings(root, USER, { workerPets: wp }))
.toThrow(/too many entries/);
});
});
// ── listPets / getPet ─────────────────────────────────────────────────────────
describe('listPets', () => {
it('returns an empty array when there are no pets', () => {
expect(listPets(root, USER)).toEqual([]);
});
it('lists pets sorted by display name and omits the manifest', () => {
makePetDir('zebra', { name: 'Aardvark' });
makePetDir('cat', { name: 'Whiskers' });
const pets = listPets(root, USER);
expect(pets.map(p => p.id)).toEqual(['zebra', 'cat']); // sorted by name, not id
expect(pets.map(p => p.name)).toEqual(['Aardvark', 'Whiskers']);
expect((pets[0] as Record<string, unknown>)['manifest']).toBeUndefined();
});
it('skips directories without a pet.json and ones with invalid ids', () => {
makePetDir('valid', { name: 'Valid' });
mkdirSync(join(petsDir(), 'no-manifest'), { recursive: true });
mkdirSync(join(petsDir(), 'Invalid ID'), { recursive: true });
const pets = listPets(root, USER);
expect(pets.map(p => p.id)).toEqual(['valid']);
});
it('skips plain files in the pets directory', () => {
writeFileSync(join(petsDir(), 'stray.txt'), 'x');
expect(listPets(root, USER)).toEqual([]);
});
});
describe('getPet', () => {
it('returns null for an invalid pet id', () => {
expect(getPet(root, USER, '../escape')).toBeNull();
expect(getPet(root, USER, 'UPPER')).toBeNull();
});
it('returns null for a missing pet', () => {
expect(getPet(root, USER, 'nope')).toBeNull();
});
it('prefers displayName over name over id', () => {
makePetDir('p1', { displayName: 'Display', name: 'Plain' });
makePetDir('p2', { name: 'Plain' });
makePetDir('p3', {});
expect(getPet(root, USER, 'p1')!.name).toBe('Display');
expect(getPet(root, USER, 'p2')!.name).toBe('Plain');
expect(getPet(root, USER, 'p3')!.name).toBe('p3');
});
it('picks sprite and preview files only when they exist on disk', () => {
makePetDir(
'p1',
{ spritesheet: 'sheet.png', preview: 'thumb.png' },
{ 'sheet.png': Buffer.from('png') },
);
const pet = getPet(root, USER, 'p1')!;
expect(pet.spriteFile).toBe('sheet.png');
expect(pet.previewFile).toBeNull(); // thumb.png referenced but absent
});
it('falls back to conventional sprite/preview filenames', () => {
makePetDir('p1', {}, {
'spritesheet.webp': Buffer.from('w'),
'preview.png': Buffer.from('p'),
});
const pet = getPet(root, USER, 'p1')!;
expect(pet.spriteFile).toBe('spritesheet.webp');
expect(pet.previewFile).toBe('preview.png');
});
it('resolves sprite from a nested manifest object with a file key', () => {
makePetDir(
'p1',
{ spritesheet: { file: 'art.png', frameWidth: 32, frameHeight: 32 } },
{ 'art.png': Buffer.from('x') },
);
const pet = getPet(root, USER, 'p1')!;
expect(pet.spriteFile).toBe('art.png');
expect(pet.frameWidth).toBe(32);
expect(pet.frameHeight).toBe(32);
});
it('reads frame dimensions and grid from top-level manifest keys', () => {
makePetDir('p1', { frameWidth: 64, frameHeight: 48, gridCols: 4, gridRows: 2 });
const pet = getPet(root, USER, 'p1')!;
expect(pet.frameWidth).toBe(64);
expect(pet.frameHeight).toBe(48);
expect(pet.gridCols).toBe(4);
expect(pet.gridRows).toBe(2);
});
it('supports snake_case frame keys', () => {
makePetDir('p1', { frame_width: 16, frame_height: 24 });
const pet = getPet(root, USER, 'p1')!;
expect(pet.frameWidth).toBe(16);
expect(pet.frameHeight).toBe(24);
});
it('nulls both frame dimensions when only one is given', () => {
makePetDir('p1', { frameWidth: 64 });
const pet = getPet(root, USER, 'p1')!;
expect(pet.frameWidth).toBeNull();
expect(pet.frameHeight).toBeNull();
});
it('ignores out-of-range or non-integer dimensions', () => {
makePetDir('p1', { frameWidth: 0, frameHeight: 5000, gridCols: 1.5, gridRows: -1 });
const pet = getPet(root, USER, 'p1')!;
expect(pet.frameWidth).toBeNull();
expect(pet.gridCols).toBeNull();
expect(pet.gridRows).toBeNull();
});
it('exposes the full manifest and an ISO updatedAt', () => {
makePetDir('p1', { name: 'X', custom: true });
const pet = getPet(root, USER, 'p1')!;
expect(pet.manifest).toEqual({ name: 'X', custom: true });
expect(() => new Date(pet.updatedAt).toISOString()).not.toThrow();
});
it('throws PetValidationError for a corrupt manifest', () => {
const dir = join(petsDir(), 'bad');
mkdirSync(dir, { recursive: true });
writeFileSync(join(dir, 'pet.json'), '{broken');
expect(() => getPet(root, USER, 'bad')).toThrow(PetValidationError);
});
it('throws PetValidationError for an array manifest', () => {
const dir = join(petsDir(), 'arr');
mkdirSync(dir, { recursive: true });
writeFileSync(join(dir, 'pet.json'), '[1,2]');
expect(() => getPet(root, USER, 'arr')).toThrow(/manifest must be an object/);
});
});
// ── importPetZip ──────────────────────────────────────────────────────────────
describe('importPetZip', () => {
it('imports a root-level pet package', () => {
const zip = makeZip({
'pet.json': JSON.stringify({ name: 'Whiskers', frameWidth: 32, frameHeight: 32 }),
'spritesheet.png': Buffer.from('fakepng'),
});
const detail = importPetZip(root, USER, zip);
expect(detail.id).toBe('whiskers');
expect(detail.name).toBe('Whiskers');
expect(detail.spriteFile).toBe('spritesheet.png');
expect(existsSync(join(petsDir(), 'whiskers', 'spritesheet.png'))).toBe(true);
});
it('strips a single base directory prefix', () => {
const zip = makeZip({
'mypet/pet.json': JSON.stringify({}),
'mypet/spritesheet.png': Buffer.from('x'),
});
const detail = importPetZip(root, USER, zip);
expect(detail.id).toBe('mypet'); // derived from the base dir name
expect(detail.spriteFile).toBe('spritesheet.png');
});
it('uses manifest id over name over base dir for the pet id', () => {
const zip = makeZip({
'pet.json': JSON.stringify({ id: 'Custom-ID', name: 'Other' }),
});
expect(importPetZip(root, USER, zip).id).toBe('custom-id');
});
it('prefers options.preferredId over the manifest', () => {
const zip = makeZip({ 'pet.json': JSON.stringify({ id: 'from-manifest' }) });
const detail = importPetZip(root, USER, zip, { preferredId: 'Forced Name' });
expect(detail.id).toBe('forced-name');
});
it('throws PetConflictError on duplicate import without overwrite', () => {
const zip = makeZip({ 'pet.json': JSON.stringify({ id: 'dup' }) });
importPetZip(root, USER, zip);
expect(() => importPetZip(root, USER, zip)).toThrow(PetConflictError);
try {
importPetZip(root, USER, zip);
} catch (err) {
expect((err as PetConflictError).petId).toBe('dup');
}
});
it('overwrites and trashes the previous version with overwrite: true', () => {
importPetZip(root, USER, makeZip({
'pet.json': JSON.stringify({ id: 'dup', name: 'Old' }),
}));
const detail = importPetZip(root, USER, makeZip({
'pet.json': JSON.stringify({ id: 'dup', name: 'New' }),
}), { overwrite: true });
expect(detail.name).toBe('New');
const trashed = readdirSync(trashDir());
expect(trashed.some(f => f.endsWith('-dup-pet'))).toBe(true);
});
it('rejects an empty body and an oversized body without parsing', () => {
expect(() => importPetZip(root, USER, Buffer.alloc(0))).toThrow(/zip body is empty/);
expect(() => importPetZip(root, USER, Buffer.alloc(12 * 1024 * 1024 + 1)))
.toThrow(/zip body is too large/);
});
it('rejects a zip without entries', () => {
expect(() => importPetZip(root, USER, new AdmZip().toBuffer())).toThrow(/zip is empty/);
});
it('requires pet.json', () => {
const zip = makeZip({ 'spritesheet.png': Buffer.from('x') });
expect(() => importPetZip(root, USER, zip)).toThrow(/pet\.json is required/);
});
it('rejects path traversal entry names', () => {
// adm-zip's addFile canonicalizes '../', so byte-patch a same-length
// placeholder to get a genuinely hostile entry name into the archive.
const zip = makeZip({
'pet.json': JSON.stringify({}),
'AA/evil.json': '{}',
});
const patched = Buffer.from(
zip.toString('latin1').split('AA/evil.json').join('../evil.json'),
'latin1',
);
expect(() => importPetZip(root, USER, patched)).toThrow(/unsafe path/);
});
it('rejects dotfile entries', () => {
const zip = makeZip({
'pet.json': JSON.stringify({}),
'.hidden.json': '{}',
});
expect(() => importPetZip(root, USER, zip)).toThrow(/unsafe path/);
});
it('rejects unsupported file types', () => {
const zip = makeZip({
'pet.json': JSON.stringify({}),
'run.sh': 'echo pwned',
});
expect(() => importPetZip(root, USER, zip)).toThrow(/unsupported file type/);
});
it('rejects nested files below the package root', () => {
const zip = makeZip({
'pet.json': JSON.stringify({}),
'sub/inner.png': Buffer.from('x'),
});
expect(() => importPetZip(root, USER, zip)).toThrow(/nested files are not supported/);
});
it('rejects symlink entries', () => {
const zip = new AdmZip();
zip.addFile('pet.json', Buffer.from('{}'));
zip.addFile('link.json', Buffer.from('/etc/passwd'));
// addFile's attr argument is not preserved through toBuffer; set the unix
// mode (high 16 bits, 0o120000 = symlink) directly on the entry header.
const link = zip.getEntry('link.json');
if (!link) throw new Error('fixture entry missing');
link.header.attr = (0o120777 << 16) >>> 0;
expect(() => importPetZip(root, USER, zip.toBuffer())).toThrow(/symlinks are not allowed/);
});
it('rejects too many files', () => {
const files: Record<string, string> = { 'pet.json': '{}' };
for (let i = 0; i < 32; i++) files[`extra-${i}.json`] = '{}'; // 33 total
expect(() => importPetZip(root, USER, makeZip(files))).toThrow(/too many files/);
});
it('rejects a single file over 5 MB uncompressed', () => {
const zip = makeZip({
'pet.json': JSON.stringify({}),
'big.png': Buffer.alloc(5 * 1024 * 1024 + 1),
});
expect(() => importPetZip(root, USER, zip)).toThrow(/file is too large/);
});
it('rejects total uncompressed size over 10 MB', () => {
const fourMb = 4 * 1024 * 1024;
const zip = makeZip({
'pet.json': JSON.stringify({}),
'a.png': Buffer.alloc(fourMb),
'b.png': Buffer.alloc(fourMb),
'c.png': Buffer.alloc(fourMb),
});
expect(() => importPetZip(root, USER, zip)).toThrow(/uncompressed size is too large/);
});
it('rejects a non-object manifest', () => {
const zip = makeZip({ 'pet.json': '[1,2,3]' });
expect(() => importPetZip(root, USER, zip)).toThrow(/manifest must be an object/);
});
it('cleans up the staging tmp dir on failure', () => {
const zip = makeZip({
'pet.json': JSON.stringify({}),
'run.sh': 'x',
});
expect(() => importPetZip(root, USER, zip)).toThrow(PetValidationError);
const leftovers = readdirSync(petsDir()).filter(n => n.startsWith('.tmp-'));
expect(leftovers).toEqual([]);
});
});
// ── deletePet ─────────────────────────────────────────────────────────────────
describe('deletePet', () => {
it('returns false for an invalid or missing pet', () => {
expect(deletePet(root, USER, '../escape')).toBe(false);
expect(deletePet(root, USER, 'nope')).toBe(false);
});
it('moves the pet directory to trash and returns true', () => {
makePetDir('cat', { name: 'Cat' });
expect(deletePet(root, USER, 'cat')).toBe(true);
expect(existsSync(join(petsDir(), 'cat'))).toBe(false);
const trashed = readdirSync(trashDir());
expect(trashed.some(f => f.endsWith('-cat-pet'))).toBe(true);
});
it('clears activePetId when the active pet is deleted', () => {
makePetDir('cat', {});
writePetSettings(root, USER, { activePetId: 'cat' });
deletePet(root, USER, 'cat');
expect(readPetSettings(root, USER).activePetId).toBeNull();
});
it('removes workerPets mappings pointing at the deleted pet, keeps others', () => {
makePetDir('cat', {});
makePetDir('dog', {});
writePetSettings(root, USER, { workerPets: { w1: 'cat', w2: 'dog' } });
deletePet(root, USER, 'cat');
expect(readPetSettings(root, USER).workerPets).toEqual({ w2: 'dog' });
});
it('leaves settings untouched when the pet was not referenced', () => {
makePetDir('cat', {});
makePetDir('dog', {});
writePetSettings(root, USER, { activePetId: 'dog', workerPets: { w1: 'dog' } });
deletePet(root, USER, 'cat');
const s = readPetSettings(root, USER);
expect(s.activePetId).toBe('dog');
expect(s.workerPets).toEqual({ w1: 'dog' });
});
});
// ── resolvePetAsset ───────────────────────────────────────────────────────────
describe('resolvePetAsset', () => {
beforeEach(() => {
makePetDir('cat', { name: 'Cat' }, {
'spritesheet.png': Buffer.from('png'),
'preview.webp': Buffer.from('webp'),
});
});
it('resolves existing files with the right content type', () => {
const png = resolvePetAsset(root, USER, 'cat', 'spritesheet.png');
expect(png).not.toBeNull();
expect(png!.contentType).toBe('image/png');
expect(png!.path).toBe(join(petsDir(), 'cat', 'spritesheet.png'));
expect(resolvePetAsset(root, USER, 'cat', 'preview.webp')!.contentType).toBe('image/webp');
expect(resolvePetAsset(root, USER, 'cat', 'pet.json')!.contentType)
.toBe('application/json; charset=utf-8');
});
it('returns null for an invalid pet id', () => {
expect(resolvePetAsset(root, USER, 'BAD ID', 'pet.json')).toBeNull();
});
it('returns null for unsafe file names', () => {
expect(resolvePetAsset(root, USER, 'cat', '')).toBeNull();
expect(resolvePetAsset(root, USER, 'cat', 'a/b.png')).toBeNull();
expect(resolvePetAsset(root, USER, 'cat', 'a\\b.png')).toBeNull();
expect(resolvePetAsset(root, USER, 'cat', '.hidden.png')).toBeNull();
expect(resolvePetAsset(root, USER, 'cat', '..')).toBeNull();
});
it('returns null for disallowed extensions', () => {
expect(resolvePetAsset(root, USER, 'cat', 'script.js')).toBeNull();
expect(resolvePetAsset(root, USER, 'cat', 'noext')).toBeNull();
});
it('returns null when the file does not exist', () => {
expect(resolvePetAsset(root, USER, 'cat', 'missing.png')).toBeNull();
});
});
+158
View File
@@ -0,0 +1,158 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { mkdtempSync, rmSync, mkdirSync, writeFileSync } from 'fs';
import { join } from 'path';
import { tmpdir } from 'os';
import type { BrowserSessionRepo } from '../db/browser-session-repo.js';
vi.mock('./script-runner.js', async (importOriginal) => {
const actual = await importOriginal<typeof import('./script-runner.js')>();
return { ...actual, runUserScript: vi.fn() };
});
vi.mock('./session-loader.js', () => ({ loadSessionStateForUser: vi.fn() }));
import { runUserScript } from './script-runner.js';
import { loadSessionStateForUser } from './session-loader.js';
import { resolveScriptForKind, resolveAndRunUserScript } from './script-orchestrator.js';
const USER = 'user-1';
let root: string;
function addMacro(name: string, source = 'export default async () => 1;\n'): string {
const dir = join(root, USER, 'browser-macros');
mkdirSync(dir, { recursive: true });
const p = join(dir, name);
writeFileSync(p, source);
return p;
}
const MACRO_WITH_SESSION = `---
description: needs a session
session_profile_id: 7
---
export default async () => 'macro';
`;
beforeEach(() => {
root = mkdtempSync(join(tmpdir(), 'script-orch-test-'));
vi.clearAllMocks();
vi.mocked(runUserScript).mockResolvedValue({ result: 'ran', logs: ['l1'], durationMs: 12 } as never);
});
afterEach(() => {
rmSync(root, { recursive: true, force: true });
});
describe('resolveScriptForKind', () => {
it('resolves a browser-macro to its path with the playwright runtime', () => {
const p = addMacro('macro.js');
const r = resolveScriptForKind(root, USER, 'macro.js', 'browser-macro');
expect(r).toEqual({ scriptPath: p, subdir: 'browser-macros', runtime: 'playwright' });
});
it('resolves without an explicit kind', () => {
const p = addMacro('macro.js');
const r = resolveScriptForKind(root, USER, 'macro.js', undefined);
expect(r).toMatchObject({ scriptPath: p, subdir: 'browser-macros' });
});
it('returns an error for a missing macro', () => {
const r = resolveScriptForKind(root, USER, 'ghost.js', undefined);
expect(r).toEqual({ error: 'browser-macro not found: browser-macros/ghost.js' });
});
it('treats traversal names as not found instead of escaping', () => {
const r = resolveScriptForKind(root, USER, '../../etc/passwd', undefined);
expect('error' in r).toBe(true);
});
});
describe('resolveAndRunUserScript', () => {
it('runs a macro and appends .js to the name', async () => {
const p = addMacro('task.js');
const r = await resolveAndRunUserScript({ rootDir: root, userId: USER, name: 'task', params: { a: 1 } });
expect(r).toMatchObject({
ok: true, result: 'ran', logs: ['l1'], subdir: 'browser-macros', runtime: 'playwright',
});
expect(runUserScript).toHaveBeenCalledWith(
expect.objectContaining({
scriptPath: p, params: { a: 1 }, runtime: 'playwright', timeoutMs: 60_000,
}),
);
expect(loadSessionStateForUser).not.toHaveBeenCalled();
});
it('returns an error result for an unknown macro', async () => {
const r = await resolveAndRunUserScript({ rootDir: root, userId: USER, name: 'ghost', params: {} });
expect(r.ok).toBe(false);
if (!r.ok) expect(r.error).toContain('not found');
expect(runUserScript).not.toHaveBeenCalled();
});
it('runs a macro without a session profile directly (no storageState)', async () => {
addMacro('macro.js');
const r = await resolveAndRunUserScript({
rootDir: root, userId: USER, name: 'macro', params: {}, headless: false,
});
expect(r).toMatchObject({ ok: true, subdir: 'browser-macros', runtime: 'playwright' });
expect(runUserScript).toHaveBeenCalledWith(
expect.objectContaining({ storageState: undefined, headless: false }),
);
});
it('fails when a macro declares a session but no repo is configured', async () => {
addMacro('macro.js', MACRO_WITH_SESSION);
const r = await resolveAndRunUserScript({ rootDir: root, userId: USER, name: 'macro', params: {} });
expect(r.ok).toBe(false);
if (!r.ok) expect(r.error).toContain('BrowserSessionRepo not configured');
expect(runUserScript).not.toHaveBeenCalled();
});
it('propagates session load failures', async () => {
addMacro('macro.js', MACRO_WITH_SESSION);
vi.mocked(loadSessionStateForUser).mockResolvedValue({
ok: false,
error: { kind: 'profile_not_found', message: 'profile 7 not found' },
} as never);
const r = await resolveAndRunUserScript({
rootDir: root, userId: USER, name: 'macro', params: {},
sessRepo: {} as BrowserSessionRepo, masterKeyPath: '/k',
});
expect(r.ok).toBe(false);
if (!r.ok) expect(r.error).toBe('profile 7 not found');
});
it('hydrates storageState and passes it to the runner', async () => {
addMacro('macro.js', MACRO_WITH_SESSION);
const state = { cookies: [] };
vi.mocked(loadSessionStateForUser).mockResolvedValue({ ok: true, storageState: state } as never);
const r = await resolveAndRunUserScript({
rootDir: root, userId: USER, name: 'macro', params: {},
sessRepo: {} as BrowserSessionRepo, masterKeyPath: '/k', timeoutMs: 5000,
});
expect(r.ok).toBe(true);
expect(loadSessionStateForUser).toHaveBeenCalledWith(
{ sessRepo: {}, masterKeyPath: '/k' }, USER, 7,
);
expect(runUserScript).toHaveBeenCalledWith(
expect.objectContaining({ storageState: state, timeoutMs: 5000 }),
);
});
it('reports a frontmatter parse failure', async () => {
addMacro('macro.js', '---\nsession_profile_id: -2\n---\nbody');
const r = await resolveAndRunUserScript({ rootDir: root, userId: USER, name: 'macro', params: {} });
expect(r.ok).toBe(false);
if (!r.ok) expect(r.error).toContain('failed to parse script frontmatter');
});
it('never throws when the runner fails — returns ok:false with duration', async () => {
addMacro('boom.js');
vi.mocked(runUserScript).mockRejectedValue(new Error('script exploded'));
const r = await resolveAndRunUserScript({ rootDir: root, userId: USER, name: 'boom', params: {} });
expect(r.ok).toBe(false);
if (!r.ok) {
expect(r.error).toBe('script exploded');
expect(typeof r.durationMs).toBe('number');
}
});
});
+110
View File
@@ -0,0 +1,110 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { mkdtempSync, rmSync } from 'fs';
import { join } from 'path';
import { tmpdir } from 'os';
import type { BrowserSessionRepo } from '../db/browser-session-repo.js';
import {
initMasterKey,
generateUserDek,
encryptUserDek,
encryptStateBlob,
} from '../crypto/sessions.js';
import { loadSessionStateForUser } from './session-loader.js';
const OWNER = 'user-1';
const STATE = { cookies: [{ name: 'sid', value: 'abc' }], origins: [] };
let dir: string;
let masterKeyPath: string;
beforeEach(() => {
dir = mkdtempSync(join(tmpdir(), 'session-loader-test-'));
masterKeyPath = join(dir, 'master.key');
});
afterEach(() => {
rmSync(dir, { recursive: true, force: true });
});
/** Build a stub repo + a valid encrypted blob for OWNER. */
function makeFixture(overrides: {
profile?: Record<string, unknown> | null;
encDek?: Buffer | null;
} = {}) {
const master = initMasterKey(masterKeyPath);
const dek = generateUserDek();
const encDek = overrides.encDek !== undefined ? overrides.encDek : encryptUserDek(master, dek);
const blob = encryptStateBlob(dek, JSON.stringify(STATE));
const profile =
overrides.profile !== undefined
? overrides.profile
: { id: 7, ownerId: OWNER, status: 'active', encryptedStateBlob: blob };
const sessRepo = {
getProfileById: (id: number, ownerId: string) =>
profile && id === 7 && ownerId === OWNER ? profile : null,
getUserDek: (userId: string) => (userId === OWNER ? encDek : null),
} as unknown as BrowserSessionRepo;
return { sessRepo, blob };
}
describe('loadSessionStateForUser', () => {
it('decrypts and parses the storageState for an active profile', async () => {
const { sessRepo } = makeFixture();
const res = await loadSessionStateForUser({ sessRepo, masterKeyPath }, OWNER, 7);
expect(res).toEqual({ ok: true, storageState: STATE });
});
it('reports profile_not_found for an unknown id', async () => {
const { sessRepo } = makeFixture();
const res = await loadSessionStateForUser({ sessRepo, masterKeyPath }, OWNER, 999);
expect(res.ok).toBe(false);
if (!res.ok) expect(res.error.kind).toBe('profile_not_found');
});
it('reports profile_not_found when the profile belongs to another user', async () => {
const { sessRepo } = makeFixture();
const res = await loadSessionStateForUser({ sessRepo, masterKeyPath }, 'other-user', 7);
expect(res.ok).toBe(false);
if (!res.ok) expect(res.error.kind).toBe('profile_not_found');
});
it('reports profile_not_active for a non-active status', async () => {
const base = makeFixture();
const { sessRepo } = makeFixture({
profile: { id: 7, ownerId: OWNER, status: 'pending', encryptedStateBlob: base.blob },
});
const res = await loadSessionStateForUser({ sessRepo, masterKeyPath }, OWNER, 7);
expect(res.ok).toBe(false);
if (!res.ok) {
expect(res.error.kind).toBe('profile_not_active');
expect(res.error.message).toContain('status=pending');
}
});
it('reports profile_not_active when the blob is missing', async () => {
const { sessRepo } = makeFixture({
profile: { id: 7, ownerId: OWNER, status: 'active', encryptedStateBlob: null },
});
const res = await loadSessionStateForUser({ sessRepo, masterKeyPath }, OWNER, 7);
expect(res.ok).toBe(false);
if (!res.ok) expect(res.error.kind).toBe('profile_not_active');
});
it('reports dek_not_found when the user has no stored DEK', async () => {
const { sessRepo } = makeFixture({ encDek: null });
const res = await loadSessionStateForUser({ sessRepo, masterKeyPath }, OWNER, 7);
expect(res.ok).toBe(false);
if (!res.ok) expect(res.error.kind).toBe('dek_not_found');
});
it('reports decrypt_error for a corrupted blob and never throws', async () => {
const { sessRepo } = makeFixture({
profile: { id: 7, ownerId: OWNER, status: 'active', encryptedStateBlob: Buffer.from('garbage') },
});
const res = await loadSessionStateForUser({ sessRepo, masterKeyPath }, OWNER, 7);
expect(res.ok).toBe(false);
if (!res.ok) expect(res.error.kind).toBe('decrypt_error');
});
});