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
46 changes: 46 additions & 0 deletions .github/workflows/backend-tests.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
name: Backend Tests

on:
pull_request:
types: [opened, synchronize, reopened]

permissions:
contents: read
pull-requests: write

jobs:
test:
runs-on: ubuntu-latest

steps:
- name: Checkout repository
uses: actions/checkout@v4

- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: '24'
cache: 'npm'

- name: Install dependencies
run: npm ci

- name: Run backend tests with coverage
run: npm run test:backend -- --coverage
env:
CI: 'true'

- name: Report coverage on PR
if: always()
uses: davelosert/vitest-coverage-report-action@v2
with:
json-summary-path: src/backend/coverage/coverage-summary.json
json-final-path: src/backend/coverage/coverage-final.json

- name: Upload coverage artifact
if: always()
uses: actions/upload-artifact@v4
with:
name: backend-coverage
path: src/backend/coverage/
retention-days: 14
12 changes: 5 additions & 7 deletions extensions/devWatcher.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
import { extension } from '@heyputer/backend/src/extensions';
import { PuterService } from '@heyputer/backend/src/services/types.js';
import { nativeImport } from '@heyputer/backend/src/util/nativeImport.js';
import { spawn, type ChildProcess } from 'node:child_process';
import { existsSync, readFileSync } from 'node:fs';
import { createRequire } from 'node:module';
import path from 'node:path';
import { pathToFileURL } from 'node:url';
import { extension } from '@heyputer/backend/src/extensions';
import { PuterService } from '@heyputer/backend/src/services/types.js';
import { nativeImport } from '@heyputer/backend/src/util/nativeImport.js';

const requireFromHere = createRequire(__filename);
const webpack = requireFromHere('webpack') as typeof import('webpack');
Expand Down Expand Up @@ -121,9 +121,7 @@ const defaultWebpackEntries: WebpackEntry[] = [

class DevWatcherService extends PuterService {
#processes: Array<{ name: string; proc: ChildProcess }> = [];
#watchers: Array<{
close: (handler: (err?: Error | null) => void) => void;
}> = [];
#watchers: ReturnType<ReturnType<typeof webpack>['watch']>[] = [];
#started = false;
#packageRoot = findPackageRoot();

Expand Down Expand Up @@ -159,7 +157,7 @@ class DevWatcherService extends PuterService {
this.#watchers.map(
(watcher) =>
new Promise<void>((resolve) => {
watcher.close((err) => {
watcher?.close((err) => {
if (err) {
console.warn(
'[devwatch] failed to close webpack watcher:',
Expand Down
89 changes: 89 additions & 0 deletions extensions/thumbnails.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
import { GetObjectCommand } from '@aws-sdk/client-s3';
import {
afterAll,
beforeAll,
describe,
expect,
it,
} from 'vitest';
import { PuterServer } from '../src/backend/server.ts';
import { setupTestServer } from '../src/backend/testUtil.ts';
import { handleThumbnailCreated } from './thumbnails.ts';

// 1x1 transparent PNG — smallest valid image sharp will accept.
const TINY_PNG_BASE64 =
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII=';

const BUCKET = 'puter-local';

const streamToBuffer = async (
body: { transformToByteArray: () => Promise<Uint8Array> } | undefined,
): Promise<Buffer> => {
if (!body) throw new Error('s3 GetObject returned no body');
return Buffer.from(await body.transformToByteArray());
};

describe('thumbnails extension — handleThumbnailCreated', () => {
let server: PuterServer;

beforeAll(async () => {
server = await setupTestServer();
});

afterAll(async () => {
await server.shutdown();
});

it('uploads a valid data: URL thumbnail to S3 and rewrites event.url to an s3:// pointer', async () => {
const s3 = server.clients.s3.get();
const event: Record<string, unknown> = {
url: `data:image/png;base64,${TINY_PNG_BASE64}`,
};

await handleThumbnailCreated(event, { s3, bucketName: BUCKET });

expect(typeof event.url).toBe('string');
const newUrl = event.url as string;
expect(newUrl.startsWith(`s3://${BUCKET}/`)).toBe(true);

const key = newUrl.slice(`s3://${BUCKET}/`.length);
const obj = await s3.send(
new GetObjectCommand({ Bucket: BUCKET, Key: key }),
);
expect(obj.ContentType).toBe('image/png');

const expected = Buffer.from(TINY_PNG_BASE64, 'base64');
const actual = await streamToBuffer(obj.Body as never);
expect(actual.equals(expected)).toBe(true);
});

it('sets event.url to null when the data: URL does not decode to a valid image', async () => {
const s3 = server.clients.s3.get();
const event: Record<string, unknown> = {
url: `data:image/png;base64,${Buffer.from('not an image').toString('base64')}`,
};

await handleThumbnailCreated(event, { s3, bucketName: BUCKET });

expect(event.url).toBeNull();
});

it('leaves event.url untouched when the URL is not a data: URL', async () => {
const s3 = server.clients.s3.get();
const original = 'https://example.com/thumb.png';
const event: Record<string, unknown> = { url: original };

await handleThumbnailCreated(event, { s3, bucketName: BUCKET });

expect(event.url).toBe(original);
});

it('returns without writing to S3 when event.url is missing', async () => {
const s3 = server.clients.s3.get();
const event: Record<string, unknown> = {};

await handleThumbnailCreated(event, { s3, bucketName: BUCKET });

expect(event.url).toBeUndefined();
});
});
50 changes: 30 additions & 20 deletions extensions/thumbnails.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,29 +99,39 @@ async function decodeAndValidateThumbnail(
// Intercept data-URL thumbnails before they hit the DB: upload to S3
// and replace the URL with an s3:// pointer.

export async function handleThumbnailCreated(
event: Record<string, unknown>,
deps: { s3: S3Client; bucketName: string },
): Promise<void> {
const url = event.url;
if (typeof url !== 'string' || !url.startsWith('data:')) return;

const decoded = await decodeAndValidateThumbnail(url);
if (!decoded) {
event.url = null;
return;
}

const key = crypto.randomUUID();
event.url = `s3://${deps.bucketName}/${key}`;

await deps.s3.send(
new PutObjectCommand({
Bucket: deps.bucketName,
Key: key,
Body: decoded.data,
ContentType: decoded.mimeType,
}),
);
}

extension.on(
'thumbnail.created',
async (_key, event: Record<string, unknown>) => {
const url = event.url;
if (typeof url !== 'string' || !url.startsWith('data:')) return;

const decoded = await decodeAndValidateThumbnail(url);
if (!decoded) {
event.url = null;
return;
}

const key = crypto.randomUUID();
event.url = `s3://${thumbnailBucketName}/${key}`;

await getClient().send(
new PutObjectCommand({
Bucket: thumbnailBucketName,
Key: key,
Body: decoded.data,
ContentType: decoded.mimeType,
}),
);
await handleThumbnailCreated(event, {
s3: getClient(),
bucketName: thumbnailBucketName,
});
},
);

Expand Down
Loading
Loading