chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
// Minimal fixture used by measure-copilotchat.test.mjs. The function under test
|
||||
// bundles `export { CopilotChat } from "<entryModule>"`, so this exports a
|
||||
// trivial CopilotChat symbol to validate the script's bundling pipeline without
|
||||
// depending on react-core's built dist.
|
||||
export const CopilotChat = () => null;
|
||||
@@ -0,0 +1,43 @@
|
||||
// Standalone Node test (not vitest) — the script under test calls esbuild,
|
||||
// which trips vitest's jsdom env probe, and the package-wide vitest setup uses
|
||||
// jsdom-only globals. Running with `node --test` keeps this isolated.
|
||||
//
|
||||
// Invoked from package.json `test:scripts` and the chained `test` command.
|
||||
import { describe, it } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import path from "node:path";
|
||||
import { measureBundle } from "../measure-copilotchat.mjs";
|
||||
|
||||
const here = path.dirname(fileURLToPath(import.meta.url));
|
||||
const fixtureDir = path.join(here, "fixtures");
|
||||
const fixtureEntry = path.join(fixtureDir, "tiny-entry.js");
|
||||
|
||||
describe("measureBundle", () => {
|
||||
it("bundles a tiny CopilotChat fixture and returns a positive gzip total", async () => {
|
||||
const result = await measureBundle({
|
||||
entryModule: fixtureEntry,
|
||||
pkgRoot: fixtureDir,
|
||||
});
|
||||
assert.ok(result.totalBytes > 0, "totalBytes should be > 0");
|
||||
// Trivial fixture; a sane upper bound guards against accidental inclusion
|
||||
// of huge externals or the loader stubs regressing.
|
||||
assert.ok(
|
||||
result.totalBytes < 50_000,
|
||||
`totalBytes should be < 50_000, got ${result.totalBytes}`,
|
||||
);
|
||||
assert.ok(result.outputCount >= 1, "outputCount should be >= 1");
|
||||
});
|
||||
|
||||
it("returns a deterministic gzip total across two runs", async () => {
|
||||
const a = await measureBundle({
|
||||
entryModule: fixtureEntry,
|
||||
pkgRoot: fixtureDir,
|
||||
});
|
||||
const b = await measureBundle({
|
||||
entryModule: fixtureEntry,
|
||||
pkgRoot: fixtureDir,
|
||||
});
|
||||
assert.equal(b.totalBytes, a.totalBytes);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,113 @@
|
||||
// Measures a relative bundle-regression signal for `CopilotChat`: the total
|
||||
// gzipped JS that bundling `{ CopilotChat }` from `@copilotkit/react-core/v2`
|
||||
// produces (entry + all code-split chunks).
|
||||
//
|
||||
// IMPORTANT — this is a relative regression signal, NOT a production figure.
|
||||
// It uses esbuild; a real consumer (Vite/Next/webpack) splits eager-vs-lazy
|
||||
// differently and reports different absolute numbers. Its value is consistency
|
||||
// across PRs: the same script every PR, so a change that grows CopilotChat's
|
||||
// JS shows up.
|
||||
//
|
||||
// Why a custom esbuild script instead of size-limit: CopilotChat's graph pulls
|
||||
// `katex/dist/katex.min.css`, whose url() font refs crash size-limit's esbuild
|
||||
// preset (which exposes no loader/config hook). Driving esbuild directly lets
|
||||
// us stub CSS/fonts to "empty" (we measure JS, not CSS). react/react-dom are
|
||||
// external — a host React app already ships them.
|
||||
//
|
||||
// Run: `node scripts/measure-copilotchat.mjs` (after `nx run react-core:build`).
|
||||
import { build } from "esbuild";
|
||||
import { gzipSync } from "node:zlib";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import path from "node:path";
|
||||
|
||||
const DEFAULT_EXTERNAL = [
|
||||
"react",
|
||||
"react-dom",
|
||||
"react/jsx-runtime",
|
||||
"react/jsx-dev-runtime",
|
||||
"react-dom/client",
|
||||
"react-dom/server",
|
||||
];
|
||||
|
||||
const EMPTY_LOADERS = {
|
||||
".css": "empty",
|
||||
".woff": "empty",
|
||||
".woff2": "empty",
|
||||
".ttf": "empty",
|
||||
".eot": "empty",
|
||||
".svg": "empty",
|
||||
};
|
||||
|
||||
/**
|
||||
* Bundle `export { CopilotChat } from <entryModule>` with esbuild (minified,
|
||||
* code-split, react/react-dom external, CSS/fonts stubbed empty) and return
|
||||
* the summed gzip byte count across all output chunks.
|
||||
*
|
||||
* @param {object} options
|
||||
* @param {string} options.entryModule - Absolute path to the module that exports CopilotChat.
|
||||
* @param {string} options.pkgRoot - Working directory for esbuild resolution and the (in-memory) outdir.
|
||||
* @param {string[]} [options.external] - External specifiers; defaults to react/react-dom subpaths.
|
||||
* @param {Record<string, string>} [options.loader] - esbuild loader map; defaults to stubbing CSS/fonts.
|
||||
* @returns {Promise<{ totalBytes: number, outputCount: number }>}
|
||||
*/
|
||||
export async function measureBundle({
|
||||
entryModule,
|
||||
pkgRoot,
|
||||
external = DEFAULT_EXTERNAL,
|
||||
loader = EMPTY_LOADERS,
|
||||
}) {
|
||||
const result = await build({
|
||||
stdin: {
|
||||
contents: `export { CopilotChat } from ${JSON.stringify(entryModule)};`,
|
||||
resolveDir: pkgRoot,
|
||||
loader: "js",
|
||||
},
|
||||
bundle: true,
|
||||
splitting: true,
|
||||
format: "esm",
|
||||
platform: "browser",
|
||||
target: "es2022",
|
||||
minify: true,
|
||||
outdir: path.join(pkgRoot, ".size-headline-tmp"),
|
||||
write: false,
|
||||
external,
|
||||
loader,
|
||||
logLevel: "silent",
|
||||
});
|
||||
let totalBytes = 0;
|
||||
for (const file of result.outputFiles) {
|
||||
totalBytes += gzipSync(Buffer.from(file.contents)).length;
|
||||
}
|
||||
return { totalBytes, outputCount: result.outputFiles.length };
|
||||
}
|
||||
|
||||
// CLI entry — only runs when invoked directly, so importing this module from
|
||||
// tests doesn't perform a real build at module-load time.
|
||||
const isMain =
|
||||
import.meta.url === `file://${process.argv[1]}` ||
|
||||
import.meta.url === `file://${path.resolve(process.argv[1] ?? "")}`;
|
||||
if (isMain) {
|
||||
const pkgRoot = path.resolve(
|
||||
path.dirname(fileURLToPath(import.meta.url)),
|
||||
"..",
|
||||
);
|
||||
const entryModule = path.join(pkgRoot, "dist/v2/index.mjs");
|
||||
const { totalBytes } = await measureBundle({ entryModule, pkgRoot });
|
||||
if (totalBytes === 0) {
|
||||
console.error("measure-copilotchat: esbuild produced no output");
|
||||
process.exit(1);
|
||||
}
|
||||
const mb = (totalBytes / 1024 / 1024).toFixed(2);
|
||||
const caption =
|
||||
"esbuild relative regression signal — not a production Vite/Next figure";
|
||||
console.log(`CopilotChat total bundled JS: ${mb} MB gzip (${caption})`);
|
||||
if (process.env.GITHUB_STEP_SUMMARY) {
|
||||
const { appendFileSync } = await import("node:fs");
|
||||
appendFileSync(
|
||||
process.env.GITHUB_STEP_SUMMARY,
|
||||
`### Bundle regression signal — react-core CopilotChat\n\n` +
|
||||
`- **CopilotChat total bundled JS: ${mb} MB gzip**\n` +
|
||||
`- _${caption}_\n`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
/**
|
||||
* Post-processes compiled Tailwind CSS to scope all @layer base rules
|
||||
* under [data-copilotkit], preventing CopilotKit styles from leaking
|
||||
* into the host application.
|
||||
*
|
||||
* Run after `tailwindcss` CLI: node scripts/scope-preflight.mjs <file>
|
||||
*/
|
||||
|
||||
import { readFileSync, writeFileSync } from "fs";
|
||||
import postcss from "postcss";
|
||||
|
||||
const SCOPE = "[data-copilotkit]";
|
||||
const file = process.argv[2];
|
||||
if (!file) {
|
||||
console.error("Usage: node scripts/scope-preflight.mjs <css-file>");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
/** Selectors that are already scoped and should be left alone. */
|
||||
function isAlreadyScoped(selector) {
|
||||
return (
|
||||
selector.includes("[data-copilot") || selector.includes("[data-sidebar")
|
||||
);
|
||||
}
|
||||
|
||||
/** Rewrite a single selector to be scoped under [data-copilotkit]. */
|
||||
function scopeSelector(sel) {
|
||||
sel = sel.trim();
|
||||
|
||||
// Already scoped — keep as-is
|
||||
if (isAlreadyScoped(sel)) return sel;
|
||||
|
||||
// html, :host → [data-copilotkit]
|
||||
if (sel === "html" || sel === ":host" || sel === "html,:host") {
|
||||
return SCOPE;
|
||||
}
|
||||
|
||||
// body → null (remove)
|
||||
if (sel === "body") return null;
|
||||
|
||||
// ::backdrop → null (cannot be scoped to a container)
|
||||
if (sel === "::backdrop") return null;
|
||||
|
||||
// Bare universal selector → scope to container + descendants
|
||||
if (sel === "*") return `${SCOPE}, ${SCOPE} *`;
|
||||
|
||||
// Pseudo-elements (::) and vendor pseudo-classes (:-) → descendant only
|
||||
// These can't be combined with an attribute selector suffix
|
||||
if (sel.startsWith(":")) {
|
||||
return `${SCOPE} ${sel}`;
|
||||
}
|
||||
|
||||
// Element / attribute selectors → descendant AND self-matching
|
||||
// e.g. button → [data-copilotkit] button, button[data-copilotkit]
|
||||
// This ensures <button data-copilotkit> also receives the preflight reset
|
||||
return `${SCOPE} ${sel}, ${sel}${SCOPE}`;
|
||||
}
|
||||
|
||||
function scopeRule(rule) {
|
||||
const newSelectors = [];
|
||||
|
||||
for (const sel of rule.selectors) {
|
||||
const scoped = scopeSelector(sel);
|
||||
if (scoped !== null) {
|
||||
// scopeSelector may return comma-separated selectors (for *)
|
||||
if (typeof scoped === "string" && scoped.includes(", ")) {
|
||||
newSelectors.push(...scoped.split(", "));
|
||||
} else if (scoped) {
|
||||
newSelectors.push(scoped);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (newSelectors.length === 0) {
|
||||
rule.remove();
|
||||
} else {
|
||||
rule.selectors = newSelectors;
|
||||
}
|
||||
}
|
||||
|
||||
/** Recursively scope all rules within a node (handles @supports, @media, etc.) */
|
||||
function scopeChildren(node) {
|
||||
node.walk((child) => {
|
||||
if (child.type === "rule") {
|
||||
scopeRule(child);
|
||||
}
|
||||
// @supports / @media blocks are walked automatically
|
||||
});
|
||||
}
|
||||
|
||||
// --- Main ---
|
||||
const css = readFileSync(file, "utf8");
|
||||
const root = postcss.parse(css);
|
||||
|
||||
root.walkAtRules("layer", (layer) => {
|
||||
if (layer.params !== "base") return;
|
||||
scopeChildren(layer);
|
||||
});
|
||||
|
||||
writeFileSync(file, root.toString());
|
||||
Reference in New Issue
Block a user