diff --git a/.gitea/workflows/build.yml b/.gitea/workflows/build.yml
deleted file mode 100644
index 5f168e1..0000000
--- a/.gitea/workflows/build.yml
+++ /dev/null
@@ -1,84 +0,0 @@
-name: Build
-
-on:
- push:
- branches: [main]
- tags:
- - 'v*'
-
-env:
- IMAGE: ${{ vars.REGISTRY_HOST }}/${{ gitea.repository_owner }}/catalyst
-
-jobs:
- test:
- runs-on: ubuntu-latest
-
- steps:
- - name: Checkout
- uses: actions/checkout@v4
-
- - name: Setup Node
- uses: actions/setup-node@v4
- with:
- node-version: 'lts/*'
- cache: npm
-
- - name: Install dependencies
- run: npm ci
-
- - name: Run tests
- run: npm test
-
- build:
- runs-on: ubuntu-latest
- needs: test
- if: startsWith(gitea.ref, 'refs/tags/v')
-
- steps:
- - name: Checkout
- uses: actions/checkout@v4
-
- - name: Docker metadata
- id: meta
- uses: docker/metadata-action@v5
- with:
- images: ${{ env.IMAGE }}
- tags: |
- type=semver,pattern={{version}}
- type=semver,pattern={{major}}.{{minor}}
- type=sha,prefix=,format=short
- type=raw,value=latest,enable={{is_default_branch}}
-
- - name: Log in to Gitea registry
- uses: docker/login-action@v3
- with:
- registry: ${{ vars.REGISTRY_HOST }}
- username: ${{ gitea.actor }}
- password: ${{ secrets.TOKEN }}
-
- - name: Build and push
- uses: docker/build-push-action@v5
- with:
- context: .
- push: true
- tags: ${{ steps.meta.outputs.tags }}
-
- release:
- runs-on: ubuntu-latest
- needs: build
- if: startsWith(gitea.ref, 'refs/tags/v')
-
- steps:
- - name: Create release
- run: |
- curl -sf -X POST \
- -H "Authorization: token ${{ secrets.TOKEN }}" \
- -H "Content-Type: application/json" \
- "${{ gitea.server_url }}/api/v1/repos/${{ gitea.repository }}/releases" \
- -d "{
- \"tag_name\": \"${{ gitea.ref_name }}\",
- \"name\": \"Catalyst ${{ gitea.ref_name }}\",
- \"body\": \"### Image\n\n\`${{ env.IMAGE }}:${{ gitea.ref_name }}\`\",
- \"draft\": false,
- \"prerelease\": false
- }"
diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml
new file mode 100644
index 0000000..cb05809
--- /dev/null
+++ b/.gitea/workflows/ci.yml
@@ -0,0 +1,53 @@
+name: CI
+
+on:
+ push:
+ branches: [dev, main]
+ pull_request:
+ branches: [dev, main]
+
+env:
+ IMAGE: ${{ vars.REGISTRY_HOST }}/${{ gitea.repository_owner }}/catalyst
+
+jobs:
+ test:
+ runs-on: ubuntu-latest
+
+ steps:
+ - uses: actions/checkout@v4
+
+ - uses: actions/setup-node@v4
+ with:
+ node-version: 'lts/*'
+
+ - run: npm ci
+
+ - run: npm test
+
+ build-dev:
+ runs-on: ubuntu-latest
+ needs: test
+ if: github.event_name == 'push' && github.ref == 'refs/heads/dev'
+
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Log in to registry
+ uses: docker/login-action@v3
+ with:
+ registry: ${{ vars.REGISTRY_HOST }}
+ username: ${{ gitea.actor }}
+ password: ${{ secrets.TOKEN }}
+
+ - name: Compute short SHA
+ run: echo "SHORT_SHA=$(git rev-parse --short HEAD)" >> $GITEA_ENV
+
+ - name: Build and push
+ uses: docker/build-push-action@v5
+ with:
+ context: .
+ push: true
+ build-args: BUILD_VERSION=dev-${{ env.SHORT_SHA }}
+ tags: |
+ ${{ env.IMAGE }}:dev
+ ${{ env.IMAGE }}:dev-${{ gitea.sha }}
diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml
new file mode 100644
index 0000000..6e08a87
--- /dev/null
+++ b/.gitea/workflows/release.yml
@@ -0,0 +1,95 @@
+name: Release
+
+on:
+ push:
+ branches: [main]
+
+env:
+ IMAGE: ${{ vars.REGISTRY_HOST }}/${{ gitea.repository_owner }}/catalyst
+
+jobs:
+ release:
+ runs-on: ubuntu-latest
+
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+ with:
+ fetch-depth: 0
+
+ - uses: actions/setup-node@v4
+ with:
+ node-version: 'lts/*'
+ cache: npm
+
+ - run: npm ci
+ - run: npm test
+
+ - name: Read version
+ run: |
+ VERSION=$(node -p "require('./package.json').version")
+ echo "VERSION=${VERSION}" >> $GITEA_ENV
+
+ - name: Assert tag does not exist
+ run: |
+ if git ls-remote --tags origin "refs/tags/v${{ env.VERSION }}" | grep -q .; then
+ echo "ERROR: tag v${{ env.VERSION }} already exists — bump version in package.json before merging to main."
+ exit 1
+ fi
+
+ - name: Create and push tag
+ run: |
+ git config user.name "gitea-actions"
+ git config user.email "actions@gitea"
+ git tag "v${{ env.VERSION }}"
+ git push origin "v${{ env.VERSION }}"
+
+ - name: Generate release notes
+ run: |
+ LAST_TAG=$(git describe --tags --abbrev=0 HEAD^ 2>/dev/null || echo "")
+ if [ -n "$LAST_TAG" ]; then
+ NOTES=$(git log "${LAST_TAG}..HEAD" --pretty=format:"- %s" --no-merges)
+ else
+ NOTES=$(git log --pretty=format:"- %s" --no-merges)
+ fi
+ NOTES_JSON=$(printf '%s' "$NOTES" | python3 -c "import sys,json; print(json.dumps(sys.stdin.read()))")
+ echo "NOTES=${NOTES_JSON}" >> $GITEA_ENV
+
+ - name: Docker metadata
+ id: meta
+ uses: docker/metadata-action@v5
+ with:
+ images: ${{ env.IMAGE }}
+ tags: |
+ type=semver,pattern={{version}},value=v${{ env.VERSION }}
+ type=semver,pattern={{major}}.{{minor}},value=v${{ env.VERSION }}
+ type=sha,prefix=,format=short
+ type=raw,value=latest
+
+ - name: Log in to registry
+ uses: docker/login-action@v3
+ with:
+ registry: ${{ vars.REGISTRY_HOST }}
+ username: ${{ gitea.actor }}
+ password: ${{ secrets.TOKEN }}
+
+ - name: Build and push
+ uses: docker/build-push-action@v5
+ with:
+ context: .
+ push: true
+ tags: ${{ steps.meta.outputs.tags }}
+
+ - name: Create Gitea release
+ run: |
+ curl -sf -X POST \
+ -H "Authorization: token ${{ secrets.TOKEN }}" \
+ -H "Content-Type: application/json" \
+ "${{ gitea.server_url }}/api/v1/repos/${{ gitea.repository }}/releases" \
+ -d "{
+ \"tag_name\": \"v${{ env.VERSION }}\",
+ \"name\": \"Catalyst v${{ env.VERSION }}\",
+ \"body\": \"### Changes\n\n${{ env.NOTES }}\n\n### Image\n\n\`${{ env.IMAGE }}:${{ env.VERSION }}\`\",
+ \"draft\": false,
+ \"prerelease\": false
+ }"
diff --git a/Dockerfile b/Dockerfile
index 6c6bcd3..0a95f88 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -7,10 +7,15 @@ COPY package*.json ./
RUN npm ci --omit=dev
COPY . .
-RUN awk -F'"' '/"version"/{printf "const VERSION = \"%s\";\n", $4; exit}' \
- package.json > js/version.js
+ARG BUILD_VERSION=""
+RUN if [ -n "$BUILD_VERSION" ]; then \
+ printf 'const VERSION = "%s";\n' "$BUILD_VERSION" > js/version.js; \
+ else \
+ awk -F'"' '/"version"/{printf "const VERSION = \"%s\";\n", $4; exit}' \
+ package.json > js/version.js; \
+ fi
-RUN chown -R app:app /app
+RUN mkdir -p /app/data && chown -R app:app /app
USER app
EXPOSE 3000
diff --git a/css/app.css b/css/app.css
index eff8043..d21a23a 100644
--- a/css/app.css
+++ b/css/app.css
@@ -289,6 +289,7 @@ select:focus { border-color: var(--accent); }
border-radius: 3px;
letter-spacing: 0.08em;
text-transform: uppercase;
+ text-align: center;
}
.badge.deployed { background: var(--accent2); color: var(--accent); }
diff --git a/js/app.js b/js/app.js
index 72cc2b0..1380b38 100644
--- a/js/app.js
+++ b/js/app.js
@@ -38,6 +38,9 @@ window.addEventListener('popstate', e => {
// ── Bootstrap ─────────────────────────────────────────────────────────────────
-if (VERSION) document.getElementById('nav-version').textContent = `v${VERSION}`;
+if (VERSION) {
+ const label = /^\d/.test(VERSION) ? `v${VERSION}` : VERSION;
+ document.getElementById('nav-version').textContent = label;
+}
handleRoute();
diff --git a/package.json b/package.json
index 5429248..9ca8736 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "catalyst",
- "version": "1.1.2",
+ "version": "1.2.0",
"type": "module",
"scripts": {
"start": "node server/server.js",
diff --git a/server/db.js b/server/db.js
index f7a0fb9..b88e974 100644
--- a/server/db.js
+++ b/server/db.js
@@ -133,5 +133,18 @@ export function _resetForTest() {
}
// ── Boot ──────────────────────────────────────────────────────────────────────
+// Skipped in test environment — parallel Vitest workers would race to open
+// the same file, causing "database is locked". _resetForTest() in beforeEach
+// handles initialisation for every test worker using :memory: instead.
-init(process.env.DB_PATH ?? DEFAULT_PATH);
+if (process.env.NODE_ENV !== 'test') {
+ const DB_PATH = process.env.DB_PATH ?? DEFAULT_PATH;
+ try {
+ init(DB_PATH);
+ } catch (e) {
+ console.error('[catalyst] fatal: could not open database at', DB_PATH);
+ console.error('[catalyst] ensure the data directory exists and is writable by the server process.');
+ console.error(e);
+ process.exit(1);
+ }
+}
diff --git a/server/routes.js b/server/routes.js
index c75b578..79eaad6 100644
--- a/server/routes.js
+++ b/server/routes.js
@@ -78,7 +78,8 @@ router.post('/instances', (req, res) => {
} catch (e) {
if (e.message.includes('UNIQUE')) return res.status(409).json({ error: 'vmid already exists' });
if (e.message.includes('CHECK')) return res.status(400).json({ error: 'invalid field value' });
- throw e;
+ console.error('POST /api/instances', e);
+ res.status(500).json({ error: 'internal server error' });
}
});
@@ -98,7 +99,8 @@ router.put('/instances/:vmid', (req, res) => {
} catch (e) {
if (e.message.includes('UNIQUE')) return res.status(409).json({ error: 'vmid already exists' });
if (e.message.includes('CHECK')) return res.status(400).json({ error: 'invalid field value' });
- throw e;
+ console.error('PUT /api/instances/:vmid', e);
+ res.status(500).json({ error: 'internal server error' });
}
});
@@ -112,6 +114,11 @@ router.delete('/instances/:vmid', (req, res) => {
if (instance.stack !== 'development')
return res.status(422).json({ error: 'only development instances can be deleted' });
- deleteInstance(vmid);
- res.status(204).end();
+ try {
+ deleteInstance(vmid);
+ res.status(204).end();
+ } catch (e) {
+ console.error('DELETE /api/instances/:vmid', e);
+ res.status(500).json({ error: 'internal server error' });
+ }
});
diff --git a/tests/api.test.js b/tests/api.test.js
index 38138ab..c807c30 100644
--- a/tests/api.test.js
+++ b/tests/api.test.js
@@ -1,7 +1,8 @@
-import { describe, it, expect, beforeEach } from 'vitest'
+import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
import request from 'supertest'
import { app } from '../server/server.js'
import { _resetForTest } from '../server/db.js'
+import * as dbModule from '../server/db.js'
beforeEach(() => _resetForTest())
@@ -277,3 +278,74 @@ describe('static assets and SPA routing', () => {
expect(res.text).toContain('')
})
})
+
+// ── Error handling — unexpected DB failures ───────────────────────────────────
+
+const dbError = () => Object.assign(
+ new Error('attempt to write a readonly database'),
+ { code: 'ERR_SQLITE_ERROR', errcode: 8 }
+)
+
+describe('error handling — unexpected DB failures', () => {
+ let consoleSpy
+
+ beforeEach(() => {
+ consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
+ })
+
+ afterEach(() => {
+ vi.restoreAllMocks()
+ })
+
+ it('POST returns 500 with friendly message when DB throws unexpectedly', async () => {
+ vi.spyOn(dbModule, 'createInstance').mockImplementationOnce(() => { throw dbError() })
+ const res = await request(app).post('/api/instances').send(base)
+ expect(res.status).toBe(500)
+ expect(res.body).toEqual({ error: 'internal server error' })
+ })
+
+ it('POST logs the error with route context when DB throws unexpectedly', async () => {
+ vi.spyOn(dbModule, 'createInstance').mockImplementationOnce(() => { throw dbError() })
+ await request(app).post('/api/instances').send(base)
+ expect(consoleSpy).toHaveBeenCalledWith(
+ expect.stringContaining('POST /api/instances'),
+ expect.any(Error)
+ )
+ })
+
+ it('PUT returns 500 with friendly message when DB throws unexpectedly', async () => {
+ await request(app).post('/api/instances').send(base)
+ vi.spyOn(dbModule, 'updateInstance').mockImplementationOnce(() => { throw dbError() })
+ const res = await request(app).put('/api/instances/100').send(base)
+ expect(res.status).toBe(500)
+ expect(res.body).toEqual({ error: 'internal server error' })
+ })
+
+ it('PUT logs the error with route context when DB throws unexpectedly', async () => {
+ await request(app).post('/api/instances').send(base)
+ vi.spyOn(dbModule, 'updateInstance').mockImplementationOnce(() => { throw dbError() })
+ await request(app).put('/api/instances/100').send(base)
+ expect(consoleSpy).toHaveBeenCalledWith(
+ expect.stringContaining('PUT /api/instances/:vmid'),
+ expect.any(Error)
+ )
+ })
+
+ it('DELETE returns 500 with friendly message when DB throws unexpectedly', async () => {
+ await request(app).post('/api/instances').send({ ...base, stack: 'development', state: 'testing' })
+ vi.spyOn(dbModule, 'deleteInstance').mockImplementationOnce(() => { throw dbError() })
+ const res = await request(app).delete('/api/instances/100')
+ expect(res.status).toBe(500)
+ expect(res.body).toEqual({ error: 'internal server error' })
+ })
+
+ it('DELETE logs the error with route context when DB throws unexpectedly', async () => {
+ await request(app).post('/api/instances').send({ ...base, stack: 'development', state: 'testing' })
+ vi.spyOn(dbModule, 'deleteInstance').mockImplementationOnce(() => { throw dbError() })
+ await request(app).delete('/api/instances/100')
+ expect(consoleSpy).toHaveBeenCalledWith(
+ expect.stringContaining('DELETE /api/instances/:vmid'),
+ expect.any(Error)
+ )
+ })
+})
diff --git a/tests/db.test.js b/tests/db.test.js
index a61073e..746f2b5 100644
--- a/tests/db.test.js
+++ b/tests/db.test.js
@@ -165,3 +165,23 @@ describe('deleteInstance', () => {
expect(getInstance(2)).not.toBeNull();
});
});
+
+// ── Test environment boot isolation ───────────────────────────────────────────
+
+describe('test environment boot isolation', () => {
+ it('vitest runs with NODE_ENV=test', () => {
+ // Vitest sets NODE_ENV=test automatically. This is the guard condition
+ // that prevents the boot init() from opening the real database file.
+ expect(process.env.NODE_ENV).toBe('test');
+ });
+
+ it('db module loads cleanly in parallel workers without locking the real db file', () => {
+ // Regression: the module-level init(DEFAULT_PATH) used to run unconditionally,
+ // causing "database is locked" when multiple test workers imported db.js at
+ // the same time. process.exit(1) then killed the worker mid-suite.
+ // Fix: boot init is skipped when NODE_ENV=test. _resetForTest() handles setup.
+ // Reaching this line proves the module loaded without calling process.exit.
+ expect(() => _resetForTest()).not.toThrow();
+ expect(getInstances()).toEqual([]);
+ });
+});
diff --git a/tests/helpers.test.js b/tests/helpers.test.js
index dc3a809..26bdd4d 100644
--- a/tests/helpers.test.js
+++ b/tests/helpers.test.js
@@ -1,5 +1,7 @@
// @vitest-environment jsdom
import { describe, it, expect } from 'vitest'
+import { readFileSync } from 'fs'
+import { join } from 'path'
// ── esc() ─────────────────────────────────────────────────────────────────────
// Mirrors the implementation in ui.js exactly (DOM-based).
@@ -112,3 +114,53 @@ describe('fmtDateFull', () => {
expect(fmtDateFull('')).toBe('—')
})
})
+
+// ── versionLabel() ───────────────────────────────────────────────────────────
+// Mirrors the logic in app.js — semver strings get a v prefix, dev strings don't.
+
+function versionLabel(v) {
+ return /^\d/.test(v) ? `v${v}` : v
+}
+
+describe('version label formatting', () => {
+ it('prepends v for semver strings', () => {
+ expect(versionLabel('1.1.2')).toBe('v1.1.2')
+ expect(versionLabel('2.0.0')).toBe('v2.0.0')
+ })
+
+ it('does not prepend v for dev build strings', () => {
+ expect(versionLabel('dev-abc1234')).toBe('dev-abc1234')
+ })
+})
+
+// ── CSS regressions ───────────────────────────────────────────────────────────
+
+const css = readFileSync(join(__dirname, '../css/app.css'), 'utf8')
+
+describe('CSS regressions', () => {
+ it('.badge has text-align: center so state labels are not left-skewed on cards', () => {
+ // Regression: badges rendered left-aligned inside the card's flex-end column.
+ // Without text-align: center, short labels (e.g. "deployed") appear
+ // left-justified inside their pill rather than centred.
+ expect(css).toMatch(/\.badge\s*\{[^}]*text-align\s*:\s*center/s)
+ })
+})
+
+// ── CI workflow regressions ───────────────────────────────────────────────────
+
+const ciYml = readFileSync(join(__dirname, '../.gitea/workflows/ci.yml'), 'utf8')
+
+describe('CI workflow regressions', () => {
+ it('build-dev job passes BUILD_VERSION build arg', () => {
+ // Regression: dev image showed semver instead of dev- because
+ // BUILD_VERSION was never passed to docker build.
+ expect(ciYml).toContain('BUILD_VERSION')
+ })
+
+ it('short SHA is computed with git rev-parse, not $GITEA_SHA (which is empty)', () => {
+ // Regression: ${GITEA_SHA::7} expands to "" on Gitea runners — nav showed "dev-".
+ // git rev-parse --short HEAD works regardless of which env vars the runner sets.
+ expect(ciYml).toContain('git rev-parse --short HEAD')
+ expect(ciYml).not.toContain('GITEA_SHA')
+ })
+})