diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index adfd4279..c85aaf72 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -113,13 +113,6 @@ jobs: - name: 校验 TS7、Vue 类型、静态规则及产物合同 run: pnpm check - - name: Validate the native Twitter Module Federation snapshot - run: >- - node scripts/verify-native-extension-distribution.mjs inspect-local - --package extensions/twitter/package.json - --core-package packages/core/package.json - --artifact-directory extensions/twitter/dist/client-web - - name: Verify independent MF publication run: >- node --test scripts/test-extension-publication.mjs scripts/test-core-sdk-publication.mjs diff --git a/docs/30-unit-tdd/native-extension-runtime.md b/docs/30-unit-tdd/native-extension-runtime.md index 5c8b64c4..94cacb26 100644 --- a/docs/30-unit-tdd/native-extension-runtime.md +++ b/docs/30-unit-tdd/native-extension-runtime.md @@ -35,6 +35,7 @@ Host 与 Web 扩展通过现有 Module Federation 配置共享 `@inkcre/ui-web` 并由每个生产者的 package.json 声明自身锁定的 UI 版本要求,构建不得用 Host 的 UI 版本覆盖它。UI 的 i18n、路由和表单上下文使用模块内的 Vue 注入 key; 如果扩展另用一份 UI 运行时,即使外观相同,也无法继承 Host 的这些上下文。 独立 playground 仍可使用包自身的 fallback 模块,样式由各自应用入口加载。 +如果生产入口没有消费 UI JavaScript,构建产物可以没有 UI share;声明开发依赖并不代表生产入口使用了该模块。产物一旦包含 UI share,就必须只有一个,并使用生产者声明的版本。 The producer emits the native `mf-manifest.json`, Remote entry, and referenced shared/exposed asset closure with relative asset semantics. The Registry Release records an immutable manifest diff --git a/docs/40-deployment/native-extension-delivery.md b/docs/40-deployment/native-extension-delivery.md index 22c8e944..bf92310f 100644 --- a/docs/40-deployment/native-extension-delivery.md +++ b/docs/40-deployment/native-extension-delivery.md @@ -194,6 +194,7 @@ manifest structure, and the exact bytes of every referenced asset. The executable contract is [`scripts/verify-native-extension-distribution.mjs`](../../scripts/verify-native-extension-distribution.mjs). +`pnpm check:package` 在本地与 CI 中检查所有生产扩展的真实 MF 产物,并确认 Twitter 这类使用 UI 组件的扩展保留共享 UI 模块。 Verify locally without publishing: ```bash diff --git a/scripts/check-package-contract.mjs b/scripts/check-package-contract.mjs index e56062e5..30f350cd 100644 --- a/scripts/check-package-contract.mjs +++ b/scripts/check-package-contract.mjs @@ -2,6 +2,7 @@ import { execFileSync } from 'node:child_process' import { access, readFile, readdir, realpath } from 'node:fs/promises' import { fileURLToPath } from 'node:url' import { inspectBrowserArtifact } from './browser-runtime-contract.mjs' +import { inspectNativeModuleFederation } from './verify-native-extension-distribution.mjs' const repoRoot = fileURLToPath(new URL('..', import.meta.url)) const jsonOutput = process.argv.includes('--json') @@ -46,6 +47,36 @@ await Promise.all( ].map((path) => requireFile(`${repoRoot}/${path}`)) ) +for (const entry of await readdir(`${repoRoot}/extensions`, { withFileTypes: true })) { + if (!entry.isDirectory()) continue + const directory = `${repoRoot}/extensions/${entry.name}` + let metadata + try { + metadata = JSON.parse(await readFile(`${directory}/package.json`, 'utf8')) + } catch (error) { + if (error.code === 'ENOENT') continue + throw error + } + if (!metadata.inkcre?.module_federation) continue + try { + await inspectNativeModuleFederation({ + packagePath: `${directory}/package.json`, + corePackagePath: `${coreRoot}/package.json`, + artifactDirectory: `${directory}/dist/client-web`, + }) + if (entry.name === 'twitter') { + const manifest = JSON.parse( + await readFile(`${directory}/dist/client-web/mf-manifest.json`, 'utf8') + ) + if (!manifest.shared?.some((shared) => shared.name === '@inkcre/ui-web')) { + errors.push('Twitter UI components must share @inkcre/ui-web with the Host') + } + } + } catch (error) { + errors.push(`${metadata.name} native distribution inspection failed: ${error.message}`) + } +} + const twitterRemoteEntry = `${repoRoot}/extensions/twitter/dist/client-web/remoteEntry.js` try { const content = await readFile(twitterRemoteEntry, 'utf8') diff --git a/scripts/tests/independent-extension-release.test.mjs b/scripts/tests/independent-extension-release.test.mjs index 9e2c3950..6743adf1 100644 --- a/scripts/tests/independent-extension-release.test.mjs +++ b/scripts/tests/independent-extension-release.test.mjs @@ -4,6 +4,55 @@ import os from 'node:os' import path from 'node:path' import { test } from 'node:test' import { captureCandidates } from '../publish-first-party-extensions.mjs' +import { inspectNativeModuleFederation } from '../verify-native-extension-distribution.mjs' + +void test('MF snapshots permit unused UI dependencies and validate emitted UI shares', async () => { + const workspace = await mkdtemp(path.join(os.tmpdir(), 'inkcre-ui-share-')) + const packagePath = path.join(workspace, 'package.json') + const corePackagePath = path.join(workspace, 'core.json') + const manifestPath = path.join(workspace, 'mf-manifest.json') + const metadata = { + version: '0.2.1', + devDependencies: { '@inkcre/ui-web': '2.3.0' }, + inkcre: { + name: 'inkcre/capture', + nickname: 'Capture', + module_federation: { host_sdk: '@inkcre/core', host_sdk_version: '>=0.4.0 <0.5.0' }, + }, + } + const coreShare = { name: '@inkcre/core', requiredVersion: '>=0.4.0 <0.5.0' } + const uiShare = { name: '@inkcre/ui-web', requiredVersion: '2.3.0' } + try { + await writeFile(corePackagePath, JSON.stringify({ version: '0.4.3' })) + await writeFile(path.join(workspace, 'remoteEntry.js'), 'export default {}') + const inspect = () => + inspectNativeModuleFederation({ packagePath, corePackagePath, artifactDirectory: workspace }) + for (const [uiShares, declaresUi, valid] of [ + [[], true, true], + [[uiShare], true, true], + [[{ ...uiShare, requiredVersion: '2.7.0' }], true, false], + [[uiShare, uiShare], true, false], + [[uiShare], false, false], + ]) { + await writeFile( + packagePath, + JSON.stringify({ ...metadata, devDependencies: declaresUi ? metadata.devDependencies : {} }) + ) + await writeFile( + manifestPath, + JSON.stringify({ + metaData: { publicPath: './', remoteEntry: { name: 'remoteEntry.js' } }, + shared: [coreShare, ...uiShares], + exposes: [], + }) + ) + if (valid) assert.equal((await inspect()).name, 'inkcre/capture') + else await assert.rejects(inspect(), assert.AssertionError) + } + } finally { + await rm(workspace, { recursive: true, force: true }) + } +}) void test('a prepared Mail release does not publish another producer with pending intent', async () => { const workspace = await mkdtemp(path.join(os.tmpdir(), 'inkcre-independent-release-')) diff --git a/scripts/tests/webext-release.test.mjs b/scripts/tests/webext-release.test.mjs index bb12ccd5..7d8882de 100644 --- a/scripts/tests/webext-release.test.mjs +++ b/scripts/tests/webext-release.test.mjs @@ -2,7 +2,13 @@ import assert from 'node:assert/strict' import { readFile } from 'node:fs/promises' import { createRequire } from 'node:module' import { test } from 'node:test' -import { assertDraft, checkManifest, extensionId, preparedVersion } from '../webext-release.mjs' +import { + assertDraft, + checkManifest, + createDraftRelease, + extensionId, + preparedVersion, +} from '../webext-release.mjs' const require = createRequire(new URL('../../apps/client-webext/package.json', import.meta.url)) @@ -21,6 +27,26 @@ void test('draft recovery cannot overwrite a public version or another source', assertDraft(draft, draft.tag_name, source) assert.throws(() => assertDraft({ ...draft, draft: false }, draft.tag_name, source)) assert.throws(() => assertDraft(draft, draft.tag_name, 'b'.repeat(40))) + assert.throws(() => assertDraft(undefined, draft.tag_name, source), /draft release was not found/) +}) + +void test('a new draft uses its creation response before release inventory catches up', async (t) => { + const source = 'a'.repeat(40) + const draft = { id: 42, tag_name: 'webext-v0.2.0', target_commitish: source, draft: true } + t.mock.method(globalThis, 'fetch', async (url, options) => { + assert.equal(url, 'https://api.github.com/repos/InKCre/impl-ts/releases') + assert.equal(options.method, 'POST') + const request = JSON.parse(options.body) + assert.equal(request.tag_name, draft.tag_name) + assert.equal(request.target_commitish, source) + assert.equal(request.draft, true) + assert.match(request.body, /发布说明/) + return Response.json(draft, { status: 201 }) + }) + assert.deepEqual( + await createDraftRelease({ tag: draft.tag_name, source, version: '0.2.0', news: '发布说明' }), + draft + ) }) void test('browser artifacts preserve identity, numeric version and readable toolbar variants', async () => { diff --git a/scripts/verify-native-extension-distribution.mjs b/scripts/verify-native-extension-distribution.mjs index 6f7e94a0..ca862b1b 100644 --- a/scripts/verify-native-extension-distribution.mjs +++ b/scripts/verify-native-extension-distribution.mjs @@ -134,9 +134,11 @@ export async function inspectNativeModuleFederation({ const uiVersion = extensionPackage.dependencies?.['@inkcre/ui-web'] ?? extensionPackage.devDependencies?.['@inkcre/ui-web'] - if (uiVersion) { - const uiShare = manifest.shared?.filter((shared) => shared.name === '@inkcre/ui-web') ?? [] - assert.equal(uiShare.length, 1, 'manifest must share @inkcre/ui-web to inherit Host UI context') + const uiShare = manifest.shared?.filter((shared) => shared.name === '@inkcre/ui-web') ?? [] + assert.ok(uiShare.length <= 1, 'manifest must not duplicate @inkcre/ui-web shares') + // 未消费 UI JavaScript 的生产入口不会在构建产物中生成 UI share。 + if (uiShare.length) { + assert.ok(uiVersion, 'shared @inkcre/ui-web must declare the producer UI version') assert.equal(uiShare[0].requiredVersion, uiVersion) } assert.equal(coreShare.length, 1, 'manifest must contain exactly one @inkcre/core shared module') diff --git a/scripts/webext-release.mjs b/scripts/webext-release.mjs index 23ee2660..0a12db34 100644 --- a/scripts/webext-release.mjs +++ b/scripts/webext-release.mjs @@ -38,11 +38,13 @@ export function checkManifest(manifest, version, browser, key) { assert.ok(!manifest.permissions.includes('debugger')) } -async function api(route) { +async function api(route, options = {}) { const response = await fetch(`https://api.github.com/repos/${repository}/${route}`, { + ...options, headers: { Authorization: `Bearer ${process.env.GH_TOKEN}`, Accept: 'application/vnd.github+json', + 'Content-Type': 'application/json', }, signal: AbortSignal.timeout(30_000), }) @@ -79,6 +81,7 @@ async function findRelease(tag) { } export function assertDraft(release, tag, source) { + assert.ok(release, 'GitHub draft release was not found') assert.equal(release.tag_name, tag) assert.equal( release.draft, @@ -92,6 +95,22 @@ export function assertDraft(release, tag, source) { ) } +export async function createDraftRelease({ tag, source, version, news }) { + const body = `${news}\n\nChromium:下载 chrome ZIP,解压到固定目录加载,覆盖目录后重新加载更新,不要卸载。Firefox ZIP 未签名,仅供临时加载,不能作为正式安装包。source.tar.gz 为对应仓库源码,不含私有依赖,尚非 AMO 独立审查包。\n\n来源 ${source}。source.json 记录实际包的 SHA256。` + const release = await api('releases', { + method: 'POST', + body: JSON.stringify({ + tag_name: tag, + target_commitish: source, + draft: true, + name: `WebExt ${version}`, + body, + }), + }) + assertDraft(release, tag, source) + return release +} + async function verifyCandidate() { const receipt = await readJson(`${directory}/source.json`) assert.equal(receipt.name, '@inkcre/client-webext') @@ -211,22 +230,7 @@ export async function main(command) { if (!release) { const changelog = await readFile(`${app}/CHANGELOG.md`, 'utf8') const news = changelog.split(`## ${version}\n`)[1]?.split('\n## ')[0]?.trim() ?? '' - const body = `${news}\n\nChromium:下载 chrome ZIP,解压到固定目录加载,覆盖目录后重新加载更新,不要卸载。Firefox ZIP 未签名,仅供临时加载,不能作为正式安装包。source.tar.gz 为对应仓库源码,不含私有依赖,尚非 AMO 独立审查包。\n\n来源 ${receipt.source_revision}。source.json 记录实际包的 SHA256。` - gh([ - 'release', - 'create', - tag, - '--repo', - repository, - '--target', - receipt.source_revision, - '--draft', - '--title', - `WebExt ${version}`, - '--notes', - body, - ]) - release = await findRelease(tag) + release = await createDraftRelease({ tag, source: receipt.source_revision, version, news }) } assertDraft(release, tag, receipt.source_revision) // Only an unpublished same-source draft may be completed/rebuilt after an interrupted upload. @@ -240,7 +244,7 @@ export async function main(command) { ...receipt.assets.map((asset) => `${directory}/${asset.name}`), `${directory}/source.json`, ]) - release = await findRelease(tag) + release = await api(`releases/${release.id}`) for (const asset of [ ...receipt.assets, { name: 'source.json', sha256: sha256(await readFile(`${directory}/source.json`)) },