Skip to content

Commit f312740

Browse files
committed
test: cover uni app hot update regressions
1 parent 6e5524d commit f312740

8 files changed

Lines changed: 414 additions & 0 deletions

File tree

e2e/watch/hot-update/shared.ts

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -165,6 +165,21 @@ interface StyleMutationMetric {
165165
rollbackNeedleCleared?: boolean
166166
}
167167

168+
interface UserReportedHotUpdateMetric {
169+
label: string
170+
sourceFile: string
171+
from: string
172+
to: string
173+
classTokens: string[]
174+
escapedClasses: string[]
175+
verifiedGlobalStyleEscapedClasses: string[]
176+
minRequiredGlobalStyleEscapedClasses: number
177+
hotUpdateOutputMs: number
178+
hotUpdateEffectiveMs: number
179+
rollbackOutputMs: number
180+
rollbackEffectiveMs: number
181+
}
182+
168183
type HotUpdateMutationMetric = TemplateOrScriptMutationMetric | StyleMutationMetric
169184

170185
interface SubPackageMutationMetric {
@@ -193,6 +208,7 @@ interface HotUpdateCaseReport {
193208
globalStyleOutput?: string
194209
globalStyleOutputs?: string[]
195210
mutationMetrics: HotUpdateMutationMetric[]
211+
userReportedHotUpdate?: UserReportedHotUpdateMetric
196212
subPackageMutationMetrics?: SubPackageMutationMetric[]
197213
summaryByMutationKind: Partial<Record<MutationKind, HotUpdateSummary>>
198214
initialReadyMs: number
@@ -514,6 +530,13 @@ function collectReportBudgetSamples(report: HotUpdateReport) {
514530
}
515531
}
516532

533+
if (oneCase.userReportedHotUpdate) {
534+
samples.push({
535+
label: `${oneCase.project}:user-reported:${oneCase.userReportedHotUpdate.label}`,
536+
hotUpdateEffectiveMs: oneCase.userReportedHotUpdate.hotUpdateEffectiveMs,
537+
})
538+
}
539+
517540
for (const subPackage of oneCase.subPackageMutationMetrics ?? []) {
518541
samples.push({
519542
label: `${oneCase.project}:subpackage:${subPackage.root}:template`,
@@ -664,6 +687,40 @@ export function assertHotUpdateReport(report: HotUpdateReport, target: WatchCase
664687
expect(scriptMetric).toBeDefined()
665688
expect(styleMetric).toBeDefined()
666689

690+
if (item.name === 'uni-app-vite-tailwindcss-v3' || item.name === 'uni-app-vite-tailwindcss-v4') {
691+
const userReportedHotUpdate = item.userReportedHotUpdate
692+
expect(userReportedHotUpdate, `[${item.project}] should include the user reported hot-update scenario`).toBeDefined()
693+
if (!userReportedHotUpdate) {
694+
throw new Error(`[${item.project}] missing user reported hot-update metric`)
695+
}
696+
expect(userReportedHotUpdate.sourceFile).toContain('src/pages/index/index.vue')
697+
expect(userReportedHotUpdate.classTokens.length).toBeGreaterThan(0)
698+
expect(userReportedHotUpdate.escapedClasses.length).toBe(userReportedHotUpdate.classTokens.length)
699+
expect(userReportedHotUpdate.verifiedGlobalStyleEscapedClasses.length).toBeGreaterThanOrEqual(
700+
userReportedHotUpdate.minRequiredGlobalStyleEscapedClasses,
701+
)
702+
expect(userReportedHotUpdate.hotUpdateEffectiveMs).toBeGreaterThan(0)
703+
expect(userReportedHotUpdate.hotUpdateEffectiveMs).toBeLessThanOrEqual(maxHotUpdateMs)
704+
expect(userReportedHotUpdate.rollbackEffectiveMs).toBeGreaterThan(0)
705+
if (item.name === 'uni-app-vite-tailwindcss-v3') {
706+
expect(userReportedHotUpdate.label).toBe('cardsColor bg-[#4268EA] to bg-[red]')
707+
expect([userReportedHotUpdate.from, userReportedHotUpdate.to]).toEqual(
708+
expect.arrayContaining(['bg-[#4268EA] shadow-indigo-100', 'bg-[red] shadow-indigo-100']),
709+
)
710+
expect(userReportedHotUpdate.classTokens.some(token => token === 'bg-[red]' || token === 'bg-[#4268EA]')).toBe(true)
711+
}
712+
if (item.name === 'uni-app-vite-tailwindcss-v4') {
713+
expect(userReportedHotUpdate.label).toBe('index text-[88rpx] to text-[188rpx]')
714+
expect([userReportedHotUpdate.from, userReportedHotUpdate.to]).toEqual(
715+
expect.arrayContaining([
716+
'text-[#00f285] text-[88rpx] font-bold underline',
717+
'text-[#00f285] text-[188rpx] font-bold underline',
718+
]),
719+
)
720+
expect(userReportedHotUpdate.classTokens.some(token => token === 'text-[88rpx]' || token === 'text-[188rpx]')).toBe(true)
721+
}
722+
}
723+
667724
expect(templateMetric?.hotUpdateEffectiveMs).toBeGreaterThan(0)
668725
expect(scriptMetric?.hotUpdateEffectiveMs).toBeGreaterThan(0)
669726
expect(templateMetric?.hotUpdateEffectiveMs).toBeLessThanOrEqual(maxHotUpdateMs)

scripts/uni-e2e-watch.mjs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,8 @@ async function runBuild() {
5454
const build = spawnPnpm(['run', 'build:mp-weixin'], {
5555
env: {
5656
UNI_BUILD_STRICT: '1',
57+
WEAPP_TW_HMR_TIMING: '0',
58+
WEAPP_TW_WATCH_REGRESSION: '0',
5759
},
5860
stdio: 'inherit',
5961
})

tools/weapp-tailwindcss-scripts/src/watch-hmr-regression/cases/demo/extended.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -193,6 +193,16 @@ export function buildDemoExtendedCases(baseCwd: string): WatchCase[] {
193193
return mutateUniAppViteV3BgObjKey(source, payload)
194194
},
195195
},
196+
userReportedHotUpdate: {
197+
label: 'cardsColor bg-[#4268EA] to bg-[red]',
198+
sourceFile: path.resolve(baseCwd, 'demo/uni-app-vite-tailwindcss-v3/src/pages/index/index.vue'),
199+
before: 'bg-[#4268EA] shadow-indigo-100',
200+
after: 'bg-[red] shadow-indigo-100',
201+
beforeClassTokens: ['bg-[#4268EA]'],
202+
afterClassTokens: ['bg-[red]'],
203+
verifyEscapedIn: ['js'],
204+
verifyClassLiteralIn: ['js'],
205+
},
196206
styleMutation: {
197207
sourceFile: path.resolve(baseCwd, 'demo/uni-app-vite-tailwindcss-v3/src/pages/index/index.vue'),
198208
mutate(source, payload) {
@@ -223,6 +233,15 @@ export function buildDemoExtendedCases(baseCwd: string): WatchCase[] {
223233
globalStyleCandidates: [
224234
path.resolve(baseCwd, 'demo/uni-app-vite-tailwindcss-v4/dist/build/mp-weixin/app.wxss'),
225235
],
236+
userReportedHotUpdate: {
237+
label: 'index text-[88rpx] to text-[188rpx]',
238+
sourceFile: path.resolve(baseCwd, 'demo/uni-app-vite-tailwindcss-v4/src/pages/index/index.vue'),
239+
before: 'text-[#00f285] text-[88rpx] font-bold underline',
240+
after: 'text-[#00f285] text-[188rpx] font-bold underline',
241+
beforeClassTokens: ['text-[88rpx]'],
242+
afterClassTokens: ['text-[188rpx]'],
243+
verifyEscapedIn: ['wxml'],
244+
},
226245
contentMutation: {
227246
sourceFile: path.resolve(baseCwd, 'demo/uni-app-vite-tailwindcss-v4/src/pages/index/index.vue'),
228247
verifyEscapedIn: ['js'],

tools/weapp-tailwindcss-scripts/src/watch-hmr-regression/mutations/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,3 +3,4 @@ export * from './shared'
33
export * from './style'
44
export * from './subpackage'
55
export * from './tokens'
6+
export * from './user-reported'
Lines changed: 248 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,248 @@
1+
import type {
2+
CliOptions,
3+
PluginProcessSample,
4+
UserReportedHotUpdateConfig,
5+
UserReportedHotUpdateMetrics,
6+
WatchCase,
7+
WatchSession,
8+
} from '../types'
9+
import process from 'node:process'
10+
import { replaceWxml } from '../../core/replace-wxml'
11+
import { formatPath } from '../cli'
12+
import {
13+
assertContainsOneOf,
14+
assertNotContains,
15+
getMtime,
16+
readFileIfExists,
17+
writeFilePreserveEol,
18+
} from '../text'
19+
import {
20+
collectPluginProcessMetrics,
21+
expandOutputFileEntries,
22+
readJoinedOutputFiles,
23+
waitForClassOutputBaseline,
24+
waitForCompileSettled,
25+
waitForOutputFilesUpdated,
26+
} from './shared'
27+
28+
interface UserReportedOutputs {
29+
wxml: string
30+
js: string
31+
globalStyle: string
32+
}
33+
34+
function htmlEscapeClassToken(value: string) {
35+
return value
36+
.replaceAll('&', '&amp;')
37+
.replaceAll('<', '&lt;')
38+
.replaceAll('>', '&gt;')
39+
.replaceAll('"', '&quot;')
40+
}
41+
42+
function createClassTokenExpectedValues(classToken: string, escaped: string) {
43+
return [
44+
escaped,
45+
classToken,
46+
htmlEscapeClassToken(classToken),
47+
]
48+
}
49+
50+
async function collectOutputMtimes(files: string[]) {
51+
const resolvedFiles = await expandOutputFileEntries(files)
52+
const entries = await Promise.all(
53+
resolvedFiles.map(async file => [file, await getMtime(file)] as const),
54+
)
55+
return new Map(entries)
56+
}
57+
58+
async function loadOutputs(watchCase: WatchCase, globalStyleOutputs: string[]): Promise<UserReportedOutputs> {
59+
const [wxml, js, globalStyle] = await Promise.all([
60+
readFileIfExists(watchCase.outputWxml),
61+
readFileIfExists(watchCase.outputJs),
62+
readJoinedOutputFiles(globalStyleOutputs),
63+
])
64+
return {
65+
wxml: wxml ?? '',
66+
js: js ?? '',
67+
globalStyle,
68+
}
69+
}
70+
71+
function assertUserReportedOutputs(
72+
watchCase: WatchCase,
73+
config: UserReportedHotUpdateConfig,
74+
phase: 'hot-update' | 'rollback',
75+
classTokens: string[],
76+
escapedClasses: string[],
77+
outputs: UserReportedOutputs,
78+
) {
79+
const verifyClassLiteralIn = config.verifyClassLiteralIn ?? []
80+
81+
for (const [index, classToken] of classTokens.entries()) {
82+
const escaped = escapedClasses[index]
83+
if (!escaped) {
84+
continue
85+
}
86+
const expectedValues = createClassTokenExpectedValues(classToken, escaped)
87+
if (config.verifyEscapedIn.includes('wxml')) {
88+
assertContainsOneOf(outputs.wxml, expectedValues, `[${watchCase.label}] user reported ${config.label} ${phase} wxml`)
89+
}
90+
if (config.verifyEscapedIn.includes('js')) {
91+
assertContainsOneOf(outputs.js, expectedValues, `[${watchCase.label}] user reported ${config.label} ${phase} js`)
92+
}
93+
if (verifyClassLiteralIn.includes('wxml')) {
94+
assertContainsOneOf(outputs.wxml, expectedValues, `[${watchCase.label}] user reported ${config.label} ${phase} wxml literal`)
95+
}
96+
if (verifyClassLiteralIn.includes('js')) {
97+
assertContainsOneOf(outputs.js, expectedValues, `[${watchCase.label}] user reported ${config.label} ${phase} js literal`)
98+
}
99+
}
100+
101+
const matchedGlobalEscapedClasses = escapedClasses.filter(escaped => outputs.globalStyle.includes(escaped))
102+
const minRequiredGlobalStyleEscapedClasses = config.minRequiredGlobalStyleEscapedClasses ?? 1
103+
if (matchedGlobalEscapedClasses.length < minRequiredGlobalStyleEscapedClasses) {
104+
throw new Error(
105+
`[${watchCase.label}] user reported ${config.label} ${phase} global style output has insufficient transformed classes: required=${minRequiredGlobalStyleEscapedClasses}, actual=${matchedGlobalEscapedClasses.length}, source=${formatPath(config.sourceFile)}`,
106+
)
107+
}
108+
109+
return matchedGlobalEscapedClasses
110+
}
111+
112+
function resolveReplacementDirection(sourceOriginal: string, config: UserReportedHotUpdateConfig) {
113+
if (sourceOriginal.includes(config.before)) {
114+
return {
115+
from: config.before,
116+
to: config.after,
117+
classTokens: config.afterClassTokens,
118+
rollbackClassTokens: config.beforeClassTokens,
119+
}
120+
}
121+
if (sourceOriginal.includes(config.after)) {
122+
return {
123+
from: config.after,
124+
to: config.before,
125+
classTokens: config.beforeClassTokens,
126+
rollbackClassTokens: config.afterClassTokens,
127+
}
128+
}
129+
throw new Error(
130+
`user reported hot-update anchor not found: ${config.label}, source=${formatPath(config.sourceFile)}`,
131+
)
132+
}
133+
134+
export async function runUserReportedHotUpdate(
135+
watchCase: WatchCase,
136+
options: CliOptions,
137+
session: WatchSession,
138+
config: UserReportedHotUpdateConfig,
139+
sourceOriginal: string,
140+
globalStyleOutputs: string[],
141+
): Promise<UserReportedHotUpdateMetrics> {
142+
await waitForClassOutputBaseline(watchCase, options, session, 'content', globalStyleOutputs)
143+
144+
const {
145+
from,
146+
to,
147+
classTokens,
148+
rollbackClassTokens,
149+
} = resolveReplacementDirection(sourceOriginal, config)
150+
const escapedClasses = classTokens.map(token => replaceWxml(token))
151+
const rollbackEscapedClasses = rollbackClassTokens.map(token => replaceWxml(token))
152+
const sourcePath = config.sourceFile
153+
const outputFiles = [watchCase.outputWxml, watchCase.outputJs, ...globalStyleOutputs]
154+
let verifiedGlobalStyleEscapedClasses: string[] = []
155+
156+
const sourceForHotUpdate = sourceOriginal.replace(from, to)
157+
if (sourceForHotUpdate === sourceOriginal) {
158+
throw new Error(`[${watchCase.label}] user reported ${config.label} produced no source change`)
159+
}
160+
161+
const baselineOutputMtimes = await collectOutputMtimes(outputFiles)
162+
const hotUpdateStartedAt = Date.now()
163+
process.stdout.write(
164+
`[watch-hmr] ${watchCase.label} user-reported=${config.label} phase=hot-update dirty=${formatPath(sourcePath)} tokens=${classTokens.join(' | ')}\n`,
165+
)
166+
await writeFilePreserveEol(sourcePath, sourceForHotUpdate, sourceOriginal)
167+
const hotUpdateOutputMs = await waitForOutputFilesUpdated(
168+
watchCase,
169+
outputFiles,
170+
baselineOutputMtimes,
171+
options,
172+
session,
173+
hotUpdateStartedAt,
174+
async () => {
175+
const outputs = await loadOutputs(watchCase, globalStyleOutputs)
176+
verifiedGlobalStyleEscapedClasses = assertUserReportedOutputs(
177+
watchCase,
178+
config,
179+
'hot-update',
180+
classTokens,
181+
escapedClasses,
182+
outputs,
183+
)
184+
return true
185+
},
186+
)
187+
const hotUpdateEffectiveMs = hotUpdateOutputMs
188+
await waitForCompileSettled(watchCase, options, session, hotUpdateStartedAt)
189+
const hotUpdatePluginMetrics = collectPluginProcessMetrics(session, hotUpdateStartedAt)
190+
191+
const updatedOutputMtimes = await collectOutputMtimes(outputFiles)
192+
const rollbackStartedAt = Date.now()
193+
process.stdout.write(
194+
`[watch-hmr] ${watchCase.label} user-reported=${config.label} phase=rollback dirty=${formatPath(sourcePath)} tokens=${rollbackClassTokens.join(' | ')}\n`,
195+
)
196+
await writeFilePreserveEol(sourcePath, sourceOriginal, sourceOriginal)
197+
const rollbackOutputMs = await waitForOutputFilesUpdated(
198+
watchCase,
199+
outputFiles,
200+
updatedOutputMtimes,
201+
options,
202+
session,
203+
rollbackStartedAt,
204+
async () => {
205+
const outputs = await loadOutputs(watchCase, globalStyleOutputs)
206+
assertUserReportedOutputs(
207+
watchCase,
208+
config,
209+
'rollback',
210+
rollbackClassTokens,
211+
rollbackEscapedClasses,
212+
outputs,
213+
)
214+
for (const escaped of escapedClasses) {
215+
if (config.verifyEscapedIn.includes('wxml')) {
216+
assertNotContains(outputs.wxml, escaped, `[${watchCase.label}] user reported ${config.label} rollback wxml`)
217+
}
218+
if (config.verifyEscapedIn.includes('js')) {
219+
assertNotContains(outputs.js, escaped, `[${watchCase.label}] user reported ${config.label} rollback js`)
220+
}
221+
}
222+
return true
223+
},
224+
)
225+
const rollbackEffectiveMs = rollbackOutputMs
226+
await waitForCompileSettled(watchCase, options, session, rollbackStartedAt)
227+
const rollbackPluginMetrics = collectPluginProcessMetrics(session, rollbackStartedAt)
228+
229+
const minRequiredGlobalStyleEscapedClasses = config.minRequiredGlobalStyleEscapedClasses ?? 1
230+
return {
231+
label: config.label,
232+
sourceFile: sourcePath,
233+
from,
234+
to,
235+
classTokens,
236+
escapedClasses,
237+
verifiedGlobalStyleEscapedClasses,
238+
minRequiredGlobalStyleEscapedClasses,
239+
hotUpdateOutputMs,
240+
hotUpdateEffectiveMs,
241+
hotUpdatePluginProcessMs: hotUpdatePluginMetrics.totalMs,
242+
hotUpdatePluginProcessSamples: hotUpdatePluginMetrics.samples as PluginProcessSample[],
243+
rollbackOutputMs,
244+
rollbackEffectiveMs,
245+
rollbackPluginProcessMs: rollbackPluginMetrics.totalMs,
246+
rollbackPluginProcessSamples: rollbackPluginMetrics.samples as PluginProcessSample[],
247+
}
248+
}

0 commit comments

Comments
 (0)