mirror of
https://github.com/zhw2590582/ArtPlayer.git
synced 2026-10-14 00:06:17 -08:00
test(ci): [CI-01] verify installed Iframe tools across window boundaries
This commit is contained in:
1 parent
4a9ef16b16
commit
ce67eaa73e
19 files changed
+584
-28
No files matched your search
@@ -39,6 +39,11 @@ test('Installed validation rejects diagnostic SDK substitutions and recovery pro
|
||||
const profile = { ...base, ARTPLAYER_MASK_PROFILE: '1' }
|
||||
assert.throws(() => browserInvocation('installed', [], profile), /Mask CPU profiling/)
|
||||
assert.equal(browserInvocation('source', [], profile).env.ARTPLAYER_MASK_PROFILE, '1')
|
||||
for (const name of ['ARTPLAYER_IFRAME_BASELINE', 'ARTPLAYER_IFRAME_LIFECYCLE_ONLY', 'ARTPLAYER_IFRAME_BOUNDARIES_ONLY']) {
|
||||
const env = { ...base, [name]: '1' }
|
||||
assert.throws(() => browserInvocation('installed', [], env), /retain Iframe candidate and historical controls/)
|
||||
assert.equal(browserInvocation('source', [], env).env[name], '1')
|
||||
}
|
||||
for (const mode of ['upstream4', 'bufferlevel4']) {
|
||||
const env = { ...base, ARTPLAYER_DASH_DIAGNOSTIC_SDK: mode }
|
||||
assert.throws(() => browserInvocation('installed', [], env), /unchanged DASH SDK/)
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import process from 'node:process'
|
||||
import { hash } from '../../refactor/scripts/releases.mjs'
|
||||
import { iframeCandidate, iframeHistorical } from '../helpers/iframe.js'
|
||||
import { iframeBrowserCandidate, iframeHistorical } from '../helpers/iframe.js'
|
||||
import { expect, test } from './fixtures.js'
|
||||
|
||||
const candidate = await iframeCandidate()
|
||||
const candidate = await iframeBrowserCandidate()
|
||||
const old = (await iframeHistorical()).filter(item => ['published-artplayer-plugin-iframe.js', 'workspace.js'].includes(item.name))
|
||||
const cases = ['same-origin', 'cross-origin', 'opaque-origin', 'redirect'].flatMap(relation => ['parent-guard', 'child-guard', 'malformed'].map(scenario => ({ relation, scenario, parent: candidate, child: candidate })))
|
||||
if (process.env.ARTPLAYER_IFRAME_BOUNDARIES_ONLY !== '1') {
|
||||
@@ -139,7 +139,7 @@ for (const item of cases) {
|
||||
}
|
||||
const packets = await page.evaluate(() => window.packets)
|
||||
expect(packets.filter(packet => packet.fromChild).every(packet => packet.origin === eventOrigin)).toBe(true)
|
||||
await testInfo.attach('iframe-boundary', { contentType: 'application/json', body: JSON.stringify({ relation: item.relation, scenario: item.scenario, parent: { name: item.parent.name, sha256: hash(item.parent.code) }, child: { name: item.child.name, sha256: hash(item.child.code) }, parentOrigin, initialUrl, childUrl, eventOrigin, redirects, result, packets, scope: 'Actual window peers and native source/origin. Redirect is an HTTP 302 from the local test server; opaque peer is sandbox allow-scripts. Mixed-version rows cover normal wire compatibility only, not security of an unchanged historical peer. No player/media integration.' }) })
|
||||
await testInfo.attach('iframe-boundary', { contentType: 'application/json', body: JSON.stringify({ relation: item.relation, scenario: item.scenario, parent: { name: item.parent.name, sha256: hash(item.parent.code), provenance: item.parent.provenance || null }, child: { name: item.child.name, sha256: hash(item.child.code), provenance: item.child.provenance || null }, parentOrigin, initialUrl, childUrl, eventOrigin, redirects, result, packets, scope: 'Actual window peers and native source/origin. Redirect is an HTTP 302 from the local test server; opaque peer is sandbox allow-scripts. Mixed-version rows cover normal wire compatibility only, not security of an unchanged historical peer. No player/media integration.' }) })
|
||||
}
|
||||
finally {
|
||||
await page.evaluate(() => {
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import fs from 'node:fs'
|
||||
import { hash } from '../../refactor/scripts/releases.mjs'
|
||||
import { iframeCandidate } from '../helpers/iframe.js'
|
||||
import { iframeBrowserCandidate } from '../helpers/iframe.js'
|
||||
import { expect, test } from './fixtures.js'
|
||||
|
||||
const candidate = await iframeCandidate()
|
||||
const candidate = await iframeBrowserCandidate()
|
||||
|
||||
test('Iframe docs editor Run releases the previous tool and its pending requests', async ({ page }, testInfo) => {
|
||||
await page.route('https://pagead2.googlesyndication.com/**', route => route.fulfill({ contentType: 'text/javascript', body: '' }))
|
||||
@@ -67,6 +67,6 @@ test('Iframe docs editor Run releases the previous tool and its pending requests
|
||||
await expect.poll(() => page.evaluate(() => window.exampleFinished)).toBe(true)
|
||||
expect(await page.evaluate(() => window.Artplayer.instances.length)).toBe(0)
|
||||
expect(await page.evaluate(() => window.previousPlayers.every(art => art.isDestroy))).toBe(true)
|
||||
await testInfo.attach('iframe-editor', { contentType: 'application/json', body: JSON.stringify({ candidateSha256: hash(candidate.code), files: Object.fromEntries(['docs/index.html', 'docs/iframe.html', 'docs/assets/example/iframe.js', 'docs/assets/js/common.js'].map(file => [file, hash(fs.readFileSync(file))])), scope: 'Actual docs index, local Monaco and Run button. Only the iframe constructor is observed through a test subclass; external advertising script is stubbed in this test only. No physical-device/BFCache claim.' }) })
|
||||
await testInfo.attach('iframe-editor', { contentType: 'application/json', body: JSON.stringify({ candidateSha256: hash(candidate.code), provenance: candidate.provenance || null, files: Object.fromEntries(['docs/index.html', 'docs/iframe.html', 'docs/assets/example/iframe.js', 'docs/assets/js/common.js'].map(file => [file, hash(fs.readFileSync(file))])), scope: 'Actual docs index, local Monaco and Run button. Only the iframe constructor is observed through a test subclass; external advertising script is stubbed in this test only. No physical-device/BFCache claim.' }) })
|
||||
await page.evaluate(() => window.demoTools.forEach(tool => tool.destroy()))
|
||||
})
|
||||
@@ -1,9 +1,9 @@
|
||||
import fs from 'node:fs'
|
||||
import { hash } from '../../refactor/scripts/releases.mjs'
|
||||
import { iframeCandidate, iframeHistorical } from '../helpers/iframe.js'
|
||||
import { iframeBrowserCandidate, iframeHistorical } from '../helpers/iframe.js'
|
||||
import { expect, test } from './fixtures.js'
|
||||
|
||||
const implementation = await iframeCandidate()
|
||||
const implementation = await iframeBrowserCandidate()
|
||||
const scenarios = ['inject-before-load', 'duplicate-inject', 'attribute-navigation', 'child-navigation', 'rapid-navigation', 'same-source-reload', 'fragment-navigation', 'attribute-fragment', 'stale-packets', 'history-back', 'srcdoc-navigation']
|
||||
const childHtml = fs.readFileSync(new URL('./iframe-boundary-child.html', import.meta.url), 'utf8')
|
||||
|
||||
@@ -222,7 +222,7 @@ for (const relation of ['same-origin', 'cross-origin', 'opaque-origin']) {
|
||||
}
|
||||
const timeline = await page.evaluate(() => window.timeline)
|
||||
expect(await page.evaluate(() => window.callbacks.some(packet => packet.type === 'artplayer-tool-iframe:session'))).toBe(false)
|
||||
await testInfo.attach('iframe-navigation', { contentType: 'application/json', body: JSON.stringify({ implementation: implementation.name, sha256: hash(implementation.code), relation, scenario, parentOrigin, childOrigin, result, timeline, scope: 'Actual native frame navigation and postMessage; history-back records actual pageshow.persisted rather than assuming bfcache. Delayed-load barrier forces injection before load. Stale-packet tests replay old metadata from the controlled current frame to isolate document filtering. No media/player integration.' }) })
|
||||
await testInfo.attach('iframe-navigation', { contentType: 'application/json', body: JSON.stringify({ implementation: implementation.name, sha256: hash(implementation.code), provenance: implementation.provenance || null, relation, scenario, parentOrigin, childOrigin, result, timeline, scope: 'Actual native frame navigation and postMessage; history-back records actual pageshow.persisted rather than assuming bfcache. Delayed-load barrier forces injection before load. Stale-packet tests replay old metadata from the controlled current frame to isolate document filtering. No media/player integration.' }) })
|
||||
}
|
||||
finally {
|
||||
releaseBarrier?.()
|
||||
@@ -278,7 +278,7 @@ for (const historical of (await iframeHistorical()).filter(item => ['published-a
|
||||
expect(result.states.new.value).toBe('2')
|
||||
expect(result.states.old.error).toBe('The iframe document has changed')
|
||||
expect(result.pending).toBe(0)
|
||||
await testInfo.attach('iframe-navigation', { contentType: 'application/json', body: JSON.stringify({ implementation: implementation.name, sha256: hash(implementation.code), relation, scenario: 'legacy-child-source-change', child: { name: historical.name, sha256: hash(historical.code) }, parentOrigin, childOrigin, result, scope: 'Actual source attribute navigation with an immutable historical child. No document metadata or internal child navigation protection is claimed for an unchanged legacy peer.' }) })
|
||||
await testInfo.attach('iframe-navigation', { contentType: 'application/json', body: JSON.stringify({ implementation: implementation.name, sha256: hash(implementation.code), provenance: implementation.provenance || null, relation, scenario: 'legacy-child-source-change', child: { name: historical.name, sha256: hash(historical.code) }, parentOrigin, childOrigin, result, scope: 'Actual source attribute navigation with an immutable historical child. No document metadata or internal child navigation protection is claimed for an unchanged legacy peer.' }) })
|
||||
}
|
||||
finally {
|
||||
await page.evaluate(() => {
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import fs from 'node:fs'
|
||||
import { hash } from '../../refactor/scripts/releases.mjs'
|
||||
import { iframeCandidate, iframeHistorical } from '../helpers/iframe.js'
|
||||
import { iframeBrowserCandidate, iframeHistorical } from '../helpers/iframe.js'
|
||||
import { expect, test } from './fixtures.js'
|
||||
|
||||
const candidate = await iframeCandidate()
|
||||
const candidate = await iframeBrowserCandidate()
|
||||
const published = (await iframeHistorical()).find(item => item.name === 'published-artplayer-plugin-iframe.js')
|
||||
const demo = fs.readFileSync('docs/assets/example/iframe.js', 'utf8')
|
||||
const childHtml = fs.readFileSync('docs/iframe.html', 'utf8')
|
||||
@@ -116,7 +116,7 @@ for (const core of ['published-4.5.9', 'published', 'candidate']) {
|
||||
return 17
|
||||
}))
|
||||
expect(afterDestroy).toBe(17)
|
||||
await testInfo.attach('iframe-player', { contentType: 'application/json', body: JSON.stringify({ core, coreSha256: hash(coreBytes), parent: { name: parentTool.name, sha256: hash(parentTool.code) }, child: { name: childTool.name, sha256: hash(childTool.code) }, relation, initial, seek, switched, destroyed, events, demoSha256: hash(demo), adaptedDemoSha256: hash(adaptedDemo), childHtmlSha256: hash(childHtml), scope: 'Actual docs iframe.html, parent example and CSS in a minimal parent harness; only source URL and explicit test-only old global-name aliases are adapted. Actual media plays/seeks/switches, fullscreenWeb control sends messages and both core/tool lifetimes are tested independently. No complete Monaco/device/BFCache claim.' }) })
|
||||
await testInfo.attach('iframe-player', { contentType: 'application/json', body: JSON.stringify({ core, coreSha256: hash(coreBytes), parent: { name: parentTool.name, sha256: hash(parentTool.code), provenance: parentTool.provenance || null }, child: { name: childTool.name, sha256: hash(childTool.code), provenance: childTool.provenance || null }, relation, initial, seek, switched, destroyed, events, demoSha256: hash(demo), adaptedDemoSha256: hash(adaptedDemo), childHtmlSha256: hash(childHtml), scope: 'Actual docs iframe.html, parent example and CSS in a minimal parent harness; only source URL and explicit test-only old global-name aliases are adapted. Actual media plays/seeks/switches, fullscreenWeb control sends messages and both core/tool lifetimes are tested independently. No complete Monaco/device/BFCache claim.' }) })
|
||||
}
|
||||
finally {
|
||||
if (child && !child.isDetached()) {
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import process from 'node:process'
|
||||
import { hash } from '../../refactor/scripts/releases.mjs'
|
||||
import { iframeCandidate, iframeHistorical } from '../helpers/iframe.js'
|
||||
import { iframeBrowserCandidate, iframeHistorical } from '../helpers/iframe.js'
|
||||
import { expect, test } from './fixtures.js'
|
||||
|
||||
const candidate = { ...await iframeCandidate(), lifecycle: true }
|
||||
const candidate = { ...await iframeBrowserCandidate(), lifecycle: true }
|
||||
const implementations = process.env.ARTPLAYER_IFRAME_LIFECYCLE_ONLY === '1'
|
||||
? [candidate]
|
||||
: [...(await iframeHistorical()).filter(item => item.global !== 'ArtplayerHelperIframe'), candidate]
|
||||
@@ -213,7 +213,7 @@ for (const implementation of implementations) {
|
||||
}
|
||||
const packets = await page.evaluate(() => window.packets)
|
||||
expect(packets.filter(packet => packet.fromChild).every(packet => packet.origin === childOrigin)).toBe(true)
|
||||
await testInfo.attach('iframe-behavior', { contentType: 'application/json', body: JSON.stringify({ implementation: implementation.name, sha256: hash(implementation.code), relation, scenario, parentOrigin, childOrigin, foreignOrigin, result, packets, lifecycleAcceptance: Boolean(implementation.lifecycle), scope: 'Actual browser iframe WindowProxy/source/origin and postMessage. Local route fulfills immutable library bytes. Date.now is controlled for collision and distinct clone-failure IDs. Only the deliberate child error/DataCloneError is intercepted. Historical defects are asserted for historical rows; lifecycle acceptance requires cleanup and absence of uncaught clone errors. Navigation and trust are not candidate acceptance yet.' }) })
|
||||
await testInfo.attach('iframe-behavior', { contentType: 'application/json', body: JSON.stringify({ implementation: implementation.name, sha256: hash(implementation.code), provenance: implementation.provenance || null, relation, scenario, parentOrigin, childOrigin, foreignOrigin, result, packets, lifecycleAcceptance: Boolean(implementation.lifecycle), scope: 'Actual browser iframe WindowProxy/source/origin and postMessage. Local route fulfills immutable library bytes. Date.now is controlled for collision and distinct clone-failure IDs. Only the deliberate child error/DataCloneError is intercepted. Historical defects are asserted for historical rows; lifecycle acceptance requires cleanup and absence of uncaught clone errors. Navigation and trust are not candidate acceptance yet.' }) })
|
||||
}
|
||||
finally {
|
||||
await page.evaluate(() => {
|
||||
|
||||
+14
-2
@@ -1,14 +1,26 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import fs from 'node:fs'
|
||||
import process from 'node:process'
|
||||
import vm from 'node:vm'
|
||||
import { verifyIframeContract } from '../../refactor/scripts/iframe-contract.mjs'
|
||||
import { readMember } from '../../refactor/scripts/releases.mjs'
|
||||
import { hash, readMember } from '../../refactor/scripts/releases.mjs'
|
||||
import { browserCandidate } from './browser-candidate.js'
|
||||
import { compilePackage } from './load.js'
|
||||
|
||||
export async function iframeCandidate() {
|
||||
if (process.env.ARTPLAYER_IFRAME_BASELINE === '1')
|
||||
return (await iframeHistorical()).find(item => item.name === 'workspace.js')
|
||||
return { name: process.env.ARTPLAYER_IFRAME_ARTIFACT ? 'candidate-artifact' : 'candidate-source', global: 'ArtplayerToolIframe', namespace: false, code: process.env.ARTPLAYER_IFRAME_ARTIFACT ? fs.readFileSync(process.env.ARTPLAYER_IFRAME_ARTIFACT, 'utf8') : await compilePackage('artplayer-tool-iframe', 'umd') }
|
||||
const artifact = process.env.ARTPLAYER_IFRAME_ARTIFACT
|
||||
const code = artifact ? fs.readFileSync(artifact, 'utf8') : await compilePackage('artplayer-tool-iframe', 'umd')
|
||||
return { name: artifact ? 'candidate-artifact' : 'candidate-source', global: 'ArtplayerToolIframe', namespace: false, code, provenance: { kind: artifact ? 'explicit-artifact' : 'source-build', file: artifact || null, sha256: hash(code) } }
|
||||
}
|
||||
|
||||
export async function iframeBrowserCandidate() {
|
||||
if (!process.env.ARTPLAYER_BROWSER_ARTIFACTS)
|
||||
return iframeCandidate()
|
||||
assert(process.env.ARTPLAYER_IFRAME_BASELINE !== '1', 'Installed Iframe cannot use the frozen workspace')
|
||||
const loaded = await browserCandidate('artplayer-tool-iframe', process.env.ARTPLAYER_IFRAME_ARTIFACT)
|
||||
return { name: 'candidate-installed', global: 'ArtplayerToolIframe', namespace: false, ...loaded }
|
||||
}
|
||||
|
||||
export async function iframeHistorical() {
|
||||
|
||||
@@ -4,8 +4,9 @@ import path from 'node:path'
|
||||
import process from 'node:process'
|
||||
// eslint-disable-next-line test/no-import-node-test -- This fixture exercises the repository's Node test runner.
|
||||
import { test } from 'node:test'
|
||||
import { verifyIframeContract } from '../refactor/scripts/iframe-contract.mjs'
|
||||
import { installedPackages } from '../scripts/browser-validation/scope.ts'
|
||||
import { checkFiles, checkPackages, packageOptions, publishedConsumer } from '../scripts/package-check.mjs'
|
||||
import { checkFiles, checkPackages, historicalDistributionFiles, packageOptions, publishedConsumer } from '../scripts/package-check.mjs'
|
||||
import { removeConsumer, runtimeConsumer } from '../scripts/package-consumer.mjs'
|
||||
import { emitterContracts } from './contracts/emitter.js'
|
||||
import { ambilightCandidate } from './helpers/ambilight.js'
|
||||
@@ -13,6 +14,7 @@ import { autoThumbnailCandidate } from './helpers/auto-thumbnail.js'
|
||||
import { browserCandidate } from './helpers/browser-candidate.js'
|
||||
import { canvasCandidate } from './helpers/canvas.js'
|
||||
import { dpipCandidate } from './helpers/dpip.js'
|
||||
import { iframeBrowserCandidate, iframeCandidate } from './helpers/iframe.js'
|
||||
import { mbCandidate } from './helpers/mediabunny.js'
|
||||
import { multipleSubtitlesCandidate } from './helpers/multiple-subtitles.js'
|
||||
import { vttThumbnailCandidate } from './helpers/vtt-thumbnail.js'
|
||||
@@ -30,10 +32,30 @@ test('Additional browser packages cannot be mistaken for full release consumer a
|
||||
await assert.rejects(checkPackages({ release: true, include: ['artplayer-plugin-ambilight'] }), /not full release acceptance/)
|
||||
})
|
||||
|
||||
test('Iframe installation preserves verified tool paths without inventing a renamed npm archive', async () => {
|
||||
const contract = await verifyIframeContract()
|
||||
const expected = ['package/dist/artplayer-tool-iframe.js', 'package/dist/artplayer-tool-iframe.legacy.js', 'package/dist/artplayer-tool-iframe.mjs', 'package/types/artplayer-tool-iframe.d.ts']
|
||||
assert.deepEqual(historicalDistributionFiles('artplayer-tool-iframe', contract).sort(), expected)
|
||||
assert.throws(() => historicalDistributionFiles('artplayer-unreviewed-name', contract), /renamed package/)
|
||||
for (const member of expected) {
|
||||
const file = member.replace('package/', 'packages/artplayer-tool-iframe/')
|
||||
const sources = new Map(contract.sources)
|
||||
sources.delete(file)
|
||||
assert.throws(() => historicalDistributionFiles('artplayer-tool-iframe', { ...contract, sources }), /Missing verified iframe/)
|
||||
const baseline = structuredClone(contract.baseline)
|
||||
delete baseline.source[file]
|
||||
assert.throws(() => historicalDistributionFiles('artplayer-tool-iframe', { ...contract, baseline }), /Missing .* file/)
|
||||
}
|
||||
const baseline = structuredClone(contract.baseline)
|
||||
baseline.release.name = 'artplayer-tool-iframe'
|
||||
assert.throws(() => historicalDistributionFiles('artplayer-tool-iframe', { ...contract, baseline }), /distinct published predecessor/)
|
||||
})
|
||||
|
||||
test('Explicit installed plugin maps never fall back to source or frozen workspace', async () => {
|
||||
const keys = ['ARTPLAYER_BROWSER_ARTIFACTS', 'ARTPLAYER_AMBILIGHT_BASELINE', 'ARTPLAYER_CANVAS_BASELINE', 'ARTPLAYER_DPIP_BASELINE', 'ARTPLAYER_DPIP_ARTIFACT', 'ARTPLAYER_VTT_THUMBNAIL_BASELINE', 'ARTPLAYER_VTT_THUMBNAIL_ARTIFACT', 'ARTPLAYER_MULTIPLE_SUBTITLES_ARTIFACT']
|
||||
keys.push('ARTPLAYER_AUTO_THUMBNAIL_BASELINE', 'ARTPLAYER_AUTO_THUMBNAIL_ARTIFACT')
|
||||
keys.push('ARTPLAYER_MB_BASELINE', 'ARTPLAYER_MB_ARTIFACT')
|
||||
keys.push('ARTPLAYER_IFRAME_BASELINE', 'ARTPLAYER_IFRAME_ARTIFACT')
|
||||
const previous = Object.fromEntries(keys.map(key => [key, process.env[key]]))
|
||||
try {
|
||||
process.env.ARTPLAYER_BROWSER_ARTIFACTS = path.resolve('refactor/.cache/absent-installed-plugin-map.json')
|
||||
@@ -49,6 +71,15 @@ test('Explicit installed plugin maps never fall back to source or frozen workspa
|
||||
delete process.env.ARTPLAYER_AUTO_THUMBNAIL_ARTIFACT
|
||||
delete process.env.ARTPLAYER_MB_BASELINE
|
||||
delete process.env.ARTPLAYER_MB_ARTIFACT
|
||||
delete process.env.ARTPLAYER_IFRAME_BASELINE
|
||||
delete process.env.ARTPLAYER_IFRAME_ARTIFACT
|
||||
await assert.rejects(iframeBrowserCandidate(), { code: 'ENOENT' })
|
||||
assert.equal((await iframeCandidate()).provenance.kind, 'source-build', 'Existing unit/history helpers do not consume the core-only map as a tool artifact')
|
||||
process.env.ARTPLAYER_IFRAME_BASELINE = '1'
|
||||
await assert.rejects(iframeBrowserCandidate(), /cannot use the frozen workspace/)
|
||||
delete process.env.ARTPLAYER_IFRAME_BASELINE
|
||||
process.env.ARTPLAYER_IFRAME_ARTIFACT = 'override.js'
|
||||
await assert.rejects(iframeBrowserCandidate(), /cannot override artplayer-tool-iframe artifact/)
|
||||
await assert.rejects(mbCandidate(), { code: 'ENOENT' })
|
||||
process.env.ARTPLAYER_MB_BASELINE = '1'
|
||||
await assert.rejects(mbCandidate(), /cannot use the frozen workspace/)
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
# Installed package checks
|
||||
|
||||
`historicalDistributionFiles()` in `scripts/package-check.mjs` distinguishes a
|
||||
same-name npm release from the Iframe package rename. Iframe verifies the real
|
||||
plugin archive plus the frozen tool workspace, then requires all four original
|
||||
tool dist/type paths. Missing verified workspace members fail packing; legacy
|
||||
plugin/helper filenames remain a separate distribution gate. This does not
|
||||
change the generic core/chapter runtime/type consumer scope. The shared browser
|
||||
roster now also checks the installed Iframe tool in five browser test files.
|
||||
|
||||
For independent core/plugin rollback, run `yarn test:package` then
|
||||
`yarn test:rollback`. The seven-step rehearsal restores frozen consumer locks,
|
||||
checks complete installed file inventories and runtime entrypoints, and saves
|
||||
|
||||
Reference in new issue
Block a user