diff --git a/package.json b/package.json
index 82934aa00..ddc23c2b1 100644
--- a/package.json
+++ b/package.json
@@ -43,12 +43,14 @@
"check:toolchain": "node scripts/check-toolchain.mjs",
"lint:fix": "eslint \"packages/*/{src,types,package.json}\" \"scripts/*.{js,mjs}\" \"test/*\" \"docs/assets/ts/*\" --fix",
"check:plan": "node refactor/scripts/plan.mjs --check",
- "test:node": "node --test test/playback.test.js test/dash-control.test.js test/toolchain.test.js test/build-docs.test.js",
+ "test:node": "yarn test:unit && node --test test/toolchain.test.js test/build-docs.test.js",
"test:baseline": "node --test refactor/scripts/*.test.mjs",
- "ci:check": "yarn check:toolchain --strict && yarn check:plan && yarn lint && yarn typecheck && yarn test:node && yarn test:baseline",
+ "ci:check": "yarn check:toolchain --strict && yarn check:plan && yarn lint && yarn typecheck && yarn test",
"ci:build": "yarn build all && yarn build:i18n && yarn build:ts && yarn build:docs && yarn test:imports",
"test:imports": "node --test test/esm.test.js test/i18n.test.js test/ssr.test.js",
- "typecheck": "node scripts/typecheck.mjs"
+ "typecheck": "node scripts/typecheck.mjs",
+ "test:unit": "node --test test/playback.test.js test/dash-control.test.js test/public-behavior.test.js test/helpers.test.js",
+ "test": "yarn test:node && yarn test:baseline"
},
"browserslist": "last 1 Chrome version",
"devDependencies": {
diff --git a/refactor/README.md b/refactor/README.md
index 5f998865b..9e9b71622 100644
--- a/refactor/README.md
+++ b/refactor/README.md
@@ -43,6 +43,7 @@
| [消费者与真实环境矩阵](environment-matrix.md) | 22 包的版本依据、能力/设备/样本、现有证据、验证任务及发布影响 |
| [类型检查与迁移入口](typechecking.md) | 根/分包配置、当前/旧编译器、严格正反例和历史声明错误 |
| [JS/TS 构建与开发](build-development.md) | 非交互选包、开发重建、资源夹具和 AMD 全局修复 |
+| [测试目录维护说明](../test/README.md) | 统一测试命令、JS/TS loader、受控媒体及新旧公共契约 |
## 维护规则
diff --git a/refactor/changes/2026-09-10-ENG-03-unit-entry.md b/refactor/changes/2026-09-10-ENG-03-unit-entry.md
new file mode 100644
index 000000000..ed2a8982d
--- /dev/null
+++ b/refactor/changes/2026-09-10-ENG-03-unit-entry.md
@@ -0,0 +1,11 @@
+# ENG-03:公共行为与单元入口
+
+日期 2026-09-10;起点 b53461da;分支 codex/compatible-modernization。
+
+新增 yarn test:unit 和 yarn test,保留 test:playback/test:dash-control/test:node/test:baseline/test:imports;ci:check 复用统一入口。播放与 DASH 的原 19 项断言保留,只提取源码加载和可控媒体夹具。新增的 JS/TS loader 会拒绝两个同名后缀同时存在,Vite 源包加载不写 dist,避免源码改名后测试仍加载旧 JS。
+
+公共 Emitter 的五组行为由同一份 contracts/emitter.js 执行:链式返回/上下文/参数、once 重入、派发快照、按原函数删除 once/重复监听、异常传播。固定发布 core 5.4.0 与当前源码均通过;ARTPLAYER_TEST_CORE 可加入实际候选产物,同一夹具分别运行 ENG-06 生成的 .js/.legacy.js/.mjs,三次各 15 项通过。候选 SHA-256 仍对应 [构建证据](../baselines/build-validation.json) 的核心三产物。
+
+新增的 loader 回归实际编译 TS/JS 并验证含糊入口被拒绝。完整 ci:check 通过只读 lint、类型、30 项单元/公共契约、2 项工具回归、22 项基线测试,共 54 项,无失败或跳过;不改生产源码、公开声明、依赖版本或产物,也未追加浏览器通过结论。已发布归档会先校验完整性,缺缓存时下载固定版本;候选事件检查不代替隔离安装/全部 API/真实媒体。
+
+实现地图、命令与维护规则在仓库 test/README.md。本任务独立提交 ENG-03;撤销可回到原 Node 入口,原测试名称与断言一直保留。下一项 ENG-05 接入浏览器自动化,再推进 chapter 特有测试与源码迁移。
diff --git a/refactor/ci-setup.md b/refactor/ci-setup.md
index cb0b6b0c7..a68fbe4d0 100644
--- a/refactor/ci-setup.md
+++ b/refactor/ci-setup.md
@@ -9,7 +9,9 @@
| `yarn lint` | 只读 ESLint,覆盖包源码/声明、JS/MJS 工具、测试和编辑器声明 |
| `yarn lint:fix` | 显式自动修复相同范围 |
| `yarn typecheck` | 根/迁移包严格检查、当前与兼容 TS 消费;历史 NodeNext ESM 错误单独核对,见 typechecking.md |
-| `yarn test:node` | 显式执行 4 个有行为断言的 Node 测试文件,保留原 test:playback/test:dash-control 入口 |
+| `yarn test:unit` | 原播放/DASH 回归、同夹具的新旧公共契约与 JS/TS loader 验证 |
+| `yarn test:node` | test:unit 加工具链/文档构建回归,保留原 test:playback/test:dash-control 入口 |
+| `yarn test` | 统一执行 Node 与基线测试,源码/夹具维护入口见仓库 test/README.md |
| `yarn test:imports` | 3 个既有包导入 smoke,构建后执行以使用新产物;不把 console 示例视为完整行为断言 |
| `yarn test:baseline` | 固定发布包完整性及本地 HTTP 基线测试;首次可能下载已固定归档到缓存 |
| `yarn ci:check` | 严格 Node/Yarn/锁检查、计划、只读 lint、类型、Node 和基线测试;允许写忽略缓存,不修改源码 |
diff --git a/refactor/plan.md b/refactor/plan.md
index dfbad0b64..89d09ecc9 100644
--- a/refactor/plan.md
+++ b/refactor/plan.md
@@ -4,7 +4,7 @@
基线:`40fcda6a37d0049d42e49c1e64e70d4fd9ba5f7f`。总任务 214 项,范围 22 个包及工作区/示例。
-状态:todo 187 / doing 0 / blocked 0 / done 27 / deferred 0。风险 L/M/H 表示兼容风险,不表示工期。
+状态:todo 186 / doing 0 / blocked 0 / done 28 / deferred 0。风险 L/M/H 表示兼容风险,不表示工期。
前置依赖是启动条件;验收是完成条件。任务可以继续拆分,但不能复用或悄悄删除旧 ID。
@@ -76,7 +76,7 @@
| ENG-01 | workspace
固定 Node、包管理器与依赖 | BASE-01 | 版本 pin、唯一锁文件、安装说明 | 干净环境可复现,最低 Node 与构建依赖一致,未夹带全量升级 | M | done |
| ENG-PM-01 | workspace
按用户选择切换固定 Yarn 包管理器 | ENG-01 | Yarn 固定版本、唯一 yarn.lock、工具检查与安装维护文档 | 干净冻结安装、Node 测试和全部构建通过;记录解析差异和依赖用途,独立提交 | M | done |
| ENG-02 | workspace
拆分只读检查并建立 PR CI | ENG-01, ENG-PM-01 | lint/lint:fix、PR 与主线检查、独立部署任务;遵循 github-ci-cd.md,PR/重构分支触发、最小权限及 workflow 静态检查 | 仓库内检查可执行且不改源码、不发布;required checks 的外部设置状态列入发布台账,不阻塞本地框架建设 | M | done |
-| ENG-03 | workspace
建立公共行为与单元测试入口 | ENG-02, BASE-03 | 保留现有 node:test,测试目录/夹具/统一入口 | 已有 19 项回归保留,旧版与候选可用同一夹具运行 | M | todo |
+| ENG-03 | workspace
建立公共行为与单元测试入口 | ENG-02, BASE-03 | 保留现有 node:test,测试目录/夹具/统一入口 | 已有 19 项回归保留,旧版与候选可用同一夹具运行 | M | done |
| ENG-04 | workspace
建立类型测试基础 | ENG-02, BASE-05 | 根与分包 tsconfig、显式 TS 依赖、正反例测试 | 核心/试点与迁移模块严格检查,未迁移第三方/包历史问题独立台账;明确最低/当前 TS 和各环境类型;复用 BASE-05 的四种消费模式,不以 skipLibCheck 掩盖 BASE-TYPE-01/03 | M | done |
| ENG-05 | workspace
建立真实浏览器测试服务 | ENG-03, BASE-04, BASE-08 | Playwright projects、本地 Range/失败媒体服务;复用 docs 页面/样本的状态隔离、错误采集与候选资源映射 | Chromium/Firefox/WebKit 的基础播放 smoke 和报告可执行;以媒体状态断言判定通过,区分轻量用例与真实编辑器交互 | M | todo |
| ENG-06 | workspace
支持按包非交互与 JS/TS 构建 | ENG-02, BASE-05 | build/dev 入口解析、指定包参数、原交互保留 | 三种产物、Less/SVG/worker 和本地 8082 demo 正常;保持 BASE-05 的 AMD 同时写入全局行为及 i18n/legacy 入口 | H | done |
@@ -428,5 +428,6 @@
- ENG-01: [记录](changes/2026-09-10-ENG-01-reproducible-toolchain.md) [记录](baselines/toolchain-validation.json)
- ENG-PM-01: [记录](changes/2026-09-10-ENG-PM-01-yarn-toolchain.md) [记录](baselines/yarn-validation.json)
- ENG-02: [记录](changes/2026-09-10-ENG-02-readonly-ci.md) [记录](baselines/ci-validation.json)
+- ENG-03: [记录](changes/2026-09-10-ENG-03-unit-entry.md)
- ENG-04: [记录](changes/2026-09-10-ENG-04-typechecking.md) [记录](typechecking.md)
- ENG-06: [记录](changes/2026-09-10-ENG-06-build-development.md) [记录](build-development.md) [记录](baselines/build-validation.json)
diff --git a/refactor/progress.md b/refactor/progress.md
index 67dbc29fc..79a01a0ad 100644
--- a/refactor/progress.md
+++ b/refactor/progress.md
@@ -1,5 +1,9 @@
# 进度与证据
+## 当前实施:ENG-03 公共行为与单元入口完成
+
+原 19 项播放/DASH 回归保留,JS/TS loader 与受控媒体夹具复用;同一份五组 Emitter 契约在发布 core 5.4.0、工作区和三格式候选上通过。统一 yarn test:unit/test 与 CI 入口已接入,见 [ENG-03](changes/2026-09-10-ENG-03-unit-entry.md)。当前 214 项,28 完成、186 待办;生产源码 TS 迁移仍为零。ENG-06 提交 b53461da,本任务独立提交,下一项 ENG-05 浏览器自动化。
+
## 当前实施:ENG-06 JS/TS 构建与开发完成
正常 build/dev 支持指定包与唯一 JS/TS 入口,dev 可禁止自动打开浏览器并串行重建。构建夹具复现并修复 AMD 全局参数错误;63 产物中 39 字节相同、24 仅该修复;内置浏览器 42 个 AMD 入口、TS worker/资源及首页真实播放通过。见 [ENG-06](changes/2026-09-10-ENG-06-build-development.md)。
diff --git a/refactor/tasks.json b/refactor/tasks.json
index a58f20c08..538f2fb28 100644
--- a/refactor/tasks.json
+++ b/refactor/tasks.json
@@ -492,11 +492,13 @@
"ENG-02",
"BASE-03"
],
- "status": "todo",
+ "status": "done",
"risk": "M",
"deliverable": "保留现有 node:test,测试目录/夹具/统一入口",
"acceptance": "已有 19 项回归保留,旧版与候选可用同一夹具运行",
- "evidence": []
+ "evidence": [
+ "changes/2026-09-10-ENG-03-unit-entry.md"
+ ]
},
{
"id": "ENG-04",
diff --git a/test/README.md b/test/README.md
new file mode 100644
index 000000000..4d0b1c693
--- /dev/null
+++ b/test/README.md
@@ -0,0 +1,21 @@
+# Tests and fixture ownership
+
+Use the pinned Node/Yarn toolchain from `../refactor/toolchain-setup.md`.
+
+| Command | Scope |
+| ------------------- | -------------------------------------------------------------------------------------------------------- |
+| `yarn test:unit` | Playback and DASH regressions, public contracts against released/current code, and JS/TS fixture loading |
+| `yarn test:node` | Unit contracts plus toolchain and documentation build failure propagation |
+| `yarn test` | Node checks and the committed baseline/tooling tests in refactor/scripts |
+| `yarn ci:check` | Toolchain, plan, read-only lint, types, then yarn test |
+| `yarn test:imports` | Existing distribution import smoke examples; run after building |
+
+`helpers/load.js` owns source and artifact selection. `loadModules` bundles named internal modules with esbuild and requires exactly one JS/TS source file. `loadPackage` uses the repository Vite configuration without writing distribution files; it requires a single self-contained JavaScript chunk. These loaders transpile but do not replace `yarn typecheck`.
+
+`helpers/playback.js` creates the controlled media facade used by the original playback regressions. It models only the exercised methods and events. It is not HTMLMediaElement validation; actual playback remains in browser tests.
+
+`contracts/emitter.js` owns public assertions, independent of module layout. `public-behavior.test.js` runs them against the integrity-checked published core and current bundled source. The combined chain/context contract corresponds to BASE-03 EVENT.chain and EVENT.context-arguments; other EVENT IDs retain their baseline names. Exceptions, callback identity, mutation during dispatch and once reentry must not be weakened during migration.
+
+Set `ARTPLAYER_TEST_CORE` to a built `.js`, `.legacy.js` or `.mjs` core file to add a candidate to the same public-contract run, then execute `node --test test/public-behavior.test.js`. An invalid path/export fails the run. This is a narrow event contract check, not isolated package installation or complete API compatibility; ENG-07 owns tarball consumption.
+
+When a production module moves, update its loader mapping and its maintenance documentation, preserving the behavioral assertions. New contracts belong in contracts/; test-specific controlled state belongs in helpers/. Do not modify frozen refactor/fixtures or baseline captures just to pass changed behavior. Record historical defects and candidate fixes separately.
diff --git a/test/contracts/emitter.js b/test/contracts/emitter.js
new file mode 100644
index 000000000..5a08957c6
--- /dev/null
+++ b/test/contracts/emitter.js
@@ -0,0 +1,61 @@
+import assert from 'node:assert/strict'
+
+export const emitterContracts = {
+ 'EVENT.chain-context-arguments': function (Emitter) {
+ const emitter = new Emitter()
+ const context = {}
+ const payload = {}
+ let observed
+ assert.equal(emitter.on('value', function (...args) {
+ observed = { context: this, args }
+ }, context), emitter)
+ assert.equal(emitter.emit('value', payload, 42), emitter)
+ assert.equal(observed.context, context)
+ assert.deepEqual(observed.args, [payload, 42])
+ assert.equal(emitter.off('value'), emitter)
+ },
+ 'EVENT.once-reentry': function (Emitter) {
+ const emitter = new Emitter()
+ let calls = 0
+ emitter.once('value', () => {
+ calls++
+ emitter.emit('value')
+ })
+ emitter.emit('value').emit('value')
+ assert.equal(calls, 1)
+ },
+ 'EVENT.dispatch-snapshot': function (Emitter) {
+ const emitter = new Emitter()
+ const trace = []
+ const second = () => trace.push('second')
+ const later = () => trace.push('later')
+ emitter.on('value', () => {
+ trace.push('first')
+ emitter.off('value', second)
+ emitter.on('value', later)
+ }).on('value', second)
+ emitter.emit('value').emit('value')
+ assert.deepEqual(trace, ['first', 'second', 'first', 'later'])
+ },
+ 'EVENT.off-original-once': function (Emitter) {
+ const emitter = new Emitter()
+ let calls = 0
+ const listener = () => calls++
+ emitter.once('value', listener).off('value', listener).emit('value')
+ assert.equal(calls, 0)
+ emitter.on('value', listener).on('value', listener).off('value', listener).emit('value')
+ assert.equal(calls, 0)
+ },
+ 'EVENT.throw-propagation': function (Emitter) {
+ const emitter = new Emitter()
+ const marker = new Error('listener failed')
+ let later = false
+ emitter.on('value', () => {
+ throw marker
+ }).on('value', () => {
+ later = true
+ })
+ assert.throws(() => emitter.emit('value'), error => error === marker)
+ assert.equal(later, false)
+ },
+}
diff --git a/test/dash-control.test.js b/test/dash-control.test.js
index 2c0df066b..d2cb3425a 100644
--- a/test/dash-control.test.js
+++ b/test/dash-control.test.js
@@ -1,24 +1,9 @@
import assert from 'node:assert/strict'
-import { Buffer } from 'node:buffer'
// eslint-disable-next-line test/no-import-node-test -- Use the built-in runner without adding a test framework.
import { mock, test } from 'node:test'
-import { fileURLToPath } from 'node:url'
-import { build } from 'vite'
-import { getViteBuildConfig } from '../scripts/utils.js'
+import { loadPackage } from './helpers/load.js'
-const config = getViteBuildConfig({
- entry: fileURLToPath(new URL('../packages/artplayer-plugin-dash-control/src/index.js', import.meta.url)),
- name: 'artplayerPluginDashControl',
- format: 'es',
- fileName: 'index.js',
- minify: false,
-})
-config.build.write = false
-const result = await build(config)
-const outputs = (Array.isArray(result) ? result : [result]).flatMap(item => item.output)
-const { default: dashControl } = await import(
- `data:text/javascript;base64,${Buffer.from(outputs.find(item => item.type === 'chunk').code).toString('base64')}`,
-)
+const { default: dashControl } = await loadPackage('artplayer-plugin-dash-control')
function createPlayer() {
const representations = [
diff --git a/test/helpers.test.js b/test/helpers.test.js
new file mode 100644
index 000000000..891e64ee3
--- /dev/null
+++ b/test/helpers.test.js
@@ -0,0 +1,28 @@
+import assert from 'node:assert/strict'
+import fs from 'node:fs'
+import path from 'node:path'
+// eslint-disable-next-line test/no-import-node-test -- Verify actual JS/TS loading used by module regressions.
+import { test } from 'node:test'
+import { loadModules, workspace } from './helpers/load.js'
+
+test('Module fixtures load JS and TS without silently selecting an obsolete sibling', async () => {
+ const parent = path.join(workspace, 'refactor/.cache')
+ fs.mkdirSync(parent, { recursive: true })
+ const directory = fs.mkdtempSync(path.join(parent, 'unit-loader-'))
+ try {
+ const ts = path.join(directory, 'double.ts')
+ const js = path.join(directory, 'double.js')
+ fs.writeFileSync(ts, 'export default (value: number): number => value * 2')
+ assert.equal((await loadModules({ double: 'double' }, directory)).double(3), 6)
+ fs.writeFileSync(js, 'export default value => value * 2')
+ await assert.rejects(loadModules({ double: 'double' }, directory), /exactly one JS\/TS module/)
+ fs.unlinkSync(ts)
+ assert.equal((await loadModules({ double: 'double' }, directory)).double(3), 6)
+ assert(!fs.existsSync(path.join(directory, 'dist')))
+ }
+ finally {
+ const relative = path.relative(parent, directory)
+ assert(relative.startsWith('unit-loader-') && !relative.includes(path.sep))
+ fs.rmSync(directory, { recursive: true, force: true })
+ }
+})
diff --git a/test/helpers/load.js b/test/helpers/load.js
new file mode 100644
index 000000000..cab5985d5
--- /dev/null
+++ b/test/helpers/load.js
@@ -0,0 +1,64 @@
+import assert from 'node:assert/strict'
+import { Buffer } from 'node:buffer'
+import fs from 'node:fs'
+import path from 'node:path'
+import { fileURLToPath, pathToFileURL } from 'node:url'
+import vm from 'node:vm'
+import { build as esbuild } from 'esbuild'
+import { build as viteBuild } from 'vite'
+import { ensureArchive, hash, readMember } from '../../refactor/scripts/releases.mjs'
+import { getEntryFile } from '../../scripts/projects.js'
+import { getGlobalName, getViteBuildConfig } from '../../scripts/utils.js'
+
+export const workspace = fileURLToPath(new URL('../../', import.meta.url))
+const importCode = code => import(`data:text/javascript;base64,${Buffer.from(code).toString('base64')}`)
+
+export function resolveSource(name, root = workspace) {
+ const files = ['.js', '.ts'].map(extension => path.resolve(root, name + extension)).filter(file => fs.existsSync(file))
+ assert.equal(files.length, 1, `Expected exactly one JS/TS module: ${name}`)
+ return files[0]
+}
+
+export async function loadModules(exports, root = workspace) {
+ const contents = Object.entries(exports).map(([alias, entry]) => {
+ const { file, name = 'default' } = typeof entry === 'string' ? { file: entry } : entry
+ assert(/^[\w$]+$/.test(alias) && /^[\w$]+$/.test(name), 'Invalid test export')
+ return `export { ${name} as ${alias} } from ${JSON.stringify(resolveSource(file, root))};`
+ }).join('\n')
+ const { outputFiles } = await esbuild({ stdin: { contents, resolveDir: root }, bundle: true, write: false, platform: 'node', format: 'esm' })
+ return importCode(outputFiles[0].contents)
+}
+
+export async function loadPackage(name, root = workspace) {
+ const project = path.join(root, 'packages', name)
+ const config = getViteBuildConfig({ entry: getEntryFile(project), name: getGlobalName(name), format: 'es', fileName: 'index.js', minify: false })
+ config.build.write = false
+ const result = await viteBuild({ root: project, ...config })
+ const chunks = (Array.isArray(result) ? result : [result]).flatMap(item => item.output).filter(item => item.type === 'chunk')
+ assert.equal(chunks.length, 1, 'Source fixture loader requires one self-contained JS chunk')
+ return importCode(chunks[0].code)
+}
+
+function commonJs(code) {
+ const module = { exports: {} }
+ vm.runInNewContext(code, { module, exports: module.exports }, { timeout: 5000 })
+ return module.exports
+}
+
+export async function loadPublishedCore() {
+ const release = JSON.parse(fs.readFileSync(path.join(workspace, 'refactor/baselines/releases.json'), 'utf8')).releases.find(item => item.name === 'artplayer')
+ const archive = await ensureArchive(release)
+ const member = 'package/dist/artplayer.js'
+ const code = readMember(archive, member)
+ assert.equal(hash(code), release.files[member], 'Published core member changed')
+ return { name: `published ${release.version}`, Artplayer: commonJs(code.toString('utf8')) }
+}
+
+export async function loadCoreArtifact(filename) {
+ const absolute = path.resolve(filename)
+ const Artplayer = absolute.endsWith('.mjs')
+ ? (await import(pathToFileURL(absolute))).default
+ : commonJs(fs.readFileSync(absolute, 'utf8'))
+ assert.equal(typeof Artplayer?.Emitter, 'function', 'Candidate artifact must expose Artplayer.Emitter')
+ return { name: `candidate ${Artplayer.version} (${path.basename(absolute)})`, Artplayer }
+}
diff --git a/test/helpers/playback.js b/test/helpers/playback.js
new file mode 100644
index 000000000..9319c7133
--- /dev/null
+++ b/test/helpers/playback.js
@@ -0,0 +1,27 @@
+import { mock } from 'node:test'
+
+export function playbackFactory({ Emitter, playMix, pauseMix, toggleMix, switchMix }) {
+ return function createArt(play = () => Promise.resolve(), playing = false) {
+ const art = Object.assign(new Emitter(), {
+ url: 'old.mp4',
+ currentTime: 37,
+ aspectRatio: '16:9',
+ playbackRate: 1.5,
+ notice: { show: '' },
+ i18n: { get: key => key },
+ option: { hotkey: true, mutex: false },
+ constructor: { instances: [] },
+ template: {
+ $video: {
+ paused: !playing,
+ play: mock.fn(play),
+ pause() { this.paused = true },
+ },
+ },
+ })
+ Object.defineProperty(art, 'playing', { get: () => !art.template.$video.paused })
+ for (const mix of [playMix, pauseMix, toggleMix, switchMix])
+ mix(art)
+ return art
+ }
+}
diff --git a/test/playback.test.js b/test/playback.test.js
index 2e3c61abb..16271b8be 100644
--- a/test/playback.test.js
+++ b/test/playback.test.js
@@ -1,58 +1,21 @@
import assert from 'node:assert/strict'
-import { Buffer } from 'node:buffer'
// eslint-disable-next-line test/no-import-node-test -- Use the built-in runner without adding a test framework.
import { mock, test } from 'node:test'
import { setImmediate } from 'node:timers/promises'
-import { fileURLToPath } from 'node:url'
-import { build } from 'esbuild'
+import { loadModules } from './helpers/load.js'
+import { playbackFactory } from './helpers/playback.js'
-// Bundle the source modules so tests also work with Node 20's ESM resolution.
-const { outputFiles } = await build({
- stdin: {
- contents: `
- export { silencePromise } from './packages/artplayer/src/utils/error.js';
- export { default as Emitter } from './packages/artplayer/src/utils/emitter.js';
- export { default as Hotkey } from './packages/artplayer/src/hotkey.js';
- export { default as playMix } from './packages/artplayer/src/player/playMix.js';
- export { default as pauseMix } from './packages/artplayer/src/player/pauseMix.js';
- export { default as toggleMix } from './packages/artplayer/src/player/toggleMix.js';
- export { default as switchMix } from './packages/artplayer/src/player/switchMix.js';
- `,
- resolveDir: fileURLToPath(new URL('../', import.meta.url)),
- },
- bundle: true,
- write: false,
- platform: 'node',
- format: 'esm',
+const modules = await loadModules({
+ silencePromise: { file: 'packages/artplayer/src/utils/error', name: 'silencePromise' },
+ Emitter: 'packages/artplayer/src/utils/emitter',
+ Hotkey: 'packages/artplayer/src/hotkey',
+ playMix: 'packages/artplayer/src/player/playMix',
+ pauseMix: 'packages/artplayer/src/player/pauseMix',
+ toggleMix: 'packages/artplayer/src/player/toggleMix',
+ switchMix: 'packages/artplayer/src/player/switchMix',
})
-const { silencePromise, Emitter, Hotkey, playMix, pauseMix, toggleMix, switchMix } = await import(
- `data:text/javascript;base64,${Buffer.from(outputFiles[0].contents).toString('base64')}`,
-)
-
-function createArt(play = () => Promise.resolve(), playing = false) {
- const art = Object.assign(new Emitter(), {
- url: 'old.mp4',
- currentTime: 37,
- aspectRatio: '16:9',
- playbackRate: 1.5,
- notice: { show: '' },
- i18n: { get: key => key },
- option: { hotkey: true, mutex: false },
- constructor: { instances: [] },
- template: {
- $video: {
- paused: !playing,
- play: mock.fn(play),
- pause() { this.paused = true },
- },
- },
- })
- Object.defineProperty(art, 'playing', { get: () => !art.template.$video.paused })
- for (const mix of [playMix, pauseMix, toggleMix, switchMix]) {
- mix(art)
- }
- return art
-}
+const { silencePromise, Hotkey } = modules
+const createArt = playbackFactory(modules)
test('silencePromise preserves fulfilled values and handles rejected promises', async () => {
assert.equal(await silencePromise(Promise.resolve(42)), 42)
diff --git a/test/public-behavior.test.js b/test/public-behavior.test.js
new file mode 100644
index 000000000..7b230a7fc
--- /dev/null
+++ b/test/public-behavior.test.js
@@ -0,0 +1,14 @@
+import process from 'node:process'
+// eslint-disable-next-line test/no-import-node-test -- Run the same public contract against each actual implementation.
+import { test } from 'node:test'
+import { emitterContracts } from './contracts/emitter.js'
+import { loadCoreArtifact, loadPackage, loadPublishedCore } from './helpers/load.js'
+
+const targets = [await loadPublishedCore(), { name: 'workspace', Artplayer: (await loadPackage('artplayer')).default }]
+if (process.env.ARTPLAYER_TEST_CORE)
+ targets.push(await loadCoreArtifact(process.env.ARTPLAYER_TEST_CORE))
+
+for (const { name, Artplayer } of targets) {
+ for (const [id, verify] of Object.entries(emitterContracts))
+ test(`${id}: ${name}`, () => verify(Artplayer.Emitter))
+}