diff --git a/docs/MARKETPLACE-0781-PORT.md b/docs/MARKETPLACE-0781-PORT.md index 45fc61f4..9cd1545a 100644 --- a/docs/MARKETPLACE-0781-PORT.md +++ b/docs/MARKETPLACE-0781-PORT.md @@ -1147,3 +1147,229 @@ The packaged build (`app.asar` sha256 freshly built asar's. The three badge variants measure the same as official's, as the table above records. +## The plugin detail page against this build's installed plugins (2026-11-02) + +The P8 wave measured the detail page against `Linear`, a plugin that exists in both catalogues. +This wave measures it against the plugins this build actually has installed, because those are the +pages the operator sees. The measurement also found a defect in the port's own CSS injector, and a +defect in the measurement harness, both of which are fixed below. + +### The defect in the CSS injector + +`marketplace-detail-view-0781-css-patch.mjs` decided which official rules to append with + +```js +const missing = rules.filter((rule) => !original.includes(rule)); +``` + +A substring search. This repository's own computer-card patch ships +`.r-conversation-computer-card .sand-1lun4ml:not(#\#)…{border-inline-end-width:1px}`, and that +scoped rule **contains** the unscoped rule +`.sand-1lun4ml:not(#\#)…{border-inline-end-width:1px}` as a substring. The injector therefore read +the unscoped rule as "already present" and skipped it. The same happened to `sand-pilrb4` +(`border-inline-start-width:1px`). + +Both rules belong to official's small secondary button, which is the recipe the Setup section's +编辑值 button uses, so the deployed build was rendering that button without its border widths while +every other guard stayed green. + +The comparison is now line-exact, and the injector fails closed if any official rule is not present +as a standalone line afterwards: + +```js +const originalLines = new Set(original.split("\n").map((line) => line.trim())); +const missing = rules.filter((rule) => !originalLines.has(rule.trim())); +``` + +The packaged build appends **15 of 38** official detail rules, against **10 of 38** before the fix. + +`tests/marketplace-detail-view-0781.test.mjs` carries the regression guard: it stages a stylesheet +whose only mention of `sand-1lun4ml` and `sand-pilrb4` is the scoped rule and asserts that both +standalone rules ship. Reverting the injector to the substring comparison turns that guard red +(36 pass / 1 fail). + +### The defect in the measurement harness + +The detail page lives in a dialog with an **internal** scroller — `window.scrollY` is 0, the +`ui-scroll-area__viewport` element scrolls instead. A section below the fold therefore has no pixels +in the viewport, and `Page.captureScreenshot` with `captureBeyondViewport: false` returns **black** +for a clip that reaches past it, not the content. + +The first section-aligned run was wrong for exactly this reason: Notion's 信息 heading sits at +viewport y = 1048 in a 1024 px viewport, so its band was black on the official side and produced a +99.953 % "difference". `.audit/diff-detail-sections.mjs` now scrolls the internal scroller so each +section is in view, then reads the band from the live viewport. Notion's total falls from 31.232 % +to 4.845 %, and the 信息 section from 99.953 % to 1.519 %. + +This also settles a question the earlier whole-page crop could not answer: the 68 px width gap +between the two apps' detail regions was not a port defect at all. With both viewports pinned to +864×1024 the detail region is x = 49, width = 766 on **both** sides. The gap was the two windows +being different sizes. + +### The guarded, deployed build + +| check | result | +|---|---| +| packaged `app.asar` sha256 | `b13738658d4f1096fc6854fb6621e31bf38418985d2e42d7ed9d7f7a19790a04` | +| deployed `/Applications/Grok Node.app` asar sha256 | the same | +| shipped renderer chunks that parse | 102 of 102 | +| `"(RLocT("` quote-bug chunks | 0 | +| official-only CSS rules shipped, standalone and byte-for-byte official | 6 of 6 | +| class tokens the ported recipes use that resolve in the deployed stylesheet | 37 of 37 | +| `tests/marketplace-detail-view-0781.test.mjs` | 37 pass, 0 fail | + +### The measured parity, plugin by plugin + +Both apps were driven to the same plugin's detail page with both viewports pinned to 864×1024 at +device scale 2, and each section was compared separately. The seven plugins this build shares with +official 0.78.1: + +| plugin | total delta over the compared sections | what the delta is | +|---|---|---| +| Gmail | 1.055 % | the Setup section node has and official does not; 23 of 23 tools against 25 of 25; the account row's text | +| Google Calendar | 1.772 % | the Tools section official has and node does not (this build's Calendar is not connected); the Setup section | +| Google Drive | 1.905 % | the Tools section official has and node does not | +| Granola | 3.013 % | the 技能 card's private skill list | +| Slack | 4.430 % | the 技能 card's private skill list; the Setup section | +| Notion | 4.845 % | the Tools section official has and node does not (not connected); the 技能 card's private skill list | +| Canva | 4.849 % | the 技能 card's private skill list | + +No section's maximum channel delta exceeds 214, and the worst rows hold 30 to 180 differing pixels +out of a 1532 px row — glyph edges, not a moved box. The sections that hold the page's structure +are the quietest: + +| section | Gmail | Google Calendar | Google Drive | Granola | Slack | +|---|---|---|---|---|---| +| the account card | 0.276 % | 0.928 % | 2.023 % | 1.502 % | 0.781 % | +| the Tools card | 0.352 % | only official | only official | both absent | only official | +| the 应用 card | 0.705 % | 1.351 % | 1.103 % | 0.879 % | 0.674 % | +| the 信息 table | 1.012 % | 1.012 % | 1.500 % | 1.517 % | 1.451 % | + +The account and Tools cards are compared against the same card in official, and the crops are +indistinguishable apart from the text: the card's border, radius, the green 已连接 badge and the +chevron are the same, while official's account row reads `tyonger@gmail.com` with an edit icon and +an 添加其他账户 row, and node's reads 默认 with neither. + +This build's eighth installed plugin is **Figma**, which official 0.78.1's marketplace does not +carry, so there is no official side to compare it against. + +### The button the CSS fix repaired + +Official 0.78.1's small secondary button is the same recipe in both apps. Measured live: + +| | official's 连接 button | node's 编辑值 button | +|---|---|---| +| class recipe | `sand-button sand-9f619 … sand-tyxrsu` | the same, character for character | +| `border-inline-end-width` | 1 px | 1 px | +| `border-inline-start-width` | 1 px | 1 px | +| `border-inline-end-color` | `rgba(0, 0, 0, 0)` | `rgba(0, 0, 0, 0)` | +| `border-inline-start-color` | `rgba(0, 0, 0, 0)` | `rgba(0, 0, 0, 0)` | +| `border-inline-end-style` | solid | solid | +| height | 26 px | 26 px | +| width | 78.64 px | 58.55 px — the text differs, not the recipe: official's button stacks 连接 / 正在连接… / 已连接 | + +Before the injector fix, node's button carried `sand-1lun4ml` and `sand-pilrb4` in its class but +had no rule for either, because the scoped computer-card rule had swallowed both. The border +widths above are the evidence that they now ship. + +### Real operation on the deployed build + +The deployed `/Applications/Grok Node.app` was driven with real mouse events — CDP +`Input.dispatchMouseEvent`, not `element.click()` — through the path an operator takes, with a +screenshot at every step: + +1. the composer's 连接应用 opens the browse dialog, with official's chip taxonomy, 为你推荐 + section and the 已连接 8 个 entry; +2. 已连接 8 个 opens 管理插件和技能; +3. 查看全部 expands the installed list; +4. the Gmail row opens the detail page, which shows the detail bar, the brand tile, the share and + 卸载 actions, the account card with the green 已连接 badge, the Tools card with its chevron, + the Setup card with the 编辑值 button, the 应用 card and the 信息 table. + +Official 0.78.1 was driven through the same path in the same session. + +### What is still different, and why + +| difference | cause | +|---|---| +| the typeface | official ships XVF, Anysphere's proprietary binary font, which this repository has no licence to redistribute. Every Latin glyph falls back to the platform face and measures a fraction of a pixel differently. This is the bulk of the remaining delta, and the P8 section already measured it. | +| the account row's text | official reads the user's e-mail from its own server; upstream 0.18's recovered connector record carries no `accountEmail` and the local credential store keeps only `{client, tokens}` under a `(user_id, server_url)` index, so there is nothing to read | +| the edit icon and the 添加其他账户 row | both are behind official's `mcp_multi_account` experiment. The gate stays off here because the local credential store indexes by `(user_id, server_url)` and forbids a second slot for the same server | +| the Setup section | official renders it from the plugin's declared `fields`; none of these ten plugins declares any, so official shows nothing. This build's Setup section carries the Google and Slack OAuth client id and secret and is the only configuration path for them, so it stays | +| the Tools section for Calendar, Notion and Drive | those three are not connected in this build, so they have no tool list to show; official has them connected | +| 23 of 23 against 25 of 25 | this build's Gmail connector exposes two fewer tools than official's | +| the 技能 card | both apps list the operator's own private skills, and the two lists differ | + +None of these is a recipe difference. Every class recipe the port writes into the chunk is +official's own, and every section that both apps render agrees on geometry and on styling. + +### Deployment record (2026-11-02) + +| check | result | +|---|---| +| packaged `app.asar` sha256 | `b13738658d4f1096fc6854fb6621e31bf38418985d2e42d7ed9d7f7a19790a04` | +| deployed `/Applications/Grok Node.app` asar sha256 | the same | +| shipped renderer chunks that parse | 102 of 102 | +| `"(RLocT("` quote-bug chunks | 0 | +| official-only detail CSS rules, standalone and byte-for-byte official | 6 of 6 | +| class tokens the ported recipes use that resolve in the deployed stylesheet | 37 of 37 | +| `tests/marketplace-detail-view-0781.test.mjs` | 37 pass, 0 fail | +| `npm run check` | 2735 tests: 2698 pass, 0 fail, 37 skipped without the packaging staging dir, and 2701 pass, 0 fail, 34 skipped with it | + +The three tests that move are the ones that read the packaged staging directory +(`.build/fidelity`), so the two figures are the same suite in the two states a +checkout can be in. Both are 0 fail. + +### Rebuilding on the merged manage-page wave + +The manage-page wave landed first, so this wave was rebased onto it and rebuilt. The +rebase moved the tree by two files, and neither one changes the bundle: + +| file | change | +|---|---| +| `docs/MARKETPLACE-0781-PORT.md` | this document | +| `scripts/clean-build.mjs` | a comment only. The rule-count wording became a mechanism description, so it cannot drift | + +The packaged `app.asar` is **not** bit-reproducible. Two independent per-build byte +sources make any two builds differ, even two builds of one tree in two worktrees: + +1. `node-gyp` regenerates `dist/node-deps/tree-sitter*/build/Makefile` and compiles + `binding.o` and `parser.o` with the absolute build path and a random + `.tmp-tree-sitter-node-XXXXXX` directory written into the file. +2. The renderer bundle copies embed the random upstream extraction directory + `grok-upstream-module-XXXXXX`. + +The `Makefile` line is the whole difference, with both worktrees' paths spelled out: + +``` +cmd_regen_makefile = cd $(srcdir); /Users/Apple/Documents/grokbot/wt-detail/node_modules/node-gyp/gyp/gyp_main.py ... + "-Dmodule_root_dir=/Users/Apple/Documents/grokbot/wt-detail/.tmp-tree-sitter-node-MWicHR/node_modules/tree-sitter-bash" +``` + +So the rebuilt asar was compared with the build the independent verifier checked, file by +file, at the content level: + +| comparison | result | +|---|---| +| files in the two asars | 1337 in each, none added and none removed | +| files whose bytes differ | 5 | +| `dist/renderer/assets/grok-node-0721-d3.mjs`, `-three.mjs`, `-visualization.mjs` | one differing run each, the random upstream module directory | +| `dist/renderer-0721-visualization.json` | the hash manifest of those three files | +| `dist/reconstruction-build.json` | the hash manifest of the tree-sitter native objects `node-gyp` rebuilt with this worktree's path | +| the patched renderer chunk `dist/renderer/assets/view-B5Ug8wEm.js` | byte-for-byte identical | +| the patched stylesheet `dist/renderer/assets/index-lCyB53CO.css` | byte-for-byte identical | + +Every file the port writes is byte-for-byte the file the verifier checked. The guards +were then re-run against the newly deployed asar, and the deployed build was driven +through 连接应用, 已连接 N 个, 查看全部 and a plugin row with real mouse events: + +| check | result | +|---|---| +| deployed `/Applications/Grok Node.app` asar sha256 | `b13738658d4f1096fc6854fb6621e31bf38418985d2e42d7ed9d7f7a19790a04`, equal to the freshly built asar's | +| official recipes present in the deployed chunk | 6 of 6, at the official counts | +| official-only detail CSS rules, standalone and byte-for-byte official | 6 of 6 | +| class tokens the ported recipes use that resolve in the deployed stylesheet | 37 of 37, 0 unresolved | +| shipped renderer chunks that parse | 102 of 102 | +| `"(RLocT("` quote-bug chunks | 0 | +| the detail page's sections in the driven build | 账户, 工具, 配置, 应用, 信息 | + diff --git a/scripts/clean-build.mjs b/scripts/clean-build.mjs index 6acb548b..2ae05171 100644 --- a/scripts/clean-build.mjs +++ b/scripts/clean-build.mjs @@ -536,7 +536,9 @@ export async function buildFidelityReconstructedAsar({ // MUST run after the browse-chrome patch: its anchors are the POST-browse text (the browse // patch rewrites the detail header's icon slot and the row's connect button). await applyMarketplaceDetailView0781({ stageRoot }); - // The 21 official 0.78.1 detail-page rules upstream 0.18's stylesheet does not define. + // The official 0.78.1 detail-page rules upstream 0.18's stylesheet does not ship. The companion + // carries them verbatim; this step appends the ones the staged stylesheet still lacks and fails + // closed when any of them does not stand on its own line. await applyMarketplaceDetailView0781Css({ stageRoot }); await applyMessageCopy0753Patch({ stageRoot }); // The 0.18 renderer already has the footer update pill and Update-ready diff --git a/scripts/lib/marketplace-detail-view-0781-css-patch.mjs b/scripts/lib/marketplace-detail-view-0781-css-patch.mjs index b074a593..6799960f 100644 --- a/scripts/lib/marketplace-detail-view-0781-css-patch.mjs +++ b/scripts/lib/marketplace-detail-view-0781-css-patch.mjs @@ -30,7 +30,13 @@ export async function applyMarketplaceDetailView0781Css({ stageRoot }) { const rules = official.split("\n").filter((line) => line.startsWith(".sand-")); // Another patch in the chain may already carry one of these rules, so only the missing ones are // appended. The rule text is the identity, not the class name. - const missing = rules.filter((rule) => !original.includes(rule)); + // + // The comparison is line-exact on purpose. A plain substring search would read + // `.r-conversation-computer-card .sand-1lun4ml{…}` as "the rule for sand-1lun4ml is already + // there", because the scoped rule contains the unscoped one as a substring — and the unscoped + // rule is the one that actually applies to the detail page's button. + const originalLines = new Set(original.split("\n").map((line) => line.trim())); + const missing = rules.filter((rule) => !originalLines.has(rule.trim())); if (missing.length > 0) { await writeFile(cssPath, `${original}\n${missing.join("\n")}\n`); } else { @@ -38,6 +44,15 @@ export async function applyMarketplaceDetailView0781Css({ stageRoot }) { } const sha256 = (text) => createHash("sha256").update(text, "utf8").digest("hex"); const patched = missing.length > 0 ? `${original}\n${missing.join("\n")}\n` : original; + // Fail closed: after this step every official rule must stand on its own line, unscoped. + const patchedLines = new Set(patched.split("\n").map((line) => line.trim())); + const unresolved = rules.filter((rule) => !patchedLines.has(rule.trim())); + if (unresolved.length > 0) { + throw new Error( + `Marketplace detail view CSS patch: ${unresolved.length} official rule(s) are not present ` + + `as standalone rules in ${file}: ${unresolved.map((r) => r.slice(0, 60)).join(" | ")}`, + ); + } const provenance = { mode: "marketplace-detail-view-0781-css", referenceVersion: "0.78.1", diff --git a/scripts/lib/marketplace-detail-view-0781-official.css b/scripts/lib/marketplace-detail-view-0781-official.css index daf2733a..60d83d7d 100644 --- a/scripts/lib/marketplace-detail-view-0781-official.css +++ b/scripts/lib/marketplace-detail-view-0781-official.css @@ -45,3 +45,9 @@ .sand-ibwnj:not(#\#):not(#\#):not(#\#){transition-duration:.15s} .sand-ymw2ht:not(#\#):not(#\#){border-radius:9999px} .sand-1v39l1b:not(#\#):not(#\#):not(#\#){outline-color:var(--sand-border-focus-primary)} +.sand-1lun4ml:not(#\#):not(#\#):not(#\#){border-inline-end-width:1px} +.sand-pilrb4:not(#\#):not(#\#):not(#\#){border-inline-start-width:1px} +.sand-18b5jzi:not(#\#):not(#\#):not(#\#){border-inline-end-style:solid} +.sand-1o3jo1z:not(#\#):not(#\#):not(#\#){border-inline-end-color:#0000} +.sand-v5lvn5:not(#\#):not(#\#):not(#\#){border-inline-start-color:#0000} +.sand-4meti:not(#\#):not(#\#):not(#\#){transition-duration:.14s} diff --git a/scripts/lib/marketplace-detail-view-0781-patch.mjs b/scripts/lib/marketplace-detail-view-0781-patch.mjs index 5977998d..40a99aae 100644 --- a/scripts/lib/marketplace-detail-view-0781-patch.mjs +++ b/scripts/lib/marketplace-detail-view-0781-patch.mjs @@ -142,6 +142,46 @@ export const MARKETPLACE_DETAIL_VIEW_0781_MESSAGES = Object.freeze({ builtIn: Object.freeze({ id: "+7fBMP", en: "Built-in", zh: "内置" }), yourTeam: Object.freeze({ id: "vhSOwR", en: "Your team", zh: "你的团队" }), publicListed: Object.freeze({ id: "7d1a0d", en: "Public", zh: "公开" }), + // The tools section's count sentence. Official composes it at runtime from its locale entry, so + // the port writes the two halves around the runtime counts. + toolsEnabled: Object.freeze({ + id: "bWyJ+b", + composed: true, + en: "{enabledCount} of {0} enabled", + zh: "已启用 {enabledCount}/{0} 个", + source: 'RLocT(h+" of "+r.length+" enabled","已启用 "+h+"/"+r.length+" 个")', + }), + // The information table's Tools row. Official's locale entry names the first value differently. + detailsEnabled: Object.freeze({ + id: "CzixCu", + composed: true, + en: "{enabledToolCount} of {0} enabled", + zh: "已启用 {enabledToolCount}/{0} 个", + source: 'RLocT(y+" of "+f.length+" enabled","已启用 "+y+"/"+f.length+" 个")', + }), + // The add-account label the port writes into upstream's own row. + addAnotherAccount: Object.freeze({ + id: "FGnQEW", + en: "Add Another Account", + zh: "添加其他账户", + }), +}); + +/** + * Official 0.78.1's own message ids for the detail page's section headings and labels. + * + * Upstream 0.18 writes these as hardcoded English strings that the extra-i18n pass localizes + * under official's own message ids, so the port does not write them itself. The ids are recorded + * here so the port record points at official's locale table and so the ids the repository's i18n + * table uses can be checked against official's own. + */ +export const MARKETPLACE_DETAIL_VIEW_0781_SECTION_MESSAGES = Object.freeze({ + tools: Object.freeze({ id: "xdA/+p", en: "Tools", zh: "工具" }), + accounts: Object.freeze({ id: "bPwFdf", en: "Accounts", zh: "账户" }), + defaultAccount: Object.freeze({ id: "ovBPCi", en: "Default", zh: "默认" }), + setup: Object.freeze({ id: "RDjuBN", en: "Setup", zh: "配置" }), + setupValues: Object.freeze({ id: "mrRDVU", en: "Setup Values", zh: "配置值" }), + editValues: Object.freeze({ id: "kDCdbL", en: "Edit Values", zh: "编辑值" }), }); /** @@ -171,6 +211,64 @@ export const MARKETPLACE_DETAIL_VIEW_0781_CATEGORIES = Object.freeze({ LOGIN_AND_CREDENTIAL_MANAGEMENT: Object.freeze({ en: "Password Managers", zh: "密码管理器" }), }); +/** + * Official 0.78.1's own class recipes for the detail page's shared surfaces. + * + * Every string here was read off official 0.78.1's own renderer — the plugin detail page's DOM, + * measured live on official's CDP port 9333 on 2026-11-01 — and cross-checked against official's + * stylesheet (`index-Dh7EWA8t.css`), where every token resolves to a declaration. A stylix class + * name is the hash of its declaration block, so a token that exists on both sides carries the same + * declarations and needs no new CSS. + * + * @evidence official 0.78.1 `chunk-plugin-detail-view-KNB3B4mm.js` — `hr` (the collapsible card), + * `Nr` (the tools section), `Br` (the setup section), `kn` (the row label recipe). + * @evidence official 0.78.1 `chunk-plugin-detail-sharepoint-write-MAPmM3e9.js` — the account row, + * the account title recipe and the add-account row recipe. + */ +const OFFICIAL_0781_SECTION_LABEL = + "sand-9f619 sand-1y1aw1k sand-1pic42t sand-10b6aqq sand-1onr9mi"; +const OFFICIAL_0781_DETAIL_CARD = + "sand-9f619 sand-7giv3 sand-13ly8rp sand-1xlntvz sand-4hv7ue sand-1y0btm7 sand-5fd6op sand-1pkpdue sand-10e981r"; +const OFFICIAL_0781_TOOLS_CARD = + "sand-9f619 sand-7giv3 sand-4hv7ue sand-1y0btm7 sand-5fd6op sand-1pkpdue sand-10e981r sand-exx8yu sand-18d9i69"; +const OFFICIAL_0781_DETAILS_ROW = + "sand-9f619 sand-78zum5 sand-6s0dn4 sand-1qughib sand-167g77z sand-oyc9vk sand-1pic42t sand-2lprc sand-1onr9mi"; +const OFFICIAL_0781_ROW_LABEL = + "sand-9f619 sand-2lah0s sand-thy2uy sand-b3r6kr sand-uxw1ft sand-lyipyv sand-dj266r sand-14z9mp sand-at24cr sand-1lziwak sand-11wthnw sand-d4r4e8 sand-12oo3zp sand-tyxrsu"; +const OFFICIAL_0781_ACCOUNT_TITLE = + "sand-9f619 sand-b3r6kr sand-11wthnw sand-d4r4e8 sand-12oo3zp sand-tyxrsu sand-lyipyv sand-uxw1ft"; +const OFFICIAL_0781_SUMMARY_BUTTON = + "sand-9f619 sand-78zum5 sand-6s0dn4 sand-1qughib sand-167g77z sand-oyc9vk sand-1pic42t sand-2lprc sand-1onr9mi sand-jyslct sand-1lugfcp sand-h8yej3 sand-dj266r sand-14z9mp sand-at24cr sand-1lziwak sand-c342km sand-ng3xce sand-jb2p0i sand-1qlqyl8 sand-1yc453h sand-1ypdohk sand-jbqb8w sand-1ecwv3n sand-1t137rt sand-1yfcfk6"; +const OFFICIAL_0781_SM_SECONDARY_BUTTON = + "sand-button sand-9f619 sand-3nfvp2 sand-6s0dn4 sand-l56j7k sand-2lah0s sand-17d4w8g sand-178xt8z " + + "sand-1lun4ml sand-so031l sand-pilrb4 sand-13fuv20 sand-18b5jzi sand-1q0q8m5 sand-1t7ytsu sand-1v8p93f " + + "sand-1o3jo1z sand-16stqrj sand-v5lvn5 sand-jb2p0i sand-1k6tqyu sand-uxw1ft sand-2b8uid sand-krqix3 " + + "sand-87ps6o sand-ggy1nq sand-1ypdohk sand-1s07b3s sand-1hc1fzr sand-uhm2yv sand-67bb7w sand-aqnwrm " + + "sand-1k57tk5 sand-784prv sand-1t137rt sand-9v5kkp sand-1uczgqu sand-1wpm9rq sand-1wfwxd8 sand-7s97pk " + + "sand-1eaenvl sand-4meti sand-1pbvl4h sand-d7y6wv sand-exx8yu sand-2vl965 sand-18d9i69 sand-e2zdcy " + + "sand-9h44rk sand-1wm8ruf sand-spwq11 sand-fwqdrz sand-19jy9ex sand-tyxrsu"; +const OFFICIAL_0781_CHEVRON = + "sand-9f619 sand-3nfvp2 sand-6s0dn4 sand-169k319 sand-11xpdln sand-12rn374 sand-wji4o3"; +const OFFICIAL_0781_ADD_ACCOUNT = + "sand-9f619 sand-78zum5 sand-6s0dn4 sand-17d4w8g sand-h8yej3 sand-oyc9vk sand-1pic42t sand-2lprc sand-1onr9mi sand-c342km sand-ng3xce sand-jbqb8w sand-1o0liin sand-n4ifyr sand-jb2p0i sand-11wthnw sand-12oo3zp sand-1yc453h sand-1ypdohk sand-1t137rt sand-1yfcfk6"; +const OFFICIAL_0781_EDIT_ACCOUNT = + "sand-9f619 sand-3nfvp2 sand-6s0dn4 sand-1c4vz4f sand-2lah0s sand-dl72j9 sand-1717udv sand-c342km sand-ng3xce sand-jbqb8w sand-169k319 sand-n4ifyr sand-1ypdohk"; + +/** Upstream 0.18's own recipes for the same surfaces, which the port replaces. */ +const UPSTREAM_018_SECTION_LABEL = + "sand-9f619 sand-1y1aw1k sand-19tmk5i sand-10b6aqq sand-zjhap9"; +const UPSTREAM_018_DETAIL_CARD = "sand-9f619 sand-ixl9f9 sand-i07v4r"; +const UPSTREAM_018_DETAILS_ROW = + "sand-9f619 sand-78zum5 sand-6s0dn4 sand-1qughib sand-167g77z sand-z9dl7a sand-19tmk5i sand-sag5q8 sand-zjhap9"; +const UPSTREAM_018_ROW_LABEL = + "sand-9f619 sand-2lah0s sand-thy2uy sand-b3r6kr sand-uxw1ft sand-lyipyv sand-dj266r sand-1yf7rl7 sand-at24cr sand-j3b58b sand-4z9k3i sand-d4r4e8 sand-12oo3zp sand-1wd3ewq"; +const UPSTREAM_018_ACCOUNT_TITLE = + "sand-9f619 sand-b3r6kr sand-4z9k3i sand-d4r4e8 sand-12oo3zp sand-1wd3ewq sand-lyipyv sand-uxw1ft"; +const UPSTREAM_018_ADD_ACCOUNT = + "sand-9f619 sand-78zum5 sand-6s0dn4 sand-17d4w8g sand-h8yej3 sand-z9dl7a sand-19tmk5i sand-sag5q8 sand-zjhap9 sand-c342km sand-ng3xce sand-jbqb8w sand-19aaqeu sand-7gh5u8 sand-jb2p0i sand-4z9k3i sand-12oo3zp sand-dpxx8g sand-1ypdohk"; +const UPSTREAM_018_EDIT_ACCOUNT = + "sand-9f619 sand-3nfvp2 sand-6s0dn4 sand-1c4vz4f sand-2lah0s sand-dl72j9 sand-1717udv sand-c342km sand-ng3xce sand-jbqb8w sand-4b2ntj sand-7gh5u8 sand-1ypdohk"; + const REPLACEMENTS = [ { // Official's marketplace renders the detail bar from the shared `sand-settings-detail-bar` @@ -306,6 +404,133 @@ const REPLACEMENTS = [ "t.jsx(RM0781DetailsList,{label:RLocT(\"Information\",\"信息\")," + "rows:RM0781DetailRows(s,_)})]})", }, + { + // Official's tools section reads its count sentence from its locale table: id `bWyJ+b` is + // "{enabledCount} of {0} enabled" in English and "已启用 {enabledCount}/{0} 个" in Chinese. + // Upstream 0.18 builds the same sentence as an English template literal, so the Chinese + // build renders "23 of 23 enabled" inside a Chinese page. + label: "detail tools section: official's enabled-count sentence", + before: "x=`${h} of ${r.length} enabled`", + after: 'x=RLocT(h+" of "+r.length+" enabled","已启用 "+h+"/"+r.length+" 个")', + }, + { + // Official's information table reads the same sentence from its own locale entry, id + // `CzixCu`: "{enabledToolCount} of {0} enabled" / "已启用 {enabledToolCount}/{0} 个". + label: "detail information table: official's enabled-count sentence", + before: "V=f.length>0?`${y} of ${f.length} enabled`:String(s.toolCount)", + after: 'V=f.length>0?RLocT(y+" of "+f.length+" enabled","已启用 "+y+"/"+f.length+" 个"):String(s.toolCount)', + }, + { + // Official's section heading carries official's own section-label recipe + // (`rt.sectionLabel`: sand-9f619 / sand-1y1aw1k / sand-1pic42t / sand-10b6aqq / sand-1onr9mi). + // Upstream 0.18 pads the heading with physical left and right padding; official pads it + // logically. Official uses the one recipe for every section heading on the page, so all four + // of upstream's heading sites take it. + label: "detail sections: official's section-label recipe", + before: `k("${UPSTREAM_018_SECTION_LABEL}")`, + after: `k("${OFFICIAL_0781_SECTION_LABEL}")`, + occurrences: 4, + }, + { + // Official's card is an elevated surface with a hairline border and the xl radius; upstream's + // is a translucent fill with a 14px radius and no border. Official's tools card drops the + // vertical padding because its only child is the summary button. + label: "detail tools card: official's card recipe", + before: `h=k(l,"${UPSTREAM_018_DETAIL_CARD}")`, + after: `h=k(l,"${OFFICIAL_0781_TOOLS_CARD}")`, + }, + { + // Official's summary button composes `$.detailsRow` with `$.summaryRow`, the interactive recipe + // and the inset focus ring. Upstream composes its own pair, which pads the row by 12px, keeps + // a 14px radius on the button and draws an outline focus ring instead of an inset one. + label: "detail tools card: official's summary button recipe", + before: + "k(Re(Fs.detailsRow,Fs.summaryRow,d&&Fs.summaryRowExpanded,wn.interactive,wn.keyboardFocus).className)", + after: `k("${OFFICIAL_0781_SUMMARY_BUTTON}")`, + }, + { + // Official's chevron wrapper carries the tertiary text colour and a 120ms transition. + label: "detail tools card: official's chevron recipe", + before: + 'k({0:{className:"sand-9f619 sand-3nfvp2 sand-6s0dn4 sand-4b2ntj sand-11xpdln sand-gdialr sand-wji4o3"},' + + '1:{className:"sand-9f619 sand-3nfvp2 sand-6s0dn4 sand-4b2ntj sand-11xpdln sand-gdialr sand-wji4o3 sand-19jd1h0"}}' + + "[!!d<<0].className)", + after: + `k({0:{className:"${OFFICIAL_0781_CHEVRON}"},` + + `1:{className:"${OFFICIAL_0781_CHEVRON} sand-19jd1h0"}}[!!d<<0].className)`, + }, + { + // Official's details row is the row every section on the page uses: the accounts row, the + // per-tool row, the setup row and the information row. Upstream pads its rows by 12px with + // physical left and right padding; official pads them by 10px logically. + label: "detail rows: official's details-row recipe", + before: `k("${UPSTREAM_018_DETAILS_ROW}")`, + after: `k("${OFFICIAL_0781_DETAILS_ROW}")`, + occurrences: 7, + }, + { + // Official's row label is one recipe (`kn`) for the tools summary, the per-tool name, the + // setup values and the information table's term. Upstream's carries a 13px size and physical + // margins; official's carries the base size and logical margins. + label: "detail row labels: official's label recipe", + before: `k("${UPSTREAM_018_ROW_LABEL}")`, + after: `k("${OFFICIAL_0781_ROW_LABEL}")`, + occurrences: 5, + }, + { + // Official's accounts card is the elevated bordered surface, not upstream's translucent fill. + label: "detail accounts section: official's card recipe", + before: `A=k("sand-plugins-detail__accounts","${UPSTREAM_018_DETAIL_CARD}")`, + after: `A=k("sand-plugins-detail__accounts","${OFFICIAL_0781_DETAIL_CARD}")`, + }, + { + // Official's account title is the base-size primary-colour label. + label: "detail accounts section: official's account-title recipe", + before: `t.jsx("span",{className:k("${UPSTREAM_018_ACCOUNT_TITLE}"),children:B})`, + after: `t.jsx("span",{className:k("${OFFICIAL_0781_ACCOUNT_TITLE}"),children:B})`, + }, + { + // Official's edit/rename icon on the account row carries official's own recipe. + label: "detail accounts section: official's edit-account recipe", + before: `k("${UPSTREAM_018_EDIT_ACCOUNT}")`, + after: `k("${OFFICIAL_0781_EDIT_ACCOUNT}")`, + }, + { + // Official's add-account row carries official's own recipe, and its label is official's own + // text under official's own message id (`FGnQEW`). The row only renders for a multi-account + // connector, which the reconstruction's local credential store is not, so this keeps the + // source faithful rather than changing a pixel. + label: "detail accounts section: official's add-account row recipe", + before: `k("sand-plugins-detail__add-account","${UPSTREAM_018_ADD_ACCOUNT}")`, + after: `k("sand-plugins-detail__add-account","${OFFICIAL_0781_ADD_ACCOUNT}")`, + }, + { + // Official's add-account label is "Add Another Account" in English and "添加其他账户" in + // Chinese, under official's message id `FGnQEW`. Upstream 0.18 leaves the English literal in + // a Chinese page. + label: "detail accounts section: official's add-account label", + before: `,"Add Another Account"`, + after: `,RLocT("Add Another Account","添加其他账户")`, + }, + { + // Official's setup section's "Edit Values" button is official's own small secondary button. + // Upstream 0.18 builds it from its own `sand-button` recipe, which differs from official's in + // the border side, the transition duration and the secondary tone. The attributes official's + // button carries are the same ones upstream's button already renders, so only the recipe + // changes. + label: "detail setup section: official's small secondary button", + before: + 't.jsx(ve,{disabled:s,onClick:a,size:"sm",type:"button",variant:"secondary",children:(RLocT("Edit Values","编辑值"))})', + after: + 't.jsx(RM0781DetailSmallSecondaryButton,{disabled:s,onClick:a,children:(RLocT("Edit Values","编辑值"))})', + }, + { + // Official's setup card is the elevated bordered surface. + label: "detail setup section: official's card recipe", + before: `i=k("sand-plugins-detail__setup","${UPSTREAM_018_DETAIL_CARD}")`, + after: `i=k("sand-plugins-detail__setup","${OFFICIAL_0781_DETAIL_CARD}")`, + }, + ]; /** @@ -342,6 +567,7 @@ const INJECTED_SOURCE = [ "const RM0781DetailBarIconClasses=\"sand-kit-icon sand-3nfvp2 sand-6s0dn4 sand-l56j7k sand-2lah0s sand-1heor9g sand-w4jnvo sand-1qx5ct2\";", "const RM0781DetailBarTitleClasses=\"sand-fc7y3v sand-1lvvshy sand-ja3w2a sand-9f619 sand-1iyjqo2 sand-s83m0k sand-dl72j9 sand-euugli sand-dj266r sand-14z9mp sand-at24cr sand-1lziwak sand-b3r6kr sand-1rhlpx6 sand-tyxrsu sand-2b8uid sand-lyipyv sand-uxw1ft sand-19991ni sand-ibwnj sand-12w9bfk sand-9lcvmn sand-g01cxk\";", "const RM0781DetailBarTrailingClasses=\"sand-78zum5 sand-6s0dn4 sand-1qab1bc sand-euugli\";", + "const RM0781DetailSmallSecondaryButtonClasses=" + JSON.stringify(OFFICIAL_0781_SM_SECONDARY_BUTTON) + ";", "const RM0781DetailCardHeaderClasses=\"sand-9f619 sand-78zum5 sand-6s0dn4 sand-17d4w8g sand-1y1aw1k sand-1pic42t sand-10b6aqq sand-1onr9mi\";", "const RM0781DetailCardClasses=\"sand-9f619 sand-7giv3 sand-13ly8rp sand-1xlntvz sand-4hv7ue sand-1y0btm7 sand-5fd6op sand-1pkpdue sand-10e981r\";", "const RM0781DetailCardClassesMore=\"sand-9f619 sand-7giv3 sand-13ly8rp sand-4hv7ue sand-1y0btm7 sand-5fd6op sand-1pkpdue sand-10e981r sand-18d9i69\";", @@ -580,6 +806,19 @@ const INJECTED_SOURCE = [ " id:cardId,children:[rows,more]});", " return t.jsxs(\"div\",{children:[header,body]});", "}", + "function RM0781DetailSmallSecondaryButton(props){", + " const{children,disabled,onClick}=props;", + " return t.jsx(\"button\",{", + " \"data-size\":\"sm\",", + " \"data-variant\":\"secondary\",", + " className:RM0781DetailSmallSecondaryButtonClasses,", + " disabled:disabled,", + " onClick:onClick,", + " tabIndex:0,", + " type:\"button\",", + " children:children,", + " });", + "}", "function RM0781DetailsList(props){", " const{label,rows}=props;", " const head=t.jsx(me,{as:\"h3\",className:RM0781DetailSectionLabelClasses,color:\"tertiary\",size:\"sm\",children:label});", @@ -607,20 +846,26 @@ export const MARKETPLACE_DETAIL_VIEW_0781_REPLACEMENTS = REPLACEMENTS; /** The injected source, exported so the regression test can parse it on its own. */ export const MARKETPLACE_DETAIL_VIEW_0781_INJECTED_SOURCE = NORMALIZED_INJECTED_SOURCE; -function replaceExactlyOnce(source, before, after, label) { - const first = source.indexOf(before); - if (first < 0) throw new Error(`Original renderer marketplace detail view anchor is missing: ${label}`); - if (source.indexOf(before, first + 1) >= 0) { +/** + * Replaces one anchor, exactly the number of times the entry declares. + * + * An anchor that is missing, or that occurs a different number of times than the entry declares, + * fails the patch: a patch that cannot identify its site must not run. + */ +function replaceExactly(source, before, after, label, occurrences) { + const count = source.split(before).length - 1; + if (count === 0) throw new Error(`Original renderer marketplace detail view anchor is missing: ${label}`); + if (count !== occurrences) { throw new Error(`Original renderer marketplace detail view anchor is ambiguous: ${label}`); } - return source.slice(0, first) + after + source.slice(first + before.length); + return source.split(before).join(after); } /** Applies official 0.78.1's plugin detail page to upstream 0.18's plugins chunk. */ export function patchMarketplaceDetailView0781(source) { let patched = source; - for (const { label, before, after } of REPLACEMENTS) { - patched = replaceExactlyOnce(patched, before, after, label); + for (const { label, before, after, occurrences } of REPLACEMENTS) { + patched = replaceExactly(patched, before, after, label, occurrences ?? 1); } patched = `${patched}\n${NORMALIZED_INJECTED_SOURCE}\n`; return patched; diff --git a/tests/fixtures/official-0781-detail-view-messages.json b/tests/fixtures/official-0781-detail-view-messages.json index 25fe1300..3c57ac89 100644 --- a/tests/fixtures/official-0781-detail-view-messages.json +++ b/tests/fixtures/official-0781-detail-view-messages.json @@ -62,6 +62,14 @@ "zh": "[\"正在移除…\"]" } }, + "CzixCu": { + "en": "{enabledToolCount} of {0} enabled", + "zh": "已启用 {enabledToolCount}/{0} 个", + "icu": { + "en": "[[\"enabledToolCount\"],\" of \",[\"0\"],\" enabled\"]", + "zh": "[\"已启用 \",[\"enabledToolCount\"],\"/\",[\"0\"],\" 个\"]" + } + }, "DbZMYM": { "en": "Scheduling", "zh": "日程安排", @@ -70,6 +78,14 @@ "zh": "[\"日程安排\"]" } }, + "FGnQEW": { + "en": "Add Another Account", + "zh": "添加其他账户", + "icu": { + "en": "[\"Add Another Account\"]", + "zh": "[\"添加其他账户\"]" + } + }, "FkMol5": { "en": "Featured", "zh": "精选", @@ -174,6 +190,14 @@ "zh": "[\"功能\"]" } }, + "RDjuBN": { + "en": "Setup", + "zh": "配置", + "icu": { + "en": "[\"Setup\"]", + "zh": "[\"配置\"]" + } + }, "UhKOdj": { "en": "# more", "zh": "另外 # 个", @@ -257,6 +281,22 @@ } } }, + "bPwFdf": { + "en": "Accounts", + "zh": "账户", + "icu": { + "en": "[\"Accounts\"]", + "zh": "[\"账户\"]" + } + }, + "bWyJ+b": { + "en": "{enabledCount} of {0} enabled", + "zh": "已启用 {enabledCount}/{0} 个", + "icu": { + "en": "[[\"enabledCount\"],\" of \",[\"0\"],\" enabled\"]", + "zh": "[\"已启用 \",[\"enabledCount\"],\"/\",[\"0\"],\" 个\"]" + } + }, "bXJKzj": { "en": "Copy link to this plugin", "zh": "复制此插件的链接", @@ -329,6 +369,14 @@ "zh": "[\"连接\"]" } }, + "kDCdbL": { + "en": "Edit Values", + "zh": "编辑值", + "icu": { + "en": "[\"Edit Values\"]", + "zh": "[\"编辑值\"]" + } + }, "lXkUEV": { "en": "Availability", "zh": "可用性", @@ -345,6 +393,14 @@ "zh": "[\"销售\"]" } }, + "mrRDVU": { + "en": "Setup Values", + "zh": "配置值", + "icu": { + "en": "[\"Setup Values\"]", + "zh": "[\"配置值\"]" + } + }, "n+xLOH": { "en": "Customer Support", "zh": "客户支持", @@ -361,6 +417,14 @@ "zh": "[\"信息\"]" } }, + "ovBPCi": { + "en": "Default", + "zh": "默认", + "icu": { + "en": "[\"Default\"]", + "zh": "[\"默认\"]" + } + }, "q1LJYo": { "en": "Managed by your team", "zh": "由你的团队管理", @@ -400,6 +464,14 @@ "en": "[\"Marketing\"]", "zh": "[\"营销\"]" } + }, + "xdA/+p": { + "en": "Tools", + "zh": "工具", + "icu": { + "en": "[\"Tools\"]", + "zh": "[\"工具\"]" + } } } } diff --git a/tests/marketplace-detail-view-0781.test.mjs b/tests/marketplace-detail-view-0781.test.mjs index 8bc73efa..050afd30 100644 --- a/tests/marketplace-detail-view-0781.test.mjs +++ b/tests/marketplace-detail-view-0781.test.mjs @@ -18,7 +18,16 @@ // The staged chunk is checked too when a build is present. import assert from "node:assert/strict"; -import { existsSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs"; +import { + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + readdirSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; import path from "node:path"; import test from "node:test"; import { fileURLToPath, pathToFileURL } from "node:url"; @@ -28,8 +37,11 @@ import { MARKETPLACE_DETAIL_VIEW_0781_INJECTED_SOURCE, MARKETPLACE_DETAIL_VIEW_0781_MESSAGES, MARKETPLACE_DETAIL_VIEW_0781_REPLACEMENTS, + MARKETPLACE_DETAIL_VIEW_0781_SECTION_MESSAGES, patchMarketplaceDetailView0781, } from "../scripts/lib/marketplace-detail-view-0781-patch.mjs"; +import { applyMarketplaceDetailView0781Css } from "../scripts/lib/marketplace-detail-view-0781-css-patch.mjs"; +import { EXTRA_I18N_FILES } from "../scripts/lib/extra-i18n-patch.mjs"; import { MARKETPLACE_BROWSE_CHROME_0781_REPLACEMENTS } from "../scripts/lib/marketplace-browse-chrome-0781-patch.mjs"; import { findDomChildrenInThirdArgument } from "../scripts/lib/marketplace-jsx-children.mjs"; @@ -40,6 +52,7 @@ const fixture = JSON.parse(read("tests", "fixtures", "official-0781-detail-view- const detailCss = read("scripts", "lib", "marketplace-detail-view-0781-official.css"); const browseCss = read("scripts", "lib", "marketplace-browse-chrome-0781-official.css"); const layoutCss = read("scripts", "lib", "marketplace-dialog-0781-layout.css"); +const routineCss = read("scripts", "lib", "routine-surface-0758-official.css"); /** * The classes upstream 0.18's stylesheet already defines, so the port does not have to lift them. @@ -62,6 +75,10 @@ const UPSTREAM_RESOLVED_CLASSES = [ "sand-h8yej3", "sand-jb2p0i", "sand-jbqb8w", "sand-jyslct", "sand-l56j7k", "sand-lshs6z", "sand-lup9mm", "sand-lyipyv", "sand-mix8c7", "sand-ng3xce", "sand-oyc9vk", "sand-rvj5dj", "sand-s2xxs2", "sand-s83m0k", "sand-sdox4t", "sand-tgyt42", "sand-thy2uy", "sand-tyxrsu", "sand-uxw1ft", "sand-v54qhq", "sand-w4jnvo", "sand-wib8y2", "sand-wz0xwf", + // The recipes the detail page's own rows, buttons and chevrons carry. + "sand-11xpdln", "sand-1717udv", "sand-19jd1h0", "sand-1v2ro7d", "sand-n4ifyr", "sand-wji4o3", + // Official's small secondary button recipe (the setup section's "Edit Values" button). + "sand-178xt8z", "sand-so031l", "sand-13fuv20", "sand-1q0q8m5", "sand-1t7ytsu", "sand-1v8p93f", "sand-16stqrj", "sand-1k6tqyu", "sand-krqix3", "sand-87ps6o", "sand-ggy1nq", "sand-1s07b3s", "sand-1hc1fzr", "sand-uhm2yv", "sand-67bb7w", "sand-aqnwrm", "sand-1uczgqu", "sand-1wfwxd8", "sand-7s97pk", "sand-1eaenvl", "sand-1pbvl4h", "sand-d7y6wv", "sand-9h44rk", "sand-spwq11", ]; /** @@ -70,7 +87,24 @@ const UPSTREAM_RESOLVED_CLASSES = [ * They are stylix namespace markers (`sand-kit-button`, `sand-kit-icon`, …), so they carry no * style on either side. */ -const STYLIX_NOOP_CLASSES = ["sand-kit-button", "sand-kit-icon", "sand-kit-icon-button", "sand-settings-detail-bar"]; +const STYLIX_NOOP_CLASSES = ["sand-button", "sand-kit-button", "sand-kit-icon", "sand-kit-icon-button", "sand-settings-detail-bar"]; + +/** + * Upstream 0.18's own semantic hooks on the detail page. + * + * They name a part of the page for upstream's own code and for the repository's tests; neither + * upstream's stylesheet nor official's gives them a rule, so they resolve to nothing on either + * side and carry no style. + */ +const SEMANTIC_MARKER_CLASSES = [ + "sand-plugins-detail__accounts", + "sand-plugins-detail__add-account", + "sand-plugins-detail__connectors", + "sand-plugins-detail__desc", + "sand-plugins-detail__header", + "sand-plugins-detail__setup", + "sand-plugins-detail__skills", +]; /** Every class the port applies — `var(--sand-…)` custom-property references are not classes. */ function appliedClasses() { @@ -86,10 +120,19 @@ function companionClasses(css) { return new Set([...css.matchAll(/\.(sand-[a-z0-9+_-]+)/g)].map((match) => match[1])); } -/** A chunk that contains every anchor exactly once (the anchors are fragments, so this is text). */ +/** + * A chunk that contains every anchor exactly as many times as the entry declares (the anchors are + * fragments, so this is text). + * + * An entry that replaces one recipe everywhere it appears declares how many places that is; the + * fragment is repeated that many times so the patch's own count check bites here as well. + */ function syntheticChunk() { let source = "const Os=1,Re=2,ue=3,C=4,t=5,k=6,RLocT=7,Pl=8,nt=9,qn={};\n"; - for (const entry of MARKETPLACE_DETAIL_VIEW_0781_REPLACEMENTS) source += `/*${entry.label}*/${entry.before}\n`; + for (const entry of MARKETPLACE_DETAIL_VIEW_0781_REPLACEMENTS) { + const times = entry.occurrences ?? 1; + for (let i = 0; i < times; i += 1) source += `/*${entry.label}*/${entry.before}\n`; + } return source; } @@ -127,11 +170,11 @@ function pluginEntry(overrides = {}) { }; } -test("every anchor is replaced exactly once", () => { +test("every anchor is replaced exactly the declared number of times", () => { const source = syntheticChunk(); for (const entry of MARKETPLACE_DETAIL_VIEW_0781_REPLACEMENTS) { const hits = source.split(entry.before).length - 1; - assert.equal(hits, 1, `the anchor for "${entry.label}" must appear exactly once`); + assert.equal(hits, entry.occurrences ?? 1, `the anchor for "${entry.label}" must appear exactly the declared number of times`); } const patched = patchMarketplaceDetailView0781(source); for (const entry of MARKETPLACE_DETAIL_VIEW_0781_REPLACEMENTS) { @@ -264,6 +307,7 @@ test("every class the port applies resolves in upstream 0.18 or in an official C ...companionClasses(browseCss), ...companionClasses(detailCss), ...companionClasses(layoutCss), + ...companionClasses(routineCss), ]); const upstream = new Set(UPSTREAM_RESOLVED_CLASSES); const noop = new Set(STYLIX_NOOP_CLASSES); @@ -279,6 +323,58 @@ test("every class the port applies resolves in upstream 0.18 or in an official C ); }); +test("every class the port writes into the upstream chunk resolves", () => { + // The port's own source is checked above. This guard covers the other half: the class recipes the + // port writes INTO upstream 0.18's chunk, where a token that resolves nowhere renders the row + // unstyled while every other guard stays green. + const companion = new Set([ + ...companionClasses(browseCss), + ...companionClasses(detailCss), + ...companionClasses(layoutCss), + ...companionClasses(routineCss), + ]); + const upstream = new Set(UPSTREAM_RESOLVED_CLASSES); + const noop = new Set([...STYLIX_NOOP_CLASSES, ...SEMANTIC_MARKER_CLASSES]); + const written = new Set(); + for (const entry of MARKETPLACE_DETAIL_VIEW_0781_REPLACEMENTS) { + for (const match of entry.after.matchAll(/sand-[a-z0-9+_-]+/g)) written.add(match[0]); + } + const missing = [...written].filter( + (name) => !companion.has(name) && !upstream.has(name) && !noop.has(name), + ); + assert.deepEqual( + missing, + [], + "these classes have no rule in upstream 0.18's stylesheet and none in an official CSS companion, so the port would render unstyled", + ); +}); + +test("every section message id is official's own and the repository's i18n table uses it", () => { + const sections = MARKETPLACE_DETAIL_VIEW_0781_SECTION_MESSAGES; + for (const message of Object.values(sections)) { + const official = fixture.messages[message.id]; + assert.ok(official != null, `the official fixture is missing ${message.id}`); + assert.equal(official.en, message.en, `official's ${message.id} must be ${message.en}`); + assert.equal(official.zh, message.zh, `official's ${message.id} must be ${message.zh}`); + } + // The repository's own extra-i18n table localizes these strings; it must use official's ids, so a + // message that drifts in either app is caught here rather than on screen. + const plugins = EXTRA_I18N_FILES.find((entry) => entry.file === "view-B5Ug8wEm.js"); + assert.ok(plugins != null, "the extra-i18n table must carry the plugins chunk"); + const byEnglish = new Map(plugins.pairs.map(([en, zh, id]) => [en, { zh, id }])); + for (const message of Object.values(sections)) { + const entry = byEnglish.get(message.en); + if (entry == null) continue; // "Tools" is localized by the marketplace wording patch + assert.equal(entry.id, message.id, `the i18n table must localize "${message.en}" under official's ${message.id}`); + assert.equal(entry.zh, message.zh, `the i18n table must write official's ${message.zh}`); + } + const wording = read("scripts", "lib", "marketplace-official-wording-patch.mjs"); + assert.ok( + wording.includes('RLocT("Tools","工具")'), + "the wording patch must localize the tools heading with official's own text", + ); +}); + test("the declared upstream class list matches upstream 0.18's own stylesheet", () => { const assets = path.join(repoRoot, "src", "app", "dist", "renderer", "assets"); if (!existsSync(assets)) return; // upstream's stylesheet only exists after npm run bootstrap @@ -315,12 +411,47 @@ test("the detail companion carries every rule the port needs that upstream lacks ...companionClasses(browseCss), ...companionClasses(detailCss), ...companionClasses(layoutCss), + ...companionClasses(routineCss), ]); for (const name of ["sand-1xr8qbc", "sand-jodmca", "sand-1pic42t", "sand-1onr9mi", "sand-2fvf9", "sand-p4054r", "sand-1lziwak"]) { assert.ok(companion.has(name), `official's ${name} must be lifted into a companion CSS`); } }); +test("the CSS patch ships a standalone rule even when a scoped rule names the same class", async () => { + // Regression: the patch used to decide "this rule is already here" with a substring search. + // Our own computer-card patch ships `.r-conversation-computer-card .sand-1lun4ml{…}`, which + // *contains* the unscoped rule as a substring, so the unscoped rule was silently skipped — and + // the detail page's button lost its border width while every other guard stayed green. + const stage = mkdtempSync(path.join(tmpdir(), "sand-detail-css-")); + try { + const assets = path.join(stage, "dist", "renderer", "assets"); + mkdirSync(assets, { recursive: true }); + writeFileSync( + path.join(assets, "index-scoped.css"), + [ + ".r-conversation-computer-card .sand-1lun4ml:not(#\\#):not(#\\#):not(#\\#){border-inline-end-width:1px}", + ".r-conversation-computer-card .sand-pilrb4:not(#\\#):not(#\\#):not(#\\#){border-inline-start-width:1px}", + "", + ].join("\n"), + ); + await applyMarketplaceDetailView0781Css({ stageRoot: stage }); + const lines = readFileSync(path.join(assets, "index-scoped.css"), "utf8") + .split("\n") + .map((line) => line.trim()); + for (const name of ["sand-1lun4ml", "sand-pilrb4"]) { + const standalone = lines.filter((line) => line.startsWith(`.${name}`)); + assert.equal( + standalone.length, + 1, + `official's unscoped ${name} rule must ship as its own rule, got ${standalone.length}`, + ); + } + } finally { + rmSync(stage, { recursive: true, force: true }); + } +}); + test("the information table carries official's own rows", () => { const { RM0781DetailRows } = builders(); const rows = RM0781DetailRows(pluginEntry(), null); @@ -453,6 +584,111 @@ test("the native tile on the detail page is official's 56×56 one", () => { ); }); +test("the detail page's rows and cards carry official's own recipes", () => { + const patched = patchMarketplaceDetailView0781(syntheticChunk()); + // The port's own injected source is appended after the chunk; the recipes this guard counts are + // the ones the port writes INTO upstream 0.18's chunk. + const chunk = patched.slice(0, patched.indexOf(MARKETPLACE_DETAIL_VIEW_0781_INJECTED_SOURCE)); + const count = (needle) => chunk.split(needle).length - 1; + const cases = [ + ["the section heading", 'k("sand-9f619 sand-1y1aw1k sand-1pic42t sand-10b6aqq sand-1onr9mi")', 4], + ["the details row", 'k("sand-9f619 sand-78zum5 sand-6s0dn4 sand-1qughib sand-167g77z sand-oyc9vk sand-1pic42t sand-2lprc sand-1onr9mi")', 7], + ["the row label", 'k("sand-9f619 sand-2lah0s sand-thy2uy sand-b3r6kr sand-uxw1ft sand-lyipyv sand-dj266r sand-14z9mp sand-at24cr sand-1lziwak sand-11wthnw sand-d4r4e8 sand-12oo3zp sand-tyxrsu")', 5], + ["the accounts card", 'k("sand-plugins-detail__accounts","sand-9f619 sand-7giv3 sand-13ly8rp sand-1xlntvz sand-4hv7ue sand-1y0btm7 sand-5fd6op sand-1pkpdue sand-10e981r")', 1], + ["the setup card", 'k("sand-plugins-detail__setup","sand-9f619 sand-7giv3 sand-13ly8rp sand-1xlntvz sand-4hv7ue sand-1y0btm7 sand-5fd6op sand-1pkpdue sand-10e981r")', 1], + ["the tools card", 'k(l,"sand-9f619 sand-7giv3 sand-4hv7ue sand-1y0btm7 sand-5fd6op sand-1pkpdue sand-10e981r sand-exx8yu sand-18d9i69")', 1], + ["the account title", 'k("sand-9f619 sand-b3r6kr sand-11wthnw sand-d4r4e8 sand-12oo3zp sand-tyxrsu sand-lyipyv sand-uxw1ft")', 1], + ["the add-account row", 'k("sand-plugins-detail__add-account","sand-9f619 sand-78zum5 sand-6s0dn4 sand-17d4w8g sand-h8yej3 sand-oyc9vk sand-1pic42t sand-2lprc sand-1onr9mi sand-c342km sand-ng3xce sand-jbqb8w sand-1o0liin sand-n4ifyr sand-jb2p0i sand-11wthnw sand-12oo3zp sand-1yc453h sand-1ypdohk sand-1t137rt sand-1yfcfk6")', 1], + ["the edit-account button", 'k("sand-9f619 sand-3nfvp2 sand-6s0dn4 sand-1c4vz4f sand-2lah0s sand-dl72j9 sand-1717udv sand-c342km sand-ng3xce sand-jbqb8w sand-169k319 sand-n4ifyr sand-1ypdohk")', 1], + ["the tools summary button", 'k("sand-9f619 sand-78zum5 sand-6s0dn4 sand-1qughib sand-167g77z sand-oyc9vk sand-1pic42t sand-2lprc sand-1onr9mi sand-jyslct sand-1lugfcp sand-h8yej3 sand-dj266r sand-14z9mp sand-at24cr sand-1lziwak sand-c342km sand-ng3xce sand-jb2p0i sand-1qlqyl8 sand-1yc453h sand-1ypdohk sand-jbqb8w sand-1ecwv3n sand-1t137rt sand-1yfcfk6")', 1], + ]; + for (const [name, recipe, expected] of cases) { + assert.equal(count(recipe), expected, `official's ${name} recipe must be applied ${expected} time(s)`); + } + const upstream = [ + ["the section heading", 'k("sand-9f619 sand-1y1aw1k sand-19tmk5i sand-10b6aqq sand-zjhap9")'], + ["the details row", 'k("sand-9f619 sand-78zum5 sand-6s0dn4 sand-1qughib sand-167g77z sand-z9dl7a sand-19tmk5i sand-sag5q8 sand-zjhap9")'], + ["the row label", 'k("sand-9f619 sand-2lah0s sand-thy2uy sand-b3r6kr sand-uxw1ft sand-lyipyv sand-dj266r sand-1yf7rl7 sand-at24cr sand-j3b58b sand-4z9k3i sand-d4r4e8 sand-12oo3zp sand-1wd3ewq")'], + ]; + for (const [name, recipe] of upstream) { + assert.equal(count(recipe), 0, `upstream's ${name} recipe must be gone`); + } +}); + +test("the setup section's button is official's own small secondary button", () => { + const source = MARKETPLACE_DETAIL_VIEW_0781_INJECTED_SOURCE; + for (const token of [ + "sand-1lun4ml", + "sand-pilrb4", + "sand-18b5jzi", + "sand-1o3jo1z", + "sand-v5lvn5", + "sand-4meti", + "sand-fwqdrz", + "sand-19jy9ex", + "sand-tyxrsu", + ]) { + assert.ok(source.includes(token), `official's ${token} must be on the button`); + } + for (const token of ["sand-s1s249", "sand-1kxuqrf", "sand-uo9n5k"]) { + assert.ok(!source.includes(token), `upstream's ${token} must not be on the button`); + } +}); + +test("mutations: swapping official's small secondary button back to upstream's fails the guard", async () => { + const original = path.join(repoRoot, "scripts/lib/marketplace-detail-view-0781-patch.mjs"); + const source = readFileSync(original, "utf8"); + const mutated = source.replace( + "sand-1lun4ml sand-so031l sand-pilrb4 sand-13fuv20 sand-18b5jzi", + "sand-s1s249 sand-so031l sand-e0pwq sand-13fuv20 sand-32b0ac", + ); + assert.notEqual(mutated, source, "the mutation must apply"); + const mutantFile = path.join(repoRoot, "scripts/lib/.mutant-0781-detail-button.mjs"); + writeFileSync(mutantFile, mutated); + try { + const mutant = await import(pathToFileURL(mutantFile).href); + assert.ok( + !mutant.MARKETPLACE_DETAIL_VIEW_0781_INJECTED_SOURCE.includes("sand-1lun4ml"), + "the guard must fail on a button that carries upstream's recipe", + ); + } finally { + rmSync(mutantFile, { force: true }); + } +}); + +test("mutations: swapping official's details row back to upstream's fails the guard", async () => { + const original = path.join(repoRoot, "scripts/lib/marketplace-detail-view-0781-patch.mjs"); + const source = readFileSync(original, "utf8"); + const mutated = source.replace( + "sand-9f619 sand-78zum5 sand-6s0dn4 sand-1qughib sand-167g77z sand-oyc9vk sand-1pic42t sand-2lprc sand-1onr9mi", + "sand-9f619 sand-78zum5 sand-6s0dn4 sand-1qughib sand-167g77z sand-z9dl7a sand-19tmk5i sand-sag5q8 sand-zjhap9", + ); + assert.notEqual(mutated, source, "the mutation must apply"); + const mutantFile = path.join(repoRoot, "scripts/lib/.mutant-0781-detail-recipes.mjs"); + writeFileSync(mutantFile, mutated); + try { + const mutant = await import(pathToFileURL(mutantFile).href); + const official = "sand-9f619 sand-78zum5 sand-6s0dn4 sand-1qughib sand-167g77z sand-oyc9vk sand-1pic42t sand-2lprc sand-1onr9mi"; + const upstream = "sand-9f619 sand-78zum5 sand-6s0dn4 sand-1qughib sand-167g77z sand-z9dl7a sand-19tmk5i sand-sag5q8 sand-zjhap9"; + const chunkOf = (module) => { + const patched = module.patchMarketplaceDetailView0781(syntheticChunk()); + return patched.slice(0, patched.indexOf(module.MARKETPLACE_DETAIL_VIEW_0781_INJECTED_SOURCE)); + }; + const real = chunkOf(await import(pathToFileURL(original).href)); + const mutatedChunk = chunkOf(mutant); + assert.ok( + real.split(official).length - 1 > mutatedChunk.split(official).length - 1, + "the guard must fail on a patch that writes upstream's details row instead of official's", + ); + assert.ok( + mutatedChunk.split(upstream).length - 1 > real.split(upstream).length - 1, + "the mutant must write upstream's details row everywhere official's was written", + ); + } finally { + rmSync(mutantFile, { force: true }); + } +}); + test("mutations: dropping the information table fails the port's guard", async () => { // The mutant has to live inside the repo so the patch module's own relative imports resolve. const original = path.join(repoRoot, "scripts/lib/marketplace-detail-view-0781-patch.mjs");