Skip to content

Commit

Permalink
Merge pull request #1877 from embroider-build/command-watcher
Browse files Browse the repository at this point in the history
Command Watcher
  • Loading branch information
mansona authored Apr 15, 2024
2 parents 80f442d + deccd95 commit 445e96b
Show file tree
Hide file tree
Showing 8 changed files with 251 additions and 165 deletions.
44 changes: 25 additions & 19 deletions packages/vite/src/assets.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,27 +52,33 @@ export function assets(): Plugin {
});
};
},
async buildStart() {
if (mode !== 'build') return;
const engines = resolverLoader.resolver.options.engines;
for (const engine of engines) {
const packages = engine.activeAddons.map(a => resolverLoader.resolver.packageCache.ownerOfFile(a.root));
packages.forEach(pkg => {
if (!pkg || !pkg.isV2Addon()) return;
const assets = pkg.meta['public-assets'] || {};
Object.entries(assets).forEach(([path, dest]) => {
// do not override app public assets
if (existsSync(join(publicDir, dest))) {
return;
}
this.emitFile({
type: 'asset',
source: readFileSync(join(pkg.root, path)),
fileName: posix.resolve('/', dest).slice(1),
buildStart: {
// we need to wait for the compatBuild plugin's buildStart hook to finish
// so that the resolver config exists before we try to read it.
sequential: true,
order: 'post',
async handler() {
if (mode !== 'build') return;
const engines = resolverLoader.resolver.options.engines;
for (const engine of engines) {
const packages = engine.activeAddons.map(a => resolverLoader.resolver.packageCache.ownerOfFile(a.root));
packages.forEach(pkg => {
if (!pkg || !pkg.isV2Addon()) return;
const assets = pkg.meta['public-assets'] || {};
Object.entries(assets).forEach(([path, dest]) => {
// do not override app public assets
if (existsSync(join(publicDir, dest))) {
return;
}
this.emitFile({
type: 'asset',
source: readFileSync(join(pkg.root, path)),
fileName: posix.resolve('/', dest).slice(1),
});
});
});
});
}
}
},
},
};
}
33 changes: 26 additions & 7 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion tests/addon-template/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@
"ember-disable-prototype-extensions": "^1.1.3",
"ember-load-initializers": "^2.1.2",
"ember-page-title": "^7.0.0",
"ember-qunit": "^6.1.1",
"ember-qunit": "^7.0.0",
"ember-resolver": "^10.1.0",
"ember-source": "~4.6.0",
"ember-source-channel-url": "^3.0.0",
Expand Down
6 changes: 6 additions & 0 deletions tests/addon-template/vite.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
templateTag,
optimizeDeps,
compatPrebuild,
assets,
} from "@embroider/vite";
import { resolve } from "path";
import { babel } from "@rollup/plugin-babel";
Expand All @@ -24,6 +25,7 @@ export default defineConfig(({ mode }) => {
scripts(),
resolver(),
compatPrebuild(),
assets(),

babel({
babelHelpers: "runtime",
Expand All @@ -42,6 +44,10 @@ export default defineConfig(({ mode }) => {
ignored: ["!**/node_modules/.embroider/rewritten-app/**"],
},
},
// If the "app" is a classic addon dummy app, the public directory is tests/dummy/public,
// any public directory at the root would rather contain the assets provided by the addon,
// which are managed by the assets plugin.
publicDir: resolve(process.cwd(), "tests/dummy/public"),
build: {
outDir: resolve(process.cwd(), "dist"),
rollupOptions: {
Expand Down
63 changes: 52 additions & 11 deletions tests/scenarios/compat-dummy-app-test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,14 @@ import { Rebuilder } from '@embroider/test-support';
import type { PreparedApp } from 'scenario-tester';
import { throwOnWarnings } from '@embroider/core';
import merge from 'lodash/merge';
import { writeFileSync } from 'fs';
import { readFileSync, writeFileSync } from 'fs';
import { join, resolve } from 'path';
import QUnit from 'qunit';
const { module: Qmodule, test } = QUnit;
import fetch from 'node-fetch';

import { dummyAppScenarios } from './scenarios';
import CommandWatcher from './helpers/command-watcher';

dummyAppScenarios
.map('compat-dummy-app-tests', project => {
Expand All @@ -19,6 +21,9 @@ dummyAppScenarios
'example.hbs': `hello`,
},
},
public: {
'from-addon.txt': 'a public asset provided by the classic addon',
},
tests: {
dummy: {
public: {
Expand All @@ -32,7 +37,7 @@ dummyAppScenarios
project.linkDevDependency('@embroider/webpack', { baseDir: __dirname });
})
.forEachScenario(scenario => {
Qmodule(scenario.name, function (hooks) {
Qmodule(`${scenario.name} - rebuild`, function (hooks) {
throwOnWarnings(hooks);

let app: PreparedApp;
Expand All @@ -59,18 +64,54 @@ dummyAppScenarios
await builder.build({ changedDirs: [app.dir] });
expectFile('../../components/example.hbs').matches(/goodbye/);
});
});

Qmodule(`${scenario.name} - public assets`, function (hooks) {
throwOnWarnings(hooks);

let app: PreparedApp;
let expectFile: ExpectFile;

test('contains public assets from dummy app', async function () {
// expectRewrittenFilesAt doesn't understand dummy apps, so even though
// we initialized it on app.dir/tests/dummy, we can't just say
// "robots.txt" here because it thinks that file belongs to the
// containing addon. By writing out the rewritten paths ourselves we
// sidestep that problem≥
hooks.before(async () => {
app = await scenario.prepare();
});

test('rewritten app contains public assets from both addon and dummy app after a build', async function (assert) {
await app.execute(`pnpm vite build`);
expectFile = expectRewrittenFilesAt(resolve(app.dir, 'tests/dummy'), {
qunit: assert,
});
expectFile('../../node_modules/.embroider/rewritten-app/addon-template/from-addon.txt').exists();
expectFile('../../node_modules/.embroider/rewritten-app/robots.txt').exists();
expectFile('../../node_modules/.embroider/rewritten-app/package.json')
let assets = expectFile('../../node_modules/.embroider/rewritten-app/package.json')
.json()
.get('ember-addon.assets')
.includes('robots.txt');
.get('ember-addon.assets');
assets.includes('robots.txt');
assets.includes('./addon-template/from-addon.txt');
});

test('production build contains public assets from both addon and dummy app after a build', async function (assert) {
await app.execute(`pnpm vite build`);
let content = readFileSync(`${app.dir}/dist/robots.txt`).toString();
assert.strictEqual(content, 'go away bots');
content = readFileSync(`${app.dir}/dist/addon-template/from-addon.txt`).toString();
assert.strictEqual(content, 'a public asset provided by the classic addon');
});

test('dev mode serves public assets from both addon and dummy app', async function (assert) {
const server = CommandWatcher.launch('vite', ['--clearScreen', 'false'], { cwd: app.dir });
try {
const [, url] = await server.waitFor(/Local:\s+(https?:\/\/.*)\//g);
let response = await fetch(`${url}/robots.txt`);
let text = await response.text();
assert.strictEqual(text, 'go away bots');

response = await fetch(`${url}/addon-template/from-addon.txt`);
text = await response.text();
assert.strictEqual(text, 'a public asset provided by the classic addon');
} finally {
await server.shutdown();
}
});
});
});
132 changes: 132 additions & 0 deletions tests/scenarios/helpers/command-watcher.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
import execa, { type Options, type ExecaChildProcess } from 'execa';
import path from 'path';
import stripAnsi from 'strip-ansi';

export const DEFAULT_TIMEOUT = process.env.CI ? 90000 : 30000;

export default class CommandWatcher {
static launch(command: string, args: readonly string[], options: Options<string> = {}): CommandWatcher {
return new CommandWatcher(
execa(path.join(options.cwd as string, 'node_modules/.bin', command), [...args], {
...options,
all: true,
})
);
}

private lines: string[] = [];
private nextWaitedLine = 0;
private exitCode: number | null = null;
private currentWaiter: (() => void) | undefined;

constructor(private process: ExecaChildProcess) {
process.all!.on('data', data => {
const lines = data.toString().split(/\r?\n/);
this.lines.push(...lines);
this.currentWaiter?.();
});

process.on('exit', code => {
// TODO why can code be null here?
this.exitCode = code ?? 0;
this.currentWaiter?.();
});
}

private async internalWait(timedOut?: Promise<void>): Promise<void> {
if (this.currentWaiter) {
throw new Error(`bug: only one wait at a time`);
}
try {
await Promise.race(
[
timedOut,
new Promise<void>(resolve => {
this.currentWaiter = resolve;
}),
].filter(Boolean)
);
} finally {
this.currentWaiter = undefined;
}
}

private searchLines(output: string | RegExp): boolean | RegExpExecArray {
while (this.nextWaitedLine < this.lines.length) {
let line = stripAnsi(this.lines[this.nextWaitedLine++]);
if (typeof output === 'string') {
if (output === line) {
return true;
}
} else {
let result = output.exec(line);
if (result) {
return result;
}
}
}
return false;
}

async waitFor(output: string | RegExp, timeout = DEFAULT_TIMEOUT): Promise<any> {
let timedOut = new Promise<void>((_resolve, reject) => {
setTimeout(() => {
let err = new Error(
'Timed out after ' +
timeout +
'ms before output "' +
output +
'" was found. ' +
'Output:\n\n' +
this.lines.join('\n')
);
reject(err);
}, timeout);
});
while (true) {
if (this.exitCode != null) {
throw new Error(
'Process exited with code ' +
this.exitCode +
' before output "' +
output +
'" was found. ' +
'Output:\n\n' +
this.lines.join('\n')
);
}
let result = this.searchLines(output);
if (result) {
return result;
}
await this.internalWait(timedOut);
}
}

async shutdown(): Promise<void> {
if (this.exitCode != null) {
return;
}

this.process.kill();

// on windows the subprocess won't close if you don't end all the sockets
// we don't just end stdout because when you register a listener for stdout it auto registers stdin and stderr... for some reason :(
this.process.stdio.forEach((socket: any) => {
if (socket) {
socket.end();
}
});

await this.waitForExit();
}

async waitForExit(): Promise<number> {
while (true) {
if (this.exitCode != null) {
return this.exitCode;
}
await this.internalWait();
}
}
}
Loading

0 comments on commit 445e96b

Please sign in to comment.