feat: complete WorkBuddy parity and Windows release workflow

This commit is contained in:
xiaohan.yuan committed 2026-10-18 12:45:21 +08:00
1 parent 143516b3a8
commit d5512df549
434 files changed
+54977 -1854

No files matched your search

+414
View File
@@ -0,0 +1,414 @@
const assert = require('node:assert/strict')
const crypto = require('node:crypto')
const fs = require('node:fs')
const path = require('node:path')
const repoRoot = path.resolve(__dirname, '..', '..')
const tmpDir = path.join(repoRoot, '.tmp')
const evidence = {
home: {
path: path.join(tmpDir, 'rea-four-modules-library-stable-20261017.json'),
id: 'ev_82a9ad8ec38c807e465646d749e2cedbb150fec62e30234c97acd0bb309c349e'
},
local: {
path: path.join(tmpDir, 'rea-four-modules-library-local-20261017.json'),
id: 'ev_b7fe1b4adac2311b04dfa454126363ca382869d6a29664152d497a920c2e8cad'
}
}
const mediaEvidence = {
path: path.join(
tmpDir,
'rea-css-evidence',
'ev_d650991b41fe1831a4caf5dd225398e29db8e91205d00c2ac3047b00baa1998e.json'
),
id: 'ev_d650991b41fe1831a4caf5dd225398e29db8e91205d00c2ac3047b00baa1998e'
}
const illustrations = [
{
component: 'RobotGuideIllustration',
importName: 'agentGuideIllustration',
assetFile: 'wb-home-guide-card-agent-illustration.svg',
selector: '.wb-home-guide-card-agent-pc .wb-home-guide-card-illustration-pc',
sourceUrl:
'https://static.workbuddy.cn/web/library/5609e40689e0ddd5b7e8ede0a4e43acd7b1ba74d/js/federation/releases/e7fafd8eaa71ac3f/guide-card-agent-illustration.1abe0c58.svg',
bytes: 2857,
sha256: '1b0984754f470c4529b09d2fa56a957f23df3bd7fc16cc27d74989b1112ff815'
},
{
component: 'HtmlGuideIllustration',
importName: 'htmlGuideIllustration',
assetFile: 'wb-home-guide-card-html-illustration.svg',
selector: '.wb-home-guide-card-html-pc .wb-home-guide-card-illustration-pc',
sourceUrl:
'https://static.workbuddy.cn/web/library/5609e40689e0ddd5b7e8ede0a4e43acd7b1ba74d/js/federation/releases/e7fafd8eaa71ac3f/guide-card-html-illustration.0c0d6493.svg',
bytes: 1413,
sha256: '1af18b1901999725af4114997bba4c779c6d04f09712c44d34add010433d1e47'
}
]
const icons = [
{
name: 'ClockIcon',
evidence: 'home',
rootIndex: 872,
parentClass: 'memory-nav-item-icon-pc'
},
{
name: 'FolderLineIcon',
evidence: 'home',
rootIndex: 878,
parentClass: 'memory-nav-item-icon-pc'
},
{
name: 'MoreIcon',
evidence: 'home',
rootIndex: 1066,
parentClass: 'wb-feed-more-btn-pc'
},
{
name: 'InfoIcon',
evidence: 'home',
rootIndex: 1290,
parentClass: 'wb-home-guide-title-pc'
},
{
name: 'LightbulbIcon',
evidence: 'home',
rootIndex: 1323,
parentClass: 'wb-home-guide-playbook-title-pc'
},
{
name: 'BackIcon',
evidence: 'local',
rootIndex: 848,
parentClass: 'kb-back-to-library__btn'
},
{
name: 'SortArrowsIcon',
evidence: 'local',
rootIndex: 878,
parentClass: 'my-files-flat-col-name my-files-flat-sortable',
variant: 'sort'
},
{
name: 'ShareIcon',
evidence: 'local',
rootIndex: 954,
parentClass: 'my-files-list-action-btn'
},
{
name: 'TableMoreIcon',
evidence: 'local',
rootIndex: 959,
parentClass: 'my-files-list-action-btn'
},
{
name: 'TaskIcon',
evidence: 'local',
rootIndex: 967,
parentClass: 'my-files-flat-col-name'
}
]
const notes = {
star_icon: {
status: 'unresolved',
reason:
'No star/favorite SVG exists in the captured library UI. WorkBuddy uses a CSS checkbox for the favorite filter and renders share plus more actions in file rows.',
evidence_ids: Object.values(evidence).map((item) => item.id),
searched_classes: [
'my-files-favorite-checkbox',
'my-files-favorite-checkbox__label',
'my-files-list-action-btn'
]
},
rejected_reusable_home_media: {
status: 'not_applicable',
reason:
'A previously archived image body exists, but it belongs to the older .wb-home-route__growth-buddy img selector and is not evidence for either current guide-card illustration.',
evidence_id: 'ev_474129797ce3ab73eaaab2cd41247653e7f128c3b89b598158b57b74e1f17e88',
selector: '.wb-home-route__growth-buddy img',
source_url:
'https://staging-download.codebuddy.cn/operation-platform/assets/_general/ffca4ecbc52c1e3e.png',
artifact_sha256: '2713ed5bbf1288d56ebeaca49bfb0d622c967914ab9c98955cc995932049e7e8',
bytes: 13426
}
}
const svgAttributeNames = new Map([
['class', 'className'],
['clip-path', 'clipPath'],
['clip-rule', 'clipRule'],
['fill-rule', 'fillRule'],
['stroke-dasharray', 'strokeDasharray'],
['stroke-dashoffset', 'strokeDashoffset'],
['stroke-linecap', 'strokeLinecap'],
['stroke-linejoin', 'strokeLinejoin'],
['stroke-miterlimit', 'strokeMiterlimit'],
['stroke-width', 'strokeWidth'],
['transform-origin', 'transformOrigin']
])
const svgTagNames = new Map([
['clippath', 'clipPath'],
['lineargradient', 'linearGradient'],
['radialgradient', 'radialGradient']
])
function sha256(value) {
return crypto.createHash('sha256').update(value).digest('hex')
}
function readEvidence(spec) {
const value = JSON.parse(fs.readFileSync(spec.path, 'utf8').replace(/^\uFEFF/, ''))
assert.equal(value.evidence_id, spec.id)
assert.equal(value.operation, 'inspect_electron_page')
return value
}
function jsxAttributeName(name) {
return svgAttributeNames.get(name) ?? name
}
function jsxAttributes(attributes) {
return Object.entries(attributes)
.map(([name, value]) => `${jsxAttributeName(name)}=${JSON.stringify(value)}`)
.join(' ')
}
function recoverIcon(document, spec) {
const layoutNodes = new Map(document.nodes.map((node) => [node.node_index, node]))
const svgByIndex = new Map(document.svg_nodes.map((node) => [node.node_index, node]))
const childrenByParent = new Map()
for (const node of document.svg_nodes) {
const children = childrenByParent.get(node.parent_index) ?? []
children.push(node)
childrenByParent.set(node.parent_index, children)
}
const root = svgByIndex.get(spec.rootIndex)
assert.ok(root, `missing SVG root ${spec.rootIndex} for ${spec.name}`)
assert.equal(root.node_name, 'svg')
const parent = layoutNodes.get(root.parent_index)
assert.ok(parent, `missing SVG parent for ${spec.name}`)
assert.equal(parent.attributes?.class, spec.parentClass)
const subtree = []
const visit = (node) => {
subtree.push({
node_index: node.node_index,
parent_index: node.parent_index,
node_name: node.node_name,
attributes: node.attributes
})
for (const child of childrenByParent.get(node.node_index) ?? []) visit(child)
}
visit(root)
return {
root,
parent,
childrenByParent,
hash: sha256(JSON.stringify(subtree))
}
}
function renderNode(recovery, node, isRoot = false, spec = null) {
const tag = svgTagNames.get(node.node_name.toLowerCase()) ?? node.node_name
const attributes = { ...node.attributes }
let prefix = ''
if (isRoot) {
delete attributes.width
delete attributes.height
if (spec?.variant === 'sort') {
delete attributes.class
prefix = `{...svgProps} width={width ?? ${JSON.stringify(recovery.root.attributes.width)}} height={height ?? ${JSON.stringify(recovery.root.attributes.height)}} className={mergeClassNames("my-files-flat-sort-icon", direction, className)} `
} else if ('class' in attributes) {
const sourceClass = attributes.class
delete attributes.class
prefix = `{...svgProps} width={width ?? ${JSON.stringify(recovery.root.attributes.width)}} height={height ?? ${JSON.stringify(recovery.root.attributes.height)}} className={mergeClassNames(${JSON.stringify(sourceClass)}, className)} `
} else {
prefix = `{...svgProps} width={width ?? ${JSON.stringify(recovery.root.attributes.width)}} height={height ?? ${JSON.stringify(recovery.root.attributes.height)}} `
}
}
const serialized = jsxAttributes(attributes)
const opener = `<${tag} ${prefix}${serialized}`
const children = recovery.childrenByParent.get(node.node_index) ?? []
if (!children.length) return `${opener} />`
return `${opener}>${children.map((child) => renderNode(recovery, child)).join('')}</${tag}>`
}
function functionSource(name, recovery, evidenceId, version, spec) {
const provenance = [
`WorkBuddy ${version}`,
`REA ${evidenceId}`,
`parent .${spec.parentClass.replaceAll(' ', '.')}`,
`root ${spec.rootIndex}`,
`subtree sha256 ${recovery.hash}`
].join('; ')
if (spec.variant === 'sort') {
return `// Exact SVG recovery: ${provenance}\nexport function ${name}({ direction, className, width, height, ...svgProps }: SortIconProps): React.JSX.Element {\n return ${renderNode(recovery, recovery.root, true, spec)}\n}`
}
return `// Exact SVG recovery: ${provenance}\nexport function ${name}(props: IconProps): React.JSX.Element {\n const { className, width, height, ...svgProps } = props\n return ${renderNode(recovery, recovery.root, true, spec)}\n}`
}
function mergeClassNames(...values) {
return values.filter(Boolean).join(' ') || undefined
}
function readMediaEvidence(spec) {
const value = JSON.parse(fs.readFileSync(spec.path, 'utf8').replace(/^\uFEFF/, ''))
assert.equal(value.evidence_id, spec.id)
const items = value.normalized_result?.visuals?.media_assets?.items
assert.ok(Array.isArray(items))
return items
}
function recoverIllustration(item, spec) {
assert.equal(item.selector, spec.selector)
assert.equal(item.status, 'exported')
assert.equal(item.source?.url, spec.sourceUrl)
assert.equal(item.source?.mime_type, 'image/svg+xml')
assert.equal(item.artifact?.mime_type, 'image/svg+xml')
assert.equal(item.artifact?.bytes, spec.bytes)
assert.equal(item.artifact?.sha256, spec.sha256)
const bytes = Buffer.from(item.artifact.data_base64, 'base64')
assert.equal(bytes.length, spec.bytes)
assert.equal(sha256(bytes), spec.sha256)
const svg = bytes.toString('utf8')
assert.match(svg.trimStart(), /^<svg\b/)
return {
bytes,
record: {
component: spec.component,
asset_file: `src/renderer/src/library/assets/${spec.assetFile}`,
evidence_id: mediaEvidence.id,
evidence_file: path.relative(repoRoot, mediaEvidence.path).replaceAll('\\', '/'),
selector: item.selector,
node_index: item.node_index,
node_class: item.selected_node?.attributes?.class,
source_url: item.source.url,
source_url_sha256: item.source.url_sha256,
status: item.status,
visibility: item.visibility,
mime_type: item.artifact.mime_type,
bytes: item.artifact.bytes,
sha256: item.artifact.sha256
}
}
}
function illustrationSource(records) {
const imports = records
.map(({ spec }) => `import ${spec.importName} from './assets/${spec.assetFile}'`)
.join('\n')
const components = records.map(({ record, spec }) => {
const provenance = [
`REA ${mediaEvidence.id}`,
`selector ${spec.selector}`,
`source ${spec.sourceUrl}`,
`${spec.bytes} bytes`,
`sha256 ${spec.sha256}`
].join('; ')
return `// Exact WorkBuddy asset recovery: ${provenance}\nexport function ${spec.component}(): React.JSX.Element {\n return (\n <img\n className="wb-home-guide-card-illustration-pc library-guide__illustration"\n src={${spec.importName}}\n alt=""\n />\n )\n}`
}).join('\n\n')
return `// Generated by scripts/rea/recover-library-icons.cjs. Do not hand edit.\n${imports}\n\n${components}\n`
}
function main() {
const loaded = Object.fromEntries(
Object.entries(evidence).map(([name, spec]) => [name, readEvidence(spec)])
)
const iconRecords = []
const iconSources = []
for (const spec of icons) {
const value = loaded[spec.evidence]
const document = value.normalized_result.visuals.layout.documents[0]
const recovery = recoverIcon(document, spec)
const version = value.normalized_result.browser.user_agent.match(/WorkBuddy\/([^\s]+)/)?.[1] ?? 'unknown'
iconSources.push(functionSource(spec.name, recovery, value.evidence_id, version, spec))
iconRecords.push({
name: spec.name,
evidence_id: value.evidence_id,
evidence_file: path.relative(repoRoot, evidence[spec.evidence].path).replaceAll('\\', '/'),
root_node_index: spec.rootIndex,
parent_class: recovery.parent.attributes.class,
source_sha256: recovery.hash,
svg_attributes: recovery.root.attributes
})
}
const iconOutput = `// Generated by scripts/rea/recover-library-icons.cjs. Do not hand edit.\nimport type { SVGProps } from 'react'\n\ntype IconProps = SVGProps<SVGSVGElement>\ntype SortIconProps = IconProps & { direction?: 'asc' | 'desc' }\n\nfunction mergeClassNames(...values: Array<string | undefined>): string | undefined {\n return values.filter(Boolean).join(' ') || undefined\n}\n\n${iconSources.join('\n\n')}\n`
const iconDestination = path.join(repoRoot, 'src', 'renderer', 'src', 'library', 'SourceLibraryIcons.tsx')
const mediaItems = readMediaEvidence(mediaEvidence)
const illustrationRecoveries = illustrations.map((spec) => {
const item = mediaItems.find((candidate) => candidate.selector === spec.selector)
assert.ok(item, `missing media item ${spec.selector}`)
return { spec, ...recoverIllustration(item, spec) }
})
const illustrationOutput = illustrationSource(illustrationRecoveries)
const illustrationDestination = path.join(
repoRoot,
'src',
'renderer',
'src',
'library',
'SourceLibraryIllustrations.tsx'
)
const assetDirectory = path.join(repoRoot, 'src', 'renderer', 'src', 'library', 'assets')
const manifestPath = path.join(tmpDir, 'library-icon-recovery-manifest.json')
const manifest = {
icon_source_file: path.relative(repoRoot, iconDestination).replaceAll('\\', '/'),
icon_file_sha256: sha256(iconOutput),
illustration_source_file: path.relative(repoRoot, illustrationDestination).replaceAll('\\', '/'),
illustration_file_sha256: sha256(illustrationOutput),
icons: iconRecords,
illustrations: illustrationRecoveries.map(({ record }) => record),
notes
}
if (process.argv.includes('--check')) {
assert.equal(fs.readFileSync(iconDestination, 'utf8'), iconOutput)
assert.equal(fs.readFileSync(illustrationDestination, 'utf8'), illustrationOutput)
for (const { bytes, record } of illustrationRecoveries) {
assert.deepEqual(fs.readFileSync(path.join(repoRoot, record.asset_file)), bytes)
}
assert.deepEqual(JSON.parse(fs.readFileSync(manifestPath, 'utf8')), manifest)
} else {
fs.mkdirSync(assetDirectory, { recursive: true })
fs.writeFileSync(iconDestination, iconOutput)
fs.writeFileSync(illustrationDestination, illustrationOutput)
for (const { bytes, spec } of illustrationRecoveries) {
fs.writeFileSync(path.join(assetDirectory, spec.assetFile), bytes)
}
fs.writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`)
}
process.stdout.write(`${JSON.stringify({
status: 'PASS',
recovered_icons: iconRecords.length,
recovered_illustrations: illustrationRecoveries.length,
unresolved: Object.entries(notes)
.filter(([, note]) => note.status === 'unresolved')
.map(([name]) => name),
icon_file: iconDestination,
icon_file_sha256: manifest.icon_file_sha256,
illustration_file: illustrationDestination,
illustration_file_sha256: manifest.illustration_file_sha256,
manifest: manifestPath
}, null, 2)}\n`)
}
main()