diff --git a/.prettierignore b/.prettierignore index 85289f4..cb0958f 100644 --- a/.prettierignore +++ b/.prettierignore @@ -3,5 +3,5 @@ coverage/ pnpm-lock.yaml LICENSE src/theme/*/built/ -packages/cli/test/upgrade/fixtures/ +packages/cli/test/*/fixtures/ .claude/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 7bde1c5..b78eab7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,41 @@ Versioning follows the policy in [CONTRIBUTING.md](CONTRIBUTING.md#versioning). ## [Unreleased] +### Added + +- **`ui-common adopt --from astryx`**: moves an app that imports Astryx + directly (`@astryxdesign/core`, `/lab`, `/theme-neutral`) onto ui-common in + one pass. It rewrites every module specifier in scripts (imports, + `export … from`, `import()`, `require()`, `typeof import()`, `vi.mock()`) and + stylesheets to the ui-common mirror, renames `Dialog` / `AlertDialog` to + `Modal` / `AlertModal`, adds `ui-common` to the cascade-layer order (and the + statement to an entry stylesheet or `index.html` without one), edits + `package.json` in every workspace member (`@lablup/ui-common` and the CLI at + the CLI's version, core kept only at ui-common's pin, theme-neutral and an + unused lab dropped, the lab override, pnpm `allowBuilds`), and writes + `ui-common-adopt-report.md` in the upgrade report's format: local Astryx + patches (and which a ui-common fork covers), `Modal` refs, body overlays, + global shortcuts, Escape handlers, ASTRYX agent blocks and tools anchored on + them, `astryx` CLI calls, and i18n wiring. `--dry-run`, `--check` (exit 1 + while anything is left, for CI), `--ignore`; `upgrade --from astryx` is an + alias. +- **`ui-common doctor [--json]`**: read-only checks with a fix and a doc + section each, exit 1 on a failure: one `@astryxdesign/core` (lockfile and + disk, pnpm and npm) with lab on it, the lab override, no direct Astryx + import, the layer order (present, first, with `ui-common`, identical in every + copy), the Vite pre-bundle fix under `@stylexjs/unplugin`, Vitest inlining + ui-common, i18n wiring, the agent block, matching ui-common / ui-common-cli + versions, and Node for the CLI. It covers every member of a workspace, and + run from a member it reads the workspace root. `doctor ` still + reaches Astryx's doctor. +- **The `ui-common-adopt` agent skill**, shipped in `@lablup/ui-common-cli` + and installed with `ui-common agents --skill [--dir ]`: the adopt, + doctor and verification sequence, with the decisions only a person makes. + The agent block names it once it is installed in the project. +- [docs/adopting-from-astryx.md](docs/adopting-from-astryx.md): the guide for + direct-Astryx apps, with each doctor check explained and a prompt for a + coding agent. + ### Changed - 0.1 is maintained on the `release/0.1` branch. Its patch releases publish diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index c61f24e..18b13d9 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -451,6 +451,18 @@ UPDATE_FIXTURES=1 pnpm vitest run packages/cli/test/upgrade and read the diff. Fixtures are consumer code: keep them free of product names, like the rest of this repository. +`ui-common adopt --from astryx` (`packages/cli/cli/adopt.mjs`, codemods in +`packages/cli/codemods/adopt/`) is not a version step: it moves a project from +Astryx itself, so it is its own command and needs no registry entry. Its +fixtures are under `packages/cli/test/adopt/fixtures/` and regenerate the same +way (`UPDATE_FIXTURES=1 pnpm vitest run packages/cli/test/adopt`). +`ui-common doctor` (`packages/cli/cli/doctor.mjs`) shares its project reading +(`packages/cli/cli/project.mjs`). A new doctor check gets a section of +[docs/adopting-from-astryx.md](docs/adopting-from-astryx.md#doctor-checks) +headed by its id, which is the anchor the check prints. The agent skill +(`packages/cli/skill/ui-common-adopt/SKILL.md`) names the commands' judgement +calls only; keep it under ~150 lines and in step with both. + ## Versioning Semver. The public surface is: exported components and their props, exported diff --git a/README.md b/README.md index 19198c3..4958d19 100644 --- a/README.md +++ b/README.md @@ -42,6 +42,10 @@ To fix a bug in 0.1, open the pull request against `release/0.1` ([CONTRIBUTING.md](CONTRIBUTING.md#patching-an-older-line)). Moving to 0.2 is [Upgrading from 0.1](#upgrading-from-01). +An app that uses Astryx directly (`@astryxdesign/*`) moves onto ui-common with +`ui-common adopt --from astryx` and `ui-common doctor`: +[docs/adopting-from-astryx.md](docs/adopting-from-astryx.md). + ## Install ``` @@ -473,7 +477,9 @@ lists every import, prop, class and stylesheet change in a form the upgrade tool reads. Before you start, read [docs/migrating-to-0.2.md](docs/migrating-to-0.2.md): -the problems the first app hit when it moved onto 0.2, and a checklist. +the problems the first app hit when it moved onto 0.2, and a checklist. (An +app on Astryx itself rather than ui-common 0.1 follows +[docs/adopting-from-astryx.md](docs/adopting-from-astryx.md) instead.) Let the upgrade tool do the mechanical part. It ships in `@lablup/ui-common-cli`, so run it one-off from the project still on 0.1: @@ -560,15 +566,19 @@ pnpm exec ui-common component Button # any Astryx command: component, search pnpm exec ui-common search "date picker" # docs, build, template, theme, hook, ... pnpm exec ui-common agents --write AGENTS.md pnpm exec ui-common upgrade --from 0.1 --dry-run +pnpm exec ui-common adopt --from astryx --dry-run # an app on @astryxdesign/* +pnpm exec ui-common doctor ``` -| Command | What it does | -| ------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `ui-common …` | Runs the pinned Astryx CLI and rewrites its output to ui-common: `@astryxdesign/core/` is `@lablup/ui-common/`, `@astryxdesign/lab` is `@lablup/ui-common/lab`, `@astryxdesign/theme-neutral` is `@lablup/ui-common/theme/neutral`, and commands read `ui-common …`. A name ui-common hides gets a note ("Use Modal, not Dialog"). `--json` output stays valid JSON; the note goes to stderr. The exit code is Astryx's. | -| `ui-common astryx …` | The same, without rewriting. | -| `ui-common agents [--write ] [--check]` | Prints the agent block: Astryx's `init --features agents` block, rewritten, plus ui-common's rules. It sits between `` and ``, which `astryx init` never touches. `--write` replaces the block in place and keeps the rest of the file; `--check` exits 1 when it is stale. | -| `ui-common upgrade [--from ] [--to ] [--dry-run] [--diff] [--report ] [--scan ]… [paths…]` | Runs the codemods between two ui-common versions over `src/` (or `paths`), updates `package.json`, and writes `ui-common-upgrade-report.md` (a `--dry-run` writes nothing and prints the report, unless `--report` names a file). The report's manual-review findings come from the whole project (tests, e2e specs, scripts), or only from the `--scan` paths. `--from` defaults to the version `package.json` declares, `--to` to the CLI's own (the ui-common version it ships with). | -| `ui-common sync-astryx [--lab ] [--as ] [--dry-run]` | Maintainers only; see [CONTRIBUTING.md](CONTRIBUTING.md#bumping-astryx). | +| Command | What it does | +| ------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `ui-common …` | Runs the pinned Astryx CLI and rewrites its output to ui-common: `@astryxdesign/core/` is `@lablup/ui-common/`, `@astryxdesign/lab` is `@lablup/ui-common/lab`, `@astryxdesign/theme-neutral` is `@lablup/ui-common/theme/neutral`, and commands read `ui-common …`. A name ui-common hides gets a note ("Use Modal, not Dialog"). `--json` output stays valid JSON; the note goes to stderr. The exit code is Astryx's. | +| `ui-common astryx …` | The same, without rewriting. | +| `ui-common agents [--write ] [--check] [--skill [--dir ]]` | Prints the agent block: Astryx's `init --features agents` block, rewritten, plus ui-common's rules. It sits between `` and ``, which `astryx init` never touches. `--write` replaces the block in place and keeps the rest of the file; `--check` exits 1 when it is stale. `--skill` installs the `ui-common-adopt` agent skill (Claude Code format) into `.claude/skills/`, or `--dir` (e.g. `~/.claude/skills` for every project); the block then names it. | +| `ui-common adopt --from astryx [--dry-run \| --check] [--diff] [--report ] [--ignore ]… [paths…]` | Moves an app that imports `@astryxdesign/*` onto ui-common: rewrites every core, lab and theme-neutral specifier (scripts and stylesheets), renames `Dialog` / `AlertDialog` to `Modal` / `AlertModal`, adds `ui-common` to the layer order, edits `package.json` (every member of a workspace), and writes `ui-common-adopt-report.md` in the `upgrade` report's format. `--check` writes nothing and exits 1 while anything is left, for CI. `upgrade --from astryx` is the same command. See [docs/adopting-from-astryx.md](docs/adopting-from-astryx.md). | +| `ui-common doctor [--json] [--verbose]` | Read-only checks: one `@astryxdesign/core` (lockfile and disk) with lab on it, the lab override, no direct Astryx imports, the layer order (first, with `ui-common`, identical in every copy), the Vite pre-bundle fix under the StyleX plugin, Vitest inlining ui-common, i18n wiring, the agent block, matching ui-common / ui-common-cli versions, and Node for the CLI. Exits 1 on a failure; each prints a fix and its section of [docs/adopting-from-astryx.md](docs/adopting-from-astryx.md#doctor-checks). `doctor ` is Astryx's doctor. | +| `ui-common upgrade [--from ] [--to ] [--dry-run] [--diff] [--report ] [--scan ]… [paths…]` | Runs the codemods between two ui-common versions over `src/` (or `paths`), updates `package.json`, and writes `ui-common-upgrade-report.md` (a `--dry-run` writes nothing and prints the report, unless `--report` names a file). The report's manual-review findings come from the whole project (tests, e2e specs, scripts), or only from the `--scan` paths. `--from` defaults to the version `package.json` declares, `--to` to the CLI's own (the ui-common version it ships with). | +| `ui-common sync-astryx [--lab ] [--as ] [--dry-run]` | Maintainers only; see [CONTRIBUTING.md](CONTRIBUTING.md#bumping-astryx). | Exit codes: a passed-through command exits with Astryx's code. ui-common's own commands exit 0 on success, 1 on a failed check or run, and 2 on bad arguments. diff --git a/docs/adopting-from-astryx.md b/docs/adopting-from-astryx.md new file mode 100644 index 0000000..82a1135 --- /dev/null +++ b/docs/adopting-from-astryx.md @@ -0,0 +1,240 @@ +# Adopting ui-common in an app that uses Astryx directly + +This page is for an app that imports Astryx itself (`@astryxdesign/core`, +`@astryxdesign/lab`, `@astryxdesign/theme-neutral`) and moves onto +`@lablup/ui-common`. An app on ui-common 0.1 reads +[migrating-to-0.2.md](migrating-to-0.2.md) instead; the problems below are the +subset of that page a direct-Astryx app meets, plus the tooling that handles +them. + +Two commands do the work: + +- **`ui-common adopt --from astryx`** rewrites the imports, renames `Dialog` + and `AlertDialog`, fixes the layer order, edits `package.json`, and writes + `ui-common-adopt-report.md` with what needs a person. +- **`ui-common doctor`** checks the result (and any later state of the + project) and names a fix and a section of this page for each failure. + +## The sequence + +``` +pnpm dlx @lablup/ui-common-cli@next adopt --from astryx --dry-run # read the report it prints +pnpm dlx @lablup/ui-common-cli@next adopt --from astryx # apply +pnpm install +pnpm exec ui-common doctor # fix, rerun, until it passes +``` + +(`npx @lablup/ui-common-cli@next …` with npm; drop `@next` once 0.2.0 is +published.) Then work through every row of `ui-common-adopt-report.md`, run the +build, the tests and a cold dev-server start, and compare screenshots of the +main screens in light and dark with the version before. + +In a workspace, run both from the root: every member package is covered. Keep +two checks in CI so the project stays adopted, since every merge can bring an +`@astryxdesign/*` import back: + +``` +pnpm exec ui-common adopt --from astryx --check # exit 1 when anything is left to rewrite +pnpm exec ui-common doctor +``` + +`ui-common upgrade --from astryx` is the same command as `adopt --from astryx`. + +### With a coding agent + +`ui-common agents --skill` installs the `ui-common-adopt` skill (Claude Code +format) into `.claude/skills/`; `--dir ~/.claude/skills` installs it for every +project of the user. Or paste this: + +> Move this app off `@astryxdesign/*` onto `@lablup/ui-common`. Work on a new +> branch with a clean tree. Run +> `pnpm dlx @lablup/ui-common-cli@next adopt --from astryx --dry-run` and read +> the report; if it says Astryx moves to a newer version, run Astryx's +> codemods first as it says. Apply with `adopt --from astryx`, run +> `pnpm install`, and commit that alone. Run `pnpm exec ui-common doctor` and +> fix what it reports until it passes. Resolve every row of +> `ui-common-adopt-report.md`, following the decision each section states; +> ask me about anything that changes behaviour. Then run the type check, the +> build and the tests, start the dev server with `node_modules/.vite` deleted +> and confirm the app renders with no page errors, and compare screenshots of +> the main screens in light and dark against `main`. Finish with +> `adopt --from astryx --check` and `doctor` both passing, and list in the PR +> what you changed by hand and why. + +## What adopt changes + +- Module specifiers, in scripts (`import`, `export … from`, `import()`, + `typeof import()`, `require()`, `vi.mock()`) and stylesheets (`@import`, + `@use`): `@astryxdesign/core[/X]` → `@lablup/ui-common[/X]`, + `@astryxdesign/lab` → `@lablup/ui-common/lab`, + `@astryxdesign/theme-neutral[/built|/theme.css]` → + `@lablup/ui-common/theme/neutral[…]`, including `theme/tokens.stylex`, + the CSS entry points and `locales/*.json`. Package names in other strings, + `declare module` augmentations, `@astryxdesign/cli` and files with an + `@generated` header are left alone. +- `Dialog` / `AlertDialog` → `Modal` / `AlertModal`, imports and references. + A module's own re-export keeps its public name (`export { Modal as Dialog }`). +- Every Astryx `@layer` order statement gains `ui-common` after + `astryx-theme`; an entry stylesheet and an `index.html` without one get the + full statement first. +- `package.json`: `@lablup/ui-common` and `@lablup/ui-common-cli` at the CLI's + version; `@astryxdesign/core` stays only where declared, at the version + ui-common pins; `@astryxdesign/theme-neutral` and an unused + `@astryxdesign/lab` go; a used lab is pinned to ui-common's canary, with the + override when nothing else gives its core peer a target. pnpm projects get + `allowBuilds` entries for the Astryx postinstalls. + +A second run changes nothing. + +## Problems, and their fixes + +**Two copies of Astryx core.** The lab canary peers on an exact core canary. +Without a core of the package's own or an override, pnpm installs a second +core and lab components lose the theme. `doctor` reads the lockfile. + +**Imports come back.** Ban them in ESLint (`no-restricted-imports` does not see +`import()`, `require()` or `typeof import()`), and run `adopt --check` in CI: + +```js +// eslint.config.js +rules: { + "no-restricted-imports": ["error", { patterns: [{ + group: ["@astryxdesign/*", "!@astryxdesign/cli", "!@astryxdesign/cli/*"], + message: "Import Astryx through @lablup/ui-common." }] }], + "no-restricted-syntax": ["error", + "ImportExpression[source.value=/^@astryxdesign\\u002F/]", + "CallExpression[callee.name='require'][arguments.0.value=/^@astryxdesign\\u002F/]", + "TSImportType[argument.literal.value=/^@astryxdesign\\u002F/]"], +} +``` + +**Local patches on Astryx.** A pnpm patch reaches only the project that +declares it, and ui-common ships its own `ComplexSelector`, lab `Drawer` and +lab `Tour`, which no patch on Astryx reaches. The report says which patch a +fork covers. + +**A `Modal` is not a ``.** Its `ref` is the element with +`role="dialog"`. It renders into a `document.body` portal and makes the rest of +the page `inert`: overlays that must stay usable need `MODAL_LIVE_ATTRIBUTE`, +and global shortcuts should skip while `MODAL_OPEN_ATTRIBUTE` is on an element +that is not `inert`. Escape closes one layer per press; your own Escape +handlers call `preventDefault()`. + +**Tools that knew Astryx.** `` blocks give way to the +`UI-COMMON` block (`ui-common agents --write`), and a script that anchored on +`` breaks. `astryx ` becomes `ui-common `. + +**`astryx theme build` fails with "Unknown file extension .css"** when the +recipe imports anything that reaches ui-common components. Run it with an +import hook that loads `.css` as an empty module: + +```js +// astryx-css-stub.mjs — NODE_OPTIONS="--import=./astryx-css-stub.mjs" astryx theme build … +import { register } from "node:module"; +register( + "data:text/javascript," + + encodeURIComponent( + `export async function load(url, context, next) { + if (url.endsWith(".css")) return { format: "module", source: "", shortCircuit: true }; + return next(url, context); + }`, + ), +); +``` + +**Fresh releases and quarantine.** A pnpm `minimumReleaseAge` holds back a +just-published ui-common; list the exact versions under +`minimumReleaseAgeExclude` (the lab canary never ages out). pnpm's `catalog:` +rejects `file:` specs: try a tarball through `overrides`. + +## Doctor checks + +Each section is what one check verifies and how to fix a failure. + +### node + +The CLI (and the Astryx CLI it wraps) needs Node 22.13 or later. The app may +build on any Node it supports. + +### versions + +`@lablup/ui-common` and `@lablup/ui-common-cli` are released in lockstep: +declare both at the same exact version (catalog entries count), install, and +bump them together. A WARN means the CLI is not a dependency; add it as a +devDependency so `doctor` and `agents` run at the project's version. + +### single-core + +Exactly one `@astryxdesign/core` is installed, at the version ui-common pins: +one `packages` entry and one `snapshots` entry in `pnpm-lock.yaml` (the same +version resolved against two peer sets is two copies), one copy in +`package-lock.json`, and one directory resolved from ui-common, lab and each +package. Fix the pins until `pnpm why -r @astryxdesign/core` (or +`npm ls @astryxdesign/core`) lists one version. + +### lab-core + +`@astryxdesign/lab`'s core peer resolves to that same core, in the lockfile and +on disk. Usually fixed by the override below. + +### lab-override + +Where a module imports `@lablup/ui-common/lab`, lab's core peer needs a +target: pnpm `overrides: { "@astryxdesign/lab>@astryxdesign/core": "" }` +in `pnpm-workspace.yaml`, or npm `"overrides": { "@astryxdesign/lab": +{ "@astryxdesign/core": "" } }` in the root `package.json`. Under pnpm, a +package that declares `@astryxdesign/core` itself at the pin passes too. + +### imports + +No module specifier names `@astryxdesign/core`, `/lab` or `/theme-neutral`. +Run `adopt --from astryx`; what it cannot move is listed in its report. + +### layer-order + +`@layer reset, theme, base, astryx-base, astryx-theme, ui-common, components, utilities;` +is the first rule of the entry stylesheet and the first `${html.slice(at)}`; +} diff --git a/packages/cli/codemods/adopt/package-json.mjs b/packages/cli/codemods/adopt/package-json.mjs new file mode 100644 index 0000000..ebd01ed --- /dev/null +++ b/packages/cli/codemods/adopt/package-json.mjs @@ -0,0 +1,208 @@ +/** + * `ui-common adopt` package.json edits, for one package of the project: + * + * - `@lablup/ui-common` is added at exactly the CLI's version (a library + * takes it as a peer and a devDependency), with the `@stylexjs/stylex` peer + * it needs when that is missing. A declaration already there is left alone. + * - `@lablup/ui-common-cli`, the `ui-common` bin, is added once per project + * (`addCli`) as a devDependency at the same version. + * - `@astryxdesign/core` stays only where it is declared, moved to exactly the + * version ui-common pins (a second version would be a second copy of + * Astryx). It is never added. A library's core peer is dropped: ui-common + * brings core. + * - `@astryxdesign/theme-neutral` is removed once no module imports it. + * - `@astryxdesign/lab` is removed once no module uses `@lablup/ui-common/lab`; + * while one does, it is pinned to the canary ui-common is built against. + * - `catalog:`, `workspace:`, `link:` and `file:` specs are never edited; a + * catalog entry that disagrees with ui-common's pin is reported. + */ +import { addDependency } from "../0.2/package-json.mjs"; +import { coerce, compare } from "../../cli/semver.mjs"; +import { DEP_FIELDS, resolveSpec } from "../../cli/project.mjs"; +import { UIC } from "./specifiers.mjs"; + +export const CLI_PACKAGE = "@lablup/ui-common-cli"; +const CORE = "@astryxdesign/core"; +const LAB = "@astryxdesign/lab"; +const NEUTRAL = "@astryxdesign/theme-neutral"; +const ASTRYX_CLI = "@astryxdesign/cli"; +const STYLEX = "@stylexjs/stylex"; + +/** @param {string} spec */ +const isIndirect = (spec) => + /^(catalog:|workspace:|link:|file:|npm:|git|https?:|portal:)/.test(spec.trim()); + +/** + * @typedef {object} AdoptPackageContext + * @property {string} rel the package's directory, project-relative ("." for the root) + * @property {string} version the ui-common version to adopt (the CLI's own) + * @property {string} corePin ui-common's `@astryxdesign/core` + * @property {string} labPin ui-common's `@astryxdesign/lab` peer + * @property {string} stylexRange ui-common's `@stylexjs/stylex` peer + * @property {string | undefined} astryxCliPin the `@astryxdesign/cli` the CLI wraps + * @property {boolean} usesLab a module of the package imports `@lablup/ui-common/lab` + * @property {boolean} keepsLab a module still imports `@astryxdesign/lab` + * @property {boolean} keepsNeutral a module still imports `@astryxdesign/theme-neutral` + * @property {boolean} addCli add `@lablup/ui-common-cli` here + * @property {ReturnType} catalogs + * @property {(message: string) => void} note + * @property {(message: string) => void} alert + */ + +/** + * @param {string} text package.json source + * @param {AdoptPackageContext} ctx + * @returns {{text: string | undefined, labAdded: boolean, declaresCore: boolean}} + */ +export function adoptPackageJson(text, ctx) { + const pkg = JSON.parse(text); + const indent = /^([ \t]+)"/m.exec(text)?.[1] ?? " "; + const where = ctx.rel === "." ? "" : `${ctx.rel}/package.json: `; + const note = (/** @type {string} */ m) => ctx.note(`${where}${m}`); + const has = (/** @type {string} */ name) => + DEP_FIELDS.some((f) => pkg[f]?.[name] != null); + const library = [CORE, LAB, NEUTRAL, UIC].some( + (n) => pkg.peerDependencies?.[n] != null, + ); + let labAdded = false; + + /** + * Where a package this project needs goes: an application's dependencies, + * a library's peers plus devDependencies. + * + * @param {string} name + * @param {string} range + * @param {string} why + */ + const ensure = (name, range, why) => { + const fields = library + ? [ + ...(pkg.peerDependencies?.[name] == null && pkg.dependencies?.[name] == null + ? ["peerDependencies"] + : []), + ...(pkg.devDependencies?.[name] == null && pkg.dependencies?.[name] == null + ? ["devDependencies"] + : []), + ] + : has(name) + ? [] + : ["dependencies"]; + for (const field of fields) addDependency(pkg, field, name, range); + if (fields.length > 0) + note(`added ${name} ${range} to ${fields.join(" and ")}${why}.`); + return fields.length > 0; + }; + + /** Remove `name` from every field. */ + const drop = (/** @type {string} */ name, /** @type {string} */ why) => { + const fields = DEP_FIELDS.filter((f) => pkg[f]?.[name] != null); + for (const f of fields) { + delete pkg[f][name]; + if (Object.keys(pkg[f]).length === 0) delete pkg[f]; + } + if (fields.length > 0) + note(`removed ${name} from ${fields.join(" and ")}: ${why}.`); + }; + + /** + * Move `name` to exactly `pin` wherever it is declared with a plain range. + * + * @param {string} name + * @param {string} pin + */ + const pinExactly = (name, pin) => { + for (const field of DEP_FIELDS) { + const spec = pkg[field]?.[name]; + if (typeof spec !== "string") continue; + if (isIndirect(spec)) { + const resolved = resolveSpec(name, spec, ctx.catalogs); + if (resolved !== spec && resolved !== pin) + note( + `${field}["${name}"] is "${spec}", which resolves to ${resolved}; ${UIC} ${ctx.version} pins ${pin}. Move that entry to ${pin}.`, + ); + continue; + } + if (spec === pin) continue; + const from = coerce(spec); + pkg[field][name] = pin; + note( + `${field}["${name}"]: "${spec}" → "${pin}", the version ${UIC} ${ctx.version} pins.`, + ); + if (name === CORE && from && compare(from, pin) < 0) { + ctx.alert( + `**Astryx moves from ${from} to ${pin}${where ? ` in ${ctx.rel}` : ""}.** ${UIC} ${ctx.version} is built on Astryx ${pin}. Run Astryx's own codemods for that span while the code still imports \`@astryxdesign/*\` (before this adopt run, or on a revert of it): \`ui-common astryx upgrade --from ${from} --path --apply\`, then adopt again.`, + ); + } + } + }; + + // ui-common itself, and what it needs. + if (!has(UIC)) { + ensure( + UIC, + ctx.version, + `: the one dependency that brings Astryx (core, theme-neutral) at the versions it pins`, + ); + ensure(STYLEX, ctx.stylexRange, `: the StyleX runtime ${UIC} and Astryx share`); + } + if (ctx.addCli && !has(CLI_PACKAGE)) { + addDependency(pkg, "devDependencies", CLI_PACKAGE, ctx.version); + note( + `added ${CLI_PACKAGE} ${ctx.version} to devDependencies: the \`ui-common\` bin (\`ui-common doctor\`, \`ui-common agents\`, the Astryx CLI in ${UIC} terms), released at the same version as ${UIC}.`, + ); + } + + // Astryx core: kept only at ui-common's pin. + if (has(CORE)) { + if (library && pkg.peerDependencies?.[CORE] != null) { + delete pkg.peerDependencies[CORE]; + if (Object.keys(pkg.peerDependencies).length === 0) delete pkg.peerDependencies; + note(`removed ${CORE} from peerDependencies: ${UIC} brings it, pinned.`); + } + pinExactly(CORE, ctx.corePin); + } + + // theme-neutral: mirrored as @lablup/ui-common/theme/neutral. + if (has(NEUTRAL)) { + if (ctx.keepsNeutral) + note( + `${NEUTRAL} stays: a module still imports it (see the report). ${UIC}/theme/neutral mirrors it.`, + ); + else drop(NEUTRAL, `${UIC} depends on it and mirrors it as ${UIC}/theme/neutral`); + } + + // lab: an optional peer of ui-common, exact-pinned. + if (ctx.usesLab) { + if (has(LAB)) pinExactly(LAB, ctx.labPin); + else { + labAdded = ensure( + LAB, + ctx.labPin, + `: ${UIC}/lab needs it, and ${UIC} pins the lab canary exactly`, + ); + } + } else if (has(LAB)) { + if (ctx.keepsLab) note(`${LAB} stays: a module still imports it (see the report).`); + else drop(LAB, `no module uses ${UIC}/lab`); + } + + // The Astryx CLI: kept, but it should be the one ui-common-cli wraps. + for (const field of DEP_FIELDS) { + const spec = pkg[field]?.[ASTRYX_CLI]; + if (typeof spec !== "string" || !ctx.astryxCliPin) continue; + const resolved = resolveSpec(ASTRYX_CLI, spec, ctx.catalogs); + if (resolved === ctx.astryxCliPin) continue; + note( + `${field}["${ASTRYX_CLI}"] is "${spec}"; ${CLI_PACKAGE} wraps ${ASTRYX_CLI} ${ctx.astryxCliPin}. Use \`ui-common \` and drop it, or keep it at ${ctx.astryxCliPin}.`, + ); + } + + const out = `${JSON.stringify(pkg, null, indent)}${text.endsWith("\n") ? "\n" : ""}`; + return { + text: out === text ? undefined : out, + labAdded, + declaresCore: DEP_FIELDS.some( + (f) => f !== "peerDependencies" && pkg[f]?.[CORE] != null, + ), + }; +} diff --git a/packages/cli/codemods/adopt/scan.mjs b/packages/cli/codemods/adopt/scan.mjs new file mode 100644 index 0000000..205fdeb --- /dev/null +++ b/packages/cli/codemods/adopt/scan.mjs @@ -0,0 +1,310 @@ +/** + * `ui-common adopt` report-only scans. Nothing here edits a file: each + * finding is a place where moving onto ui-common changes behaviour or + * tooling in a way a person has to decide about. + */ +import { existsSync, readdirSync, readFileSync } from "node:fs"; +import { basename, extname, join } from "node:path"; + +import { ASTRYX_COMMANDS } from "../../cli/rewrite.mjs"; +import { + lineAt, + lineTextAt, + readText, + relPath, + SCRIPT_EXTENSIONS, +} from "../../cli/project.mjs"; + +/** @type {Record} */ +export const CATEGORIES = { + unmirrored: { + title: "`@astryxdesign/*` imports left in place", + help: "ui-common has no mirror for these, or adopt could not move them. Replace each with a ui-common export (the detail says which), or keep Astryx for it on purpose. `adopt --check` and `ui-common doctor` fail while any is left.", + }, + "dialog-ref": { + title: "Dialog → Modal: refs", + help: 'A `Modal` / `AlertModal` ref reaches the element with `role="dialog"`, not an `HTMLDialogElement`. Check each use: retype it, and drive the modal with `isOpen` / `onOpenChange` instead of `showModal()` / `close()`.', + }, + patches: { + title: "Local patches on Astryx", + help: "A pnpm or patch-package patch on `@astryxdesign/*` applies only in this project, and ui-common's own copies of some components do not see it. Drop each patch ui-common's fork covers; for the rest decide whether the fix still matters on the Astryx version ui-common pins, and keep the patch at that version or drop it.", + }, + portals: { + title: "Overlays rendered into document.body", + help: "While a `Modal` is open, every other child of `` is `inert`. A toast viewport, notification area or debug overlay that must stay usable over a modal needs `MODAL_LIVE_ATTRIBUTE` (from `@lablup/ui-common/Modal`) on its root, and `refreshModalBackground()` if it mounts while a modal is open. Check in a real browser: jsdom ignores `inert`.", + }, + hotkeys: { + title: "Global keyboard shortcuts", + help: "A document- or window-level shortcut still fires while a modal is open. Skip it while an element with `MODAL_OPEN_ATTRIBUTE` (from `@lablup/ui-common/Modal`) that is not `inert` exists.", + }, + escape: { + title: "Escape handlers of your own", + help: "`Modal`, the lab `Drawer` and popovers close through one layer stack: one Escape closes the top layer only. A handler of yours that consumes Escape (inline edit, a search box) must call `event.preventDefault()`, or the stack closes the surrounding layer too.", + }, + "agent-blocks": { + title: "Astryx agent blocks, and tools anchored on them", + help: "Replace each `` block with ui-common's: `ui-common agents --write ` adds the `UI-COMMON` block (keep your own lines outside its markers), then delete the ASTRYX block. A script or workflow that finds its place by the ASTRYX markers breaks when they go: point it at `` or at your own marker.", + }, + "astryx-cli": { + title: "`astryx` CLI invocations", + help: "`ui-common ` runs the Astryx CLI ui-common pins and rewrites its output to ui-common paths; `ui-common astryx ` runs it unrewritten. Switch each invocation, or keep `@astryxdesign/cli` as a devDependency at the version `@lablup/ui-common-cli` pins. `astryx theme build` keeps working either way (a recipe that imports ui-common components needs the `.css` stub in docs/adopting-from-astryx.md).", + }, + i18n: { + title: "InternationalizationProvider without ui-common's strings", + help: "ui-common's built-in strings resolve through Astryx's `InternationalizationProvider`. Merge `uiCommonMessages` (from `@lablup/ui-common/i18n-catalog`) into its `messages` with `mergeMessages`, or ui-common's components stay in English. Map your language codes to Astryx locale names (`ko` → `ko-KR`).", + }, +}; + +/** + * @typedef {{category: string, file: string, line: number, text: string, detail?: string}} Finding + */ + +/** + * @param {string} category + * @param {string} file + * @param {string} text + * @param {number} index + * @param {string} [detail] + * @returns {Finding} + */ +function at(category, file, text, index, detail) { + return { + category, + file, + line: lineAt(text, index), + text: lineTextAt(text, index), + detail, + }; +} + +const ASTRYX_INVOCATION = new RegExp( + String.raw`(^|[^\w@/.-])astryx\s+(${ASTRYX_COMMANDS.join("|")})\b`, + "g", +); + +/** + * Findings in one file. `file` is project-relative, `text` its content after + * the run. + * + * @param {string} file + * @param {string} text + * @returns {Finding[]} + */ +export function scanFile(file, text) { + /** @type {Finding[]} */ + const out = []; + const ext = extname(file); + const name = basename(file); + const script = SCRIPT_EXTENSIONS.has(ext); + + if (script) { + const live = /MODAL_LIVE_ATTRIBUTE|data-uic-modal-live/.test(text); + if (!live) { + for (const m of text.matchAll(/\bcreatePortal\s*\(/g)) { + const call = text.slice(m.index, (m.index ?? 0) + 600); + if (/document\.body\b/.test(call)) + out.push( + at("portals", file, text, m.index ?? 0, "a portal into document.body"), + ); + } + for (const m of text.matchAll(//.test(text)) { + const m = //.exec(text); + out.push( + at( + "agent-blocks", + file, + text, + m?.index ?? 0, + //.test(text) + ? "the UI-COMMON block is there too: delete this one" + : "an Astryx agent block", + ), + ); + } else if (/ASTRYX:(?:START|END)/.test(text) && !/\.mdx?$/.test(name)) { + const m = /ASTRYX:(?:START|END)/.exec(text); + out.push( + at("agent-blocks", file, text, m?.index ?? 0, "anchors on the ASTRYX markers"), + ); + } + + const invocations = name === "package.json" ? scriptsOf(text) : [{ text, offset: 0 }]; + if (name === "package.json" || /\.(ya?ml|sh|md|mdx|json)$/.test(name) || script) { + for (const part of invocations) { + for (const m of part.text.matchAll(ASTRYX_INVOCATION)) { + const index = part.offset + (m.index ?? 0) + m[1].length; + // `ui-common astryx …` is the raw passthrough, already through ui-common. + if (/ui-common\s+$/.test(text.slice(Math.max(0, index - 12), index))) continue; + if (script && !/['"`]/.test(lineTextAt(text, index))) continue; + out.push(at("astryx-cli", file, text, index)); + } + } + } + return out; +} + +/** The `scripts` values of a package.json, with their offsets. */ +function scriptsOf(/** @type {string} */ text) { + const block = /"scripts"\s*:\s*\{[^}]*\}/.exec(text); + return block ? [{ text: block[0], offset: block.index ?? 0 }] : []; +} + +/** + * Astryx components ui-common ships its own copy of (src/forks), by the path + * fragment a patch on Astryx's dist would touch. + */ +export const FORKED = [ + { name: "ComplexSelector", pattern: /ComplexSelector/ }, + { name: "Drawer (lab)", pattern: /Drawer/ }, + { name: "Tour (lab)", pattern: /\bTour/ }, +]; + +/** + * Local patches on Astryx: pnpm `patchedDependencies` (pnpm-workspace.yaml or + * package.json `pnpm`), and patch-package files under `patches/`. + * + * @param {string} installDir the install root + * @param {string} projectDir + * @returns {Finding[]} + */ +export function scanPatches(installDir, projectDir) { + /** @type {Finding[]} */ + const out = []; + /** @type {Set} */ + const seen = new Set(); + const describe = (/** @type {string | null} */ patchText) => { + if (!patchText) return "patch file not found"; + const files = [...patchText.matchAll(/^diff --git a\/(\S+)/gm)].map((m) => m[1]); + const covered = FORKED.filter((f) => files.some((p) => f.pattern.test(p))).map( + (f) => f.name, + ); + const touched = + files.length > 0 + ? `touches ${files.slice(0, 4).join(", ")}${files.length > 4 ? ", …" : ""}` + : ""; + return covered.length > 0 + ? `${touched}; ui-common ships its own ${covered.join(" and ")}, so this patch does not reach it: drop it unless the rest still matters` + : `${touched}; not covered by a ui-common fork`; + }; + const record = ( + /** @type {string} */ file, + /** @type {string} */ text, + /** @type {number} */ index, + /** @type {string} */ patchPath, + ) => { + const abs = join(installDir, patchPath); + if (seen.has(abs)) return; + seen.add(abs); + out.push( + at("patches", relPath(projectDir, file), text, index, describe(readText(abs))), + ); + }; + const yamlFile = join(installDir, "pnpm-workspace.yaml"); + const yaml = readText(yamlFile); + const block = yaml + ? /^patchedDependencies:[ \t]*(?:#.*)?\r?\n((?:[ \t]+.*(?:\r?\n|$)|[ \t]*\r?\n)*)/m.exec( + yaml, + ) + : null; + if (yaml && block) { + const offset = (block.index ?? 0) + block[0].length - block[1].length; + for (const m of block[1].matchAll( + /^[ \t]+["']?(@astryxdesign\/[^"':\s]+)["']?\s*:\s*["']?([^"'\s#]+)/gm, + )) + record(yamlFile, yaml, offset + (m.index ?? 0), m[2]); + } + const pkgFile = join(installDir, "package.json"); + const pkgText = readText(pkgFile); + if (pkgText) { + try { + const patched = JSON.parse(pkgText)?.pnpm?.patchedDependencies ?? {}; + for (const [key, path] of Object.entries(patched)) { + if (!key.startsWith("@astryxdesign/")) continue; + record(pkgFile, pkgText, Math.max(0, pkgText.indexOf(key)), String(path)); + } + } catch { + // not JSON: nothing to read + } + } + for (const dir of ["patches", ".patches"]) { + const full = join(installDir, dir); + if (!existsSync(full)) continue; + let entries = []; + try { + entries = readdirSync(full); + } catch { + continue; + } + for (const entry of entries) { + if (!/^@astryxdesign(\+|__)/.test(entry) || !entry.endsWith(".patch")) continue; + const abs = join(full, entry); + if (seen.has(abs)) continue; + seen.add(abs); + const text = readFileSync(abs, "utf8"); + out.push({ + category: "patches", + file: relPath(projectDir, abs), + line: 1, + text: entry, + detail: describe(text), + }); + } + } + return out; +} diff --git a/packages/cli/codemods/adopt/specifiers.mjs b/packages/cli/codemods/adopt/specifiers.mjs new file mode 100644 index 0000000..ff8002f --- /dev/null +++ b/packages/cli/codemods/adopt/specifiers.mjs @@ -0,0 +1,237 @@ +/** + * `@astryxdesign/*` module specifiers → their `@lablup/ui-common` mirror. + * + * Only specifiers in a module position are touched: `from "…"`, a bare + * `import "…"`, `import("…")` (and `typeof import("…")`), `require("…")`, + * `require.resolve("…")`, `import.meta.resolve("…")`, `vi.mock("…")` and its + * jest/vitest siblings, and CSS/Sass `@import` / `@use` / `@forward`. A + * package name in any other string (a list of package names in a script, a + * `declare module "…"` augmentation, which must name the real module) is + * left alone. `@astryxdesign/cli` is tooling, not UI, and stays. + */ +import { lineAt, lineTextAt } from "../../cli/project.mjs"; + +export const UIC = "@lablup/ui-common"; + +/** Astryx packages ui-common mirrors; the rest of `@astryxdesign/*` is not UI. */ +export const MIRRORED_PACKAGES = [ + "@astryxdesign/core", + "@astryxdesign/lab", + "@astryxdesign/theme-neutral", +]; + +const SPEC = String.raw`(@astryxdesign\/[A-Za-z0-9._-]+(?:\/[^'"\s)]*)?)`; + +/** + * Script positions, each its own pattern so that one inside another + * (`vi.importActual("…")`) is found too. Group 1: the + * lead-in; 2: the quote; 3: the specifier. + */ +export const SCRIPT_SPECIFIERS = [ + String.raw`\bfrom\s*`, + String.raw`\bimport\s*`, + String.raw`\bimport\s*\(\s*`, + String.raw`\brequire(?:\.resolve)?\s*\(\s*`, + String.raw`\bimport\.meta\.resolve\s*\(\s*`, + String.raw`\b(?:vi|jest)\.(?:mock|doMock|unmock|doUnmock|requireActual|importActual|importMock|requireMock)\s*(?:<[^<>]*(?:<[^<>]*>[^<>]*)*>)?\s*\(\s*`, +].map((lead) => new RegExp(String.raw`(${lead})(['"])${SPEC}\2`, "g")); + +/** Stylesheet positions: `@import "…"`, `@import url(…)`, Sass `@use` / `@forward`. */ +export const STYLE_SPECIFIERS = [ + new RegExp( + String.raw`(@(?:import|use|forward)\s+(?:url\(\s*)?)(['"]?)${SPEC}\2`, + "g", + ), +]; + +/** + * Each specifier in a module position: where it starts and what it says. + * + * @param {string} text + * @param {"script" | "style"} kind + * @returns {Array<{spec: string, index: number}>} by position, no duplicates + */ +function locate(text, kind) { + /** @type {Map} */ + const found = new Map(); + for (const pattern of kind === "style" ? STYLE_SPECIFIERS : SCRIPT_SPECIFIERS) { + for (const m of text.matchAll(new RegExp(pattern.source, "g"))) { + found.set((m.index ?? 0) + m[1].length + m[2].length, m[3]); + } + } + return [...found.entries()] + .sort((a, b) => a[0] - b[0]) + .map(([index, spec]) => ({ spec, index })); +} + +/** + * Whether a subpath exists in an `exports` map, wildcards included + * (`./locales/*.json` covers `./locales/ko-KR.json`). + * + * @param {Record} exportsMap + * @param {string} subpath "./X" + */ +export function exportsHas(exportsMap, subpath) { + if (subpath in exportsMap) return true; + for (const key of Object.keys(exportsMap)) { + const star = key.indexOf("*"); + if (star === -1) continue; + const head = key.slice(0, star); + const tail = key.slice(star + 1); + if ( + subpath.length > head.length + tail.length && + subpath.startsWith(head) && + subpath.endsWith(tail) && + !subpath.slice(head.length, subpath.length - tail.length).includes("/") + ) + return true; + } + return false; +} + +/** The core subpaths ui-common hides, with what replaces them (exports.exclude.json). */ +export const HIDDEN_SUBPATHS = { + Dialog: { replacement: "Modal", subpath: "Modal" }, + AlertDialog: { replacement: "AlertModal", subpath: "AlertModal" }, +}; + +/** + * Where an `@astryxdesign/*` specifier goes. + * + * @param {string} spec + * @param {Record} exportsMap the target ui-common's `exports` + * @returns {{to: string, row: string} | {left: string} | null} null: not ui-common's business + */ +export function mapSpecifier(spec, exportsMap) { + const m = /^(@astryxdesign\/[^/]+)(?:\/(.*))?$/.exec(spec); + if (!m) return null; + const [, pkg, sub = ""] = m; + if (!MIRRORED_PACKAGES.includes(pkg)) { + if (pkg === "@astryxdesign/cli") return null; + return { left: `${pkg} is not part of ${UIC}; nothing mirrors it.` }; + } + const via = (/** @type {string} */ target, /** @type {string} */ row) => + exportsHas(exportsMap, `./${target}`) + ? { to: `${UIC}/${target}`, row } + : { left: `${UIC} has no \`./${target}\` export to mirror ${spec}.` }; + if (sub === "package.json") + return { + left: `${spec} is Astryx's own manifest; read it from Astryx, or drop it.`, + }; + + if (pkg === "@astryxdesign/core") { + if (sub === "") return { to: UIC, row: "core (root)" }; + const head = sub.split("/")[0]; + const hidden = HIDDEN_SUBPATHS[/** @type {keyof typeof HIDDEN_SUBPATHS} */ (head)]; + if (hidden) { + return { + left: `${UIC} hides ${head}: import ${hidden.replacement} from ${UIC}/${hidden.subpath} (adopt renames named imports; this one it could not).`, + }; + } + if (/\.css$/.test(sub)) return via(sub, "core stylesheets"); + if (sub === "theme/tokens.stylex") return via(sub, "core/theme/tokens.stylex"); + if (/^locales\//.test(sub)) return via(sub, "core/locales/*.json"); + return via(sub, "core/"); + } + if (pkg === "@astryxdesign/lab") { + if (sub === "") return { to: `${UIC}/lab`, row: "lab" }; + return via(`lab/${sub}`, /\.css$/.test(sub) ? "lab/lab.css" : "lab/"); + } + // @astryxdesign/theme-neutral + if (sub === "") return via("theme/neutral", "theme-neutral"); + return via( + `theme/neutral/${sub}`, + /\.css$/.test(sub) ? "theme-neutral/theme.css" : "theme-neutral/", + ); +} + +/** + * Every `@astryxdesign/*` specifier in a module position, with its line. + * + * @param {string} text + * @param {"script" | "style"} kind + */ +export function findSpecifiers(text, kind) { + return locate(text, kind).map(({ spec, index }) => ({ + spec, + index, + line: lineAt(text, index), + text: lineTextAt(text, index), + })); +} + +/** + * Rewrite every mappable specifier in `text`. + * + * `keep(spec, index)` may veto one rewrite (a root import that still names a + * hidden Dialog export). + * + * @param {string} text + * @param {"script" | "style"} kind + * @param {Record} exportsMap + * @param {(spec: string, index: number) => string | null} [keep] a reason to keep it, or null + * @returns {{text: string, rows: Record, left: Array<{spec: string, line: number, text: string, reason: string}>}} + */ +export function rewriteSpecifiers(text, kind, exportsMap, keep) { + /** @type {Record} */ + const rows = {}; + /** @type {Array<{spec: string, line: number, text: string, reason: string}>} */ + const left = []; + /** @type {Array<{at: number, spec: string, to: string}>} */ + const edits = []; + for (const { spec, index: at } of locate(text, kind)) { + const mapped = mapSpecifier(spec, exportsMap); + if (!mapped) continue; + if ("left" in mapped) { + left.push({ + spec, + line: lineAt(text, at), + text: lineTextAt(text, at), + reason: mapped.left, + }); + continue; + } + const veto = keep?.(spec, at); + if (veto) { + left.push({ + spec, + line: lineAt(text, at), + text: lineTextAt(text, at), + reason: veto, + }); + continue; + } + rows[mapped.row] = (rows[mapped.row] ?? 0) + 1; + edits.push({ at, spec, to: mapped.to }); + } + let out = text; + for (const e of edits.reverse()) + out = out.slice(0, e.at) + e.to + out.slice(e.at + e.spec.length); + return { text: out, rows, left }; +} + +/** + * The names an import or export statement takes from a module, from the + * statement's text: `{ A, B as C, type D }` → A, B, D. + * + * @param {string} text the whole file + * @param {number} at index of the specifier + * @returns {string[] | null} null for a statement with no named clause + */ +export function importedNamesAt(text, at) { + const before = text.slice(Math.max(0, at - 4000), at); + const start = Math.max(before.lastIndexOf("import"), before.lastIndexOf("export")); + if (start === -1) return null; + const clause = /\{([^}]*)\}\s*from\s*['"]?$/.exec(before.slice(start)); + if (!clause) return null; + return clause[1] + .split(",") + .map((s) => + s + .replace(/\/\*[\s\S]*?\*\/|\/\/.*$/gm, "") + .trim() + .replace(/^type\s+/, ""), + ) + .filter(Boolean) + .map((s) => s.split(/\s+as\s+/)[0].trim()); +} diff --git a/packages/cli/package.json b/packages/cli/package.json index 57da650..400ed90 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,7 +1,7 @@ { "name": "@lablup/ui-common-cli", "version": "0.2.0-alpha.15", - "description": "The ui-common command line: the pinned Astryx CLI in @lablup/ui-common terms, the agent block, and the upgrade codemods", + "description": "The ui-common command line: the pinned Astryx CLI in @lablup/ui-common terms, the agent block and skill, the upgrade codemods, adopt (from Astryx) and doctor", "license": "Apache-2.0", "author": "Lablup Inc.", "type": "module", @@ -29,6 +29,7 @@ "cli", "codemods", "migration", + "skill", "README.md", "NOTICE" ], diff --git a/packages/cli/skill/ui-common-adopt/SKILL.md b/packages/cli/skill/ui-common-adopt/SKILL.md new file mode 100644 index 0000000..310f17a --- /dev/null +++ b/packages/cli/skill/ui-common-adopt/SKILL.md @@ -0,0 +1,122 @@ +--- +name: ui-common-adopt +description: Move an app that imports Astryx directly (@astryxdesign/core, @astryxdesign/lab, @astryxdesign/theme-neutral) onto @lablup/ui-common in one guided pass, then prove it works. Use when asked to "migrate to ui-common", "adopt ui-common", "move onto @lablup/ui-common", "replace @astryxdesign with ui-common", "stop importing Astryx directly", or when `ui-common doctor` or `ui-common adopt --check` fails. +--- + +# Adopt @lablup/ui-common in an app that uses Astryx directly + +`ui-common adopt --from astryx` does the mechanical part and writes +`ui-common-adopt-report.md`; `ui-common doctor` says what is still wired wrong. +This skill is the order to run them in and the calls only a person (or you, +deliberately) can make. Do not re-derive what the commands print: read their +output and act on it. Background for every check: +`docs/adopting-from-astryx.md` in the ui-common repository (each doctor +failure prints its section). + +Run the CLI one-off until the project has it (`npx …` with npm): + +``` +pnpm dlx @lablup/ui-common-cli@next +``` + +After `pnpm install` it is `pnpm exec ui-common `. + +## 1. Preflight + +- The working tree is clean, on a branch of its own. Stop and ask if not. +- Record the baseline: the build, test and dev-server commands from + package.json, and whether they pass now. A failure that predates the + migration is not yours to fix; note it. +- Find where Storybook or the main screens can be screenshotted now, in light + and dark. Take the "before" screenshots now: after the change there is no + going back for them. + +## 2. Dry run, then apply + +``` +ui-common adopt --from astryx --dry-run --report /tmp/adopt-dry-run.md +``` + +Read the dry-run report before applying: + +- **Action required: "Astryx moves from X to Y".** The app is on an older + Astryx than ui-common pins. Run Astryx's codemods first, while the code still + imports `@astryxdesign/*`: `ui-common astryx upgrade --from X --path --apply`. + Commit that separately, then dry-run adopt again. +- **Files you did not expect to change** (vendored code, generated output + without an `@generated` header): rerun with `--ignore ` for each. + +Then apply (`ui-common adopt --from astryx`), run the package manager's +install, and commit the mechanical change on its own, report included, so the +hand edits that follow review separately. + +If install stops on `minimumReleaseAge`, add the exact versions the report's +note lists to `minimumReleaseAgeExclude`. Never lower the age policy itself. + +## 3. Doctor until it passes + +``` +ui-common doctor +``` + +Fix each FAIL with the printed fix and rerun. The judgement calls: + +- **single-core / lab-core**: find which package pulls the second + `@astryxdesign/core` (`pnpm why -r @astryxdesign/core`, `npm ls`). Fix the + pin or add the override; never silence it with `peerDependencyRules`. +- **vite-prebundle / vitest-inline**: add the snippet from the doc to the + config the check names. Do not restructure the config. +- **agents**: `ui-common agents --write `, + then delete the `` block. Keep project lines outside + the markers. +- A WARN is a decision, not a failure: fix it, or say in the PR why not. + +## 4. Work through the report + +Every row of `ui-common-adopt-report.md` gets a decision. The rules: + +| Report section | Decide | +| ------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `@astryxdesign/*` imports left | Replace with the ui-common export the row names. With no replacement (`useImperativeDialog`), rewrite the call site to `Modal` state; keep the Astryx import only if the user agrees, and say so in the PR. | +| Dialog → Modal: refs | A ref used only for focus or measuring can stay (retype to `HTMLDivElement`). A ref used for `showModal()` / `close()` / `open` becomes `isOpen` / `onOpenChange`. | +| Local patches on Astryx | Covered by a ui-common fork: drop the patch. Otherwise check whether the bug still exists on the pinned Astryx; keep the patch (re-cut for that version) only if it does. | +| Overlays into document.body | Must it work while a modal is open (toasts, notifications, devtools)? Then add `MODAL_LIVE_ATTRIBUTE` to its root (and `refreshModalBackground()` if it can mount over an open modal). If not, leave it inert. | +| Global keyboard shortcuts | Should it fire over a modal? Almost never: guard it with `MODAL_OPEN_ATTRIBUTE`. | +| Escape handlers | Add `event.preventDefault()` where the handler consumed the key. | +| ASTRYX agent blocks / anchored tools | Replace the block (step 3); repoint each tool at `` or its own marker. | +| `astryx` CLI invocations | `astryx ` → `ui-common ` in scripts, CI and docs. Keep `astryx theme build` if the project builds a theme; add the `.css` stub only if that build then fails. | +| InternationalizationProvider | Merge `uiCommonMessages` for every supported locale; map app language codes to Astryx locale names; list unsupported ones explicitly. | + +Then add the guard that keeps it adopted: ban `@astryxdesign/*` in ESLint +(static and dynamic imports, see the doc) and run +`ui-common adopt --from astryx --check` and `ui-common doctor` in CI. + +## 5. Prove it + +All of these, and report each result: + +1. `ui-common adopt --from astryx --check` exits 0; `ui-common doctor` exits 0. +2. Type check, lint, the production build, and the test suite pass (or fail + only as they did at baseline). +3. Cold dev-server start: delete the optimizer cache (`node_modules/.vite`), + start the dev server, load the app in a browser, and confirm it renders + with no page errors. A warm start proves nothing here. +4. Screenshot the same Storybook stories or screens as in step 1, light and + dark, and diff them pixel by pixel. Explain every difference: expected ones + (ui-common's layer now beats Astryx's base styles where it wraps a + primitive) go in the PR; anything else is a bug to fix. +5. In a real browser, open a modal and check: toasts still dismiss, Escape + closes one layer per press, shortcuts stay quiet. + +## Done when + +- adopt `--check` and doctor are green, and both run in CI. +- Every report row is resolved or explained in the PR description. +- Build, tests and a cold dev start pass; the screenshot diff is explained. +- No `@astryxdesign/*` dependency remains except `@astryxdesign/core` at + ui-common's exact pin (and `@astryxdesign/lab`, `@astryxdesign/cli` where + used), and `@lablup/ui-common` equals `@lablup/ui-common-cli`. + +Hand back to a person when: an Astryx patch fixes something ui-common does not, +a screenshot difference has no explanation, or a call site depends on +`HTMLDialogElement` behaviour `Modal` does not have. diff --git a/packages/cli/test/adopt/adopt.test.ts b/packages/cli/test/adopt/adopt.test.ts new file mode 100644 index 0000000..8cd09f4 --- /dev/null +++ b/packages/cli/test/adopt/adopt.test.ts @@ -0,0 +1,427 @@ +/** + * `ui-common adopt --from astryx` against fixture projects that use Astryx + * directly: an app (Vite, StyleX, lab, theme-neutral, a local Astryx patch, + * Storybook, an ASTRYX agent block), a pnpm workspace with a library and an + * app, and an npm app on lab. + * + * Each fixture is copied to a temp dir, adopted, and compared file by file + * with `expected/` (report and package.json included). Regenerate after an + * intended change with `UPDATE_FIXTURES=1 pnpm vitest run packages/cli/test/adopt`, + * then read the diff. + */ +import { spawnSync } from "node:child_process"; +import { + cpSync, + existsSync, + mkdirSync, + mkdtempSync, + readdirSync, + readFileSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join, relative, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +import jscodeshift from "jscodeshift"; +import { afterEach, describe, expect, it } from "vitest"; + +import { runAdopt } from "../../cli/adopt.mjs"; +import { transformDialogs } from "../../codemods/adopt/dialog.mjs"; +import { + addUiCommonLayer, + ensureHtmlLayerStatement, + ensureLayerStatement, + LAYER_ORDER, + withUiCommonLayer, +} from "../../codemods/adopt/layers.mjs"; +import { + exportsHas, + findSpecifiers, + mapSpecifier, + rewriteSpecifiers, +} from "../../codemods/adopt/specifiers.mjs"; + +const here = dirname(fileURLToPath(import.meta.url)); +const FIXTURES = join(here, "fixtures"); +const UPDATE = process.env.UPDATE_FIXTURES === "1"; +const VERSION = "0.2.0"; +const bin = resolve(here, "../../bin/ui-common.mjs"); +const exportsMap = JSON.parse( + readFileSync(resolve(here, "../../../../package.json"), "utf8"), +).exports as Record; + +const temps: string[] = []; +afterEach(() => { + for (const dir of temps.splice(0)) rmSync(dir, { recursive: true, force: true }); +}); + +function tree(dir: string): string[] { + const out: string[] = []; + const walk = (d: string) => { + for (const entry of readdirSync(d, { withFileTypes: true })) { + const full = join(d, entry.name); + if (entry.isDirectory()) walk(full); + else out.push(relative(dir, full)); + } + }; + walk(dir); + return out.sort(); +} + +function copyFixture(name: string) { + const dir = mkdtempSync(join(tmpdir(), `uic-adopt-${name}-`)); + temps.push(dir); + cpSync(join(FIXTURES, name, "input"), dir, { recursive: true }); + return dir; +} + +function snapshot(dir: string) { + return new Map(tree(dir).map((f) => [f, readFileSync(join(dir, f), "utf8")])); +} + +const quiet = { log: () => {}, warn: () => {} }; + +const cases = readdirSync(FIXTURES).filter((name) => + existsSync(join(FIXTURES, name, "input")), +); + +describe("ui-common adopt --from astryx", () => { + it.each(cases)("%s", async (name) => { + const dir = copyFixture(name); + const result = await runAdopt({ cwd: dir, version: VERSION, ...quiet }); + expect(result.code, JSON.stringify(result.errors)).toBe(0); + + const expectedDir = join(FIXTURES, name, "expected"); + if (UPDATE) { + rmSync(expectedDir, { recursive: true, force: true }); + for (const file of tree(dir)) { + mkdirSync(dirname(join(expectedDir, file)), { recursive: true }); + writeFileSync(join(expectedDir, file), readFileSync(join(dir, file), "utf8")); + } + } + expect(tree(dir)).toEqual(tree(expectedDir)); + for (const file of tree(dir)) { + expect(readFileSync(join(dir, file), "utf8"), file).toBe( + readFileSync(join(expectedDir, file), "utf8"), + ); + } + }); + + it.each(cases)("%s: a second run changes nothing", async (name) => { + const dir = copyFixture(name); + await runAdopt({ cwd: dir, version: VERSION, ...quiet }); + const before = snapshot(dir); + const again = await runAdopt({ cwd: dir, version: VERSION, ...quiet }); + expect(again.code).toBe(0); + expect(again.changed).toEqual([]); + for (const [file, text] of before) { + if (file.endsWith(".md") && file.includes("adopt-report")) continue; + expect(readFileSync(join(dir, file), "utf8"), file).toBe(text); + } + }); + + it("--check writes nothing, fails while work is left, and passes after adopting", async () => { + const dir = copyFixture("workspace"); + const before = snapshot(dir); + const lines: string[] = []; + const first = await runAdopt({ + cwd: dir, + version: VERSION, + check: true, + log: () => {}, + warn: (l: string) => lines.push(l), + }); + expect(first.code).toBe(1); + expect(snapshot(dir)).toEqual(before); + expect(lines.join("\n")).toContain("would change packages/web/src/main.tsx"); + + await runAdopt({ cwd: dir, version: VERSION, ...quiet }); + const after = await runAdopt({ cwd: dir, version: VERSION, check: true, ...quiet }); + expect(after.code).toBe(0); + }); + + it("--check fails on an Astryx import adopt cannot move", async () => { + const dir = copyFixture("app"); + await runAdopt({ cwd: dir, version: VERSION, ...quiet }); + const check = await runAdopt({ cwd: dir, version: VERSION, check: true, ...quiet }); + expect(check.code).toBe(1); + expect(check.changed).toEqual([]); + expect(check.left?.map((l) => `${l.file}:${l.line}`)).toEqual([ + "src/ConfirmDelete.tsx:2", + ]); + }); + + it("--dry-run writes nothing and prints the report", async () => { + const dir = copyFixture("app"); + const before = snapshot(dir); + const lines: string[] = []; + const result = await runAdopt({ + cwd: dir, + version: VERSION, + dryRun: true, + log: (l: string) => lines.push(l), + warn: () => {}, + }); + expect(result.code).toBe(0); + expect(snapshot(dir)).toEqual(before); + const printed = lines.join("\n"); + expect(printed).toContain("~ src/App.tsx"); + expect(printed).toContain("# ui-common adopt report"); + expect(printed).toContain("dry run: nothing was written"); + }); + + it("leaves a file it did not write as the report alone", async () => { + const dir = copyFixture("npm-lab"); + writeFileSync(join(dir, "ui-common-adopt-report.md"), "# Notes of mine\n"); + const result = await runAdopt({ cwd: dir, version: VERSION, ...quiet }); + expect(result.code).toBe(2); + expect(readFileSync(join(dir, "ui-common-adopt-report.md"), "utf8")).toBe( + "# Notes of mine\n", + ); + }); + + it("raises an Astryx version move as an action, with the codemod command", async () => { + const dir = copyFixture("npm-lab"); + const pkg = JSON.parse(readFileSync(join(dir, "package.json"), "utf8")); + pkg.dependencies["@astryxdesign/core"] = "^0.5.4"; + writeFileSync(join(dir, "package.json"), JSON.stringify(pkg, null, 2)); + const result = await runAdopt({ cwd: dir, version: VERSION, ...quiet }); + expect(result.report).toContain("## Action required"); + expect(result.report).toContain("Astryx moves from 0.5.4 to 0.6.2"); + expect(result.report).toContain("ui-common astryx upgrade --from 0.5.4"); + }); + + it("--ignore and paths narrow what is rewritten", async () => { + const dir = copyFixture("app"); + await runAdopt({ + cwd: dir, + version: VERSION, + ignore: ["src/legacy.cjs"], + paths: ["src"], + ...quiet, + }); + expect(readFileSync(join(dir, "src/legacy.cjs"), "utf8")).toContain( + "@astryxdesign/core/Text", + ); + expect(readFileSync(join(dir, ".storybook/preview.ts"), "utf8")).toContain( + "@astryxdesign/core", + ); + expect(readFileSync(join(dir, "src/main.tsx"), "utf8")).not.toContain( + "@astryxdesign/", + ); + }); + + it("leaves generated files alone and says so", async () => { + const dir = copyFixture("npm-lab"); + writeFileSync( + join(dir, "src/built-theme.js"), + '/**\n * @generated by `astryx theme build`\n */\nimport { defineTheme } from "@astryxdesign/core/theme";\n', + ); + const result = await runAdopt({ cwd: dir, version: VERSION, ...quiet }); + expect(readFileSync(join(dir, "src/built-theme.js"), "utf8")).toContain( + "@astryxdesign/core/theme", + ); + expect(result.report).toContain("generated file"); + }); +}); + +describe("the command line", { timeout: 60_000 }, () => { + const run = (args: string[], cwd: string) => { + const r = spawnSync(process.execPath, [bin, ...args], { cwd, encoding: "utf8" }); + return { code: r.status, stdout: r.stdout, stderr: r.stderr }; + }; + + it("needs --from astryx", () => { + const dir = copyFixture("npm-lab"); + expect(run(["adopt"], dir).code).toBe(2); + expect(run(["adopt", "--from", "0.1"], dir).code).toBe(2); + expect(run(["adopt", "--from", "astryx", "--check", "--dry-run"], dir).code).toBe( + 2, + ); + expect(run(["adopt", "--bogus"], dir).code).toBe(2); + }); + + it("`upgrade --from astryx` is adopt", () => { + const dir = copyFixture("npm-lab"); + const check = run(["upgrade", "--from", "astryx", "--check"], dir); + expect(check.code).toBe(1); + expect(check.stderr).toContain("ui-common adopt --check"); + expect(run(["upgrade", "--from=astryx"], dir).code).toBe(0); + expect(run(["adopt", "--from", "astryx", "--check"], dir).code).toBe(0); + }); +}); + +describe("specifiers", () => { + it("maps every kind of Astryx specifier onto ui-common's mirror", () => { + const to = (spec: string) => { + const m = mapSpecifier(spec, exportsMap); + return m && "to" in m ? m.to : m && "left" in m ? "LEFT" : null; + }; + expect(to("@astryxdesign/core")).toBe("@lablup/ui-common"); + expect(to("@astryxdesign/core/Button")).toBe("@lablup/ui-common/Button"); + expect(to("@astryxdesign/core/Table/utils")).toBe("@lablup/ui-common/Table/utils"); + expect(to("@astryxdesign/core/reset.css")).toBe("@lablup/ui-common/reset.css"); + expect(to("@astryxdesign/core/astryx.css")).toBe("@lablup/ui-common/astryx.css"); + expect(to("@astryxdesign/core/theme/tokens.stylex")).toBe( + "@lablup/ui-common/theme/tokens.stylex", + ); + expect(to("@astryxdesign/core/locales/ko-KR.json")).toBe( + "@lablup/ui-common/locales/ko-KR.json", + ); + expect(to("@astryxdesign/lab")).toBe("@lablup/ui-common/lab"); + expect(to("@astryxdesign/lab/lab.css")).toBe("@lablup/ui-common/lab/lab.css"); + expect(to("@astryxdesign/theme-neutral")).toBe("@lablup/ui-common/theme/neutral"); + expect(to("@astryxdesign/theme-neutral/built")).toBe( + "@lablup/ui-common/theme/neutral/built", + ); + expect(to("@astryxdesign/theme-neutral/theme.css")).toBe( + "@lablup/ui-common/theme/neutral/theme.css", + ); + expect(to("@astryxdesign/core/Dialog")).toBe("LEFT"); + expect(to("@astryxdesign/core/AlertDialog")).toBe("LEFT"); + expect(to("@astryxdesign/core/docs.mjs")).toBe("LEFT"); + expect(to("@astryxdesign/core/package.json")).toBe("LEFT"); + expect(to("@astryxdesign/core/NoSuchThing")).toBe("LEFT"); + expect(to("@astryxdesign/cli/authoring")).toBeNull(); + expect(exportsHas({ "./locales/*.json": "" }, "./locales/a/b.json")).toBe(false); + }); + + it("touches module positions only", () => { + const source = [ + `import { Button } from '@astryxdesign/core';`, + `import "@astryxdesign/core/astryx.css";`, + `export * from "@astryxdesign/core/Text";`, + `const lazy = () => import( '@astryxdesign/core/Table' );`, + `type T = typeof import("@astryxdesign/core/Toast");`, + `const r = require("@astryxdesign/lab");`, + `const u = import.meta.resolve("@astryxdesign/core/astryx.css");`, + `vi.mock("@astryxdesign/core/Toast");`, + `jest.requireActual("@astryxdesign/core/Badge");`, + `const names = ["@astryxdesign/core", '@astryxdesign/lab'];`, + `declare module "@astryxdesign/core/Text" {}`, + `import { parseDoc } from "@astryxdesign/cli/authoring";`, + "// `@astryxdesign/core/src/Banner.tsx` in prose", + ].join("\n"); + const out = rewriteSpecifiers(source, "script", exportsMap).text.split("\n"); + expect(out[0]).toBe(`import { Button } from '@lablup/ui-common';`); + expect(out[1]).toBe(`import "@lablup/ui-common/astryx.css";`); + expect(out[2]).toBe(`export * from "@lablup/ui-common/Text";`); + expect(out[3]).toBe(`const lazy = () => import( '@lablup/ui-common/Table' );`); + expect(out[4]).toBe(`type T = typeof import("@lablup/ui-common/Toast");`); + expect(out[5]).toBe(`const r = require("@lablup/ui-common/lab");`); + expect(out[6]).toBe( + `const u = import.meta.resolve("@lablup/ui-common/astryx.css");`, + ); + expect(out[7]).toBe(`vi.mock("@lablup/ui-common/Toast");`); + expect(out[8]).toBe( + `jest.requireActual("@lablup/ui-common/Badge");`, + ); + expect(out.slice(9)).toEqual(source.split("\n").slice(9)); + expect(findSpecifiers(source, "script")).toHaveLength(11); + }); + + it("rewrites stylesheet imports, quoted or in url()", () => { + const css = `@import "@astryxdesign/core/reset.css";\n@import url(@astryxdesign/lab/lab.css) layer(astryx-base);\n@use '@astryxdesign/theme-neutral/theme.css';\n`; + expect(rewriteSpecifiers(css, "style", exportsMap).text).toBe( + `@import "@lablup/ui-common/reset.css";\n@import url(@lablup/ui-common/lab/lab.css) layer(astryx-base);\n@use '@lablup/ui-common/theme/neutral/theme.css';\n`, + ); + }); +}); + +describe("the layer-order statement", () => { + it("adds ui-common after astryx-theme, and nowhere else", () => { + expect( + withUiCommonLayer(["reset", "astryx-base", "astryx-theme", "components"]), + ).toEqual(["reset", "astryx-base", "astryx-theme", "ui-common", "components"]); + expect(withUiCommonLayer(["astryx-base", "app"])).toEqual([ + "astryx-base", + "ui-common", + "app", + ]); + expect(withUiCommonLayer(["a", "b"])).toBeNull(); + expect(withUiCommonLayer(["astryx-theme", "ui-common"])).toBeNull(); + const text = + "/* @layer a, astryx-theme; */\n@layer base, astryx-theme;\n@layer x, y;\n"; + expect(addUiCommonLayer(text).text).toBe( + "/* @layer a, astryx-theme; */\n@layer base, astryx-theme, ui-common;\n@layer x, y;\n", + ); + }); + + it("puts a statement first where Astryx's sheets load without one", () => { + expect( + ensureLayerStatement('@import "@lablup/ui-common/astryx.css";\n', "a.css"), + ).toBe(`${LAYER_ORDER}\n\n@import "@lablup/ui-common/astryx.css";\n`); + expect( + ensureLayerStatement( + `${LAYER_ORDER}\n@import "@lablup/ui-common/astryx.css";\n`, + "a.css", + ), + ).toBeNull(); + expect(ensureLayerStatement("a { color: red }\n", "a.css")).toBeNull(); + expect( + ensureLayerStatement( + '@use "sass:math";\n@import "@lablup/ui-common/reset.css";\n', + "a.scss", + ), + ).toBe( + `@use "sass:math";\n\n${LAYER_ORDER}\n\n@import "@lablup/ui-common/reset.css";\n`, + ); + }); + + it("gives index.html the statement as its first \n "), + ).toBeNull(); + }); +}); + +describe("Dialog → Modal", () => { + const j = jscodeshift.withParser("tsx"); + const run = (source: string) => transformDialogs({ source, path: "x.tsx" }, j); + + it("splits names with no ui-common counterpart off a root import", () => { + const out = run( + `import { Button, useImperativeDialog, AlertDialog } from "@astryxdesign/core";\n;\n`, + ); + expect(out.text).toContain( + `import { Button, AlertModal } from "@astryxdesign/core";`, + ); + expect(out.text).toContain( + `import { useImperativeDialog } from "@astryxdesign/core/Dialog";`, + ); + expect(out.text).toContain(""); + }); + + it("keeps a module's own names: shadowed locals, properties, its re-exports", () => { + const out = run( + [ + `import { Dialog } from "@astryxdesign/core/Dialog";`, + `const ui = { Dialog, other: theme.Dialog };`, + `function f(Dialog: string) { return Dialog; }`, + `export { Dialog };`, + `;`, + ].join("\n"), + ); + expect(out.text).toContain(`import { Modal } from "@lablup/ui-common/Modal";`); + expect(out.text).toContain("const ui = { Dialog: Modal, other: theme.Dialog };"); + expect(out.text).toContain("function f(Dialog: string) { return Dialog; }"); + expect(out.text).toContain("export { Modal as Dialog };"); + expect(out.text).toContain(";"); + }); + + it("leaves a module without Dialog imports alone", () => { + expect( + run(`import { Button } from "@astryxdesign/core";\nconst Dialog = 1;\n`).text, + ).toBeNull(); + expect(run(`import { Modal } from "@lablup/ui-common/Modal";\n`).text).toBeNull(); + }); +}); diff --git a/packages/cli/test/adopt/fixtures/app/expected/.github/workflows/ci.yml b/packages/cli/test/adopt/fixtures/app/expected/.github/workflows/ci.yml new file mode 100644 index 0000000..0ddcb1a --- /dev/null +++ b/packages/cli/test/adopt/fixtures/app/expected/.github/workflows/ci.yml @@ -0,0 +1,10 @@ +name: ci +on: [push] +jobs: + check: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + - run: pnpm install --frozen-lockfile + - run: pnpm exec astryx upgrade --from 0.6.0 + - run: pnpm test diff --git a/packages/cli/test/adopt/fixtures/app/expected/.storybook/preview.ts b/packages/cli/test/adopt/fixtures/app/expected/.storybook/preview.ts new file mode 100644 index 0000000..435263a --- /dev/null +++ b/packages/cli/test/adopt/fixtures/app/expected/.storybook/preview.ts @@ -0,0 +1,4 @@ +import "@lablup/ui-common/reset.css"; +import "@lablup/ui-common/astryx.css"; + +export default {}; diff --git a/packages/cli/test/adopt/fixtures/app/expected/AGENTS.md b/packages/cli/test/adopt/fixtures/app/expected/AGENTS.md new file mode 100644 index 0000000..46f23f2 --- /dev/null +++ b/packages/cli/test/adopt/fixtures/app/expected/AGENTS.md @@ -0,0 +1,8 @@ +# Agents + +Project rules stay outside the managed block. + + +Astryx v0.6.2 · 90+ components +CLI: run every command as `pnpm exec astryx ` (shown below as `astryx ...`). + diff --git a/packages/cli/test/adopt/fixtures/app/expected/index.html b/packages/cli/test/adopt/fixtures/app/expected/index.html new file mode 100644 index 0000000..c960fc0 --- /dev/null +++ b/packages/cli/test/adopt/fixtures/app/expected/index.html @@ -0,0 +1,13 @@ + + + + + + + Metrics console + + +
+ + + diff --git a/packages/cli/test/adopt/fixtures/app/expected/package.json b/packages/cli/test/adopt/fixtures/app/expected/package.json new file mode 100644 index 0000000..80c228f --- /dev/null +++ b/packages/cli/test/adopt/fixtures/app/expected/package.json @@ -0,0 +1,27 @@ +{ + "name": "metrics-console", + "private": true, + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "test": "vitest run", + "theme": "astryx theme build src/brand.ts -o src/brand.css", + "agents": "astryx init --features agents" + }, + "dependencies": { + "@astryxdesign/core": "0.6.2", + "@astryxdesign/lab": "0.6.2-canary.c9fb1ad", + "@lablup/ui-common": "0.2.0", + "@stylexjs/stylex": "0.19.0", + "react": "^19.2.0", + "react-dom": "^19.2.0" + }, + "devDependencies": { + "@astryxdesign/cli": "0.6.2", + "@lablup/ui-common-cli": "0.2.0", + "@stylexjs/unplugin": "0.19.0", + "vite": "^8.0.0", + "vitest": "^4.1.0" + } +} diff --git a/packages/cli/test/adopt/fixtures/app/expected/patches/@astryxdesign__core@0.6.2.patch b/packages/cli/test/adopt/fixtures/app/expected/patches/@astryxdesign__core@0.6.2.patch new file mode 100644 index 0000000..c17af07 --- /dev/null +++ b/packages/cli/test/adopt/fixtures/app/expected/patches/@astryxdesign__core@0.6.2.patch @@ -0,0 +1,8 @@ +diff --git a/dist/ComplexSelector/ComplexSelector.js b/dist/ComplexSelector/ComplexSelector.js +index 1111111..2222222 100644 +--- a/dist/ComplexSelector/ComplexSelector.js ++++ b/dist/ComplexSelector/ComplexSelector.js +@@ -1,3 +1,3 @@ +-const clearable = false; ++const clearable = true; + export {}; diff --git a/packages/cli/test/adopt/fixtures/app/expected/pnpm-workspace.yaml b/packages/cli/test/adopt/fixtures/app/expected/pnpm-workspace.yaml new file mode 100644 index 0000000..52c00b1 --- /dev/null +++ b/packages/cli/test/adopt/fixtures/app/expected/pnpm-workspace.yaml @@ -0,0 +1,8 @@ +minimumReleaseAge: 1440 + +patchedDependencies: + "@astryxdesign/core@0.6.2": patches/@astryxdesign__core@0.6.2.patch + +allowBuilds: + "@astryxdesign/core": false + "@astryxdesign/cli": false diff --git a/packages/cli/test/adopt/fixtures/app/expected/scripts/append-rules.mjs b/packages/cli/test/adopt/fixtures/app/expected/scripts/append-rules.mjs new file mode 100644 index 0000000..5df3d95 --- /dev/null +++ b/packages/cli/test/adopt/fixtures/app/expected/scripts/append-rules.mjs @@ -0,0 +1,6 @@ +import { readFileSync, writeFileSync } from "node:fs"; + +// Appends our rules right after the managed agent block. +const text = readFileSync("AGENTS.md", "utf8"); +const at = text.indexOf(""); +writeFileSync("AGENTS.md", `${text.slice(0, at)}\n\nOur rules.\n`); diff --git a/packages/cli/test/adopt/fixtures/app/expected/src/App.test.tsx b/packages/cli/test/adopt/fixtures/app/expected/src/App.test.tsx new file mode 100644 index 0000000..d8a8859 --- /dev/null +++ b/packages/cli/test/adopt/fixtures/app/expected/src/App.test.tsx @@ -0,0 +1,16 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; + +vi.mock("@lablup/ui-common/Toast", async () => ({ + ...(await vi.importActual("@lablup/ui-common/Toast")), + ToastViewport: () => null, +})); + +import { App } from "./App"; + +describe("App", () => { + it("renders", () => { + render(); + expect(screen.getByText("Metrics")).toBeTruthy(); + }); +}); diff --git a/packages/cli/test/adopt/fixtures/app/expected/src/App.tsx b/packages/cli/test/adopt/fixtures/app/expected/src/App.tsx new file mode 100644 index 0000000..fb3f1be --- /dev/null +++ b/packages/cli/test/adopt/fixtures/app/expected/src/App.tsx @@ -0,0 +1,39 @@ +import { Button, Modal, Text, type ModalProps } from "@lablup/ui-common"; +import { ToastViewport } from "@lablup/ui-common/Toast"; +import { Drawer } from "@lablup/ui-common/lab"; +import { useEffect, useRef, useState } from "react"; +import { createPortal } from "react-dom"; + +import { ConfirmDelete } from "./ConfirmDelete"; + +const purpose: ModalProps["purpose"] = "info"; + +export function App() { + const [open, setOpen] = useState(false); + const [drawer, setDrawer] = useState(false); + const dialogRef = useRef(null); + + useEffect(() => { + const onKey = (event: KeyboardEvent) => { + if (event.key === "k" && event.metaKey) setOpen(true); + }; + window.addEventListener("keydown", onKey); + return () => window.removeEventListener("keydown", onKey); + }, []); + + return ( +
+ Metrics +
+ ); +} diff --git a/packages/cli/test/adopt/fixtures/app/expected/src/ConfirmDelete.tsx b/packages/cli/test/adopt/fixtures/app/expected/src/ConfirmDelete.tsx new file mode 100644 index 0000000..9afa943 --- /dev/null +++ b/packages/cli/test/adopt/fixtures/app/expected/src/ConfirmDelete.tsx @@ -0,0 +1,21 @@ +import { AlertModal, type AlertModalProps } from "@lablup/ui-common/AlertModal"; +import { useImperativeDialog } from "@astryxdesign/core/Dialog"; +import { useState } from "react"; + +type Props = Pick & { name: string }; + +export function ConfirmDelete({ name }: Props) { + const [open, setOpen] = useState(false); + const imperative = useImperativeDialog(); + void imperative; + return ( + { + if (event.key === "Escape") setOpen(false); + }} + /> + ); +} diff --git a/packages/cli/test/adopt/fixtures/app/expected/src/augment.ts b/packages/cli/test/adopt/fixtures/app/expected/src/augment.ts new file mode 100644 index 0000000..f47ab16 --- /dev/null +++ b/packages/cli/test/adopt/fixtures/app/expected/src/augment.ts @@ -0,0 +1,8 @@ +// Module augmentation names the real module, so it stays on Astryx. +declare module "@astryxdesign/core/Dialog" { + interface DialogVariantMap { + sheet: true; + } +} + +export {}; diff --git a/packages/cli/test/adopt/fixtures/app/expected/src/index.css b/packages/cli/test/adopt/fixtures/app/expected/src/index.css new file mode 100644 index 0000000..1b0ecf4 --- /dev/null +++ b/packages/cli/test/adopt/fixtures/app/expected/src/index.css @@ -0,0 +1,13 @@ +/* App entry stylesheet. */ +@layer reset, theme, base, astryx-base, astryx-theme, ui-common, components, utilities; + +@import "@lablup/ui-common/reset.css"; +@import "@lablup/ui-common/astryx.css"; +@import "@lablup/ui-common/theme/neutral/theme.css"; +@import "@lablup/ui-common/lab/lab.css"; + +@layer components { + .console-shell { + min-height: 100vh; + } +} diff --git a/packages/cli/test/adopt/fixtures/app/expected/src/legacy.cjs b/packages/cli/test/adopt/fixtures/app/expected/src/legacy.cjs new file mode 100644 index 0000000..16c7a81 --- /dev/null +++ b/packages/cli/test/adopt/fixtures/app/expected/src/legacy.cjs @@ -0,0 +1,4 @@ +const { Text } = require("@lablup/ui-common/Text"); +const cssPath = require.resolve("@lablup/ui-common/astryx.css"); + +module.exports = { Text, cssPath }; diff --git a/packages/cli/test/adopt/fixtures/app/expected/src/main.tsx b/packages/cli/test/adopt/fixtures/app/expected/src/main.tsx new file mode 100644 index 0000000..69ade38 --- /dev/null +++ b/packages/cli/test/adopt/fixtures/app/expected/src/main.tsx @@ -0,0 +1,20 @@ +import "./index.css"; + +import { Theme } from "@lablup/ui-common"; +import { InternationalizationProvider } from "@lablup/ui-common/i18n"; +import astryxKo from "@lablup/ui-common/locales/ko-KR.json"; +import { neutralTheme } from "@lablup/ui-common/theme/neutral/built"; +import { StrictMode } from "react"; +import { createRoot } from "react-dom/client"; + +import { App } from "./App"; + +createRoot(document.getElementById("root")!).render( + + + + + + + , +); diff --git a/packages/cli/test/adopt/fixtures/app/expected/src/panel.scss b/packages/cli/test/adopt/fixtures/app/expected/src/panel.scss new file mode 100644 index 0000000..e533718 --- /dev/null +++ b/packages/cli/test/adopt/fixtures/app/expected/src/panel.scss @@ -0,0 +1,9 @@ +@use "sass:math"; + +@layer reset, theme, base, astryx-base, astryx-theme, ui-common, components, utilities; + +@import "@lablup/ui-common/reset.css"; + +.panel { + padding: math.div(16px, 2); +} diff --git a/packages/cli/test/adopt/fixtures/app/expected/src/tokens.ts b/packages/cli/test/adopt/fixtures/app/expected/src/tokens.ts new file mode 100644 index 0000000..65c2984 --- /dev/null +++ b/packages/cli/test/adopt/fixtures/app/expected/src/tokens.ts @@ -0,0 +1,16 @@ +import * as stylex from "@stylexjs/stylex"; +import { colorVars, spacingVars } from "@lablup/ui-common/theme/tokens.stylex"; + +export type { ButtonProps } from "@lablup/ui-common/Button"; +export { Badge as StatusBadge } from "@lablup/ui-common/Badge"; + +export type ToastModule = typeof import("@lablup/ui-common/Toast"); + +export const loadTable = () => import("@lablup/ui-common/Table"); + +// The package names this file talks about are data, not imports. +export const ASTRYX_PACKAGES = ["@astryxdesign/core", "@astryxdesign/lab"]; + +export const styles = stylex.create({ + panel: { color: colorVars["--color-text"], padding: spacingVars["--spacing-4"] }, +}); diff --git a/packages/cli/test/adopt/fixtures/app/expected/ui-common-adopt-report.md b/packages/cli/test/adopt/fixtures/app/expected/ui-common-adopt-report.md new file mode 100644 index 0000000..d6643da --- /dev/null +++ b/packages/cli/test/adopt/fixtures/app/expected/ui-common-adopt-report.md @@ -0,0 +1,141 @@ +# ui-common adopt report + +`ui-common adopt` astryx → @lablup/ui-common 0.2.0 (@lablup/ui-common-cli 0.2.0). + +Ran the codemods over 13 files under `.`; searched 17 files under the project root for manual-review findings. + +## Summary + +| | Count | +|---|---:| +| Files changed | 12 | +| package.json changed | yes | +| TODO markers left in code | 0 | +| Manual-review findings | 13 | + +## Steps + +- 0.2.0: Astryx (`@astryxdesign/*`) → @lablup/ui-common + +## Rewritten specifiers + +| Kind | Count | +|---|---:| +| core/ | 10 | +| core stylesheets | 6 | +| core (root) | 2 | +| core/locales/*.json | 1 | +| core/theme/tokens.stylex | 1 | +| lab | 1 | +| lab/lab.css | 1 | +| theme-neutral/ | 1 | +| theme-neutral/theme.css | 1 | + +## Changed files + +- `.storybook/preview.ts`: +2 −2, specifiers +- `index.html`: +1 −0, layer-order +- `package.json`: +3 −2, package-json +- `pnpm-workspace.yaml`: +4 −0, package-json +- `src/App.test.tsx`: +2 −2, specifiers +- `src/App.tsx`: +6 −6, dialog-to-modal, specifiers +- `src/ConfirmDelete.tsx`: +3 −3, dialog-to-modal +- `src/index.css`: +5 −5, specifiers, layer-order +- `src/legacy.cjs`: +2 −2, specifiers +- `src/main.tsx`: +4 −4, specifiers +- `src/panel.scss`: +3 −1, specifiers, layer-order +- `src/tokens.ts`: +5 −5, specifiers + +## package.json + +- added @lablup/ui-common 0.2.0 to dependencies: the one dependency that brings Astryx (core, theme-neutral) at the versions it pins. +- added @lablup/ui-common-cli 0.2.0 to devDependencies: the `ui-common` bin (`ui-common doctor`, `ui-common agents`, the Astryx CLI in @lablup/ui-common terms), released at the same version as @lablup/ui-common. +- dependencies["@astryxdesign/core"]: "^0.6.2" → "0.6.2", the version @lablup/ui-common 0.2.0 pins. +- removed @astryxdesign/theme-neutral from dependencies: @lablup/ui-common depends on it and mirrors it as @lablup/ui-common/theme/neutral. +- added allowBuilds declining @astryxdesign/core, @astryxdesign/cli to pnpm-workspace.yaml: their postinstall only prints an `astryx init` nudge, and pnpm 11 stops the install (ERR_PNPM_IGNORED_BUILDS) until each is allowed or declined. + +## Manual review + +### TODO markers (0) + +None. + +### `@astryxdesign/*` imports left in place (1) + +ui-common has no mirror for these, or adopt could not move them. Replace each with a ui-common export (the detail says which), or keep Astryx for it on purpose. `adopt --check` and `ui-common doctor` fail while any is left. + +| Where | What | Detail | +|---|---|---| +| `src/ConfirmDelete.tsx:2` | `import { useImperativeDialog } from "@astryxdesign/core/Dialog";` | @lablup/ui-common hides Dialog: import Modal from @lablup/ui-common/Modal (adopt renames named imports; this one it could not). | + +### Dialog → Modal: refs (2) + +A `Modal` / `AlertModal` ref reaches the element with `role="dialog"`, not an `HTMLDialogElement`. Check each use: retype it, and drive the modal with `isOpen` / `onOpenChange` instead of `showModal()` / `close()`. + +| Where | What | Detail | +|---|---|---| +| `src/App.tsx:14` | `const dialogRef = useRef(null);` | This module moved from Dialog to Modal, whose ref is an HTMLDivElement (role="dialog"). Retype the ref and drop dialog-element calls. | +| `src/App.tsx:29` | `` | `ref` on Modal reaches the element with role="dialog", not an HTMLDialogElement: `showModal()`, `close()` and `open` are not there. Open and close it with `isOpen` / `onOpenChange`. | + +### Local patches on Astryx (1) + +A pnpm or patch-package patch on `@astryxdesign/*` applies only in this project, and ui-common's own copies of some components do not see it. Drop each patch ui-common's fork covers; for the rest decide whether the fix still matters on the Astryx version ui-common pins, and keep the patch at that version or drop it. + +| Where | What | Detail | +|---|---|---| +| `pnpm-workspace.yaml:4` | `"@astryxdesign/core@0.6.2": patches/@astryxdesign__core@0.6.2.patch` | touches dist/ComplexSelector/ComplexSelector.js; ui-common ships its own ComplexSelector, so this patch does not reach it: drop it unless the rest still matters | + +### Overlays rendered into document.body (1) + +While a `Modal` is open, every other child of `` is `inert`. A toast viewport, notification area or debug overlay that must stay usable over a modal needs `MODAL_LIVE_ATTRIBUTE` (from `@lablup/ui-common/Modal`) on its root, and `refreshModalBackground()` if it mounts while a modal is open. Check in a real browser: jsdom ignores `inert`. + +| Where | What | Detail | +|---|---|---| +| `src/App.tsx:36` | `{createPortal(, document.body)}` | a portal into document.body; Astryx's toast viewport does not mark itself live | + +### Global keyboard shortcuts (1) + +A document- or window-level shortcut still fires while a modal is open. Skip it while an element with `MODAL_OPEN_ATTRIBUTE` (from `@lablup/ui-common/Modal`) that is not `inert` exists. + +| Where | What | Detail | +|---|---|---| +| `src/App.tsx:20` | `window.addEventListener("keydown", onKey);` | | + +### Escape handlers of your own (1) + +`Modal`, the lab `Drawer` and popovers close through one layer stack: one Escape closes the top layer only. A handler of yours that consumes Escape (inline edit, a search box) must call `event.preventDefault()`, or the stack closes the surrounding layer too. + +| Where | What | Detail | +|---|---|---| +| `src/ConfirmDelete.tsx:17` | `if (event.key === "Escape") setOpen(false);` | no preventDefault() in this module | + +### Astryx agent blocks, and tools anchored on them (2) + +Replace each `` block with ui-common's: `ui-common agents --write ` adds the `UI-COMMON` block (keep your own lines outside its markers), then delete the ASTRYX block. A script or workflow that finds its place by the ASTRYX markers breaks when they go: point it at `` or at your own marker. + +| Where | What | Detail | +|---|---|---| +| `AGENTS.md:5` | `` | an Astryx agent block | +| `scripts/append-rules.mjs:5` | `const at = text.indexOf("");` | anchors on the ASTRYX markers | + +### `astryx` CLI invocations (3) + +`ui-common ` runs the Astryx CLI ui-common pins and rewrites its output to ui-common paths; `ui-common astryx ` runs it unrewritten. Switch each invocation, or keep `@astryxdesign/cli` as a devDependency at the version `@lablup/ui-common-cli` pins. `astryx theme build` keeps working either way (a recipe that imports ui-common components needs the `.css` stub in docs/adopting-from-astryx.md). + +| Where | What | Detail | +|---|---|---| +| `.github/workflows/ci.yml:9` | `- run: pnpm exec astryx upgrade --from 0.6.0` | | +| `package.json:9` | `"theme": "astryx theme build src/brand.ts -o src/brand.css",` | | +| `package.json:10` | `"agents": "astryx init --features agents"` | | + +### InternationalizationProvider without ui-common's strings (1) + +ui-common's built-in strings resolve through Astryx's `InternationalizationProvider`. Merge `uiCommonMessages` (from `@lablup/ui-common/i18n-catalog`) into its `messages` with `mergeMessages`, or ui-common's components stay in English. Map your language codes to Astryx locale names (`ko` → `ko-KR`). + +| Where | What | Detail | +|---|---|---| +| `src/main.tsx:14` | `` | | + +## Notes + +- pnpm-workspace.yaml sets `minimumReleaseAge`: a @lablup/ui-common release younger than that will not install. Until it ages out, list `@lablup/ui-common@0.2.0`, `@lablup/ui-common-cli@0.2.0`, `@astryxdesign/lab@0.6.2-canary.c9fb1ad` under `minimumReleaseAgeExclude` (a lab canary never ages out). diff --git a/packages/cli/test/adopt/fixtures/app/expected/vite.config.ts b/packages/cli/test/adopt/fixtures/app/expected/vite.config.ts new file mode 100644 index 0000000..546a733 --- /dev/null +++ b/packages/cli/test/adopt/fixtures/app/expected/vite.config.ts @@ -0,0 +1,10 @@ +import stylex from "@stylexjs/unplugin/vite"; +import react from "@vitejs/plugin-react"; +import { defineConfig } from "vite"; + +export default defineConfig({ + plugins: [stylex({ useCSSLayers: false }), react()], + test: { + environment: "jsdom", + }, +}); diff --git a/packages/cli/test/adopt/fixtures/app/input/.github/workflows/ci.yml b/packages/cli/test/adopt/fixtures/app/input/.github/workflows/ci.yml new file mode 100644 index 0000000..0ddcb1a --- /dev/null +++ b/packages/cli/test/adopt/fixtures/app/input/.github/workflows/ci.yml @@ -0,0 +1,10 @@ +name: ci +on: [push] +jobs: + check: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + - run: pnpm install --frozen-lockfile + - run: pnpm exec astryx upgrade --from 0.6.0 + - run: pnpm test diff --git a/packages/cli/test/adopt/fixtures/app/input/.storybook/preview.ts b/packages/cli/test/adopt/fixtures/app/input/.storybook/preview.ts new file mode 100644 index 0000000..9583804 --- /dev/null +++ b/packages/cli/test/adopt/fixtures/app/input/.storybook/preview.ts @@ -0,0 +1,4 @@ +import "@astryxdesign/core/reset.css"; +import "@astryxdesign/core/astryx.css"; + +export default {}; diff --git a/packages/cli/test/adopt/fixtures/app/input/AGENTS.md b/packages/cli/test/adopt/fixtures/app/input/AGENTS.md new file mode 100644 index 0000000..46f23f2 --- /dev/null +++ b/packages/cli/test/adopt/fixtures/app/input/AGENTS.md @@ -0,0 +1,8 @@ +# Agents + +Project rules stay outside the managed block. + + +Astryx v0.6.2 · 90+ components +CLI: run every command as `pnpm exec astryx ` (shown below as `astryx ...`). + diff --git a/packages/cli/test/adopt/fixtures/app/input/index.html b/packages/cli/test/adopt/fixtures/app/input/index.html new file mode 100644 index 0000000..8d8aa42 --- /dev/null +++ b/packages/cli/test/adopt/fixtures/app/input/index.html @@ -0,0 +1,12 @@ + + + + + + Metrics console + + +
+ + + diff --git a/packages/cli/test/adopt/fixtures/app/input/package.json b/packages/cli/test/adopt/fixtures/app/input/package.json new file mode 100644 index 0000000..50c6ec6 --- /dev/null +++ b/packages/cli/test/adopt/fixtures/app/input/package.json @@ -0,0 +1,26 @@ +{ + "name": "metrics-console", + "private": true, + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "test": "vitest run", + "theme": "astryx theme build src/brand.ts -o src/brand.css", + "agents": "astryx init --features agents" + }, + "dependencies": { + "@astryxdesign/core": "^0.6.2", + "@astryxdesign/lab": "0.6.2-canary.c9fb1ad", + "@astryxdesign/theme-neutral": "0.6.2", + "@stylexjs/stylex": "0.19.0", + "react": "^19.2.0", + "react-dom": "^19.2.0" + }, + "devDependencies": { + "@astryxdesign/cli": "0.6.2", + "@stylexjs/unplugin": "0.19.0", + "vite": "^8.0.0", + "vitest": "^4.1.0" + } +} diff --git a/packages/cli/test/adopt/fixtures/app/input/patches/@astryxdesign__core@0.6.2.patch b/packages/cli/test/adopt/fixtures/app/input/patches/@astryxdesign__core@0.6.2.patch new file mode 100644 index 0000000..c17af07 --- /dev/null +++ b/packages/cli/test/adopt/fixtures/app/input/patches/@astryxdesign__core@0.6.2.patch @@ -0,0 +1,8 @@ +diff --git a/dist/ComplexSelector/ComplexSelector.js b/dist/ComplexSelector/ComplexSelector.js +index 1111111..2222222 100644 +--- a/dist/ComplexSelector/ComplexSelector.js ++++ b/dist/ComplexSelector/ComplexSelector.js +@@ -1,3 +1,3 @@ +-const clearable = false; ++const clearable = true; + export {}; diff --git a/packages/cli/test/adopt/fixtures/app/input/pnpm-workspace.yaml b/packages/cli/test/adopt/fixtures/app/input/pnpm-workspace.yaml new file mode 100644 index 0000000..8b074e5 --- /dev/null +++ b/packages/cli/test/adopt/fixtures/app/input/pnpm-workspace.yaml @@ -0,0 +1,4 @@ +minimumReleaseAge: 1440 + +patchedDependencies: + "@astryxdesign/core@0.6.2": patches/@astryxdesign__core@0.6.2.patch diff --git a/packages/cli/test/adopt/fixtures/app/input/scripts/append-rules.mjs b/packages/cli/test/adopt/fixtures/app/input/scripts/append-rules.mjs new file mode 100644 index 0000000..5df3d95 --- /dev/null +++ b/packages/cli/test/adopt/fixtures/app/input/scripts/append-rules.mjs @@ -0,0 +1,6 @@ +import { readFileSync, writeFileSync } from "node:fs"; + +// Appends our rules right after the managed agent block. +const text = readFileSync("AGENTS.md", "utf8"); +const at = text.indexOf(""); +writeFileSync("AGENTS.md", `${text.slice(0, at)}\n\nOur rules.\n`); diff --git a/packages/cli/test/adopt/fixtures/app/input/src/App.test.tsx b/packages/cli/test/adopt/fixtures/app/input/src/App.test.tsx new file mode 100644 index 0000000..fbb5bd1 --- /dev/null +++ b/packages/cli/test/adopt/fixtures/app/input/src/App.test.tsx @@ -0,0 +1,16 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; + +vi.mock("@astryxdesign/core/Toast", async () => ({ + ...(await vi.importActual("@astryxdesign/core/Toast")), + ToastViewport: () => null, +})); + +import { App } from "./App"; + +describe("App", () => { + it("renders", () => { + render(); + expect(screen.getByText("Metrics")).toBeTruthy(); + }); +}); diff --git a/packages/cli/test/adopt/fixtures/app/input/src/App.tsx b/packages/cli/test/adopt/fixtures/app/input/src/App.tsx new file mode 100644 index 0000000..090e576 --- /dev/null +++ b/packages/cli/test/adopt/fixtures/app/input/src/App.tsx @@ -0,0 +1,39 @@ +import { Button, Dialog, Text, type DialogProps } from "@astryxdesign/core"; +import { ToastViewport } from "@astryxdesign/core/Toast"; +import { Drawer } from "@astryxdesign/lab"; +import { useEffect, useRef, useState } from "react"; +import { createPortal } from "react-dom"; + +import { ConfirmDelete } from "./ConfirmDelete"; + +const purpose: DialogProps["purpose"] = "info"; + +export function App() { + const [open, setOpen] = useState(false); + const [drawer, setDrawer] = useState(false); + const dialogRef = useRef(null); + + useEffect(() => { + const onKey = (event: KeyboardEvent) => { + if (event.key === "k" && event.metaKey) setOpen(true); + }; + window.addEventListener("keydown", onKey); + return () => window.removeEventListener("keydown", onKey); + }, []); + + return ( +
+ Metrics +
+ ); +} diff --git a/packages/cli/test/adopt/fixtures/app/input/src/ConfirmDelete.tsx b/packages/cli/test/adopt/fixtures/app/input/src/ConfirmDelete.tsx new file mode 100644 index 0000000..095bc80 --- /dev/null +++ b/packages/cli/test/adopt/fixtures/app/input/src/ConfirmDelete.tsx @@ -0,0 +1,21 @@ +import { AlertDialog, type AlertDialogProps } from "@astryxdesign/core/AlertDialog"; +import { useImperativeDialog } from "@astryxdesign/core/Dialog"; +import { useState } from "react"; + +type Props = Pick & { name: string }; + +export function ConfirmDelete({ name }: Props) { + const [open, setOpen] = useState(false); + const imperative = useImperativeDialog(); + void imperative; + return ( + { + if (event.key === "Escape") setOpen(false); + }} + /> + ); +} diff --git a/packages/cli/test/adopt/fixtures/app/input/src/augment.ts b/packages/cli/test/adopt/fixtures/app/input/src/augment.ts new file mode 100644 index 0000000..f47ab16 --- /dev/null +++ b/packages/cli/test/adopt/fixtures/app/input/src/augment.ts @@ -0,0 +1,8 @@ +// Module augmentation names the real module, so it stays on Astryx. +declare module "@astryxdesign/core/Dialog" { + interface DialogVariantMap { + sheet: true; + } +} + +export {}; diff --git a/packages/cli/test/adopt/fixtures/app/input/src/index.css b/packages/cli/test/adopt/fixtures/app/input/src/index.css new file mode 100644 index 0000000..b16710a --- /dev/null +++ b/packages/cli/test/adopt/fixtures/app/input/src/index.css @@ -0,0 +1,13 @@ +/* App entry stylesheet. */ +@layer reset, theme, base, astryx-base, astryx-theme, components, utilities; + +@import "@astryxdesign/core/reset.css"; +@import "@astryxdesign/core/astryx.css"; +@import "@astryxdesign/theme-neutral/theme.css"; +@import "@astryxdesign/lab/lab.css"; + +@layer components { + .console-shell { + min-height: 100vh; + } +} diff --git a/packages/cli/test/adopt/fixtures/app/input/src/legacy.cjs b/packages/cli/test/adopt/fixtures/app/input/src/legacy.cjs new file mode 100644 index 0000000..4b947b5 --- /dev/null +++ b/packages/cli/test/adopt/fixtures/app/input/src/legacy.cjs @@ -0,0 +1,4 @@ +const { Text } = require("@astryxdesign/core/Text"); +const cssPath = require.resolve("@astryxdesign/core/astryx.css"); + +module.exports = { Text, cssPath }; diff --git a/packages/cli/test/adopt/fixtures/app/input/src/main.tsx b/packages/cli/test/adopt/fixtures/app/input/src/main.tsx new file mode 100644 index 0000000..3dac895 --- /dev/null +++ b/packages/cli/test/adopt/fixtures/app/input/src/main.tsx @@ -0,0 +1,20 @@ +import "./index.css"; + +import { Theme } from "@astryxdesign/core"; +import { InternationalizationProvider } from "@astryxdesign/core/i18n"; +import astryxKo from "@astryxdesign/core/locales/ko-KR.json"; +import { neutralTheme } from "@astryxdesign/theme-neutral/built"; +import { StrictMode } from "react"; +import { createRoot } from "react-dom/client"; + +import { App } from "./App"; + +createRoot(document.getElementById("root")!).render( + + + + + + + , +); diff --git a/packages/cli/test/adopt/fixtures/app/input/src/panel.scss b/packages/cli/test/adopt/fixtures/app/input/src/panel.scss new file mode 100644 index 0000000..a4ecc7f --- /dev/null +++ b/packages/cli/test/adopt/fixtures/app/input/src/panel.scss @@ -0,0 +1,7 @@ +@use "sass:math"; + +@import "@astryxdesign/core/reset.css"; + +.panel { + padding: math.div(16px, 2); +} diff --git a/packages/cli/test/adopt/fixtures/app/input/src/tokens.ts b/packages/cli/test/adopt/fixtures/app/input/src/tokens.ts new file mode 100644 index 0000000..b5aada5 --- /dev/null +++ b/packages/cli/test/adopt/fixtures/app/input/src/tokens.ts @@ -0,0 +1,16 @@ +import * as stylex from "@stylexjs/stylex"; +import { colorVars, spacingVars } from "@astryxdesign/core/theme/tokens.stylex"; + +export type { ButtonProps } from "@astryxdesign/core/Button"; +export { Badge as StatusBadge } from "@astryxdesign/core/Badge"; + +export type ToastModule = typeof import("@astryxdesign/core/Toast"); + +export const loadTable = () => import("@astryxdesign/core/Table"); + +// The package names this file talks about are data, not imports. +export const ASTRYX_PACKAGES = ["@astryxdesign/core", "@astryxdesign/lab"]; + +export const styles = stylex.create({ + panel: { color: colorVars["--color-text"], padding: spacingVars["--spacing-4"] }, +}); diff --git a/packages/cli/test/adopt/fixtures/app/input/vite.config.ts b/packages/cli/test/adopt/fixtures/app/input/vite.config.ts new file mode 100644 index 0000000..546a733 --- /dev/null +++ b/packages/cli/test/adopt/fixtures/app/input/vite.config.ts @@ -0,0 +1,10 @@ +import stylex from "@stylexjs/unplugin/vite"; +import react from "@vitejs/plugin-react"; +import { defineConfig } from "vite"; + +export default defineConfig({ + plugins: [stylex({ useCSSLayers: false }), react()], + test: { + environment: "jsdom", + }, +}); diff --git a/packages/cli/test/adopt/fixtures/npm-lab/expected/package-lock.json b/packages/cli/test/adopt/fixtures/npm-lab/expected/package-lock.json new file mode 100644 index 0000000..92f247d --- /dev/null +++ b/packages/cli/test/adopt/fixtures/npm-lab/expected/package-lock.json @@ -0,0 +1,6 @@ +{ + "name": "drawer-demo", + "lockfileVersion": 3, + "requires": true, + "packages": {} +} diff --git a/packages/cli/test/adopt/fixtures/npm-lab/expected/package.json b/packages/cli/test/adopt/fixtures/npm-lab/expected/package.json new file mode 100644 index 0000000..7d45058 --- /dev/null +++ b/packages/cli/test/adopt/fixtures/npm-lab/expected/package.json @@ -0,0 +1,19 @@ +{ + "name": "drawer-demo", + "private": true, + "dependencies": { + "@astryxdesign/lab": "0.6.2-canary.c9fb1ad", + "@lablup/ui-common": "0.2.0", + "@stylexjs/stylex": "^0.19.0", + "react": "^19.2.0", + "react-dom": "^19.2.0" + }, + "devDependencies": { + "@lablup/ui-common-cli": "0.2.0" + }, + "overrides": { + "@astryxdesign/lab": { + "@astryxdesign/core": "0.6.2" + } + } +} diff --git a/packages/cli/test/adopt/fixtures/npm-lab/expected/src/Filters.jsx b/packages/cli/test/adopt/fixtures/npm-lab/expected/src/Filters.jsx new file mode 100644 index 0000000..cc3d834 --- /dev/null +++ b/packages/cli/test/adopt/fixtures/npm-lab/expected/src/Filters.jsx @@ -0,0 +1,14 @@ +import { Button } from '@lablup/ui-common/Button'; +import { Drawer, Tour } from '@lablup/ui-common/lab'; +import { useState } from 'react'; + +export function Filters() { + const [open, setOpen] = useState(false); + return ( + <> +