add monorepo test coverage for Meteor app

This commit is contained in:
Nacho Codoñer
2025-09-17 10:44:22 +02:00
parent 15f951807b
commit a0bc83d6d6
18 changed files with 346 additions and 69 deletions

View File

@@ -0,0 +1 @@
install-strategy=nested

View File

@@ -0,0 +1,22 @@
# Meteor packages used by this project, one per line.
# Check this file (and the other files in this directory) into your repository.
#
# 'meteor add' and 'meteor remove' will edit this file for you,
# but you can also edit it by hand.
meteor-base # Packages every Meteor app needs to have
mobile-experience # Packages for a great mobile UX
mongo # The database Meteor supports right now
reactive-var # Reactive variable for tracker
standard-minifier-css # CSS minifier run for production mode
standard-minifier-js # JS minifier run for production mode
es5-shim # ECMAScript 5 compatibility for older browsers
ecmascript # Enable ECMAScript2015+ syntax in app code
typescript # Enable TypeScript syntax in .ts and .tsx modules
shell-server # Server-side component of the `meteor shell` command
hot-module-replacement # Update client in development without reloading the page
static-html # Define static page content in .html files
react-meteor-data # React higher-order component for reactively tracking Meteor data

View File

@@ -0,0 +1,2 @@
server
browser

View File

@@ -0,0 +1 @@
none

View File

@@ -0,0 +1,4 @@
body {
padding: 10px;
font-family: sans-serif;
}

View File

@@ -0,0 +1,8 @@
<head>
<title>monorepo</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0">
</head>
<body>
<div id="react-target"></div>
</body>

View File

@@ -0,0 +1,10 @@
import React from 'react';
import { createRoot } from 'react-dom/client';
import { Meteor } from 'meteor/meteor';
import { App } from '/imports/ui/App';
Meteor.startup(() => {
const container = document.getElementById('react-target');
const root = createRoot(container);
root.render(<App />);
});

View File

@@ -0,0 +1,3 @@
import { Mongo } from 'meteor/mongo';
export const LinksCollection = new Mongo.Collection('links');

View File

@@ -0,0 +1,11 @@
import React from 'react';
import { Hello } from './Hello.jsx';
import { Info } from './Info.jsx';
export const App = () => (
<div>
<h1>Welcome to Meteor!</h1>
<Hello/>
<Info/>
</div>
);

View File

@@ -0,0 +1,16 @@
import React, { useState } from 'react';
export const Hello = () => {
const [counter, setCounter] = useState(0);
const increment = () => {
setCounter(counter + 1);
};
return (
<div>
<button onClick={increment}>Click Me</button>
<p>You've pressed the button {counter} times.</p>
</div>
);
};

View File

@@ -0,0 +1,23 @@
import React from 'react';
import { useFind, useSubscribe } from 'meteor/react-meteor-data';
import { LinksCollection } from '../api/links';
export const Info = () => {
const isLoading = useSubscribe('links');
const links = useFind(() => LinksCollection.find());
if(isLoading()) {
return <div>Loading...</div>;
}
return (
<div>
<h2>Learn Meteor!</h2>
<ul>{links.map(
link => <li key={link._id}>
<a href={link.url} target="_blank">{link.title}</a>
</li>
)}</ul>
</div>
);
};

View File

@@ -0,0 +1,28 @@
{
"name": "app",
"private": true,
"scripts": {
"start": "meteor run",
"test": "meteor test --once --driver-package meteortesting:mocha",
"test-app": "TEST_WATCH=1 meteor test --full-app --driver-package meteortesting:mocha",
"visualize": "meteor --production --extra-packages bundle-visualizer"
},
"dependencies": {
"@babel/runtime": "^7.23.5",
"@swc/helpers": "^0.5.17",
"meteor-node-stubs": "^1.2.12",
"react": "^18.2.0",
"react-dom": "^18.2.0"
},
"devDependencies": {
"playwright": "^1.54.2"
},
"meteor": {
"mainModule": {
"client": "client/main.jsx",
"server": "server/main.js"
},
"testModule": "tests/main.js",
"modern": true
}
}

View File

@@ -0,0 +1,37 @@
import { Meteor } from 'meteor/meteor';
import { LinksCollection } from '/imports/api/links';
async function insertLink({ title, url }) {
await LinksCollection.insertAsync({ title, url, createdAt: new Date() });
}
Meteor.startup(async () => {
// If the Links collection is empty, add some data.
if (await LinksCollection.find().countAsync() === 0) {
await insertLink({
title: 'Do the Tutorial',
url: 'https://react-tutorial.meteor.com/simple-todos/01-creating-app.html',
});
await insertLink({
title: 'Follow the Guide',
url: 'https://guide.meteor.com',
});
await insertLink({
title: 'Read the Docs',
url: 'https://docs.meteor.com',
});
await insertLink({
title: 'Discussions',
url: 'https://forums.meteor.com',
});
}
// We publish the entire Links collection to all clients.
// In order to be fetched in real-time to the clients
Meteor.publish("links", function () {
return LinksCollection.find();
});
});

View File

@@ -0,0 +1,25 @@
import assert from "assert";
describe("monorepo", function () {
it("package.json has correct name", async function () {
const { name } = await import("../package.json");
assert.strictEqual(name, "app");
});
if (Meteor.isClient) {
it("client is not server", function () {
assert.strictEqual(Meteor.isServer, false);
});
}
if (Meteor.isServer) {
it("server is not client", function () {
assert.strictEqual(Meteor.isClient, false);
});
}
it("is test", function () {
assert.strictEqual(Meteor.isTest, true);
assert.strictEqual(Meteor.isAppTest, false);
});
});

View File

@@ -0,0 +1,10 @@
{
"name": "monorepo",
"private": true,
"workspaces": [
"app"
],
"engines": {
"node": ">=22.0.0"
}
}

View File

@@ -13,9 +13,13 @@ const METEOR_EXECUTABLE = path.join(REPO_ROOT, 'meteor');
* Helper function to set up a Meteor app in a temporary directory
* Copies the app and runs npm install
* @param {string} appName - Name of the app in the apps directory
* @param {Object} options - Additional options
* @param {boolean} options.isMonorepo - Whether the app is a monorepo
* @returns {string} - Path to the temporary directory containing the app
*/
export async function setupMeteorApp(appName) {
export async function setupMeteorApp(appName, options = {}) {
const { isMonorepo = false } = options;
// Create a unique temporary directory
const randomSuffix = Math.random().toString(36).substring(2, 10);
const tempDir = path.join(os.tmpdir(), `meteortest-${appName}-${randomSuffix}`);
@@ -42,13 +46,30 @@ export async function setupMeteorApp(appName) {
console.error('Error during copy:', err);
}
// Run npm install in the temporary directory
console.log('Running npm install...');
await execa.command('npm install', {
cwd: tempDir,
stdio: 'inherit',
shell: true,
});
if (isMonorepo) {
// For monorepo, install dependencies at both root and app level
console.log('Running npm install at root level...');
await execa.command('npm install', {
cwd: tempDir,
stdio: 'inherit',
shell: true,
});
console.log('Running npm install at app level...');
await execa.command('npm install', {
cwd: path.join(tempDir, 'app'),
stdio: 'inherit',
shell: true,
});
} else {
// For regular apps, just install at the root
console.log('Running npm install...');
await execa.command('npm install', {
cwd: tempDir,
stdio: 'inherit',
shell: true,
});
}
return { tempDir };
}
@@ -61,9 +82,12 @@ export async function setupMeteorApp(appName) {
* @param {string|RegExp} options.waitForOutput - Output pattern to wait for
* @param {Object} options.waitOptions - Options for waitForMeteorOutput
* @param {string[]} options.commandOptions - Additional command line options for the run command (e.g. ['--production'])
* @param {boolean} options.isMonorepo - Whether the app is a monorepo
* @returns {Object} - The meteor process and output lines
*/
export async function runMeteorApp(tempDir, port, options = {}) {
const { isMonorepo = false } = options;
// Start Meteor CLI in dev mode
console.log(`Starting Meteor app on port ${port}...`);
@@ -76,11 +100,14 @@ export async function runMeteorApp(tempDir, port, options = {}) {
args.push(...options.commandOptions);
}
// For monorepo, run the meteor command from the app subdirectory
const appDir = isMonorepo ? path.join(tempDir, 'app') : tempDir;
// Run the meteor command
const { meteorProcess, outputLines } = await runMeteorCommand(
'run',
args,
tempDir,
appDir,
{
captureOutput
}
@@ -476,9 +503,12 @@ export async function appendFileContent(tempDir, filePath, options = {}) {
* @param {Object} options.waitOptions - Options for waitForMeteorOutput
* @param {string[]} options.commandOptions - Additional command line options for the test command
* @param {boolean} options.checkTestResults - Whether to check test results and propagate failures to Jest
* @param {boolean} options.isMonorepo - Whether the app is a monorepo
* @returns {Object} - The meteor process and output lines
*/
export async function runMeteorTests(tempDir, port, options = {}) {
const { isMonorepo = false } = options;
// Start Meteor tests
console.log(`Starting Meteor tests on port ${port}...`);
@@ -491,11 +521,14 @@ export async function runMeteorTests(tempDir, port, options = {}) {
args.push(...options.commandOptions);
}
// For monorepo, run the meteor command from the app subdirectory
const appDir = isMonorepo ? path.join(tempDir, 'app') : tempDir;
// Run the meteor test command
const { meteorProcess, outputLines, processResult } = await runMeteorCommand(
'test',
args,
tempDir,
appDir,
{
execaOptions: {
env: {
@@ -647,9 +680,12 @@ export async function waitForPlaywrightConsole(pattern, options = {}) {
* @param {Object} options - Additional options
* @param {string[]} options.commandOptions - Additional command line options for the build command
* @param {boolean} options.captureOutput - Whether to capture the command's output
* @param {boolean} options.isMonorepo - Whether the app is a monorepo
* @returns {Object} - The build output directory and the meteor process result
*/
export async function buildMeteorApp(tempDir, options = {}) {
const { isMonorepo = false } = options;
// Create a unique temporary directory for the build output
const randomSuffix = Math.random().toString(36).substring(2, 10);
const buildOutputDir = path.join(os.tmpdir(), `meteor-build-${randomSuffix}`);
@@ -667,11 +703,14 @@ export async function buildMeteorApp(tempDir, options = {}) {
args.push(...options.commandOptions);
}
// For monorepo, run the meteor command from the app subdirectory
const appDir = isMonorepo ? path.join(tempDir, 'app') : tempDir;
// Run the meteor build command with automatic exit code checking
const result = await runMeteorCommand(
'build',
args,
tempDir,
appDir,
{
execaOptions: options.execaOptions || {},
captureOutput: options.captureOutput !== undefined ? options.captureOutput : true,

View File

@@ -0,0 +1,27 @@
import {
waitForMeteorOutput,
} from "./helpers";
import { testMeteorRspackBundler } from './test-helpers';
describe('Monorepo App Bundling /', () => {
describe('Meteor+Rspack Bundler /', testMeteorRspackBundler({
appName: 'monorepo',
port: 3133,
isMonorepo: true,
filePaths: {
client: 'app/client/main.jsx',
server: 'app/server/main.js',
test: 'app/tests/main.js'
},
customAssertions: {
afterRunRebuildClient: async ({ allConsoleLogs }) => {
// Check for HMR output as enabled by default
await waitForMeteorOutput(allConsoleLogs, /.*HMR.*Updated modules:.*/);
},
afterRunProductionRebuildClient: async ({ allConsoleLogs }) => {
// Check for HMR to not be enabled in production-like mode
await waitForMeteorOutput(allConsoleLogs, /.*HMR.*Updated modules:*/, { negate: true });
},
}
}));
});

View File

@@ -115,10 +115,11 @@ export function testMeteorBundler(options) {
* @returns {Function} - Jest test function
*/
export function testMeteorRspackBundler(options) {
const {
appName,
port,
filePaths = {
const {
appName,
port,
isMonorepo = false,
filePaths = {
client: 'client/main.jsx',
server: 'server/main.js',
test: 'tests/main.js',
@@ -160,6 +161,7 @@ export function testMeteorRspackBundler(options) {
return () => {
let meteorProcess;
let tempDir;
let appDir;
beforeAll(async () => {
// Run additional beforeAll behavior
@@ -172,20 +174,22 @@ export function testMeteorRspackBundler(options) {
await killProcessByPort('8080');
// Setup the Meteor app
tempDir = (await setupMeteorApp(appName))?.tempDir;
tempDir = (await setupMeteorApp(appName, { isMonorepo }))?.tempDir;
// Add Rspack package
await runMeteorCommand('add', ['rspack'], tempDir, { checkExitCode: true });
appDir = isMonorepo ? path.join(tempDir, 'app') : tempDir;
await runMeteorCommand('add', ['rspack'], appDir, { checkExitCode: true });
// Set meteor.modern.verbose to true
if (verbose) {
await execa('npm', ['pkg', 'delete', 'meteor.modern'], { cwd: tempDir });
await execa('npm', ['pkg', 'set', 'meteor.modern.verbose=true'], { cwd: tempDir });
await execa('npm', ['pkg', 'delete', 'meteor.modern'], { cwd: appDir });
await execa('npm', ['pkg', 'set', 'meteor.modern.verbose=true'], { cwd: appDir });
}
// Run the Meteor app to install Rspack
const result = await runMeteorApp(tempDir, port, {
waitForOutput: "=> App running at:",
isMonorepo
});
meteorProcess = result.meteorProcess;
@@ -193,8 +197,8 @@ export function testMeteorRspackBundler(options) {
await wait(1000);
// Assert that the config files exists
await assertFileExist(tempDir, '.gitignore', { content: buildDir });
await assertFileExist(tempDir, configFile, { content: '@meteorjs/rspack' });
await assertFileExist(appDir, '.gitignore', { content: buildDir });
await assertFileExist(appDir, configFile, { content: '@meteorjs/rspack' });
// Kill the meteor process
await killMeteorProcess(meteorProcess);
@@ -218,6 +222,7 @@ export function testMeteorRspackBundler(options) {
// Run the Meteor app and wait for "restarted at" output
const result = await runMeteorApp(tempDir, port, {
waitForOutput: "=> App running at:",
isMonorepo
});
meteorProcess = result.meteorProcess;
@@ -225,12 +230,12 @@ export function testMeteorRspackBundler(options) {
await wait(500);
// Assert that the app files exists
await assertFileExist(tempDir, `${buildDir}/main-dev/client-entry.js`);
await assertFileExist(tempDir, `${buildDir}/main-dev/client-rspack.js`);
await assertFileExist(tempDir, `${buildDir}/main-dev/client-meteor.js`);
await assertFileExist(tempDir, `${buildDir}/main-dev/server-entry.js`);
await assertFileExist(tempDir, `${buildDir}/main-dev/server-rspack.js`);
await assertFileExist(tempDir, `${buildDir}/main-dev/server-meteor.js`);
await assertFileExist(appDir, `${buildDir}/main-dev/client-entry.js`);
await assertFileExist(appDir, `${buildDir}/main-dev/client-rspack.js`);
await assertFileExist(appDir, `${buildDir}/main-dev/client-meteor.js`);
await assertFileExist(appDir, `${buildDir}/main-dev/server-entry.js`);
await assertFileExist(appDir, `${buildDir}/main-dev/server-rspack.js`);
await assertFileExist(appDir, `${buildDir}/main-dev/server-meteor.js`);
// Assert that the Meteor app is running correctly
await assertMeteorReactApp(port, { title: appName });
@@ -257,11 +262,11 @@ export function testMeteorRspackBundler(options) {
// Run custom assertions if provided
if (customAssertions && customAssertions.afterRunRebuildClient) {
await customAssertions.afterRunRebuildClient({
tempDir,
port,
meteorProcess,
result,
await customAssertions.afterRunRebuildClient({
tempDir,
port,
meteorProcess,
result,
allConsoleLogs: consoleLogs.allLogs
});
}
@@ -307,6 +312,7 @@ export function testMeteorRspackBundler(options) {
const result = await runMeteorApp(tempDir, port, {
waitForOutput: "=> App running at:",
commandOptions: ['--production'],
isMonorepo
});
meteorProcess = result.meteorProcess;
@@ -314,13 +320,13 @@ export function testMeteorRspackBundler(options) {
await wait(500);
// Assert that the app files exists
await assertFileExist(tempDir, `${buildDir}/main-prod/client-entry.js`);
await assertFileExist(tempDir, `${buildDir}/main-prod/client-rspack.js`);
await assertFileExist(tempDir, `${buildDir}/main-prod/client-meteor.js`);
await assertFileExist(tempDir, `${buildDir}/main-prod/server-entry.js`);
await assertFileExist(tempDir, `${buildDir}/main-prod/server-rspack.js`);
await assertFileExist(tempDir, `${buildDir}/main-prod/server-meteor.js`);
await assertFileExist(tempDir, `${buildDir}/main-prod/index.html`);
await assertFileExist(appDir, `${buildDir}/main-prod/client-entry.js`);
await assertFileExist(appDir, `${buildDir}/main-prod/client-rspack.js`);
await assertFileExist(appDir, `${buildDir}/main-prod/client-meteor.js`);
await assertFileExist(appDir, `${buildDir}/main-prod/server-entry.js`);
await assertFileExist(appDir, `${buildDir}/main-prod/server-rspack.js`);
await assertFileExist(appDir, `${buildDir}/main-prod/server-meteor.js`);
await assertFileExist(appDir, `${buildDir}/main-prod/index.html`);
await assertFileExist(tempDir, filePaths.server);
@@ -349,10 +355,10 @@ export function testMeteorRspackBundler(options) {
// Run custom assertions if provided
if (customAssertions && customAssertions.afterRunProductionRebuildClient) {
await customAssertions.afterRunProductionRebuildClient({
tempDir,
port,
meteorProcess,
await customAssertions.afterRunProductionRebuildClient({
tempDir,
port,
meteorProcess,
result,
allConsoleLogs: consoleLogs.allLogs
});
@@ -401,6 +407,7 @@ export function testMeteorRspackBundler(options) {
const result = await runMeteorApp(tempDir, port, {
waitForOutput: "=> App running at:",
commandOptions: ['--extra-packages', 'bundle-visualizer', '--production'],
isMonorepo
});
meteorProcess = result.meteorProcess;
@@ -408,13 +415,13 @@ export function testMeteorRspackBundler(options) {
await wait(500);
// Assert that the app files exists
await assertFileExist(tempDir, `${buildDir}/main-prod/client-entry.js`);
await assertFileExist(tempDir, `${buildDir}/main-prod/client-rspack.js`);
await assertFileExist(tempDir, `${buildDir}/main-prod/client-meteor.js`);
await assertFileExist(tempDir, `${buildDir}/main-prod/server-entry.js`);
await assertFileExist(tempDir, `${buildDir}/main-prod/server-rspack.js`);
await assertFileExist(tempDir, `${buildDir}/main-prod/server-meteor.js`);
await assertFileExist(tempDir, `${buildDir}/main-prod/index.html`);
await assertFileExist(appDir, `${buildDir}/main-prod/client-entry.js`);
await assertFileExist(appDir, `${buildDir}/main-prod/client-rspack.js`);
await assertFileExist(appDir, `${buildDir}/main-prod/client-meteor.js`);
await assertFileExist(appDir, `${buildDir}/main-prod/server-entry.js`);
await assertFileExist(appDir, `${buildDir}/main-prod/server-rspack.js`);
await assertFileExist(appDir, `${buildDir}/main-prod/server-meteor.js`);
await assertFileExist(appDir, `${buildDir}/main-prod/index.html`);
// Assert that the Meteor app is running correctly
await assertMeteorReactApp(port, { title: appName });
@@ -459,6 +466,7 @@ export function testMeteorRspackBundler(options) {
waitForOutput: "=> App running at:",
commandOptions: testFullApp ? ['--full-app'] : [],
checkTestResults: false,
isMonorepo
});
meteorProcess = result.meteorProcess;
@@ -469,16 +477,16 @@ export function testMeteorRspackBundler(options) {
// Assert that the app files exists
if (isTestModule) {
await assertFileExist(tempDir, `${buildDir}/test/test-entry.js`);
await assertFileExist(tempDir, `${buildDir}/test/test-rspack.js`);
await assertFileExist(tempDir, `${buildDir}/test/test-meteor.js`);
await assertFileExist(appDir, `${buildDir}/test/test-entry.js`);
await assertFileExist(appDir, `${buildDir}/test/test-rspack.js`);
await assertFileExist(appDir, `${buildDir}/test/test-meteor.js`);
} else {
await assertFileExist(tempDir, `${buildDir}/test/client-entry.js`);
await assertFileExist(tempDir, `${buildDir}/test/client-rspack.js`);
await assertFileExist(tempDir, `${buildDir}/test/client-meteor.js`);
await assertFileExist(tempDir, `${buildDir}/test/server-entry.js`);
await assertFileExist(tempDir, `${buildDir}/test/server-rspack.js`);
await assertFileExist(tempDir, `${buildDir}/test/server-meteor.js`);
await assertFileExist(appDir, `${buildDir}/test/client-entry.js`);
await assertFileExist(appDir, `${buildDir}/test/client-rspack.js`);
await assertFileExist(appDir, `${buildDir}/test/client-meteor.js`);
await assertFileExist(appDir, `${buildDir}/test/server-entry.js`);
await assertFileExist(appDir, `${buildDir}/test/server-rspack.js`);
await assertFileExist(appDir, `${buildDir}/test/server-meteor.js`);
}
// Run custom assertions if provided
@@ -542,6 +550,7 @@ export function testMeteorRspackBundler(options) {
waitForOutput: "=> App running at:",
commandOptions: testFullApp ? ['--full-app', '--once'] : ['--once'],
checkTestResults: true,
isMonorepo
});
// Wait for a margin
@@ -551,16 +560,16 @@ export function testMeteorRspackBundler(options) {
// Assert that the app files exists
if (isTestModule) {
await assertFileExist(tempDir, `${buildDir}/test/test-entry.js`);
await assertFileExist(tempDir, `${buildDir}/test/test-rspack.js`);
await assertFileExist(tempDir, `${buildDir}/test/test-meteor.js`);
await assertFileExist(appDir, `${buildDir}/test/test-entry.js`);
await assertFileExist(appDir, `${buildDir}/test/test-rspack.js`);
await assertFileExist(appDir, `${buildDir}/test/test-meteor.js`);
} else {
await assertFileExist(tempDir, `${buildDir}/test/client-entry.js`);
await assertFileExist(tempDir, `${buildDir}/test/client-rspack.js`);
await assertFileExist(tempDir, `${buildDir}/test/client-meteor.js`);
await assertFileExist(tempDir, `${buildDir}/test/server-entry.js`);
await assertFileExist(tempDir, `${buildDir}/test/server-rspack.js`);
await assertFileExist(tempDir, `${buildDir}/test/server-meteor.js`);
await assertFileExist(appDir, `${buildDir}/test/client-entry.js`);
await assertFileExist(appDir, `${buildDir}/test/client-rspack.js`);
await assertFileExist(appDir, `${buildDir}/test/client-meteor.js`);
await assertFileExist(appDir, `${buildDir}/test/server-entry.js`);
await assertFileExist(appDir, `${buildDir}/test/server-rspack.js`);
await assertFileExist(appDir, `${buildDir}/test/server-meteor.js`);
}
if (verbose) {
@@ -587,7 +596,8 @@ export function testMeteorRspackBundler(options) {
// Build the app with Rspack
const { buildOutputDir, processResult: result } = await buildMeteorApp(tempDir, {
commandOptions: ['--directory'],
captureOutput: true
captureOutput: true,
isMonorepo
});
// Wait for a margin