feat: complete WorkBuddy parity and Windows release workflow
This commit is contained in:
1 parent
143516b3a8
commit
d5512df549
434 files changed
+54977
-1854
No files matched your search
@@ -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()
|
||||
Reference in new issue
Block a user