Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { readdir, readFile } from "node:fs/promises"
import { fileURLToPath } from "node:url"

export async function checkAssets({
assets = new URL("../assets/", import.meta.url),
assets = new URL("../", import.meta.url),
build = () => execFileSync("npm", ["run", "extension:build"], { stdio: "inherit" }),
} = {}) {
const before = await snapshot(assets)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -66,11 +66,11 @@ describe("checkAssets()", () => {
}
})

it("runs the CLI check from a path with spaces without invoking the real build", async () => {
it("checks generated Node modules outside browser assets from a path with spaces", async () => {
const directory = path.join(process.cwd(), "storage/replica-extension-tests", randomUUID())
const extension = path.join(directory, "extension with spaces")
const scriptDirectory = path.join(extension, "check-assets")
const assetFile = path.join(extension, "assets", "renderer.js")
const assetFile = path.join(extension, "read-remote-branch", "index.mjs")
const script = path.join(scriptDirectory, "index.mjs")
await mkdir(scriptDirectory, { recursive: true })
await mkdir(path.dirname(assetFile), { recursive: true })
Expand All @@ -94,7 +94,7 @@ describe("checkAssets()", () => {
},
stdio: "pipe",
}),
/Extension assets differ after build.*renderer\.js/s,
/Extension assets differ after build.*read-remote-branch\/index\.mjs/s,
)
} finally {
await rm(directory, { recursive: true, force: true })
Expand Down
44 changes: 33 additions & 11 deletions .github/extensions/replica-prototype/read-remote-branch/index.mjs
Original file line number Diff line number Diff line change
@@ -1,13 +1,17 @@
// Edit index.mts; rebuild index.mjs with Hub's extension:modules:build command.
import { runCommand } from "../run-command/index.mjs"

// Matching refs returns [] for a missing branch; authorization/HTTP errors stay unknown.
export async function readRemoteBranch(
{ repository, branch, commitSha, repositoryPath },
run = runCommand,
) {
const identity = { repository, branch, commitSha }
if (!branch)
return { ...identity, state: "detached", error: "Open a named branch before publishing." }
return {
...identity,
state: "detached",
error: "Open a named branch before publishing.",
}
if (!repository || !commitSha)
return {
...identity,
Expand All @@ -29,20 +33,38 @@ export async function readRemoteBranch(
try {
refs = JSON.parse(result.stdout)
} catch {
return { ...identity, state: "unknown", error: "GitHub returned invalid branch data." }
return {
...identity,
state: "unknown",
error: "GitHub returned invalid branch data.",
}
}
if (
!Array.isArray(refs) ||
refs.some(
!refs.every(
(ref) =>
!ref ||
typeof ref.ref !== "string" ||
!/^refs\/heads\//.test(ref.ref) ||
ref.object?.type !== "commit" ||
!/^[0-9a-f]{40}$/.test(ref.object?.sha || ""),
typeof ref === "object" &&
ref !== null &&
"ref" in ref &&
typeof ref.ref === "string" &&
/^refs\/heads\//.test(ref.ref) &&
"object" in ref &&
typeof ref.object === "object" &&
ref.object !== null &&
"type" in ref.object &&
ref.object.type === "commit" &&
"sha" in ref.object &&
typeof ref.object.sha === "string" &&
/^[0-9a-f]{40}$/.test(ref.object.sha),
)
)
return { ...identity, state: "unknown", error: "GitHub returned invalid branch refs." }
return {
...identity,
state: "unknown",
error: "GitHub returned invalid branch refs.",
}
const exact = refs.find((ref) => ref.ref === `refs/heads/${branch}`)
return { ...identity, state: exact ? "present" : "absent", remoteSha: exact?.object.sha || null }
return exact
? { ...identity, state: "present", remoteSha: exact.object.sha }
: { ...identity, state: "absent", remoteSha: null }
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
// Edit index.mts; rebuild index.mjs with Hub's extension:modules:build command.
import { runCommand } from "../run-command/index.mjs"

type BranchIdentity = {
repository: string | null
branch: string | null
commitSha: string | null
}

type RemoteBranchResult = BranchIdentity &
(
| { state: "detached" | "unknown"; error: string }
| { state: "present"; remoteSha: string }
| { state: "absent"; remoteSha: null }
)

type BranchRef = { ref: string; object: { type: "commit"; sha: string } }

// Matching refs returns [] for a missing branch; authorization/HTTP errors stay unknown.
export async function readRemoteBranch(
{ repository, branch, commitSha, repositoryPath }: BranchIdentity & { repositoryPath: string },
run: typeof runCommand = runCommand,
): Promise<RemoteBranchResult> {
const identity = { repository, branch, commitSha }
if (!branch)
return {
...identity,
state: "detached",
error: "Open a named branch before publishing.",
}
if (!repository || !commitSha)
return {
...identity,
state: "unknown",
error: "The GitHub origin or checkout commit could not be verified.",
}
const result = await run(
"gh",
[
"api",
"--hostname",
"github.com",
`repos/${repository}/git/matching-refs/heads/${encodeURIComponent(branch)}`,
],
repositoryPath,
)
if (!result.ok) return { ...identity, state: "unknown", error: result.error }
let refs: unknown
try {
refs = JSON.parse(result.stdout)
} catch {
return {
...identity,
state: "unknown",
error: "GitHub returned invalid branch data.",
}
}
if (
!Array.isArray(refs) ||
!refs.every(
(ref: unknown): ref is BranchRef =>
typeof ref === "object" &&
ref !== null &&
"ref" in ref &&
typeof ref.ref === "string" &&
/^refs\/heads\//.test(ref.ref) &&
"object" in ref &&
typeof ref.object === "object" &&
ref.object !== null &&
"type" in ref.object &&
ref.object.type === "commit" &&
"sha" in ref.object &&
typeof ref.object.sha === "string" &&
/^[0-9a-f]{40}$/.test(ref.object.sha),
)
)
return {
...identity,
state: "unknown",
error: "GitHub returned invalid branch refs.",
}
const exact = refs.find((ref) => ref.ref === `refs/heads/${branch}`)
return exact
? { ...identity, state: "present", remoteSha: exact.object.sha }
: { ...identity, state: "absent", remoteSha: null }
}
15 changes: 15 additions & 0 deletions .github/extensions/replica-prototype/run-command/index.d.mts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
export function runCommand(
command: string,
args: string[],
cwd?: string,
options?: { env?: Record<string, string | undefined> },
): Promise<
| { ok: true; stdout: string }
| {
ok: false
error: string
exitCode?: string | number | null
stdout?: string
stderr?: string
}
>
17 changes: 17 additions & 0 deletions .github/extensions/replica-prototype/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
{
"compilerOptions": {
"target": "ES2023",
"lib": ["ES2023"],
"module": "NodeNext",
"moduleResolution": "NodeNext",
"strict": true,
"noUncheckedIndexedAccess": true,
"isolatedModules": true,
"verbatimModuleSyntax": true,
"noEmit": true,
"noEmitOnError": true,
"skipLibCheck": true,
"types": []
},
"include": ["**/*.mts"]
}
2 changes: 1 addition & 1 deletion eslint.config.mjs
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import hubConfig from "./hub/eslint.config.ts"

export default hubConfig.map((config) =>
config.files?.includes("../.github/extensions/replica-prototype/**/*.mjs")
config.files?.some((pattern) => pattern.startsWith("../.github/extensions/replica-prototype/"))
? {
...config,
files: config.files.map((pattern) =>
Expand Down
4 changes: 3 additions & 1 deletion hub/docs/prototype-canvas.md
Original file line number Diff line number Diff line change
Expand Up @@ -454,6 +454,8 @@ Plugin releases, the Controls definition format and the Canvas interface are ver

Canvas frontend source follows Hub's normal layers under `hub/app/javascript/`. `application/prototype-canvas/` composes the interface; `application/start-prototype-canvas/` starts it, and `application/prototype-preview-bridge/` owns the injected preview entry point. Workflow panels belong in `features/`, reusable fields and dialogs in `components/`, and stateful browser behavior in `hooks/`. Shared contracts, locale copy, protocols and URL helpers live in `utils/`; the Controls engine and definition schema live in `utils/prototype-controls/`. Extension registration and server operations remain in `.github/extensions/replica-prototype/`.

The canvas frontend already uses TypeScript. Extension Node modules are migrating separately, starting with `read-remote-branch/index.mts`. Edit that source, not its generated `index.mjs`. The extension's `tsconfig.json` checks migrated modules in strict mode; declarations describe dependencies that remain JavaScript. Hub's `typecheck` and `lint` commands include these modules. Run `extension:modules:build` to compile them without rebuilding the browser assets. Commit generated `.mjs` files alongside their TypeScript source so the plugin still runs without installed packages or a TypeScript loader. The host entry point remains `extension.mjs`.

Hub builds renderer files, Brand fonts and browser runtime into the plugin's `assets/`; commit them so installed users need no Node packages. Build configuration lives in `hub/vite.prototype-canvas.config.mjs` and `hub/script/`. Generated assets are excluded from formatting checks; do not edit them manually. There is no Vite/npm distribution or release preparation.

For example, a preview reload must cancel an unconfirmed click without losing a command already dequeued by the agent. Test each hook's public behavior beside its implementation, using fake timers for polling and timeouts. Keep cross-feature checks under `application/prototype-canvas/tests/`, including iframe preservation, focus restoration and explicit publication or sharing actions. Keep `index.test.tsx` beside the application component and test-only orchestration helpers under its `tests/`. Shared publication fixtures stay with the publication utility so lower-layer tests do not import application code. Create fresh fixtures inside each case without suite-level reset hooks. Shared jsdom setup supplies stateless browser shims and restores real timers; Vitest configuration resets mocks and stubbed globals before each case.
Expand All @@ -469,4 +471,4 @@ bin/devcontainer exec npm run extension:build
bin/devcontainer exec npm run extension:assets:check
```

`extension:test` runs the root plugin's Node `.mjs` tests, including a real-browser delivery check when `/usr/bin/chromium` is available (as in the devcontainer). It covers cross-origin frames, `script-src 'self'`, HTTPS, runtime replacement, and late registration recovery. Hub's `npm test` discovers runtime and renderer Vitest tests; `npm test -- PATH` selects a test path. `npm run lint` checks authored source with ESLint and Prettier. `extension:assets:check` rebuilds and compares root plugin assets, failing for changed, missing, or unexpected generated files and files over the installer's 1,000,000-byte per-file limit. Run `extension:build` before that check, then include generated files with source changes. Both local `bin/ci` and GitHub CI run the extension checks. See [Development](development.md) and [Testing and operations](testing-and-operations.md).
`extension:test` runs the root plugin's Node `.mjs` tests, including a real-browser delivery check when `/usr/bin/chromium` is available (as in the devcontainer). It covers cross-origin frames, `script-src 'self'`, HTTPS, runtime replacement, and late registration recovery. Hub's `npm test` discovers runtime and renderer Vitest tests; `npm test -- PATH` selects a test path. `npm run lint` checks authored source with ESLint and Prettier. `extension:build` compiles migrated Node modules before building the browser assets. `extension:assets:check` rebuilds and compares the plugin's files, including generated Node modules outside `assets/`, failing for changed, missing, or unexpected generated files and files over the installer's 1,000,000-byte per-file limit. Run `extension:build` before that check, then include generated files with source changes. Both local `bin/ci` and GitHub CI run the extension checks. See [Development](development.md) and [Testing and operations](testing-and-operations.md).
3 changes: 2 additions & 1 deletion hub/eslint.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ export default defineConfig(
js.configs.recommended,
...tseslint.configs.recommendedTypeChecked,
{
files: ["**/*.{ts,tsx}"],
files: ["**/*.{ts,tsx,mts}", "../.github/extensions/replica-prototype/**/*.mts"],
languageOptions: {
globals: { ...globals.browser, ...globals.node },
parserOptions: { projectService: true, tsconfigRootDir: import.meta.dirname },
Expand Down Expand Up @@ -103,6 +103,7 @@ export default defineConfig(
"script/**/*.mjs",
"test/**/*.mjs",
"../.github/extensions/replica-prototype/**/*.mjs",
"../.github/extensions/replica-prototype/**/*.d.mts",
"app/javascript/utils/prototype-controls/**/*.mjs",
"app/javascript/utils/prototype-controls/**/*.d.mts",
"../packages/learning/**/*.mjs",
Expand Down
7 changes: 4 additions & 3 deletions hub/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,14 +16,15 @@
"build": "npm run typecheck && vite build",
"dev": "vite dev",
"extension:assets:check": "node ../.github/extensions/replica-prototype/check-assets/index.mjs",
"extension:build": "vite build --config vite.prototype-canvas.config.mjs && node script/build-prototype-preview-bridge.mjs",
"extension:build": "npm run extension:modules:build && vite build --config vite.prototype-canvas.config.mjs && node script/build-prototype-preview-bridge.mjs",
"extension:modules:build": "tsc --project ../.github/extensions/replica-prototype/tsconfig.json --noEmit false && prettier --write '../.github/extensions/replica-prototype/read-remote-branch/index.mjs' --config .prettierrc.json",
"extension:test": "npm run controls:runtime:test && node --test ../.github/extensions/replica-prototype/**/*.test.mjs",
"format": "prettier --write .",
"lint": "eslint . ../.github/extensions/replica-prototype ../packages/learning && prettier --check . '../eslint.config.mjs' '../.github/extensions/replica-prototype/**/*.mjs' '../.github/extensions/replica-prototype/*.json' '../packages/learning/**/*.{mjs,mts,json}' --config .prettierrc.json",
"lint": "eslint . ../.github/extensions/replica-prototype ../packages/learning && prettier --check . '../eslint.config.mjs' '../.github/extensions/replica-prototype/**/*.{mjs,mts}' '../.github/extensions/replica-prototype/*.json' '../packages/learning/**/*.{mjs,mts,json}' --config .prettierrc.json",
"storybook": "STORYBOOK_DISABLE_TELEMETRY=1 storybook dev --host 0.0.0.0 --port 6006 --initial-path /story/hub-design-overview--taxonomy",
"storybook:build": "STORYBOOK_DISABLE_TELEMETRY=1 storybook build --output-dir storybook-static",
"test": "vitest run",
"typecheck": "tsc --noEmit"
"typecheck": "tsc --noEmit && tsc --project ../.github/extensions/replica-prototype/tsconfig.json"
},
"dependencies": {
"@primer/octicons-react": "19.40.0",
Expand Down
Loading