Implement wiring hardening, runtime API config, smoke tests, and build scripts

This commit is contained in:
Ryan Lancaster
2026-03-17 12:58:43 -04:00
parent a3949c32ee
commit ca46302aff
19 changed files with 2358 additions and 77 deletions

15
scripts/adhoc-sign.js Normal file
View File

@@ -0,0 +1,15 @@
// Post-build hook: ad-hoc sign the .app bundle so macOS 14+ will open it
// without requiring an Apple Developer certificate.
const { execSync } = require('child_process');
const path = require('path');
exports.default = async function (context) {
const appPath = path.join(
context.appOutDir,
`${context.packager.appInfo.productFilename}.app`,
);
console.log(`Ad-hoc signing: ${appPath}`);
execSync(`xattr -cr "${appPath}"`);
execSync(`codesign --force --deep --sign - "${appPath}"`);
console.log('Ad-hoc signing complete.');
};

View File

@@ -0,0 +1,185 @@
#!/usr/bin/env node
const { execSync } = require('child_process');
const args = new Set(process.argv.slice(2));
if (args.has('--help') || args.has('-h')) {
console.log('Project Hub persistence self-test');
console.log('');
console.log('Usage:');
console.log(' node scripts/persistence-self-test.cjs [--restart-backend]');
console.log('');
console.log('Environment:');
console.log(' API_BASE default: http://localhost:4000/api');
console.log(' WRITE_API_KEY optional x-api-key for write endpoints');
process.exit(0);
}
const API_BASE = process.env.API_BASE || 'http://localhost:4000/api';
const HEALTH_URL = API_BASE.replace(/\/api\/?$/, '') + '/health';
const WRITE_API_KEY = process.env.WRITE_API_KEY || '';
const SHOULD_RESTART = args.has('--restart-backend');
const marker = `selftest-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
const state = {
memberId: null,
projectId: null,
taskId: null,
};
function log(msg) {
console.log(`[persistence-test] ${msg}`);
}
async function sleep(ms) {
await new Promise(resolve => setTimeout(resolve, ms));
}
async function request(path, options = {}) {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), options.timeoutMs || 10000);
const headers = {
...(options.body ? { 'Content-Type': 'application/json' } : {}),
...(WRITE_API_KEY ? { 'x-api-key': WRITE_API_KEY } : {}),
...(options.headers || {}),
};
const response = await fetch(`${API_BASE}${path}`, {
method: options.method || 'GET',
headers,
body: options.body ? JSON.stringify(options.body) : undefined,
signal: controller.signal,
}).finally(() => clearTimeout(timeout));
const text = await response.text();
let parsed = null;
try {
parsed = text ? JSON.parse(text) : null;
} catch {
parsed = null;
}
if (!response.ok) {
const details = parsed ? JSON.stringify(parsed) : text;
throw new Error(`${response.status} ${response.statusText}${details ? `: ${details}` : ''}`);
}
return parsed;
}
async function waitForHealth(maxSeconds = 30) {
const start = Date.now();
while ((Date.now() - start) / 1000 < maxSeconds) {
try {
const response = await fetch(HEALTH_URL, { signal: AbortSignal.timeout(3000) });
if (response.ok) return;
} catch {
// Keep polling.
}
await sleep(1000);
}
throw new Error(`Health check failed after ${maxSeconds}s at ${HEALTH_URL}`);
}
function restartBackendContainer() {
log('Restarting backend via docker compose...');
execSync('docker compose restart backend', { stdio: 'inherit' });
}
async function cleanup() {
if (state.projectId) {
try {
await request(`/projects/${state.projectId}`, { method: 'DELETE' });
log(`Cleanup: deleted project ${state.projectId}`);
} catch (e) {
log(`Cleanup warning: failed to delete project ${state.projectId}: ${e.message}`);
}
}
if (state.memberId) {
try {
await request(`/members/${state.memberId}`, { method: 'DELETE' });
log(`Cleanup: deleted member ${state.memberId}`);
} catch (e) {
log(`Cleanup warning: failed to delete member ${state.memberId}: ${e.message}`);
}
}
}
async function run() {
log(`Using API base ${API_BASE}`);
await waitForHealth();
log('Health check passed.');
const member = await request('/members', {
method: 'POST',
body: {
name: `Self Test ${marker}`,
role: 'QA Bot',
initials: 'ST',
email: `${marker}@local.test`,
},
});
state.memberId = member.id;
log(`Created member ${state.memberId}`);
const project = await request('/projects', {
method: 'POST',
body: {
name: `Persistence ${marker}`,
description: 'Automated persistence verification',
status: 'active',
color: '#10b981',
startDate: new Date().toISOString().slice(0, 10),
dueDate: new Date(Date.now() + 86400000).toISOString().slice(0, 10),
members: [],
tasks: [],
milestones: [],
},
});
state.projectId = project.id;
log(`Created project ${state.projectId}`);
await request(`/projects/${state.projectId}/members/${state.memberId}`, { method: 'POST' });
log(`Assigned member ${state.memberId} to project ${state.projectId}`);
const task = await request(`/projects/${state.projectId}/tasks`, {
method: 'POST',
body: {
title: `Task ${marker}`,
description: 'Persistence check task',
memberId: state.memberId,
},
});
state.taskId = task.id;
log(`Created task ${state.taskId}`);
if (SHOULD_RESTART) {
restartBackendContainer();
await waitForHealth(45);
log('Backend is healthy after restart.');
}
const projects = await request('/projects');
const persistedProject = projects.find(p => p.id === state.projectId);
if (!persistedProject) {
throw new Error(`Persisted project not found after verification step: ${state.projectId}`);
}
const tasks = await request(`/projects/${state.projectId}/tasks`);
const persistedTask = tasks.find(t => t.id === state.taskId);
if (!persistedTask) {
throw new Error(`Persisted task not found after verification step: ${state.taskId}`);
}
log('Persistence verified: project and task were read back successfully.');
await cleanup();
log('PASS');
}
run().catch(async err => {
console.error(`[persistence-test] FAIL: ${err.message}`);
await cleanup();
process.exit(1);
});

View File

@@ -0,0 +1,190 @@
#!/usr/bin/env node
const API_BASE = process.env.API_BASE || 'http://localhost:4000/api';
const HEALTH_URL = API_BASE.replace(/\/api\/?$/, '') + '/health';
const WRITE_API_KEY = process.env.WRITE_API_KEY || '';
const marker = `wiring-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`;
const state = {
memberId: null,
projectId: null,
taskId: null,
inviteToken: null,
};
function log(message) {
console.log(`[wiring-test] ${message}`);
}
async function sleep(ms) {
await new Promise(resolve => setTimeout(resolve, ms));
}
async function waitForHealth(maxSeconds = 30) {
const started = Date.now();
while ((Date.now() - started) / 1000 < maxSeconds) {
try {
const response = await fetch(HEALTH_URL, { signal: AbortSignal.timeout(3000) });
if (response.ok) return;
} catch {
// keep polling
}
await sleep(1000);
}
throw new Error(`Backend not healthy at ${HEALTH_URL} after ${maxSeconds}s`);
}
async function request(path, options = {}) {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), options.timeoutMs || 10000);
const headers = {
...(options.body || options.formData ? {} : {}),
...(options.body ? { 'Content-Type': 'application/json' } : {}),
...(options.write && WRITE_API_KEY ? { 'x-api-key': WRITE_API_KEY } : {}),
...(options.headers || {}),
};
const response = await fetch(`${API_BASE}${path}`, {
method: options.method || 'GET',
headers,
body: options.formData || (options.body ? JSON.stringify(options.body) : undefined),
signal: controller.signal,
}).finally(() => clearTimeout(timeout));
const text = await response.text();
let parsed = null;
try {
parsed = text ? JSON.parse(text) : null;
} catch {
parsed = null;
}
if (!response.ok) {
const details = parsed ? JSON.stringify(parsed) : text;
throw new Error(`${response.status} ${response.statusText}${details ? `: ${details}` : ''}`);
}
return parsed;
}
async function cleanup() {
if (state.projectId) {
try {
await request(`/projects/${state.projectId}`, { method: 'DELETE', write: true });
log(`Cleanup: deleted project ${state.projectId}`);
} catch (err) {
log(`Cleanup warning: project delete failed: ${err.message}`);
}
}
if (state.memberId) {
try {
await request(`/members/${state.memberId}`, { method: 'DELETE', write: true });
log(`Cleanup: deleted member ${state.memberId}`);
} catch (err) {
log(`Cleanup warning: member delete failed: ${err.message}`);
}
}
}
async function run() {
log(`Using API base ${API_BASE}`);
await waitForHealth();
log('Health check passed');
const member = await request('/members', {
method: 'POST',
write: true,
body: {
name: `Wiring Member ${marker}`,
role: 'QA Bot',
email: `${marker}@local.test`,
},
});
state.memberId = member.id;
log(`Created member ${state.memberId}`);
const project = await request('/projects', {
method: 'POST',
write: true,
body: {
name: `Wiring Project ${marker}`,
description: 'Wiring smoke test',
status: 'active',
color: '#6366f1',
members: [],
tasks: [],
milestones: [],
},
});
state.projectId = project.id;
log(`Created project ${state.projectId}`);
const assigned = await request(`/projects/${state.projectId}/members/${state.memberId}`, {
method: 'POST',
write: true,
});
if (!Array.isArray(assigned.members) || !assigned.members.some(m => m.id === state.memberId)) {
throw new Error('Assigned member missing from project.members');
}
log('Assigned member to project');
const task = await request(`/projects/${state.projectId}/tasks`, {
method: 'POST',
write: true,
body: {
title: `Wiring Task ${marker}`,
description: 'Task route check',
assignedTo: state.memberId,
status: 'todo',
priority: 'medium',
},
});
state.taskId = task.id;
log(`Created task ${state.taskId}`);
const updatedTask = await request(`/projects/${state.projectId}/tasks/${state.taskId}`, {
method: 'PUT',
write: true,
body: { status: 'done' },
});
if (updatedTask.status !== 'done') {
throw new Error('Task update did not persist expected status');
}
log('Updated task status to done');
const invite = await request(`/projects/${state.projectId}/invites`, {
method: 'POST',
write: true,
body: { email: `${marker}.invite@local.test` },
});
state.inviteToken = invite.token;
log('Created invite token');
await request(`/invites/${state.inviteToken}`);
await request(`/invites/${state.inviteToken}/accept`, {
method: 'POST',
body: { name: `Invitee ${marker}`, role: 'Collaborator', email: `${marker}.invite@local.test` },
});
log('Invite get/accept flow passed');
await request(`/projects/${state.projectId}/tasks/${state.taskId}`, {
method: 'DELETE',
write: true,
});
log('Deleted task');
const tasksAfterDelete = await request(`/projects/${state.projectId}/tasks`);
if (tasksAfterDelete.some(t => t.id === state.taskId)) {
throw new Error('Task still present after delete');
}
log('PASS');
await cleanup();
}
run().catch(async err => {
console.error(`[wiring-test] FAIL: ${err.message}`);
await cleanup();
process.exit(1);
});