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
5 changes: 3 additions & 2 deletions .github/workflows/public-sdk.yml
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,8 @@ jobs:
cache: npm
- run: npm ci
- run: npm audit --omit=dev --audit-level=high
- run: node --test test/cloud-lite.test.mjs test/cloud-lite-provider.test.mjs test/model-provider.test.mjs test/selfhost-modal.test.mjs test/public-sdk.test.mjs test/template-catalog.test.mjs test/factory-team-template.test.mjs test/flujo-workspace.test.mjs test/savia-case-plan.test.mjs test/savia-topology.test.mjs test/managed-fleet.test.mjs test/managed-fleet-real-module.test.mjs test/managed-fleet-provider-closure.test.mjs test/provider-cell-retirement.test.mjs test/presentation.test.mjs test/presentation-modal.test.mjs test/presentation-closure.test.mjs test/observatory-client.test.mjs test/workflows.test.mjs test/workflow-native-wake.test.mjs test/workflow-m0-lifecycle.test.mjs test/workflow-m1-portable-artifacts.test.mjs test/workflow-m2-ack-recovery.test.mjs test/workflow-m2-authority-recovery.test.mjs
- name: Focused tests, leaving nothing in the temp location
run: node scripts/test-temp-guard.mjs test/cloud-lite.test.mjs test/cloud-lite-provider.test.mjs test/model-provider.test.mjs test/selfhost-modal.test.mjs test/public-sdk.test.mjs test/template-catalog.test.mjs test/factory-team-template.test.mjs test/flujo-workspace.test.mjs test/savia-case-plan.test.mjs test/savia-topology.test.mjs test/managed-fleet.test.mjs test/managed-fleet-real-module.test.mjs test/managed-fleet-provider-closure.test.mjs test/provider-cell-retirement.test.mjs test/presentation.test.mjs test/presentation-modal.test.mjs test/presentation-closure.test.mjs test/observatory-client.test.mjs test/workflows.test.mjs test/workflow-native-wake.test.mjs test/workflow-m0-lifecycle.test.mjs test/workflow-m1-portable-artifacts.test.mjs test/workflow-m2-ack-recovery.test.mjs test/workflow-m2-authority-recovery.test.mjs
- name: Self-hosted model settings (Python, offline)
run: python3 -B -m unittest discover -s selfhost/modal -p "test_*.py"
- name: Install and exercise the packed public package
Expand All @@ -39,4 +40,4 @@ jobs:
node "$RUNNER_TEMP/consumer/node_modules/flujo-factory/bin/factory-selfhost.mjs" profiles
FACTORY_TEST_MCP="$RUNNER_TEMP/consumer/node_modules/flujo-factory/bin/factory-mcp.mjs" node --test --test-name-pattern="public MCP stdio" test/public-sdk.test.mjs
- name: Generic workflow and evidence recovery regressions
run: node --test test/generic-swarm-evidence.test.mjs
run: node scripts/test-temp-guard.mjs test/generic-swarm-evidence.test.mjs
21 changes: 21 additions & 0 deletions scripts/test-temp-guard.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
#!/usr/bin/env node
// Runs `node --test <files>` with the temp location pointed at a private empty
// folder, then fails if anything is left in it. Tests that leave folders behind
// would otherwise fill a developer's temp directory run after run.
import { mkdtempSync, readdirSync, rmSync } from 'node:fs';
import { spawnSync } from 'node:child_process';
import { tmpdir } from 'node:os';
import { join } from 'node:path';

const files = process.argv.slice(2);
if (files.length === 0) throw new Error('usage: test-temp-guard.mjs <test files...>');
const root = mkdtempSync(join(tmpdir(), 'factory-temp-guard-'));
const env = { ...process.env, TEMP: root, TMP: root, TMPDIR: root };
delete env.FACTORY_TEST_TMP;
const result = spawnSync(process.execPath, ['--test', ...files], { stdio: 'inherit', env });
if (result.error) throw result.error;
const left = readdirSync(root).sort();
console.log(`temp guard: ${left.length} item(s) left in the test temp location`);
for (const name of left.slice(0, 50)) console.log(` ${name}`);
rmSync(root, { recursive: true, force: true, maxRetries: 10, retryDelay: 200 });
process.exitCode = result.status || (left.length ? 1 : 0);
3 changes: 2 additions & 1 deletion test/cloud-lite-provider.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,9 @@ import { validateCloudLitePlan, secret } from '../src/cloud-lite/protocol.mjs';
import { cloudLiteBootstrap } from '../src/cloud-lite/bootstrap.mjs';
import { FactoryCloudLiteFleet } from '../src/cloud-lite/fleet.mjs';
import { codexEnvironment, isStreamingDelta, nativeAgentConfig, providerConfigToml, runPrepareHook } from '../src/cloud-lite/runtime.mjs';
import { tempDir } from './support/temp-dir.mjs';

const fresh = () => mkdtempSync(join(tmpdir(), 'factory-cloud-lite-provider-'));
const fresh = () => tempDir('factory-cloud-lite-provider-');
// A self-hosted model with its real context window and an earlier compaction point.
const provider = () => ({ id: 'selfhosted', name: 'Self-hosted model', envKey: 'SELF_HOSTED_MODEL_KEY', model: 'served-model',
baseUrls: ['https://model-a.example.test/v1', 'https://model-b.example.test/v1'], contextWindow: 262144, autoCompactTokenLimit: 80000 });
Expand Down
3 changes: 2 additions & 1 deletion test/cloud-lite.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,10 @@ import { NativeOperatorStop } from '../src/cloud-lite/operator-stop.mjs';
import { collectCloudLiteSnapshot } from '../src/cloud-lite/collect.mjs';
import { finalizeNativeStopCommand, isCurrentNativeTurn, nativeStopTurn } from '../src/cloud-lite/runtime.mjs';
import { DatabaseSync } from 'node:sqlite';
import { tempDir } from './support/temp-dir.mjs';

const plan = () => validateCloudLitePlan({ runId: 'lite-test', app: 'factory-lite-test', repository: 'mario-andreschak/FLUJO', goal: 'Analyze and merge existing PRs', machines: 10, agentsPerMachine: 10 });
const fresh = () => mkdtempSync(join(tmpdir(), 'factory-cloud-lite-'));
const fresh = () => tempDir('factory-cloud-lite-');
test('requested 10 by 10 topology and Fly resource selection retain exact values', () => {
const input = plan(); const agents = agentInventory(input);
assert.equal(agents.length, 100); assert.equal(agents.filter(x => x.role === 'orchestrator').length, 10);
Expand Down
3 changes: 2 additions & 1 deletion test/generic-swarm-evidence.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,10 @@ import { runVerification } from '../src/workflows/verifier.mjs';
import { ContinuousEvidenceCollector, createEvidenceClient, startEvidenceServer } from '../src/evidence.mjs';
import { FactoryCloudLiteFleet } from '../src/cloud-lite/fleet.mjs';
import { validateCloudLitePlan } from '../src/cloud-lite/protocol.mjs';
import { tempDir } from './support/temp-dir.mjs';

// The invoking release harness owns cleanup through FLUJO Bash MCP; retain failure evidence.
function fixture(){const parent=process.env.FACTORY_TEST_TMP??tmpdir();mkdirSync(parent,{recursive:true});const root=mkdtempSync(join(parent,'factory-generic-'));const source=join(root,'source');mkdirSync(source);return{root,source};}
function fixture(){const root=tempDir('factory-generic-');const source=join(root,'source');mkdirSync(source);return{root,source};}
function workflowFixture(){
const f=fixture(),database=join(f.root,'control.sqlite'),control=new FactoryControl(database);
control.initializeSwarm({mission:'Research a question',budgetCents:0,maxCells:6,maxDepth:2},[
Expand Down
3 changes: 2 additions & 1 deletion test/model-provider.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { fileURLToPath } from 'node:url';
import { claudeProviderEnvironment, codexProviderConfig, codexProviderOverrides, liteWorkerCommand, providerPrograms, validateModelProvider } from '../src/public-sdk.mjs';
import { validateCloudLitePlan, secret } from '../src/cloud-lite/protocol.mjs';
import { providerConfigToml } from '../src/cloud-lite/runtime.mjs';
import { tempDir } from './support/temp-dir.mjs';

// One description of a self-hosted vLLM endpoint that serves every wire API.
const endpoint = (extra = {}) => ({ id: 'selfhosted', name: 'Self-hosted model', model: 'served-model', envKey: 'SELF_HOSTED_MODEL_KEY',
Expand Down Expand Up @@ -74,7 +75,7 @@ test('local lite workers launch Codex and Claude Code against a self-hosted mode
});

test('factory-lite reads an endpoint description and refuses to start without its key', () => {
const dir = mkdtempSync(join(tmpdir(), 'factory-lite-provider-'));
const dir = tempDir('factory-lite-provider-');
const file = join(dir, 'endpoint.json'); writeFileSync(file, JSON.stringify(endpoint()));
const cli = fileURLToPath(new URL('../bin/factory-lite.mjs', import.meta.url));
const env = { ...process.env }; delete env.SELF_HOSTED_MODEL_KEY;
Expand Down
3 changes: 2 additions & 1 deletion test/selfhost-modal.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,9 @@ import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { ModalSelfHostedModel, selfHostedProfiles, codexProviderConfig, claudeProviderEnvironment, validateCloudLitePlan } from '../src/public-sdk.mjs';
import { tempDir } from './support/temp-dir.mjs';

const fresh = () => mkdtempSync(join(tmpdir(), 'factory-selfhost-'));
const fresh = () => tempDir('factory-selfhost-');
const read = (path) => readFileSync(path, 'utf8');

// A fake Modal account: the deploy script, the Modal CLI and the served model.
Expand Down
22 changes: 22 additions & 0 deletions test/support/temp-dir.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { mkdirSync, mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join, resolve } from 'node:path';

// Every test file runs in its own process, so that process owns its temporary
// folders and removes them when it exits, whichever way its tests ended.
const owned = new Set();
process.once('exit', () => {
// Windows can hold a file briefly after a child process or database closes.
for (const directory of owned) {
try { rmSync(directory, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 }); } catch { /* The temp guard reports leftovers. */ }
}
});

/** A new temporary folder named `<prefix>XXXXXX`, removed when this test process exits. */
export function tempDir(prefix) {
const parent = resolve(process.env.FACTORY_TEST_TMP ?? tmpdir());
mkdirSync(parent, { recursive: true });
const directory = mkdtempSync(join(parent, prefix));
owned.add(directory);
return directory;
}
5 changes: 2 additions & 3 deletions test/workflow-m0-lifecycle.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,12 @@ import { fileURLToPath } from 'node:url';
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
import { Factory, FactoryWorkflow, captureGitSource, contentDigest, runVerification } from '../src/public-sdk.mjs';
import { tempDir } from './support/temp-dir.mjs';

const mcpServer = fileURLToPath(new URL('../bin/factory-mcp.mjs', import.meta.url));

function fixture({ ignoredGateInput = false } = {}) {
const parent = resolve(process.env.FACTORY_TEST_TMP ?? tmpdir());
mkdirSync(parent, { recursive: true });
const root = mkdtempSync(join(parent, 'factory-workflow-m0-'));
const root = tempDir('factory-workflow-m0-');
const cwd = join(root, 'source'); mkdirSync(cwd);
const git = (...args) => execFileSync('git', args, { cwd, encoding: 'utf8', windowsHide: true }).trim();
git('init'); git('config', 'user.email', 'workflow-test@example.invalid'); git('config', 'user.name', 'Workflow test');
Expand Down
23 changes: 12 additions & 11 deletions test/workflow-m1-portable-artifacts.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,12 @@ import { createHash } from 'node:crypto';
import { DatabaseSync } from 'node:sqlite';
import { collectCloudLiteSnapshot } from '../src/cloud-lite/collect.mjs';
import { Factory, FactoryArtifactStore, FactoryWorkflow, captureGitSource, interpretVerification, reconcileVerification, runVerification, startVerification } from '../src/public-sdk.mjs';
import { tempDir } from './support/temp-dir.mjs';

const sha256 = bytes => createHash('sha256').update(bytes).digest('hex');

test('SQLite snapshot backup supports deep workspace paths', async () => {
const root = mkdtempSync(join(tmpdir(), 'factory-deep-sqlite-'));
const root = tempDir('factory-deep-sqlite-');
const database = new DatabaseSync(toNamespacedPath(join(root, 'state.sqlite')));
database.exec("CREATE TABLE evidence(value TEXT); INSERT INTO evidence VALUES ('deep-workspace')");
database.close();
Expand All @@ -28,7 +29,7 @@ test('SQLite snapshot backup supports deep workspace paths', async () => {
});

test('portable snapshot preserves and verifies real evidence while excluding private paths', async () => {
const root = mkdtempSync(join(tmpdir(), 'factory-workflow-m1-portable-'));
const root = tempDir('factory-workflow-m1-portable-');
mkdirSync(join(root, 'logs'), { recursive: true });
mkdirSync(join(root, 'cache', '.npm'), { recursive: true });
mkdirSync(join(root, 'safe-dir'), { recursive: true });
Expand Down Expand Up @@ -80,8 +81,8 @@ test('portable snapshot preserves and verifies real evidence while excluding pri
});

test('portable snapshot retains internal directory links and omits links escaping its root', async t => {
const root = mkdtempSync(join(tmpdir(), 'factory-workflow-m1-links-'));
const external = mkdtempSync(join(tmpdir(), 'factory-workflow-m1-outside-'));
const root = tempDir('factory-workflow-m1-links-');
const external = tempDir('factory-workflow-m1-outside-');
mkdirSync(join(root, 'safe-dir'));
mkdirSync(join(external, 'outside-dir'));
writeFileSync(join(root, 'safe-dir', 'inside.txt'), 'inside root');
Expand Down Expand Up @@ -128,7 +129,7 @@ test('portable snapshot retains internal directory links and omits links escapin
});

test('public FactoryArtifactStore preserves content identity across relocation and rejects corruption and invalid ranges', t => {
const root = mkdtempSync(join(tmpdir(), 'factory-workflow-m1-store-'));
const root = tempDir('factory-workflow-m1-store-');
const directory = join(root, 'store');
const moved = join(root, 'relocated-store');
const store = new FactoryArtifactStore(directory);
Expand Down Expand Up @@ -208,7 +209,7 @@ function verifiedFixture(root, { failingGate = false, environmentCanary = false,
}

test('candidate submission carries the captured archive and verifier binds execution to it', async t => {
const root = mkdtempSync(join(tmpdir(), 'factory-workflow-m1-source-'));
const root = tempDir('factory-workflow-m1-source-');
const f = verifiedFixture(root);
t.after(() => f.workflow.close());
assert.equal(f.candidate.source.commit, f.source.commit);
Expand Down Expand Up @@ -236,15 +237,15 @@ test('node-tap adapter rejects an exit-zero run with only skipped tests', () =>
});

test('verification refuses a submitted archive that differs from the gate source', t => {
const root = mkdtempSync(join(tmpdir(), 'factory-workflow-m1-mismatch-'));
const root = tempDir('factory-workflow-m1-mismatch-');
const f = verifiedFixture(root, { mismatchArchive: true });
t.after(() => f.workflow.close());
assert.throws(() => startVerification(f.workflow, { runId: 'm1-source-mismatch', candidateId: f.candidate.id,
gateId: f.gate.id, cwd: f.cwd }), /Portable source snapshot does not match/);
});

test('ignored host overlay absent from captured archive cannot make a gate pass', async t => {
const root = mkdtempSync(join(tmpdir(), 'factory-workflow-m1-ignored-'));
const root = tempDir('factory-workflow-m1-ignored-');
const f = verifiedFixture(root, { ignoredGate: true });
t.after(() => f.workflow.close());
writeFileSync(join(f.cwd, '.git', 'info', 'exclude'), 'check.mjs\n');
Expand All @@ -258,7 +259,7 @@ test('ignored host overlay absent from captured archive cannot make a gate pass'
});

test('empty gate sets require an explicit review-only policy', t => {
const root = mkdtempSync(join(tmpdir(), 'factory-workflow-m1-review-only-'));
const root = tempDir('factory-workflow-m1-review-only-');
const f = verifiedFixture(root);
t.after(() => f.workflow.close());
const input = { taskId: 'review-only-m1', projectId: 'project-m1', branch: 'codex/review-only', problem: 'Review evidence',
Expand All @@ -271,7 +272,7 @@ test('runner drops inherited canary environment and diagnostics retain no secret
const secret = Buffer.from('synthetic bootstrap secret').toString('base64');
const prior = process.env.FACTORY_M1_CANARY;
process.env.FACTORY_M1_CANARY = secret;
const root = mkdtempSync(join(tmpdir(), 'factory-workflow-m1-env-'));
const root = tempDir('factory-workflow-m1-env-');
const f = verifiedFixture(root, { environmentCanary: true });
t.after(() => { f.workflow.close(); if (prior === undefined) delete process.env.FACTORY_M1_CANARY; else process.env.FACTORY_M1_CANARY = prior; });
const result = await runVerification(f.workflow, { runId: 'm1-env-isolation', candidateId: f.candidate.id, gateId: f.gate.id, cwd: f.cwd });
Expand All @@ -282,7 +283,7 @@ test('runner drops inherited canary environment and diagnostics retain no secret
});

test('runner receipt replay rejects a fabricated pass for an actually failing candidate', async t => {
const root = mkdtempSync(join(tmpdir(), 'factory-workflow-m1-receipt-'));
const root = tempDir('factory-workflow-m1-receipt-');
const f = verifiedFixture(root, { failingGate: true });
t.after(() => f.workflow.close());
const runId = 'm1-real-run';
Expand Down
3 changes: 2 additions & 1 deletion test/workflow-m2-ack-recovery.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,9 @@ import { join } from 'node:path';
import { CloudLiteTrace } from '../src/cloud-lite/trace.mjs';
import { startCloudLiteCoordinator } from '../src/cloud-lite/server.mjs';
import { validateCloudLitePlan, agentInventory, secret } from '../src/cloud-lite/protocol.mjs';
import { tempDir } from './support/temp-dir.mjs';

const fresh = () => mkdtempSync(join(tmpdir(), 'factory-workflow-m2-ack-'));
const fresh = () => tempDir('factory-workflow-m2-ack-');
const event = seq => ({ seq, kind: `durable-${seq}`, body: { payload: `event-${seq}` }, observedAt: '2026-10-15T00:00:00.000Z' });

test('recovery does not trust a local ack that is ahead of the coordinator', async () => {
Expand Down
3 changes: 2 additions & 1 deletion test/workflow-m2-authority-recovery.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,9 @@ import { createWorkflowClient, createWorkflowComparison, launchWorkflowCompariso
import { contentDigest } from '../src/workflows/artifacts.mjs';
import { startCloudLiteCoordinator } from '../src/cloud-lite/server.mjs';
import { agentInventory, secret } from '../src/cloud-lite/protocol.mjs';
import { tempDir } from './support/temp-dir.mjs';

const fresh = () => mkdtempSync(join(tmpdir(), 'factory-workflow-m2-authority-'));
const fresh = () => tempDir('factory-workflow-m2-authority-');

function sourceFixture(root) {
const cwd = join(root, 'source'); mkdirSync(cwd);
Expand Down
3 changes: 2 additions & 1 deletion test/workflow-native-wake.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,9 @@ import {mkdtempSync,mkdirSync} from 'node:fs';
import {tmpdir} from 'node:os';
import {join} from 'node:path';
import {NativeWorkflowWake} from '../src/cloud-lite/workflow-wake.mjs';
import { tempDir } from './support/temp-dir.mjs';
const threadId='00000000-0000-0000-0000-000000000001';
function file(){const parent=process.env.FACTORY_TEST_TMP??tmpdir();mkdirSync(parent,{recursive:true});return join(mkdtempSync(join(parent,'factory-wake-')),'wake.json');}
function file(){return join(tempDir('factory-wake-'),'wake.json');}
test('native wake retains a rejected start and retries only after native idle readback',async()=>{
let starts=0;const rpc={request:async method=>{if(method==='thread/read')return {thread:{id:threadId,status:{type:'idle'},turns:[{id:'before'}]}};
if(++starts===1)throw Object.assign(new Error('Invalid request'),{code:-32602});return {turn:{id:'after'}};}};
Expand Down
Loading
Loading