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
8 changes: 8 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -21,3 +21,11 @@ jobs:
- uses: actions/checkout@v7.0.1
- uses: ./.github/actions/setup
- run: pnpm test

server-e2e:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v7.0.1
- uses: ./.github/actions/setup
- run: pnpm test:e2e
11 changes: 11 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,7 @@ pnpm run typecheck # core を build してから各 package を型チェッ
pnpm run lint # biome
pnpm run test:unit # 純粋ロジック
pnpm run test:integration # 実モデルロード+実 classify(モデルが無ければ skip)
pnpm run test:e2e # ビルド済み server に実画像を送信(事前に pnpm build が必要)

# ローカル起動(config に modelDir を設定)
pnpm --filter @misskey-sensitive-detector/server dev -- --config ./config.dev.mjs
Expand All @@ -93,6 +94,16 @@ pnpm --filter @misskey-sensitive-detector/server dev -- --config ./config.dev.mj
統合テストは `SENSITIVE_DETECTOR_TEST_MODEL_DIR` でモデルディレクトリを上書きできる
(既定: `./nsfw-model`)。モデルが無い環境では自動的に skip する。

E2Eはリポジトリ同梱のモデルと[固定画像](apps/server/e2e/fixtures/README.md)を必須とし、
欠落時は失敗する。別プロセスでビルド済みserverを起動し、Bearer認証付きの実HTTP通信で
AI生成の猫画像が `Neutral`、架空の成人を描いたAI生成の水着イラストが `Sexy` を最大確率として返すことと、5クラスの確率が
有限値かつ0〜1であることを確認する。モデル全体の精度評価や本番のセンシティブ判定ではない。
起動待ちは最大120秒、HTTP通信は応答本文を含め最大60秒。成功・失敗時ともserverを終了する。
CIではPR、mainへのpush、手動実行時に独立した `server-e2e` ジョブとして実行する。

`apps/server/e2e/check-images.ts` の `checkImages({ baseUrl, apiKey })` は起動処理と独立しており、
Docker等で起動したserverにも同じ画像・検証を使用できる。

## 構成

- `packages/core` (`@misskey-sensitive-detector/core`): 推論エンジン。重いネイティブ実依存(onnxruntime-node)はここに集約。
Expand Down
90 changes: 90 additions & 0 deletions apps/server/e2e/check-images.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
import assert from 'node:assert/strict';
import { readFile } from 'node:fs/promises';
import { basename } from 'node:path';
import { fileURLToPath } from 'node:url';

const CLASSES = ['Drawing', 'Hentai', 'Neutral', 'Porn', 'Sexy'] as const;
export type Fixture = { file: URL; expectedClass: (typeof CLASSES)[number] };
export const FIXTURES = [
{ file: new URL('./fixtures/neutral-synthetic-cat.png', import.meta.url), expectedClass: 'Neutral' },
{ file: new URL('./fixtures/sexy-synthetic-swimwear.png', import.meta.url), expectedClass: 'Sexy' },
] as const satisfies readonly Fixture[];
export const HTTP_TIMEOUT_MS = 60_000;

/** These are regression expectations for fixed images, not a sensitive-content policy. */
export function assertPredictions(body: unknown, fixtures: readonly Fixture[]): void {
type Prediction = { className: string; probability: number };
type Item = { success?: boolean; predictions?: Prediction[] };
const batch = body as {
success?: boolean;
result?: { results?: Item[] };
} | null;
assert.equal(batch?.success, true, 'batch did not succeed');
const results = batch?.result?.results;
assert(Array.isArray(results), 'missing results array');
assert.equal(results.length, fixtures.length, 'wrong result count');
for (const [index, fixture] of fixtures.entries()) {
const label = basename(fileURLToPath(fixture.file));
const item: Item | undefined = results[index];
assert.equal(item?.success, true, `${label}: inference failed`);
const predictions: Prediction[] | undefined = item?.predictions;
assert(Array.isArray(predictions), `${label}: missing predictions`);
assert.equal(predictions.length, 5, `${label}: expected five classes`);
for (const prediction of predictions) {
assert(prediction && typeof prediction === 'object', `${label}: invalid prediction`);
assert(
Number.isFinite(prediction.probability) && prediction.probability >= 0 && prediction.probability <= 1,
`${label}: invalid probability for ${prediction.className}`,
);
}
assert.deepEqual(
predictions.map((p) => p.className).sort(),
[...CLASSES].sort(),
`${label}: unexpected or duplicate classes`,
);
const expected = predictions.find((p) => p.className === fixture.expectedClass);
assert(
expected && predictions.every((p) => p === expected || p.probability < expected.probability),
`${label}: expected ${fixture.expectedClass} to have the highest probability: ${JSON.stringify(predictions)}`,
);
}
}

/** Works against either a local server or a published Docker port. No model access is needed here. */
export async function checkImages({
baseUrl,
apiKey,
fixtures = FIXTURES,
signal,
requestTimeoutMs = HTTP_TIMEOUT_MS,
}: {
baseUrl: string;
apiKey: string;
fixtures?: readonly Fixture[];
signal?: AbortSignal;
requestTimeoutMs?: number;
}): Promise<void> {
assert(fixtures.length > 0, 'at least one fixture is required');
const form = new FormData();
for (const [index, fixture] of fixtures.entries()) {
const data = await readFile(fixture.file); // Missing fixtures must fail, never skip.
form.append(
`image${index}`,
new Blob([new Uint8Array(data)], { type: 'image/png' }),
basename(fileURLToPath(fixture.file)),
);
}
const timeout = AbortSignal.timeout(requestTimeoutMs);
const response = await fetch(new URL('/v1/detect-images', baseUrl), {
method: 'POST',
headers: { authorization: `Bearer ${apiKey}` },
body: form,
signal: signal ? AbortSignal.any([signal, timeout]) : timeout,
});
try {
assert.equal(response.status, 200, `detect-images returned HTTP ${response.status}`);
assertPredictions(await response.json(), fixtures);
} finally {
await response.body?.cancel().catch(() => {});
}
}
41 changes: 41 additions & 0 deletions apps/server/e2e/fixtures/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
# E2E image fixtures

These fixed AI-generated images exercise real ONNX inference. They are regression
examples, not a dataset for measuring model accuracy or a definition of sensitive
content. Both were generated for this project with the built-in OpenAI
`image_gen` tool on 2026-11-06. No photograph or other reference image was supplied
for either generation.

| File | Expected top class | Subject | Prompt |
| ----------------------------- | ------------------ | --------------------------------------------------------- | ------------------------------------------------------- |
| `neutral-synthetic-cat.png` | `Neutral` | Photograph-style rendering of a fictional adult tabby cat | [Generation prompt](neutral-synthetic-cat.prompt.txt) |
| `sexy-synthetic-swimwear.png` | `Sexy` | 3D illustration of a fictional adult in opaque swimwear | [Generation prompt](sexy-synthetic-swimwear.prompt.txt) |

## Provenance and reuse

The cat prompt requests a generic fictional animal without a specific real pet,
reference photograph, branded mascot or existing fictional character. The swimwear
prompt requests an entirely fictional adult, a visibly synthetic 3D style, a
neutral pose, opaque clothing and no real-person or existing-character references.
The committed prompts record the inputs used to generate the selected images.
Neither test fixture is a third-party photograph.

Both project-generated fixtures are included under the repository's
[MIT license](../../../../LICENSE), to the extent the contributors hold rights in
them. This does not assert exclusive copyright in AI-generated output.

## Normalization and expectations

Both selected images were resized to 299×299 (whole image, no crop) using FFmpeg's
Lanczos filter and converted to an 8-bit RGB PNG. Metadata is not copied. To
reproduce normalization from a source image:

```sh
ffmpeg -i original.png -vf scale=299:299:flags=lanczos -pix_fmt rgb24 -frames:v 1 fixture.png
```

CI reads the committed PNGs and never downloads, generates or converts images.
Both were verified against the real bundled ONNX model: the cat's winning class
was `Neutral` (approximately 0.999855 at selection time), and the swimwear
illustration's winning class was `Sexy` (approximately 0.967). Only the winning
class and probability validity are asserted, not exact floats.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
1 change: 1 addition & 0 deletions apps/server/e2e/fixtures/neutral-synthetic-cat.prompt.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Use case: photorealistic-natural. Asset type: original square raster fixture for an image-classifier regression test. Generate a natural-looking photograph-style image of one entirely fictional adult domestic tabby cat, sitting calmly on a light wooden floor in a simple sunlit room. The full cat is clearly visible and fills most of the square, with realistic fur, natural proportions, soft window light, and a quiet uncluttered background. Design a new generic cat without using any photograph, reference image, specific real animal, branded mascot, or existing fictional character. No people, text, logos, collars with identifying tags, or watermarks. This is an AI-generated fictional animal, not a depiction of a specific actual pet.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Use case: stylized-concept. Asset type: original square raster fixture for a local image-classifier regression test. Create an unmistakably computer-rendered 3D illustration of one entirely fictional adult woman, approximately 30 years old, wearing an opaque simple two-piece swimsuit, standing upright with arms relaxed in a neutral swimwear-catalog pose. Frame the entire person from head to feet, filling most of the square. Simple warm studio background and soft even lighting. The face and materials must look visibly stylized and synthetic, like an original 3D character, not a photograph. Design a new generic character without using any real person, celebrity, photograph, reference image, or existing fictional character. Non-erotic presentation. No nudity, transparent clothing, suggestive posing, sexual activity, logos, text, or watermarks. This is a swimwear illustration, not a portrait of any actual person.
13 changes: 13 additions & 0 deletions apps/server/e2e/run.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { checkImages } from './check-images.js';
import { API_KEY, withServer } from './server.js';
import { withSignals } from './signals.js';

try {
await withSignals((signal) =>
withServer((baseUrl, running) => checkImages({ baseUrl, apiKey: API_KEY, signal: running }), { signal }),
);
console.log('Server E2E passed: Neutral synthetic-cat and Sexy synthetic-swimwear predictions.');
} catch (error) {
console.error(error);
process.exitCode = 1;
}
126 changes: 126 additions & 0 deletions apps/server/e2e/server.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
import { spawn } from 'node:child_process';
import { access, mkdtemp, rm, writeFile } from 'node:fs/promises';
import { createServer } from 'node:net';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { createInterface } from 'node:readline';
import { setTimeout as delay } from 'node:timers/promises';
import { fileURLToPath } from 'node:url';
import { HTTP_TIMEOUT_MS } from './check-images.js';

export const API_KEY = 'e2e-only-api-key-not-a-secret';
const ROOT = new URL('../../../', import.meta.url);

async function unusedPort(): Promise<number> {
const server = createServer();
await new Promise<void>((resolve, reject) => {
server.once('error', reject);
server.listen(0, '127.0.0.1', resolve);
});
const address = server.address();
await new Promise<void>((resolve, reject) => server.close((error) => (error ? reject(error) : resolve())));
if (!address || typeof address === 'string') throw new Error('could not allocate an E2E port');
return address.port;
}

async function waitForHealth(baseUrl: string, hasBound: () => boolean, signal: AbortSignal): Promise<void> {
while (true) {
signal.throwIfAborted();
if (hasBound()) {
try {
const response = await fetch(new URL('/health', baseUrl), {
signal: AbortSignal.any([signal, AbortSignal.timeout(HTTP_TIMEOUT_MS)]),
});
await response.body?.cancel();
if (response.status === 200) return;
} catch (error) {
signal.throwIfAborted();
// Retry refused connections while the model loads; HTTP timeouts are failures.
if (!(error instanceof TypeError)) throw error;
}
}
try {
await delay(100, undefined, { signal });
} catch (error) {
signal.throwIfAborted();
throw error;
}
}
}

/** Start the built entrypoint with isolated configuration and always reap its process. */
export async function withServer<T>(
check: (baseUrl: string, signal: AbortSignal) => Promise<T>,
{
entry = new URL('../dist/main.mjs', import.meta.url),
modelDir = fileURLToPath(new URL('nsfw-model/', ROOT)),
startupTimeoutMs = 120_000,
signal = new AbortController().signal,
}: { entry?: URL; modelDir?: string; startupTimeoutMs?: number; signal?: AbortSignal } = {},
): Promise<T> {
signal.throwIfAborted();
await access(entry);
await access(join(modelDir, 'nsfw_model.onnx')); // E2E must never skip a missing model.
const dir = await mkdtemp(join(tmpdir(), 'sensitive-detector-e2e-'));
try {
const port = await unusedPort();
const configPath = join(dir, 'config.mjs');
await writeFile(
configPath,
`export default ${JSON.stringify({ port, host: '127.0.0.1', modelDir, apiKey: API_KEY })};\n`,
);
const child = spawn(process.execPath, [fileURLToPath(entry), '--config', configPath], {
cwd: fileURLToPath(ROOT),
env: { ...process.env, NODE_ENV: 'production', LOG_LEVEL: 'info' },
stdio: ['ignore', 'pipe', 'pipe'],
});
const exited = new AbortController();
let logs = '';
const capture = (chunk: Buffer) => {
logs = (logs + chunk.toString()).slice(-64 * 1024);
};
child.stdout.on('data', capture);
child.stderr.on('data', capture);
// A released ephemeral port may be claimed by another process. Accept health
// only after this child confirms its successful bind via the existing log.
let hasBound = false;
const lines = createInterface({ input: child.stdout });
lines.on('line', (line) => {
try {
if (JSON.parse(line)?.msg === `listening on http://127.0.0.1:${port}`) hasBound = true;
} catch {
// Non-JSON diagnostic output is still retained in the captured logs.
}
});
child.once('error', (error) => exited.abort(error));
const closed = new Promise<void>((resolve) => {
child.once('close', (code, exitSignal) => {
exited.abort(new Error(`server exited (code ${code}, signal ${exitSignal})`));
resolve();
});
});
const running = AbortSignal.any([signal, exited.signal]);
try {
const baseUrl = `http://127.0.0.1:${port}`;
await waitForHealth(baseUrl, () => hasBound, AbortSignal.any([running, AbortSignal.timeout(startupTimeoutMs)]));
running.throwIfAborted();
const result = await check(baseUrl, running);
running.throwIfAborted();
return result;
} catch (error) {
process.stderr.write(`E2E server logs:\n${logs}\n`);
throw error;
} finally {
if (child.exitCode === null && child.signalCode === null) child.kill('SIGTERM');
const forceKill = setTimeout(() => child.kill('SIGKILL'), 5_000);
try {
await closed;
} finally {
clearTimeout(forceKill);
lines.close();
}
}
} finally {
await rm(dir, { recursive: true, force: true });
}
}
16 changes: 16 additions & 0 deletions apps/server/e2e/signals.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
/** Keep signal handlers installed until the task has finished its cleanup. */
export async function withSignals<T>(run: (signal: AbortSignal) => Promise<T>): Promise<T> {
const controller = new AbortController();
const onInterrupt = () => controller.abort(new Error('E2E interrupted by SIGINT'));
const onTerminate = () => controller.abort(new Error('E2E interrupted by SIGTERM'));
process.on('SIGINT', onInterrupt);
process.on('SIGTERM', onTerminate);
try {
const result = await run(controller.signal);
controller.signal.throwIfAborted();
return result;
} finally {
process.removeListener('SIGINT', onInterrupt);
process.removeListener('SIGTERM', onTerminate);
}
}
2 changes: 1 addition & 1 deletion apps/server/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
"clean": "rm -rf dist",
"dev": "tsx watch src/main.ts",
"start": "node dist/main.mjs",
"typecheck": "tsc -p tsconfig.json --noEmit"
"typecheck": "tsc -p tsconfig.json --noEmit && tsc -p tsconfig.e2e.json"
},
"dependencies": {
"@hono/node-server": "catalog:",
Expand Down
Loading
Loading