refactor: testing

This commit is contained in:
cha0s 2024-02-11 21:03:06 -06:00
parent 2a98363942
commit 27e3275a39
39 changed files with 1364 additions and 580 deletions

View File

@ -8,15 +8,58 @@ on:
workflow_dispatch: {}
jobs:
lint:
uses: ./.github/workflows/node.yml
with:
run: npm run lint
test:
uses: ./.github/workflows/node.yml
with:
run: npm run -- test -p 360000
build:
uses: ./.github/workflows/node.yml
with:
run: npm run build
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Use Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v4
with:
node-version: 20.x
cache: 'npm'
- run: npm config set registry ${{ vars.NPM_CI_REGISTRY }}
if: ${{ vars.NPM_CI_REGISTRY }}
- run: |
npm ci
npm run build
ci:
runs-on: ubuntu-latest
strategy:
max-parallel: ${{ vars.CI_PARALLEL || 256 }}
matrix:
node-version: [16.x, 18.x, 20.x]
steps:
- uses: actions/checkout@v4
- run: |
sudo apt-get update
sudo apt-get install -y libgconf-2-4 libatk1.0-0 libatk-bridge2.0-0 libgdk-pixbuf2.0-0 libgtk-3-0 libgbm-dev libnss3-dev libxss-dev libasound2
- uses: browser-actions/setup-chrome@latest
- name: Use Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }}
cache: 'npm'
- run: npm config set registry ${{ vars.NPM_CI_REGISTRY }}
if: ${{ vars.NPM_CI_REGISTRY }}
- run: |
npm ci
npm run -- test -t 360000
npm run -- test -t 360000 -p e2e
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Use Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v4
with:
node-version: 20.x
cache: 'npm'
- run: npm config set registry ${{ vars.NPM_CI_REGISTRY }}
if: ${{ vars.NPM_CI_REGISTRY }}
- run: |
npm ci
npm run lint

View File

@ -1,12 +0,0 @@
name: End-to-end tests
on:
schedule:
- cron: '20 4 * * *'
workflow_dispatch: {}
jobs:
e2e:
uses: ./.github/workflows/node.yml
with:
run: npm run -- test -t 360000 -p e2e

View File

@ -1,27 +0,0 @@
name: Continuous Integration
on:
workflow_call:
inputs:
run:
required: true
type: string
jobs:
node:
runs-on: ubuntu-latest
strategy:
matrix:
node-version: [18.x, 20.x]
steps:
- uses: actions/checkout@v4
- name: Use Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }}
cache: 'npm'
- run: npm config set registry ${{ vars.NPM_CI_REGISTRY }}
if: ${{ vars.NPM_CI_REGISTRY }}
- run: |
npm ci
${{ inputs.run }}

811
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -261,11 +261,12 @@ exports.commands = (program, flecks) => {
const spawnWebpack = () => {
webpack = spawnWith(cmd, options);
webpack.on('message', (message) => {
switch (message) {
switch (message.type) {
case 'kill':
debug('killing...');
webpack.kill();
watcher.close();
process.exitCode = message.payload;
break;
case 'restart':
debug('restarting webpack...');

View File

@ -41,6 +41,7 @@ module.exports = async (flecks) => ({
'test/**/*.js',
],
rules: {
'prefer-arrow-callback': ['error', {allowNamedFunctions: true}],
'brace-style': 'off',
'class-methods-use-this': 'off',
'import/no-extraneous-dependencies': 'off',

View File

@ -26,9 +26,11 @@ exports.hooks = {
}
config.plugins.push(new ProcessAssets(target, flecks));
},
'@flecks/build.config.alter': async ({test}) => {
'@flecks/build.config.alter': async ({test}, env, argv, flecks) => {
if (test) {
// Externalize the rest.
test.externals = await externals({
additionalModuleDirs: flecks.resolver.modules,
allowlist: Object.keys(test.resolve.fallback).map((fallback) => new RegExp(fallback)),
});
}

View File

@ -1,7 +1,7 @@
import {randomBytes} from 'crypto';
import {mkdir} from 'fs/promises';
import {tmpdir} from 'os';
import {join} from 'path';
import {basename, join} from 'path';
import {rimraf} from 'rimraf';
@ -12,8 +12,23 @@ export function id() {
}
export async function createWorkspace() {
const workspace = join(tmpdir(), '@flecks', 'core', 'testing', await id());
let workspace = join(tmpdir(), '@flecks', 'core', 'testing', await id());
try {
throw new Error();
}
catch (error) {
workspace += `-${basename(
error.stack
.split('\n').slice(-1)[0]
.split('at ')[1]
.match(/\((.*)\)$/)[1]
.split(':').slice(-3, -2)[0],
)}`;
}
await mkdir(workspace, {recursive: true});
process.on('exit', () => {
rimraf.sync(workspace);
});
// sheeeeesh
process.prependListener('message', async (message) => {
if ('__workerpool-terminate__' === message) {

View File

@ -1,10 +1,10 @@
const {access} = require('fs/promises');
const {join, relative} = require('path');
const {join} = require('path');
const {commands: coreCommands} = require('@flecks/build/build/commands');
const {rimraf} = require('@flecks/build/src/server');
const {D} = require('@flecks/core/src');
const {glob} = require('@flecks/core/src/server');
const {glob, pipesink, processCode} = require('@flecks/core/src/server');
const Mocha = require('mocha');
const {watchParallelRun} = require('mocha/lib/cli/watch-run');
@ -12,6 +12,7 @@ const debug = D('@flecks/build.commands');
const {
FLECKS_CORE_ROOT = process.cwd(),
TERM,
} = process.env;
module.exports = (program, flecks) => {
@ -41,47 +42,36 @@ module.exports = (program, flecks) => {
watch,
} = opts;
const {build} = coreCommands(program, flecks);
let files = [];
if (platforms.includes('default')) {
files.push(...await glob(join(FLECKS_CORE_ROOT, 'test', '*.js')));
}
await Promise.all(
platforms
.filter((platform) => 'default' !== platform)
.map(async (platform) => {
files.push(...await glob(join(FLECKS_CORE_ROOT, 'test', platform, '*.js')));
}),
);
if (0 === files.length) {
// eslint-disable-next-line no-console
console.log('No tests found.');
return undefined;
}
files = files.map((path) => relative(FLECKS_CORE_ROOT, path));
if (only) {
if (files.includes(only)) {
files = [only];
}
else {
throw new Error(`Test '${only}' does not exist!`);
}
}
files = files.map((file) => join('dist', file));
// Remove the previous test.
// Remove the previous test(s).
await rimraf(join(FLECKS_CORE_ROOT, 'dist', 'test'));
// Kick off building the test and wait for the file to exist.
await build.action(
const child = await build.action(
'test',
{
env: {FLECKS_CORE_TEST_PLATFORMS: JSON.stringify(platforms)},
env: {
FLECKS_CORE_TEST_PLATFORMS: JSON.stringify(platforms),
FORCE_COLOR: 'dumb' !== TERM,
},
production,
stdio: 'ignore',
stdio: watch ? 'inherit' : 'pipe',
watch,
},
);
if (!watch) {
const stdout = pipesink(child.stdout);
if (0 !== await processCode(child)) {
const buffer = await stdout;
if (!process.stdout.write(buffer)) {
await new Promise((resolve, reject) => {
process.stdout.on('error', reject);
process.stdout.on('drain', resolve);
});
}
program.error('\nbuilding tests failed!\n');
}
}
debug('Testing...', opts);
// eslint-disable-next-line no-constant-condition
while (true) {
while (watch) {
try {
// eslint-disable-next-line no-await-in-loop
await access(join(FLECKS_CORE_ROOT, 'dist', 'test'));
@ -94,6 +84,19 @@ module.exports = (program, flecks) => {
});
}
}
let files = await glob(join(FLECKS_CORE_ROOT, 'dist', 'test', '**', '*.js'));
if (0 === files.length) {
return undefined;
}
if (only) {
const index = files.indexOf(join(FLECKS_CORE_ROOT, 'dist', only));
if (-1 !== index) {
files = [files[index]];
}
else {
throw new Error(`Test '${only}' does not exist!`);
}
}
// Magic.
require('@flecks/core/build/resolve')(
{

View File

@ -92,15 +92,17 @@ class StartServerPlugin {
...(inspectPort && {inspectPort}),
});
this.worker = cluster.fork(env);
this.worker.on('exit', (code) => {
if (killOnExit) {
process.send({type: 'kill', payload: code});
process.exit(code);
}
});
this.worker.on('disconnect', () => {
if (this.worker.exitedAfterDisconnect) {
// eslint-disable-next-line no-console
console.error('[HMR] Restarting application...');
process.send('restart');
}
else if (killOnExit) {
process.send('kill');
process.exit(0);
process.send({type: 'restart'});
}
});
return new Promise((resolve, reject) => {

View File

@ -19,7 +19,8 @@
},
"files": [
"entry.js",
"runtime.js"
"runtime.js",
"server.js"
],
"dependencies": {
"@flecks/core": "^4.0.5"

View File

@ -20,15 +20,9 @@ import {D, Flecks} from '@flecks/core';
}
const debug = D('@flecks/server/entry');
debug('starting server...');
try {
global.flecks = await Flecks.from({...runtime, flecks: await loadFlecks()});
await global.flecks.invokeSequentialAsync('@flecks/server.up');
debug('up!');
}
catch (error) {
// eslint-disable-next-line no-console
console.error(error);
}
global.flecks = await Flecks.from({...runtime, flecks: await loadFlecks()});
await global.flecks.invokeSequentialAsync('@flecks/server.up');
debug('up!');
})();
if (module.hot) {

View File

@ -0,0 +1,49 @@
import cluster from 'cluster';
import {createConnection} from 'net';
const {
FLECKS_SERVER_TEST_SOCKET,
NODE_ENV,
} = process.env;
export const hooks = {
'@flecks/server.up': (flecks) => {
if (!FLECKS_SERVER_TEST_SOCKET || 'test' !== NODE_ENV) {
return;
}
const socket = createConnection(FLECKS_SERVER_TEST_SOCKET);
if (cluster.isWorker) {
cluster.worker.on('disconnect', () => {
socket.end();
});
}
flecks.server.socket = socket;
socket.on('connect', () => {
socket.on('data', (data) => {
const {meta, payload, type} = JSON.parse(data);
switch (type) {
case 'config.get':
socket.write(JSON.stringify({
meta,
payload: flecks.get(payload),
}));
break;
case 'exit':
socket.end();
process.exit(payload);
break;
default:
}
});
});
},
};
export const mixin = (Flecks) => class FlecksWithServer extends Flecks {
constructor(runtime) {
super(runtime);
this.server = {};
}
};

View File

@ -2,9 +2,14 @@ import {cp} from 'fs/promises';
import {join} from 'path';
import {createWorkspace} from '@flecks/core/build/testing';
import {binaryPath, processCode, spawnWith} from '@flecks/core/server';
import {
binaryPath,
pipesink,
processCode,
spawnWith,
} from '@flecks/core/server';
import {listen} from './listen';
import {socketListener} from './listen';
const {
FLECKS_CORE_ROOT = process.cwd(),
@ -18,43 +23,85 @@ export async function createApplication() {
return workspace;
}
export async function buildChild(path, {args = [], opts = {}} = {}) {
return spawnWith(
class TestingServer {
constructor(path, child, socketServer) {
this.path = path;
this.child = child;
this.socketServer = socketServer;
}
async waitForSocket(options) {
return this.socketServer.waitForSocket(options);
}
}
export async function startServer({
args = ['-h'],
beforeBuild,
failOnErrorCode = true,
opts = {},
path: request,
task,
} = {}) {
let previousTimeout;
const start = Date.now();
if (task) {
previousTimeout = task.timeout();
task.timeout(0);
}
const {socketPath, socketServer} = await socketListener();
const path = request || await createApplication();
if (beforeBuild) {
await beforeBuild({path, task});
}
const server = spawnWith(
[await binaryPath('flecks', '@flecks/build'), 'build', ...args],
{
stdio: 'ignore',
stdio: 'pipe',
...opts,
env: {
FLECKS_ENV__flecks_server__stats: '{"preset": "none"}',
FLECKS_ENV__flecks_server__start: 0,
FLECKS_ENV__flecks_server__start: true,
FLECKS_CORE_ROOT: path,
FLECKS_SERVER_TEST_SOCKET: socketPath,
NODE_ENV: 'test',
NODE_PATH: join(FLECKS_CORE_ROOT, '..', '..', 'node_modules'),
...opts.env,
},
},
);
}
export async function build(path, {args = [], opts = {}} = {}) {
return processCode(await buildChild(path, {args, opts}));
}
export async function serverActions(path, actions) {
const {listening, path: socketPath, socketServer} = await listen();
await listening;
const server = spawnWith(
['node', join(path, 'dist', 'server')],
{
env: {
FLECKS_SERVER_TEST_SOCKET: socketPath,
NODE_PATH: join(FLECKS_CORE_ROOT, '..', '..', 'node_modules'),
},
stdio: 'ignore',
},
if (failOnErrorCode) {
const stderr = pipesink(server.stderr);
server.on('exit', async (code) => {
if (0 !== code) {
const buffer = await stderr;
if (!process.stderr.write(buffer)) {
await new Promise((resolve, reject) => {
process.stderr.on('error', reject);
process.stderr.on('drain', resolve);
});
}
// eslint-disable-next-line no-console
console.error('\nserver process exited unexpectedly\n');
process.exit(code);
}
});
}
task?.timeout(previousTimeout + (Date.now() - start));
return new TestingServer(
path,
server,
socketServer,
);
const [code, results] = await Promise.all([
processCode(server),
socketServer.waitForSocket().then(async (socket) => {
}
export function withServer(task, options) {
return async function withServer() {
const server = await startServer({...options, task: this});
const socket = await server.waitForSocket({task: this});
server.actions = async (actions) => {
const results = [];
await actions.reduce(
(p, action) => (
@ -68,7 +115,25 @@ export async function serverActions(path, actions) {
Promise.resolve(),
);
return results;
}),
]);
return {code, results};
};
return task({server, socket});
};
}
export async function build(path, {args = [], opts = {}} = {}) {
return processCode(spawnWith(
[await binaryPath('flecks', '@flecks/build'), 'build', ...args],
{
stdio: 'ignore',
...opts,
env: {
FLECKS_ENV__flecks_server__stats: '{"preset": "none"}',
FLECKS_ENV__flecks_server__start: 0,
FLECKS_CORE_ROOT: path,
NODE_ENV: 'test',
NODE_PATH: join(FLECKS_CORE_ROOT, '..', '..', 'node_modules'),
...opts.env,
},
},
));
}

View File

@ -33,12 +33,12 @@ class SocketWrapper {
});
}
async waitForHmr() {
async waitForAction(type) {
return new Promise((resolve, reject) => {
this.socket.on('error', reject);
this.socket.on('data', (data) => {
const action = JSON.parse(data.toString());
if ('hmr' === action.type) {
if (action.type === type) {
resolve(action);
}
});
@ -47,31 +47,39 @@ class SocketWrapper {
}
export async function listen() {
export async function socketListener() {
const path = join(tmpdir(), 'flecks', 'ci', await id());
await mkdir(dirname(path), {recursive: true});
const server = createServer();
server.listen(path);
server.waitForSocket = () => (
server.waitForSocket = ({task, timeout = 30000} = {}) => (
new Promise((resolve, reject) => {
server.on('error', reject);
let previousTimeout;
const start = Date.now();
if (task) {
previousTimeout = task.timeout();
task.timeout(0);
}
const handle = setTimeout(() => {
reject(new Error('timeout waiting for IPC connection'));
}, timeout);
const finish = () => {
clearTimeout(handle);
task?.timeout(previousTimeout + (Date.now() - start));
};
server.on('error', (error) => {
finish();
reject(error);
});
server.on('connection', (socket) => {
finish();
resolve(new SocketWrapper(socket));
});
})
);
return {
listening: new Promise((resolve, reject) => {
server.on('error', reject);
server.on('listening', resolve);
}),
path,
socketServer: server,
};
}
export async function socketListener() {
const {listening, path: socketPath, socketServer} = await listen();
await listening;
return {socketServer, socketPath};
await new Promise((resolve, reject) => {
server.on('error', reject);
server.on('listening', resolve);
});
return {socketServer: server, socketPath: path};
}

View File

@ -1,38 +1,15 @@
import {join} from 'path';
import {heavySetup} from '@flecks/core/build/testing';
import {writeFile} from '@flecks/core/server';
import {expect} from 'chai';
import {build, createApplication} from './build/build';
import {socketListener} from './build/listen';
import {withServer} from './build/build';
let path;
let socket;
before(heavySetup(async () => {
path = await createApplication();
const {socketPath, socketServer} = await socketListener();
build(
path,
{
args: ['-h'],
opts: {
env: {
FLECKS_ENV__flecks_server__start: true,
FLECKS_SERVER_TEST_SOCKET: socketPath,
},
},
},
);
socket = await socketServer.waitForSocket();
}));
it('updates config', async () => {
it('allows updates to fail', withServer(async ({server, socket}) => {
expect((await socket.send({type: 'config.get', payload: 'comm.foo'})).payload)
.to.equal('bar');
await writeFile(
join(path, 'build', 'flecks.yml'),
join(server.path, 'build', 'flecks.yml'),
`
'@flecks/build': {}
'@flecks/core': {}
@ -40,12 +17,12 @@ it('updates config', async () => {
'comm:./comm': {foo: 'baz'}
`,
);
await socket.waitForHmr();
await socket.waitForAction('hmr');
expect((await socket.send({type: 'config.get', payload: 'comm.foo'})).payload)
.to.equal('baz');
let restarted;
const whatHappened = Promise.race([
socket.waitForHmr()
socket.waitForAction('hmr')
.then(() => {
restarted = false;
})
@ -58,7 +35,7 @@ it('updates config', async () => {
}),
]);
await writeFile(
join(path, 'build', 'flecks.yml'),
join(server.path, 'build', 'flecks.yml'),
`
'@flecks/build': {}
'@flecks/core': {}
@ -69,4 +46,4 @@ it('updates config', async () => {
await whatHappened;
expect(restarted)
.to.be.true;
});
}));

View File

@ -1,40 +1,14 @@
import {join} from 'path';
import {heavySetup} from '@flecks/core/build/testing';
import {writeFile} from '@flecks/core/server';
import {expect} from 'chai';
import {buildChild, createApplication} from './build/build';
import {socketListener} from './build/listen';
import {withServer} from './build/build';
let path;
let listener;
let socket;
before(heavySetup(async () => {
path = await createApplication();
listener = await socketListener();
const {socketPath, socketServer} = listener;
await buildChild(
path,
{
args: ['-w'],
opts: {
env: {
FLECKS_ENV__flecks_server__start: true,
FLECKS_SERVER_TEST_SOCKET: socketPath,
},
},
},
);
socket = await socketServer.waitForSocket();
}));
async function restart() {
this.timeout(0);
it('restarts when config keys change', withServer(async ({server, socket}) => {
let restarted;
const whatHappened = Promise.race([
socket.waitForHmr()
socket.waitForAction('hmr')
.then(() => {
restarted = false;
})
@ -47,7 +21,7 @@ async function restart() {
}),
]);
await writeFile(
join(path, 'build', 'flecks.yml'),
join(server.path, 'build', 'flecks.yml'),
`
'@flecks/build': {}
'@flecks/core': {}
@ -60,15 +34,10 @@ async function restart() {
expect(restarted)
.to.be.true;
let config;
const before = Date.now();
await listener.socketServer.waitForSocket()
await server.socketServer.waitForSocket({task: this})
.then(async (socket) => {
({payload: config} = await socket.send({type: 'config.get', payload: '@flecks/repl/server'}));
});
// Had to rebuild...
this.timeout(2000 + (Date.now() - before));
expect(config)
.to.not.be.undefined;
}
it('restarts when config keys change', restart);
}));

View File

@ -1,38 +1,15 @@
import {join} from 'path';
import {heavySetup} from '@flecks/core/build/testing';
import {writeFile} from '@flecks/core/server';
import {expect} from 'chai';
import {build, createApplication} from './build/build';
import {socketListener} from './build/listen';
import {withServer} from './build/build';
let path;
let socket;
before(heavySetup(async () => {
path = await createApplication();
const {socketPath, socketServer} = await socketListener();
build(
path,
{
args: ['-h'],
opts: {
env: {
FLECKS_ENV__flecks_server__start: true,
FLECKS_SERVER_TEST_SOCKET: socketPath,
},
},
},
);
socket = await socketServer.waitForSocket();
}));
it('updates config', async () => {
it('updates config', withServer(async ({server, socket}) => {
expect((await socket.send({type: 'config.get', payload: '@flecks/core.id'})).payload)
.to.equal('flecks');
await writeFile(
join(path, 'build', 'flecks.yml'),
join(server.path, 'build', 'flecks.yml'),
`
'@flecks/build': {}
'@flecks/core': {id: 'testing'}
@ -40,8 +17,8 @@ it('updates config', async () => {
'comm:./comm': {}
`,
);
await socket.waitForHmr();
await socket.waitForAction('hmr');
expect((await socket.send({type: 'config.get', payload: '@flecks/core.id'})).payload)
.to.equal('testing');
await socket.send({type: 'exit'});
});
}));

View File

@ -1,48 +1,46 @@
import {mkdir} from 'fs/promises';
import {join} from 'path';
import {heavySetup} from '@flecks/core/build/testing';
import {writeFile} from '@flecks/core/server';
import {expect} from 'chai';
import {build, createApplication, serverActions} from './build/build';
import {withServer} from './build/build';
let path;
before(heavySetup(async () => {
path = await createApplication();
await mkdir(join(path, 'server-only', 'build'), {recursive: true});
await writeFile(join(path, 'server-only', 'package.json'), '{}');
const config = `
exports.hooks = {
'@flecks/core.config': () => ({
foo: 'bar',
blah: {one: 2, three: 4},
}),
};
`;
await writeFile(join(path, 'server-only', 'build', 'flecks.bootstrap.js'), config);
await writeFile(
join(path, 'build', 'flecks.yml'),
`
'@flecks/build': {}
'@flecks/core': {}
'@flecks/server': {}
'comm:./comm': {}
'server-only:./server-only': {foo: 'baz'}
`,
);
await build(path, {args: ['-d']});
}));
it('propagates bootstrap config', async () => {
const {results: [{payload: foo}, {payload: blah}]} = await serverActions(path, [
{type: 'config.get', payload: 'server-only.foo'},
{type: 'config.get', payload: 'server-only.blah'},
{type: 'exit'},
]);
expect(foo)
.to.equal('baz');
expect(blah)
.to.deep.equal({one: 2, three: 4});
});
it('propagates bootstrap config', withServer(
async ({server}) => {
const [{payload: foo}, {payload: blah}] = await server.actions([
{type: 'config.get', payload: 'server-only.foo'},
{type: 'config.get', payload: 'server-only.blah'},
{type: 'exit'},
]);
expect(foo)
.to.equal('baz');
expect(blah)
.to.deep.equal({one: 2, three: 4});
},
{
beforeBuild: async ({path}) => {
await mkdir(join(path, 'server-only', 'build'), {recursive: true});
await writeFile(join(path, 'server-only', 'package.json'), '{}');
const config = `
exports.hooks = {
'@flecks/core.config': () => ({
foo: 'bar',
blah: {one: 2, three: 4},
}),
};
`;
await writeFile(join(path, 'server-only', 'build', 'flecks.bootstrap.js'), config);
await writeFile(
join(path, 'build', 'flecks.yml'),
`
'@flecks/build': {}
'@flecks/core': {}
'@flecks/server': {}
'comm:./comm': {}
'server-only:./server-only': {foo: 'baz'}
`,
);
},
},
));

View File

@ -1,35 +1,33 @@
import {join} from 'path';
import {heavySetup} from '@flecks/core/build/testing';
import {writeFile} from '@flecks/core/server';
import {expect} from 'chai';
import {build, createApplication, serverActions} from './build/build';
import {withServer} from './build/build';
let path;
before(heavySetup(async () => {
path = await createApplication();
await writeFile(
join(path, 'build', 'flecks.yml'),
`
'@flecks/build': {}
'@flecks/core': {id: 'testing'}
'@flecks/server': {}
'comm:./comm': {foo: 'baz'}
`,
);
await build(path, {args: ['-d']});
}));
it('propagates bootstrap config', async () => {
const {results: [{payload: id}, {payload: foo}]} = await serverActions(path, [
{type: 'config.get', payload: '@flecks/core.id'},
{type: 'config.get', payload: 'comm.foo'},
{type: 'exit'},
]);
expect(id)
.to.equal('testing');
expect(foo)
.to.equal('baz');
});
it('propagates bootstrap config', withServer(
async ({server}) => {
const [{payload: id}, {payload: foo}] = await server.actions([
{type: 'config.get', payload: '@flecks/core.id'},
{type: 'config.get', payload: 'comm.foo'},
{type: 'exit'},
]);
expect(id)
.to.equal('testing');
expect(foo)
.to.equal('baz');
},
{
beforeBuild: async ({path}) => {
await writeFile(
join(path, 'build', 'flecks.yml'),
`
'@flecks/build': {}
'@flecks/core': {id: 'testing'}
'@flecks/server': {}
'comm:./comm': {foo: 'baz'}
`,
);
},
},
));

View File

@ -1,20 +1,12 @@
import {heavySetup} from '@flecks/core/build/testing';
import {expect} from 'chai';
import {build, createApplication, serverActions} from './build/build';
import {withServer} from './build/build';
let path;
before(heavySetup(async () => {
path = await createApplication();
await build(path, {args: ['-d']});
}));
it('propagates runtime config', async () => {
const {results: [{payload: foo}]} = await serverActions(path, [
it('propagates runtime config', withServer(async ({server}) => {
const [{payload: foo}] = await server.actions([
{type: 'config.get', payload: 'comm.foo'},
{type: 'exit'},
]);
expect(foo)
.to.equal('bar');
});
}));

View File

@ -1,19 +1,16 @@
import {heavySetup} from '@flecks/core/build/testing';
import {processCode} from '@flecks/core/src/server';
import {expect} from 'chai';
import {build, createApplication, serverActions} from './build/build';
import {withServer} from './build/build';
let path;
before(heavySetup(async () => {
path = await createApplication();
await build(path, {args: ['-d']});
}));
it('connects', async () => {
const {code} = await serverActions(path, [
{type: 'exit', payload: 42},
]);
expect(code)
.to.equal(42);
});
it('connects', withServer(
async ({server}) => {
const code = processCode(server.child);
await server.actions([
{type: 'exit', payload: 42},
]);
expect(await code)
.to.equal(42);
},
{failOnErrorCode: false},
));

View File

@ -1,37 +1,14 @@
import {join} from 'path';
import {heavySetup} from '@flecks/core/build/testing';
import {writeFile} from '@flecks/core/server';
import {expect} from 'chai';
import {buildChild, createApplication} from './build/build';
import {socketListener} from './build/listen';
import {withServer} from './build/build';
let path;
let socket;
before(heavySetup(async () => {
path = await createApplication();
const {socketPath, socketServer} = await socketListener();
await buildChild(
path,
{
args: ['-w'],
opts: {
env: {
FLECKS_ENV__flecks_server__start: true,
FLECKS_SERVER_TEST_SOCKET: socketPath,
},
},
},
);
socket = await socketServer.waitForSocket();
}));
it('restarts when root sources change', async () => {
it('restarts when root sources change', withServer(async ({server, socket}) => {
let restarted;
const whatHappened = Promise.race([
socket.waitForHmr()
socket.waitForAction('hmr')
.then(() => {
restarted = false;
})
@ -43,8 +20,8 @@ it('restarts when root sources change', async () => {
});
}),
]);
await writeFile(join(path, 'comm', 'package.json'), '{}');
await writeFile(join(server.path, 'comm', 'package.json'), '{}');
await whatHappened;
expect(restarted)
.to.be.true;
});
}));

View File

@ -1,10 +1,3 @@
import cluster from 'cluster';
import {createConnection} from 'net';
const {
FLECKS_SERVER_TEST_SOCKET,
} = process.env;
export const hooks = {
'@flecks/core.reload': (fleck, config) => {
if ('comm' === fleck && 'fail' === config.foo) {
@ -12,42 +5,10 @@ export const hooks = {
}
},
'@flecks/core.hmr': async (path, M, flecks) => {
if (!flecks.socket) {
return;
}
flecks.socket.write(JSON.stringify({
const {socket} = flecks.server;
socket.write(JSON.stringify({
type: 'hmr',
payload: path,
}));
},
'@flecks/server.up': async (flecks) => {
if (!FLECKS_SERVER_TEST_SOCKET) {
return;
}
const socket = createConnection(FLECKS_SERVER_TEST_SOCKET);
if (cluster.isWorker) {
cluster.worker.on('disconnect', () => {
socket.end();
});
}
flecks.socket = socket;
socket.on('connect', () => {
socket.on('data', (data) => {
const {meta, payload, type} = JSON.parse(data);
switch (type) {
case 'config.get':
socket.write(JSON.stringify({
meta,
payload: flecks.get(payload),
}));
break;
case 'exit':
socket.end();
process.exit(payload);
break;
default:
}
});
});
},
};

View File

@ -1,15 +1,23 @@
const {stat, unlink} = require('fs/promises');
const {join} = require('path');
const {
basename,
dirname,
extname,
join,
relative,
} = require('path');
const Build = require('@flecks/build/build/build');
const {regexFromExtensions} = require('@flecks/build/src/server');
const {binaryPath, spawnWith} = require('@flecks/core/src/server');
const {binaryPath, glob, spawnWith} = require('@flecks/core/src/server');
const MiniCssExtractPlugin = require('mini-css-extract-plugin');
const {
FLECKS_CORE_ROOT = process.cwd(),
} = process.env;
const tests = join(FLECKS_CORE_ROOT, 'test');
exports.hooks = {
'@flecks/build.config': async (target, config, env, argv, flecks) => {
const isProduction = 'production' === argv.mode;
@ -25,6 +33,19 @@ exports.hooks = {
config.plugins.push(new MiniCssExtractPlugin({filename: 'assets/[name].css'}));
break;
}
case 'test': {
finalLoader = {loader: MiniCssExtractPlugin.loader};
config.plugins.push(new MiniCssExtractPlugin({filename: 'assets/[name].css'}));
(await glob(join(tests, 'client', '*.js')))
.forEach((path) => {
const entry = relative(tests, path);
config.entry[join(dirname(entry), basename(entry, extname(entry)))] = [
'source-map-support/register',
path,
];
});
break;
}
case 'web': {
if (isProduction) {
finalLoader = {loader: MiniCssExtractPlugin.loader};

View File

@ -1,20 +1,18 @@
const {access, readFile} = require('fs/promises');
const {access} = require('fs/promises');
const {
basename,
dirname,
extname,
join,
} = require('path');
const Build = require('@flecks/build/build/build');
const {glob} = require('@flecks/core/server');
module.exports = async (config, env, argv, flecks) => {
const buildFlecks = await Build.from({
config: flecks.realiasedConfig,
platforms: ['client', '!server'],
});
const {resolver, flecks: webFlecks} = buildFlecks;
const {flecks: webFlecks} = buildFlecks;
const paths = Object.keys(webFlecks)
.filter((fleck) => !['@flecks/server'].includes(fleck));
const styles = (
@ -44,7 +42,6 @@ module.exports = async (config, env, argv, flecks) => {
)
.filter((filename) => !!filename);
const runtime = await flecks.resolver.resolve(join('@flecks/web/runtime'));
const isProduction = 'production' === argv.mode;
const resolvedPaths = (await Promise.all(
paths.map(async (path) => [path, await flecks.resolver.resolve(path)]),
))
@ -98,56 +95,4 @@ module.exports = async (config, env, argv, flecks) => {
await buildFlecks.runtimeCompiler('web', config, env, argv);
// Styles.
config.entry.index.push(...styles);
// Tests.
if (!isProduction) {
const testEntries = (await Promise.all(
buildFlecks.roots
.map(async ([root, request]) => {
const tests = [];
const resolved = dirname(
await buildFlecks.resolver.resolve(join(request, 'package.json')),
);
const rootTests = await glob(join(resolved, 'test', '*.js'));
tests.push(...rootTests.map((test) => test.replace(resolved, root)));
const platformTests = await Promise.all(
buildFlecks.platforms.map((platform) => (
glob(join(resolved, 'test', platform, '*.js'))
)),
);
tests.push(...platformTests.flat().map((test) => test.replace(resolved, root)));
return [root, tests];
}),
))
.filter(([, tests]) => tests.length > 0);
const tests = await resolver.resolve(
join('@flecks/web', 'server', 'build', 'tests'),
);
const testsSource = (await readFile(tests)).toString();
config.module.rules.push({
test: tests,
use: [
{
loader: runtime,
options: {
source: testsSource.replace(
" await import('@flecks/web/tests');",
testEntries
.map(([root, tests]) => (
[
` describe('${root}', () => {`,
` ${tests.map((test) => `require('${test}');`).join('\n ')}`,
' });',
].join('\n')
)).join('\n\n'),
),
},
},
],
});
// Fix a little derp in mocha 10.2.0.
config.module.rules.push({
test: /mocha\/mocha\.js$/,
use: await flecks.resolver.resolve('@flecks/web/build/fix-mocha-critical-dependency'),
});
}
};

View File

@ -15,6 +15,7 @@ const WaitForManifestPlugin = require('./wait-for-manifest');
const {
FLECKS_CORE_ROOT = process.cwd(),
NODE_ENV,
} = process.env;
module.exports = async (env, argv, flecks) => {
@ -70,12 +71,6 @@ module.exports = async (env, argv, flecks) => {
entry: '@flecks/web/server/build/entry',
}],
];
if (!isProduction) {
entries.push(['tests', {
entry: '@flecks/web/server/build/tests',
title: 'Testbed',
}]);
}
await Promise.all(
entries
.map(async ([name, mainsConfig]) => {
@ -195,6 +190,7 @@ module.exports = async (env, argv, flecks) => {
},
optimization: {
minimize: isProduction,
nodeEnv: NODE_ENV,
runtimeChunk: 'single',
splitChunks: {
cacheGroups: {

View File

@ -18,6 +18,7 @@
"access": "public"
},
"files": [
"client.js",
"entry.js",
"index.js",
"runtime.js",
@ -29,11 +30,13 @@
"@babel/parser": "^7.17.0",
"@babel/types": "^7.17.0",
"@flecks/core": "^4.0.5",
"@flecks/server": "^4.0.5",
"@webpack-cli/serve": "^2.0.5",
"add-asset-html-webpack-plugin": "^6.0.0",
"assert": "^2.1.0",
"autoprefixer": "^10.4.17",
"before-build-webpack": "^0.2.13",
"body-parser": "^1.20.2",
"browserify-zlib": "^0.2.0",
"buffer": "^6.0.3",
"clean-webpack-plugin": "4.0.0",
@ -42,8 +45,8 @@
"express": "^4.17.1",
"html-loader": "^4.2.0",
"html-webpack-plugin": "^5.5.3",
"husky": "^9.0.7",
"http-proxy": "^1.17.0",
"husky": "^9.0.7",
"mini-css-extract-plugin": "^2.7.6",
"mocha": "^10.2.0",
"null-loader": "^4.0.1",
@ -61,6 +64,7 @@
},
"devDependencies": {
"@flecks/build": "^4.0.5",
"@flecks/fleck": "^4.0.5"
"@flecks/fleck": "^4.0.5",
"puppeteer": "^22.0.0"
}
}

View File

@ -0,0 +1,24 @@
export const mixin = (Flecks) => class FlecksWithWebClient extends Flecks {
constructor(runtime) {
super(runtime);
if ('test' !== process.env.NODE_ENV) {
return;
}
this.web = {
test: ({payload, type}) => (
fetch(
`/@flecks/web/testing?type=${type}`,
{
body: JSON.stringify(payload),
headers: {
'Content-Type': 'application/json',
},
method: 'POST',
},
)
),
};
}
};

View File

@ -3,6 +3,7 @@ import {createServer, ServerResponse} from 'http';
import {join} from 'path';
import {D} from '@flecks/core';
import bodyParser from 'body-parser';
import compression from 'compression';
import express from 'express';
import httpProxy from 'http-proxy';
@ -31,6 +32,8 @@ export const createHttpServer = async (flecks) => {
const httpServer = createServer(app);
httpServer.app = app;
flecks.web.server = httpServer;
// Body parser.
app.use(bodyParser.json());
// Compression. heheh
app.use(compression({level: 'production' === NODE_ENV ? 6 : 9}));
// Socket connection.

View File

@ -1,19 +1,36 @@
import {configSource, inlineConfig} from './config';
import {createHttpServer} from './http';
const {
NODE_ENV,
} = process.env;
export {configSource};
export const hooks = {
'@flecks/web.routes': (flecks) => [
{
method: 'get',
path: '/flecks.config.js',
middleware: async (req, res) => {
res.setHeader('Content-Type', 'application/javascript; charset=UTF-8');
res.send(await configSource(flecks, req));
'@flecks/web.routes': (flecks) => {
const routes = [
{
method: 'get',
path: '/flecks.config.js',
middleware: async (req, res) => {
res.setHeader('Content-Type', 'application/javascript; charset=UTF-8');
res.send(await configSource(flecks, req));
},
},
},
],
];
if ('test' === NODE_ENV) {
routes.push({
method: 'post',
path: '/@flecks/web/testing',
middleware: (req, res, next) => {
flecks.server.socket.write(JSON.stringify({payload: req.body, type: req.query.type}));
next();
},
});
}
return routes;
},
'@flecks/web/server.stream.html': inlineConfig,
'@flecks/server.up': (flecks) => createHttpServer(flecks),
};

View File

@ -0,0 +1,41 @@
import {expect} from 'chai';
import {withWeb} from '../helpers/with-web';
let report;
const options = {
beforeConnect: ({socket}) => {
report = socket.waitForAction('report');
},
};
it('brings a client up', withWeb(
async function test({
browser,
page,
response,
}) {
expect(response)
.to.not.be.null;
const {
payload: {
config,
id,
request,
},
} = await report;
const appMountSelector = await page.waitForSelector(`#${id}`);
expect(await appMountSelector?.evaluate((el) => el.textContent))
.to.equal('hello world');
const yepSelector = await page.waitForSelector(`.${request}`);
expect(await yepSelector?.evaluate((el) => el.textContent))
.to.equal('YEP');
expect(config)
.to.deep.equal({why: 'hello there'});
expect(request)
.to.equal('testing-value-value');
await browser.close();
},
options,
));

View File

@ -0,0 +1,69 @@
import {startServer} from '@flecks/server/test/server/build/build';
import puppeteer from 'puppeteer';
export async function connectBrowser(url, options = {}) {
let previousTimeout;
const start = Date.now();
if (options.task) {
previousTimeout = options.task.timeout();
options.task.timeout(0);
}
const {timeout = 30000} = options;
const browser = await puppeteer.launch({
// For CI.
args: ['--no-sandbox'],
});
const page = await browser.newPage();
let response;
const handle = setTimeout(() => {
throw new Error(`timed out trying to connect browser to '${url}'!`);
}, timeout);
/* eslint-disable no-await-in-loop */
while (!response) {
try {
response = await page.goto(url, {...options, timeout: timeout - (Date.now() - start)});
if (response) {
break;
}
}
catch {
await new Promise((resolve) => {
setTimeout(resolve, 250);
});
}
}
/* eslint-enable no-await-in-loop */
clearTimeout(handle);
options.task?.timeout(previousTimeout + (Date.now() - start));
return {browser, page, response};
}
export function withWeb(task, options) {
return async function withWeb() {
const server = await startServer({...options, task: this});
const socket = await server.waitForSocket({...options, task: this});
if (options.beforeConnect) {
await options.beforeConnect({server, socket});
}
const start = Date.now();
const previousTimeout = this.timeout();
this.timeout(0);
const {payload: config} = await socket.send({type: 'config.get', payload: '@flecks/web'});
this.timeout(previousTimeout + (Date.now() - start));
const {browser, page, response} = await connectBrowser(
// @todo schema
`http://${config.public}`,
{
...options,
task: this,
},
);
return task({
browser,
page,
response,
server,
socket,
});
};
}

View File

@ -0,0 +1,4 @@
'@flecks/core': {}
'@flecks/server': {}
'@flecks/web': {}
'test:./test': {}

View File

@ -0,0 +1 @@
{}

View File

@ -0,0 +1 @@
{}

View File

@ -0,0 +1,13 @@
export const hooks = {
'@flecks/web/client.up': async (container, flecks) => {
container.innerHTML = 'hello world';
await flecks.web.test({
type: 'report',
payload: {
id: flecks.get('@flecks/web.appMountId'),
config: flecks.get('test'),
env: process.env.NODE_ENV,
},
});
},
};

View File

@ -0,0 +1,5 @@
export const hooks = {
'@flecks/web.config': () => ({
why: 'hello there',
}),
};

View File

@ -0,0 +1,18 @@
import {Readable} from 'stream';
import {pipesink} from '@flecks/core/server';
export const hooks = {
'@flecks/web/server.request.route': () => (req, res, next) => {
req.body.request += '-value';
next();
},
'@flecks/web/server.request.socket': () => (req, res, next) => {
req.body.request = 'testing-value';
next();
},
'@flecks/web/server.stream.html': async (stream, req) => {
const html = (await pipesink(stream)).toString();
return Readable.from(html.replace('<body>', `<body><p class="${req.body.request}">YEP</p>`));
},
};