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
37 changes: 37 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,43 @@ pnpm --filter=@examples/helloworld-typescript run dev
pnpm --filter=site run dev
```

### Local External Services

Some DAL, ORM, Redis, and ecosystem benchmark paths need local MySQL and Redis services. Start the repository-aligned Docker services before running those tests on a clean machine:

```bash
utoo run dev:services:start
Comment thread
killagu marked this conversation as resolved.
```

This starts MySQL 8 and Redis 7, matching the main CI service versions, and creates the databases used by local DAL/ORM/e2e fixtures: `test`, `apple`, `banana`, `test_runtime_datasource`, `test_runtime_dao`, `test_dal_plugin`, `test_dal_standalone`, `cnpmcore`, and `cnpmcore_unittest`.

Useful commands:

```bash
utoo run dev:services:status
utoo run dev:services:stop
utoo run dev:services:reset
Comment thread
killagu marked this conversation as resolved.
```

The default host ports are `127.0.0.1:3306` for MySQL and `127.0.0.1:6379` for Redis. If either port is already used, the start command stops before changing containers. Keep using the existing service if it is compatible with CI, or stop it and run the command again. You can change Docker host ports with `EGG_DEV_SERVICES_MYSQL_PORT` and `EGG_DEV_SERVICES_REDIS_PORT`; however, the full DAL/ORM/Redis local test path still expects the default host ports.

Image overrides are available for compatibility checks:

```bash
EGG_DEV_SERVICES_MYSQL_IMAGE=mysql:5.7 utoo run dev:services:start
EGG_DEV_SERVICES_REDIS_IMAGE=redis:7 utoo run dev:services:start
```

Run `utoo run dev:services:reset` before switching MySQL image families, for example between MySQL 8 and MySQL 5.7, because MySQL data directories are not downgrade-compatible across major versions.
Comment thread
killagu marked this conversation as resolved.

Current hard-coded service assumptions:

- Redis plugin fixtures under `plugins/redis/test/fixtures/apps/**/config.*` use `127.0.0.1:6379`; skipped Redis plugin tests become runnable when that port is available.
- Session Redis fixtures under `plugins/session/test/fixtures/redis-session/config/config.default.js` use `127.0.0.1:6379`.
- DAL runtime tests in `tegg/core/dal-runtime/test/DataSource.test.ts` and `tegg/core/dal-runtime/test/DAO.test.ts` use local MySQL on port `3306`.
- DAL module fixtures in `tegg/plugin/dal/test/fixtures/apps/dal-app/modules/dal/module.yml` and `tegg/standalone/standalone/test/fixtures/dal-*/module.yml` use local MySQL on port `3306`.
- ORM fixtures in `tegg/plugin/orm/test/fixtures/prepare.js` and `tegg/plugin/orm/test/fixtures/apps/orm-app/config/config.default.ts` use local MySQL on port `3306`.

## Documentations

- [Documentations](https://eggjs.org/)
Expand Down
30 changes: 30 additions & 0 deletions dev-services.compose.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
name: ${EGG_DEV_SERVICES_PROJECT:-eggjs_dev_services}

services:
mysql:
image: ${EGG_DEV_SERVICES_MYSQL_IMAGE:-mysql:8}
environment:
MYSQL_ALLOW_EMPTY_PASSWORD: 'yes'
MYSQL_DATABASE: test
ports:
- '127.0.0.1:${EGG_DEV_SERVICES_MYSQL_PORT:-3306}:3306'
healthcheck:
test: ['CMD', 'mysqladmin', 'ping', '-h', '127.0.0.1', '-uroot', '--silent']
interval: 5s
timeout: 3s
retries: 30
volumes:
- mysql-data:/var/lib/mysql
Comment thread
killagu marked this conversation as resolved.

redis:
image: ${EGG_DEV_SERVICES_REDIS_IMAGE:-redis:7}
ports:
- '127.0.0.1:${EGG_DEV_SERVICES_REDIS_PORT:-6379}:6379'
Comment thread
killagu marked this conversation as resolved.
healthcheck:
test: ['CMD', 'redis-cli', 'ping']
interval: 5s
timeout: 3s
retries: 30

volumes:
mysql-data:
4 changes: 4 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,10 @@
"test:cov": "ut run test -- --coverage",
"preci": "ut run pretest --workspaces --if-present",
"ci": "ut run test -- --coverage",
"dev:services:start": "node scripts/dev-services.js start",
"dev:services:stop": "node scripts/dev-services.js stop",
"dev:services:status": "node scripts/dev-services.js status",
"dev:services:reset": "node scripts/dev-services.js reset",
"site:dev": "cd site && npm run dev",
"site:build": "cd site && npm run build",
"puml": "puml . --dest ./site",
Expand Down
247 changes: 247 additions & 0 deletions scripts/dev-services.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,247 @@
#!/usr/bin/env node

import { spawnSync } from 'node:child_process';
import net from 'node:net';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';

const rootDir = dirname(dirname(fileURLToPath(import.meta.url)));
const composeFile = join(rootDir, 'dev-services.compose.yml');

const databaseNames = [
'test',
'apple',
'banana',
'test_runtime_datasource',
'test_runtime_dao',
'test_dal_plugin',
'test_dal_standalone',
'cnpmcore',
'cnpmcore_unittest',
];

function readPositiveInteger(name, defaultValue) {
const rawValue = process.env[name];
if (!rawValue) {
return defaultValue;
}

const value = Number(rawValue);
if (!Number.isInteger(value) || value <= 0) {
throw new Error(`${name} must be a positive integer, got ${rawValue}`);
}
return value;
}

function readPort(name, defaultValue) {
const port = readPositiveInteger(name, defaultValue);
if (port > 65535) {
throw new Error(`${name} must be between 1 and 65535, got ${port}`);
}
return port;
}

function readConfig() {
return {
mysqlPort: readPort('EGG_DEV_SERVICES_MYSQL_PORT', 3306),
redisPort: readPort('EGG_DEV_SERVICES_REDIS_PORT', 6379),
waitTimeout: readPositiveInteger('EGG_DEV_SERVICES_WAIT_TIMEOUT', 150),
};
}

function dockerCompose(args, options = {}) {
const result = spawnSync('docker', ['compose', '-f', composeFile, ...args], {
cwd: rootDir,
encoding: 'utf8',
stdio: options.stdio ?? 'pipe',
});
if (result.error) {
throw result.error;
}
if (result.status !== 0 && !options.allowFailure) {
const output = [result.stdout, result.stderr].filter(Boolean).join('\n').trim();
throw new Error(output || `docker compose ${args.join(' ')} failed`);
}
return result;
}

function runDocker(args, options = {}) {
const result = spawnSync('docker', args, {
cwd: rootDir,
encoding: 'utf8',
stdio: options.stdio ?? 'pipe',
});
if (result.error) {
throw result.error;
}
if (result.status !== 0 && !options.allowFailure) {
const output = [result.stdout, result.stderr].filter(Boolean).join('\n').trim();
throw new Error(output || `docker ${args.join(' ')} failed`);
}
return result;
}

function sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}

async function portIsFree(port) {
return await new Promise((resolve) => {
const server = net.createServer();
server.once('error', () => resolve(false));
server.once('listening', () => {
server.close(() => resolve(true));
});
server.listen(port, '127.0.0.1');
});
}

function runningServices() {
const result = dockerCompose(['ps', '--status', 'running', '--services'], { allowFailure: true });
if (result.status !== 0) {
return new Set();
}
return new Set(result.stdout.trim().split(/\s+/).filter(Boolean));
}

function publishedEndpoint(service, containerPort) {
const result = dockerCompose(['port', service, String(containerPort)], { allowFailure: true });
if (result.status !== 0) {
return undefined;
}
const line = result.stdout.trim().split(/\r?\n/).filter(Boolean).at(0);
const match = line?.match(/^(.+):(\d+)$/);
if (!match) {
return undefined;
}
return {
host: match[1],
port: Number.parseInt(match[2], 10),
};
}

async function assertPortAvailable(service, port, containerPort, running) {
if (running.has(service)) {
const published = publishedEndpoint(service, containerPort);
if (published?.host === '127.0.0.1' && published.port === port) {
return;
}

const current = published ? `${published.host}:${published.port}` : 'an unknown host port';
throw new Error(
[
`${service} is already running for this compose project on ${current}, but this run requested 127.0.0.1:${port}.`,
'Re-run with the same EGG_DEV_SERVICES_* port override, or use `utoo run dev:services:reset` before changing ports.',
].join('\n'),
);
}
if (await portIsFree(port)) {
return;
}

throw new Error(
[
`Port ${port} is already in use, so the ${service} container was not started.`,
'If the existing service is compatible, keep using it and run the tests directly.',
`Otherwise stop that service, or choose another Docker host port with EGG_DEV_SERVICES_${service.toUpperCase()}_PORT.`,
'Most DAL/ORM/Redis test fixtures still default to 127.0.0.1:3306 or 127.0.0.1:6379, so the default ports are required for the full local test path.',
].join('\n'),
);
}

async function waitFor(command, label, waitTimeout) {
const deadline = Date.now() + waitTimeout * 1000;
Comment thread
killagu marked this conversation as resolved.
let lastOutput = '';

while (Date.now() < deadline) {
const result = dockerCompose(['exec', '-T', ...command], { allowFailure: true });
lastOutput = [result.stdout, result.stderr].filter(Boolean).join('\n').trim();
if (result.status === 0) {
return;
}
await sleep(1000);
}

throw new Error(`${label} was not ready within ${waitTimeout}s.\n${lastOutput}`);
}

async function initMysql(waitTimeout) {
await waitFor(['mysql', 'mysqladmin', 'ping', '-h', '127.0.0.1', '-uroot', '--silent'], 'MySQL', waitTimeout);

const sql = databaseNames.map((name) => `CREATE DATABASE IF NOT EXISTS \`${name}\`;`).join(' ');
dockerCompose(['exec', '-T', 'mysql', 'mysql', '-uroot', '-e', sql], { stdio: 'inherit' });
}

async function waitRedis(waitTimeout) {
await waitFor(['redis', 'redis-cli', 'ping'], 'Redis', waitTimeout);
}

async function start() {
const { mysqlPort, redisPort, waitTimeout } = readConfig();

runDocker(['compose', 'version']);

const running = runningServices();
await assertPortAvailable('mysql', mysqlPort, 3306, running);
await assertPortAvailable('redis', redisPort, 6379, running);

dockerCompose(['up', '-d'], { stdio: 'inherit' });
try {
await initMysql(waitTimeout);
await waitRedis(waitTimeout);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
throw new Error(
[
message,
'The compose stack is still running so Docker health status and logs can be inspected.',
'After fixing the issue, run `utoo run dev:services:reset` to clean up before starting again.',
].join('\n'),
{ cause: err },
);
}

console.log(`Local services are ready: MySQL 127.0.0.1:${mysqlPort}, Redis 127.0.0.1:${redisPort}`);
}

function stop() {
dockerCompose(['down'], { stdio: 'inherit' });
}

function reset() {
dockerCompose(['down', '-v'], { stdio: 'inherit' });
}

function status() {
dockerCompose(['ps'], { stdio: 'inherit' });
}

async function main() {
const command = process.argv[2] || 'start';
switch (command) {
case 'start':
case 'up':
await start();
break;
case 'stop':
case 'down':
stop();
break;
case 'reset':
reset();
break;
case 'status':
case 'ps':
status();
break;
default:
console.error(`Unknown command: ${command}`);
console.error('Usage: node scripts/dev-services.js <start|up|stop|down|status|ps|reset>');
process.exitCode = 1;
}
}

main().catch((err) => {
console.error(err instanceof Error ? err.message : String(err));
process.exitCode = 1;
});
Loading