slidev-theme-practicum 0.1.0 → 0.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +72 -10
- package/components/Slide.vue +81 -26
- package/components/Slot.vue +33 -48
- package/components/Text.vue +68 -2
- package/components/Timeline.vue +2 -23
- package/composables/deck-slot-markup.cjs +249 -0
- package/composables/decor-catalog.mjs +169 -47
- package/composables/layout-authoring.ts +6 -2
- package/composables/layout-markdown.mjs +33 -0
- package/composables/layout-recipes.ts +51 -5
- package/composables/layout-shorthands.ts +251 -81
- package/composables/markdown-to-vnodes.cjs +1 -0
- package/composables/markdown-to-vnodes.mjs +237 -0
- package/composables/slot-placement.ts +1 -1
- package/composables/slot-spacing.ts +66 -0
- package/composables/text-fit-runtime.ts +458 -76
- package/composables/text-fit-scope.ts +13 -0
- package/composables/text-slot-markdown.mjs +193 -0
- package/composables/text-typography.mjs +2 -0
- package/composables/theme-assets.mjs +60 -0
- package/composables/timeline-grid.ts +27 -0
- package/composables/typescript-require.cjs +29 -0
- package/composables/validate-deck-layouts.cjs +277 -0
- package/decor-library.md +2 -0
- package/example.md +132 -10
- package/package.json +25 -11
- package/public/photos/photo-1.webp +0 -0
- package/public/photos/photo-10.webp +0 -0
- package/public/photos/photo-11.webp +0 -0
- package/public/photos/photo-12.webp +0 -0
- package/public/photos/photo-13.webp +0 -0
- package/public/photos/photo-14.webp +0 -0
- package/public/photos/photo-15.webp +0 -0
- package/public/photos/photo-16.webp +0 -0
- package/public/photos/photo-17.webp +0 -0
- package/public/photos/photo-18.webp +0 -0
- package/public/photos/photo-19.webp +0 -0
- package/public/photos/photo-2.webp +0 -0
- package/public/photos/photo-20.webp +0 -0
- package/public/photos/photo-21.webp +0 -0
- package/public/photos/photo-22.webp +0 -0
- package/public/photos/photo-23.webp +0 -0
- package/public/photos/photo-24.webp +0 -0
- package/public/photos/photo-25.webp +0 -0
- package/public/photos/photo-26.webp +0 -0
- package/public/photos/photo-27.webp +0 -0
- package/public/photos/photo-28.webp +0 -0
- package/public/photos/photo-29.webp +0 -0
- package/public/photos/photo-3.webp +0 -0
- package/public/photos/photo-30.webp +0 -0
- package/public/photos/photo-4.webp +0 -0
- package/public/photos/photo-5.webp +0 -0
- package/public/photos/photo-6.webp +0 -0
- package/public/photos/photo-7.webp +0 -0
- package/public/photos/photo-8.webp +0 -0
- package/public/photos/photo-9.webp +0 -0
- package/scripts/browser-smoke-runtime.mjs +428 -0
- package/scripts/browser-smoke.mjs +424 -0
- package/scripts/build-artifact-assets.mjs +55 -0
- package/scripts/check-build-artifact.mjs +243 -0
- package/scripts/check-package.mjs +132 -0
- package/scripts/check-test-architecture.mjs +176 -0
- package/scripts/npm-spawn.mjs +31 -0
- package/scripts/test-architecture-policy.mjs +245 -0
- package/scripts/theme-asset-inventory.mjs +83 -0
- package/scripts/validate-deck.cjs +18 -0
- package/setup/decor-save-middleware.ts +244 -0
- package/setup/vite-plugins.ts +14 -84
- package/skills/slidev-practicum/SKILL.md +25 -0
- package/skills/slidev-practicum/agents/openai.yaml +4 -0
- package/styles/index.css +10 -1
- package/composables/layout-registry.ts +0 -47
- package/env.d.ts +0 -6
- package/public/photos/photo-1.png +0 -0
- package/public/photos/photo-10.png +0 -0
- package/public/photos/photo-2.png +0 -0
- package/public/photos/photo-3.png +0 -0
- package/public/photos/photo-4.png +0 -0
- package/public/photos/photo-5.png +0 -0
- package/public/photos/photo-6.png +0 -0
- package/public/photos/photo-7.png +0 -0
- package/public/photos/photo-8.png +0 -0
- package/public/photos/photo-9.png +0 -0
- package/slidev-theme-practicum.png +0 -0
- package/slidev-theme-practicum.svg +0 -102
|
@@ -0,0 +1,424 @@
|
|
|
1
|
+
import assert from 'node:assert/strict'
|
|
2
|
+
import { resolve } from 'node:path'
|
|
3
|
+
import { chromium } from 'playwright-chromium'
|
|
4
|
+
import {
|
|
5
|
+
classifyBrowserConsoleMessage,
|
|
6
|
+
createInFlightRequestTracker,
|
|
7
|
+
createStaticDistServer,
|
|
8
|
+
isSameOriginUrl,
|
|
9
|
+
} from './browser-smoke-runtime.mjs'
|
|
10
|
+
|
|
11
|
+
const PROJECT_ROOT = resolve(import.meta.dirname, '..')
|
|
12
|
+
const DIST_DIR = resolve(PROJECT_ROOT, 'dist')
|
|
13
|
+
const REPRESENTATIVE_SLIDES = [1, 9, 15, 20, 32, 40, 41]
|
|
14
|
+
const LIFECYCLE_EVENT = 'practicum:slide-lifecycle'
|
|
15
|
+
const LIFECYCLE_STATE_KEY = '__practicumBrowserLifecycle'
|
|
16
|
+
const THEME_MEDIA = [
|
|
17
|
+
{
|
|
18
|
+
label: 'декор',
|
|
19
|
+
path: '/theme/decor/decor-1.svg',
|
|
20
|
+
},
|
|
21
|
+
{
|
|
22
|
+
label: 'фотография',
|
|
23
|
+
path: '/theme/photos/photo-1.webp',
|
|
24
|
+
},
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
function formatDiagnostic(diagnostic) {
|
|
28
|
+
return typeof diagnostic === 'string'
|
|
29
|
+
? diagnostic
|
|
30
|
+
: JSON.stringify(diagnostic)
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function assertNoDiagnostics(label, diagnostics) {
|
|
34
|
+
assert.equal(
|
|
35
|
+
diagnostics.length,
|
|
36
|
+
0,
|
|
37
|
+
`${label}:\n${diagnostics.map(formatDiagnostic).join('\n')}`,
|
|
38
|
+
)
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async function settleSlide(page) {
|
|
42
|
+
await page.evaluate(async () => {
|
|
43
|
+
await Promise.race([
|
|
44
|
+
document.fonts?.ready ?? Promise.resolve(),
|
|
45
|
+
new Promise(resolveTimeout => setTimeout(resolveTimeout, 3_000)),
|
|
46
|
+
])
|
|
47
|
+
await new Promise((resolveFrame) => {
|
|
48
|
+
requestAnimationFrame(() => requestAnimationFrame(resolveFrame))
|
|
49
|
+
})
|
|
50
|
+
})
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
async function readLifecycleSnapshot(page) {
|
|
54
|
+
return page.evaluate((stateKey) => {
|
|
55
|
+
const state = window[stateKey]
|
|
56
|
+
|
|
57
|
+
return {
|
|
58
|
+
active: structuredClone(state?.active ?? {}),
|
|
59
|
+
cleanedTokens: structuredClone(state?.cleanedTokens ?? {}),
|
|
60
|
+
mounts: structuredClone(state?.mounts ?? {}),
|
|
61
|
+
unmounts: structuredClone(state?.unmounts ?? {}),
|
|
62
|
+
}
|
|
63
|
+
}, LIFECYCLE_STATE_KEY)
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function countFor(snapshot, collection, slideNumber) {
|
|
67
|
+
return snapshot[collection][String(slideNumber)] ?? 0
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function assertLifecycleTransition(before, after, slideNumber, previousSlideNumber) {
|
|
71
|
+
const slideKey = String(slideNumber)
|
|
72
|
+
assert.ok(
|
|
73
|
+
countFor(after, 'mounts', slideNumber) > countFor(before, 'mounts', slideNumber),
|
|
74
|
+
`слайд ${slideNumber}: компонент не смонтировался при переходе; `
|
|
75
|
+
+ `состояние=${JSON.stringify(after)}`,
|
|
76
|
+
)
|
|
77
|
+
assert.ok(
|
|
78
|
+
(after.active[slideKey]?.length ?? 0) > 0,
|
|
79
|
+
`слайд ${slideNumber}: после перехода нет активного маркера жизненного цикла`,
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
if (previousSlideNumber === undefined)
|
|
83
|
+
return
|
|
84
|
+
|
|
85
|
+
const previousKey = String(previousSlideNumber)
|
|
86
|
+
const previousTokens = before.active[previousKey] ?? []
|
|
87
|
+
assert.ok(
|
|
88
|
+
previousTokens.length > 0,
|
|
89
|
+
`слайд ${previousSlideNumber}: до перехода нет активного маркера жизненного цикла`,
|
|
90
|
+
)
|
|
91
|
+
assert.ok(
|
|
92
|
+
countFor(after, 'unmounts', previousSlideNumber)
|
|
93
|
+
> countFor(before, 'unmounts', previousSlideNumber),
|
|
94
|
+
`слайд ${previousSlideNumber}: компонент не демонтировался при переходе; `
|
|
95
|
+
+ `до=${JSON.stringify(before)}, после=${JSON.stringify(after)}`,
|
|
96
|
+
)
|
|
97
|
+
assert.equal(
|
|
98
|
+
after.active[previousKey]?.length ?? 0,
|
|
99
|
+
0,
|
|
100
|
+
`слайд ${previousSlideNumber}: маркер жизненного цикла остался активным после перехода`,
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
for (const token of previousTokens) {
|
|
104
|
+
assert.equal(
|
|
105
|
+
after.cleanedTokens[token],
|
|
106
|
+
true,
|
|
107
|
+
`слайд ${previousSlideNumber}: маркер очистки ${token} не установлен`,
|
|
108
|
+
)
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
async function assertStrongEmphasis(slide, slideNumber) {
|
|
113
|
+
const strong = slide.locator('strong')
|
|
114
|
+
const strongCount = await strong.count()
|
|
115
|
+
|
|
116
|
+
assert.equal(
|
|
117
|
+
strongCount,
|
|
118
|
+
1,
|
|
119
|
+
`слайд ${slideNumber}: ожидается один семантический strong, найдено ${strongCount}`,
|
|
120
|
+
)
|
|
121
|
+
assert.equal(
|
|
122
|
+
await strong.evaluate(element => getComputedStyle(element).fontWeight),
|
|
123
|
+
'600',
|
|
124
|
+
`слайд ${slideNumber}: strong должен использовать YS Text Bold с весом 600`,
|
|
125
|
+
)
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
async function assertClosingLayout(slide, slideNumber, expectedTextCount) {
|
|
129
|
+
const grid = slide.locator('.Slide-Grid')
|
|
130
|
+
const primary = grid.locator(':scope > .Slot').first()
|
|
131
|
+
const [gridBox, primaryBox, placement, textCount] = await Promise.all([
|
|
132
|
+
grid.boundingBox(),
|
|
133
|
+
primary.boundingBox(),
|
|
134
|
+
primary.evaluate((element) => {
|
|
135
|
+
const style = getComputedStyle(element)
|
|
136
|
+
|
|
137
|
+
return {
|
|
138
|
+
columnEnd: style.gridColumnEnd,
|
|
139
|
+
columnStart: style.gridColumnStart,
|
|
140
|
+
rowEnd: style.gridRowEnd,
|
|
141
|
+
rowStart: style.gridRowStart,
|
|
142
|
+
}
|
|
143
|
+
}),
|
|
144
|
+
primary.locator(':scope > .Slot-Content > .Text').count(),
|
|
145
|
+
])
|
|
146
|
+
|
|
147
|
+
assert.deepEqual(placement, {
|
|
148
|
+
columnEnd: '12',
|
|
149
|
+
columnStart: '2',
|
|
150
|
+
rowEnd: '13',
|
|
151
|
+
rowStart: '1',
|
|
152
|
+
}, `слайд ${slideNumber}: primary использует неверную область сетки`)
|
|
153
|
+
assert.ok(gridBox, `слайд ${slideNumber}: не найдена геометрия Slide-Grid`)
|
|
154
|
+
assert.ok(primaryBox, `слайд ${slideNumber}: не найдена геометрия primary`)
|
|
155
|
+
assert.ok(
|
|
156
|
+
Math.abs(primaryBox.y - gridBox.y) <= 1,
|
|
157
|
+
`слайд ${slideNumber}: primary не начинается с первой строки`,
|
|
158
|
+
)
|
|
159
|
+
assert.ok(
|
|
160
|
+
Math.abs(primaryBox.y + primaryBox.height - gridBox.y - gridBox.height) <= 1,
|
|
161
|
+
`слайд ${slideNumber}: primary не заканчивается на последней строке`,
|
|
162
|
+
)
|
|
163
|
+
assert.equal(
|
|
164
|
+
textCount,
|
|
165
|
+
expectedTextCount,
|
|
166
|
+
`слайд ${slideNumber}: неожиданное количество текстовых элементов`,
|
|
167
|
+
)
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
async function inspectSlide(
|
|
171
|
+
page,
|
|
172
|
+
origin,
|
|
173
|
+
slideNumber,
|
|
174
|
+
previousSlideNumber,
|
|
175
|
+
waitForNetworkIdle,
|
|
176
|
+
) {
|
|
177
|
+
const hashTarget = `${origin}/#/${slideNumber}`
|
|
178
|
+
const beforeLifecycle = previousSlideNumber === undefined
|
|
179
|
+
? { active: {}, cleanedTokens: {}, mounts: {}, unmounts: {} }
|
|
180
|
+
: await readLifecycleSnapshot(page)
|
|
181
|
+
|
|
182
|
+
if (previousSlideNumber === undefined) {
|
|
183
|
+
await page.goto(hashTarget, { waitUntil: 'domcontentloaded' })
|
|
184
|
+
}
|
|
185
|
+
else {
|
|
186
|
+
await page.evaluate((hash) => {
|
|
187
|
+
window.location.hash = hash
|
|
188
|
+
}, `#/${slideNumber}`)
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
await page.waitForFunction(
|
|
192
|
+
expectedHash => window.location.hash === expectedHash,
|
|
193
|
+
`#/${slideNumber}`,
|
|
194
|
+
)
|
|
195
|
+
|
|
196
|
+
const slide = page.locator(`.slidev-page[data-slidev-no="${slideNumber}"]`)
|
|
197
|
+
await slide.waitFor({ state: 'visible' })
|
|
198
|
+
if (previousSlideNumber !== undefined) {
|
|
199
|
+
await page
|
|
200
|
+
.locator(`.slidev-page[data-slidev-no="${previousSlideNumber}"]`)
|
|
201
|
+
.waitFor({ state: 'hidden' })
|
|
202
|
+
}
|
|
203
|
+
await settleSlide(page)
|
|
204
|
+
await waitForNetworkIdle()
|
|
205
|
+
|
|
206
|
+
const [result, afterLifecycle] = await Promise.all([
|
|
207
|
+
slide.evaluate(element => ({
|
|
208
|
+
heightOverflow: element.scrollHeight - element.clientHeight,
|
|
209
|
+
text: element.textContent?.trim() ?? '',
|
|
210
|
+
widthOverflow: element.scrollWidth - element.clientWidth,
|
|
211
|
+
})),
|
|
212
|
+
readLifecycleSnapshot(page),
|
|
213
|
+
])
|
|
214
|
+
|
|
215
|
+
assert.ok(result.text.length > 0, `слайд ${slideNumber}: отсутствует текст`)
|
|
216
|
+
assert.ok(
|
|
217
|
+
result.widthOverflow <= 1,
|
|
218
|
+
`слайд ${slideNumber}: горизонтальное переполнение ${result.widthOverflow}px`,
|
|
219
|
+
)
|
|
220
|
+
assert.ok(
|
|
221
|
+
result.heightOverflow <= 1,
|
|
222
|
+
`слайд ${slideNumber}: вертикальное переполнение ${result.heightOverflow}px`,
|
|
223
|
+
)
|
|
224
|
+
if (slideNumber === 32)
|
|
225
|
+
await assertStrongEmphasis(slide, slideNumber)
|
|
226
|
+
if (slideNumber === 40)
|
|
227
|
+
await assertClosingLayout(slide, slideNumber, 1)
|
|
228
|
+
if (slideNumber === 41)
|
|
229
|
+
await assertClosingLayout(slide, slideNumber, 2)
|
|
230
|
+
assertLifecycleTransition(
|
|
231
|
+
beforeLifecycle,
|
|
232
|
+
afterLifecycle,
|
|
233
|
+
slideNumber,
|
|
234
|
+
previousSlideNumber,
|
|
235
|
+
)
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
async function loadThemeImage(page, origin, asset) {
|
|
239
|
+
const url = `${origin}${asset.path}?browser-smoke=${encodeURIComponent(asset.label)}`
|
|
240
|
+
const responsePromise = page.waitForResponse(response => response.url() === url)
|
|
241
|
+
const imagePromise = page.evaluate(async (imageUrl) => {
|
|
242
|
+
const image = new Image()
|
|
243
|
+
image.src = imageUrl
|
|
244
|
+
await image.decode()
|
|
245
|
+
|
|
246
|
+
return {
|
|
247
|
+
height: image.naturalHeight,
|
|
248
|
+
width: image.naturalWidth,
|
|
249
|
+
}
|
|
250
|
+
}, url)
|
|
251
|
+
|
|
252
|
+
const [response, dimensions] = await Promise.all([responsePromise, imagePromise])
|
|
253
|
+
assert.equal(response.status(), 200, `${asset.label}: HTTP ${response.status()}`)
|
|
254
|
+
assert.ok(dimensions.width > 0, `${asset.label}: изображение имеет нулевую ширину`)
|
|
255
|
+
assert.ok(dimensions.height > 0, `${asset.label}: изображение имеет нулевую высоту`)
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
async function closeResources(browser, server) {
|
|
259
|
+
const cleanupErrors = []
|
|
260
|
+
|
|
261
|
+
if (browser) {
|
|
262
|
+
try {
|
|
263
|
+
await browser.close()
|
|
264
|
+
}
|
|
265
|
+
catch (error) {
|
|
266
|
+
cleanupErrors.push(error)
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
if (server) {
|
|
271
|
+
try {
|
|
272
|
+
await server.close()
|
|
273
|
+
}
|
|
274
|
+
catch (error) {
|
|
275
|
+
cleanupErrors.push(error)
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
return cleanupErrors
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
export async function runBrowserSmoke() {
|
|
283
|
+
let activeSlide = 'инициализация'
|
|
284
|
+
let browser
|
|
285
|
+
let inFlightTracker
|
|
286
|
+
let server
|
|
287
|
+
let runError
|
|
288
|
+
const consoleErrors = []
|
|
289
|
+
const failedRequests = []
|
|
290
|
+
const failedResponses = []
|
|
291
|
+
const pageErrors = []
|
|
292
|
+
|
|
293
|
+
try {
|
|
294
|
+
server = await createStaticDistServer(DIST_DIR)
|
|
295
|
+
browser = await chromium.launch()
|
|
296
|
+
const context = await browser.newContext({
|
|
297
|
+
serviceWorkers: 'block',
|
|
298
|
+
viewport: {
|
|
299
|
+
height: 720,
|
|
300
|
+
width: 1280,
|
|
301
|
+
},
|
|
302
|
+
})
|
|
303
|
+
await context.addInitScript(({ eventName, stateKey }) => {
|
|
304
|
+
const state = {
|
|
305
|
+
active: {},
|
|
306
|
+
cleanedTokens: {},
|
|
307
|
+
mounts: {},
|
|
308
|
+
unmounts: {},
|
|
309
|
+
}
|
|
310
|
+
window[stateKey] = state
|
|
311
|
+
window.addEventListener(eventName, (event) => {
|
|
312
|
+
const detail = event.detail ?? {}
|
|
313
|
+
const phase = detail.phase
|
|
314
|
+
const slide = String(detail.slide ?? '')
|
|
315
|
+
const token = String(detail.token ?? '')
|
|
316
|
+
|
|
317
|
+
if (!slide || !token || (phase !== 'mounted' && phase !== 'unmounted'))
|
|
318
|
+
return
|
|
319
|
+
|
|
320
|
+
state.active[slide] ??= []
|
|
321
|
+
if (phase === 'mounted') {
|
|
322
|
+
state.mounts[slide] = (state.mounts[slide] ?? 0) + 1
|
|
323
|
+
if (!state.active[slide].includes(token))
|
|
324
|
+
state.active[slide].push(token)
|
|
325
|
+
return
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
state.unmounts[slide] = (state.unmounts[slide] ?? 0) + 1
|
|
329
|
+
state.active[slide] = state.active[slide].filter(activeToken => activeToken !== token)
|
|
330
|
+
state.cleanedTokens[token] = true
|
|
331
|
+
})
|
|
332
|
+
}, {
|
|
333
|
+
eventName: LIFECYCLE_EVENT,
|
|
334
|
+
stateKey: LIFECYCLE_STATE_KEY,
|
|
335
|
+
})
|
|
336
|
+
await context.grantPermissions(['screen-wake-lock'], { origin: server.origin })
|
|
337
|
+
const page = await context.newPage()
|
|
338
|
+
page.setDefaultTimeout(15_000)
|
|
339
|
+
inFlightTracker = createInFlightRequestTracker({
|
|
340
|
+
origin: server.origin,
|
|
341
|
+
})
|
|
342
|
+
|
|
343
|
+
page.on('pageerror', (error) => {
|
|
344
|
+
pageErrors.push(`[${activeSlide}] ${error.stack || String(error)}`)
|
|
345
|
+
})
|
|
346
|
+
page.on('console', (message) => {
|
|
347
|
+
const classification = classifyBrowserConsoleMessage(message.type(), message.text())
|
|
348
|
+
if (classification === 'rejected') {
|
|
349
|
+
consoleErrors.push({
|
|
350
|
+
location: message.location(),
|
|
351
|
+
slide: activeSlide,
|
|
352
|
+
text: message.text(),
|
|
353
|
+
})
|
|
354
|
+
}
|
|
355
|
+
})
|
|
356
|
+
page.on('request', request => inFlightTracker.requestStarted(request))
|
|
357
|
+
page.on('requestfinished', request => inFlightTracker.requestSettled(request))
|
|
358
|
+
page.on('response', (response) => {
|
|
359
|
+
if (response.status() >= 400) {
|
|
360
|
+
failedResponses.push({
|
|
361
|
+
method: response.request().method(),
|
|
362
|
+
slide: activeSlide,
|
|
363
|
+
status: response.status(),
|
|
364
|
+
url: response.url(),
|
|
365
|
+
})
|
|
366
|
+
}
|
|
367
|
+
})
|
|
368
|
+
page.on('requestfailed', (request) => {
|
|
369
|
+
inFlightTracker.requestSettled(request)
|
|
370
|
+
if (isSameOriginUrl(server.origin, request.url())) {
|
|
371
|
+
failedRequests.push({
|
|
372
|
+
error: request.failure()?.errorText ?? 'неизвестная ошибка',
|
|
373
|
+
method: request.method(),
|
|
374
|
+
slide: activeSlide,
|
|
375
|
+
url: request.url(),
|
|
376
|
+
})
|
|
377
|
+
}
|
|
378
|
+
})
|
|
379
|
+
|
|
380
|
+
for (const [index, slideNumber] of REPRESENTATIVE_SLIDES.entries()) {
|
|
381
|
+
activeSlide = `слайд ${slideNumber}`
|
|
382
|
+
await inspectSlide(
|
|
383
|
+
page,
|
|
384
|
+
server.origin,
|
|
385
|
+
slideNumber,
|
|
386
|
+
REPRESENTATIVE_SLIDES[index - 1],
|
|
387
|
+
() => inFlightTracker.waitForIdle(),
|
|
388
|
+
)
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
for (const asset of THEME_MEDIA) {
|
|
392
|
+
activeSlide = `медиа: ${asset.label}`
|
|
393
|
+
await loadThemeImage(page, server.origin, asset)
|
|
394
|
+
await inFlightTracker.waitForIdle()
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
await inFlightTracker.waitForIdle()
|
|
398
|
+
assertNoDiagnostics('ошибки страницы', pageErrors)
|
|
399
|
+
assertNoDiagnostics('ошибки консоли', consoleErrors)
|
|
400
|
+
assertNoDiagnostics('ответы HTTP со статусом >= 400', failedResponses)
|
|
401
|
+
assertNoDiagnostics('неуспешные запросы того же источника', failedRequests)
|
|
402
|
+
|
|
403
|
+
console.log(
|
|
404
|
+
`Браузерная проверка: слайды=${REPRESENTATIVE_SLIDES.length}, `
|
|
405
|
+
+ `ошибкиСтраницы=${pageErrors.length}, ошибкиКонсоли=${consoleErrors.length}, `
|
|
406
|
+
+ `ошибочныеОтветы=${failedResponses.length}, активныеЗапросы=${inFlightTracker.size}, `
|
|
407
|
+
+ `переполнение=0, медиа=${THEME_MEDIA.length}, переходыЖЦ=${REPRESENTATIVE_SLIDES.length - 1}`,
|
|
408
|
+
)
|
|
409
|
+
}
|
|
410
|
+
catch (error) {
|
|
411
|
+
runError = error
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
inFlightTracker?.dispose()
|
|
415
|
+
const cleanupErrors = await closeResources(browser, server)
|
|
416
|
+
if (runError && cleanupErrors.length > 0)
|
|
417
|
+
throw new AggregateError([runError, ...cleanupErrors], 'Браузерная проверка и очистка завершились с ошибками')
|
|
418
|
+
if (runError)
|
|
419
|
+
throw runError
|
|
420
|
+
if (cleanupErrors.length > 0)
|
|
421
|
+
throw new AggregateError(cleanupErrors, 'Очистка браузерной проверки завершилась с ошибками')
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
await runBrowserSmoke()
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto'
|
|
2
|
+
|
|
3
|
+
function contentHash(content) {
|
|
4
|
+
return createHash('sha256').update(content).digest('hex')
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* @template {{ relativePath: string, size: number }} T
|
|
9
|
+
* @param {T[]} files
|
|
10
|
+
* @param {number} maxBytes
|
|
11
|
+
* @param {(file: T) => Promise<string | NodeJS.ArrayBufferView>} readContent
|
|
12
|
+
*/
|
|
13
|
+
export async function readArtifactContentsWithinLimit(files, maxBytes, readContent) {
|
|
14
|
+
const totalBytes = files.reduce((sum, file) => sum + file.size, 0)
|
|
15
|
+
|
|
16
|
+
if (totalBytes > maxBytes) {
|
|
17
|
+
return {
|
|
18
|
+
filesWithContent: [],
|
|
19
|
+
overLimit: true,
|
|
20
|
+
totalBytes,
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const filesWithContent = await Promise.all(files.map(async file => ({
|
|
25
|
+
relativePath: file.relativePath,
|
|
26
|
+
content: await readContent(file),
|
|
27
|
+
})))
|
|
28
|
+
|
|
29
|
+
return {
|
|
30
|
+
filesWithContent,
|
|
31
|
+
overLimit: false,
|
|
32
|
+
totalBytes,
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* @param {Array<{ relativePath: string, content: string | NodeJS.ArrayBufferView }>} themeAssets
|
|
38
|
+
* @param {Array<{ relativePath: string, content: string | NodeJS.ArrayBufferView }>} distFiles
|
|
39
|
+
*/
|
|
40
|
+
export function findThemeAssetOccurrences(themeAssets, distFiles) {
|
|
41
|
+
const pathsByHash = new Map()
|
|
42
|
+
|
|
43
|
+
for (const file of distFiles) {
|
|
44
|
+
const hash = contentHash(file.content)
|
|
45
|
+
const paths = pathsByHash.get(hash) ?? []
|
|
46
|
+
|
|
47
|
+
paths.push(file.relativePath)
|
|
48
|
+
pathsByHash.set(hash, paths)
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
return themeAssets.map(asset => ({
|
|
52
|
+
assetPath: asset.relativePath,
|
|
53
|
+
occurrences: [...(pathsByHash.get(contentHash(asset.content)) ?? [])].sort(),
|
|
54
|
+
}))
|
|
55
|
+
}
|