diff --git a/.eslintignore b/.eslintignore deleted file mode 100644 index 7e5a61fe..00000000 --- a/.eslintignore +++ /dev/null @@ -1,20 +0,0 @@ -/tests/ -.yarn/ -dist/ -node_modules/ -test/test-project-host-hello-world-app-* -test/hello-world-app -jest.builder.config.ts -build-ng-package.ts -schema-merge.ts -test/util -commitlint.config.js -script -**/fixture -*.xspec.ts -src/library/common -src/libary/forms -test -src/library/platform/http -deploy -docs \ No newline at end of file diff --git a/.eslintrc.json b/.eslintrc.json deleted file mode 100644 index 347344de..00000000 --- a/.eslintrc.json +++ /dev/null @@ -1,194 +0,0 @@ -{ - "ignorePatterns": [ - "src/library/forms/**/*", - "src/library/common/**/*", - "*.d.ts", - "src/builder/karma/client/**/*", - "src/builder/karma/plugin/**/*" - ], - "root": true, - "env": { - "es6": true, - "node": true - }, - "extends": [ - "eslint:recommended", - "plugin:import/typescript", - "plugin:@typescript-eslint/recommended", - "plugin:@typescript-eslint/recommended-requiring-type-checking", - "prettier" - ], - "parser": "@typescript-eslint/parser", - "parserOptions": { - "project": "tsconfig.json", - "sourceType": "module" - }, - "plugins": ["eslint-plugin-import", "@typescript-eslint"], - "rules": { - "@typescript-eslint/consistent-type-assertions": "warn", - "@typescript-eslint/no-explicit-any": "warn", - "@typescript-eslint/no-non-null-assertion": "off", - "@typescript-eslint/no-unnecessary-qualifier": "warn", - "@typescript-eslint/no-unused-expressions": "warn", - "curly": "warn", - "import/first": "warn", - "import/newline-after-import": "warn", - "import/no-absolute-path": "warn", - "import/no-duplicates": "warn", - "import/no-extraneous-dependencies": [ - "off", - { - "devDependencies": false - } - ], - "import/no-unassigned-import": [ - "warn", - { - "allow": ["miniprogram-api-typings"] - } - ], - "import/order": [ - "warn", - { - "alphabetize": { - "order": "asc" - }, - "groups": [["builtin", "external"], "parent", "sibling", "index"] - } - ], - "max-len": [ - "warn", - { - "code": 140, - "ignoreUrls": true, - "ignoreStrings": true, - "ignoreTemplateLiterals": true, - "ignoreComments": true, - "ignoreRegExpLiterals": true - } - ], - "max-lines-per-function": [ - "warn", - { - "max": 400 - } - ], - "no-caller": "warn", - "no-console": "warn", - "no-empty": [ - "warn", - { - "allowEmptyCatch": true - } - ], - "no-eval": "warn", - "no-multiple-empty-lines": ["warn"], - "no-throw-literal": "warn", - "no-var": "warn", - "sort-imports": [ - "warn", - { - "ignoreDeclarationSort": true - } - ], - "spaced-comment": [ - "warn", - "always", - { - "markers": ["/"] - } - ], - /* TODO: evaluate usage of these rules and fix issues as needed */ - "no-case-declarations": "off", - "no-fallthrough": "off", - "no-underscore-dangle": "off", - "@typescript-eslint/await-thenable": "off", - "@typescript-eslint/ban-types": "off", - "@typescript-eslint/no-empty-function": "off", - "@typescript-eslint/explicit-module-boundary-types": "off", - "@typescript-eslint/no-implied-eval": "off", - "@typescript-eslint/no-var-requires": "off", - "@typescript-eslint/no-unnecessary-type-assertion": "off", - "@typescript-eslint/no-unsafe-assignment": "off", - "@typescript-eslint/no-unsafe-call": "off", - "@typescript-eslint/no-unsafe-member-access": "off", - "@typescript-eslint/no-unsafe-return": "off", - "@typescript-eslint/no-unused-vars": "off", - "@typescript-eslint/prefer-regexp-exec": "off", - "@typescript-eslint/require-await": "off", - "@typescript-eslint/restrict-plus-operands": "off", - "@typescript-eslint/restrict-template-expressions": "off", - "@typescript-eslint/unbound-method": "off", - "@typescript-eslint/no-unsafe-argument": "off", - "prefer-const": "warn", - "prefer-rest-params": "warn", - "prefer-spread": "warn", - "@typescript-eslint/no-floating-promises": "warn", - "@typescript-eslint/no-inferrable-types": "off", - "@typescript-eslint/no-misused-promises": [ - "warn", - { - "checksVoidReturn": false - } - ], - "@typescript-eslint/no-this-alias": [ - "warn", - { - "allowedNames": ["_this"] - } - ] - }, - "overrides": [ - { - "files": ["**/*.spec.ts"], - "parserOptions": { - "project": "tsconfig.spec.json" - }, - "rules": { - "import/no-extraneous-dependencies": [ - "warn", - { - "devDependencies": true, - "packageDir": "./" - } - ], - "max-lines-per-function": "off", - "no-console": "off" - } - }, - { - "files": ["./src/builder/**/*.ts"], - "excludedFiles": [ - "./src/**/*.template.ts", - "./src/**/*.d.ts", - "**/*.spec.ts", - "test" - ], - "parserOptions": { - "project": "./tsconfig.builder.json" - } - }, - { - "files": ["./src/library/**/*.ts"], - "excludedFiles": [ - "./src/**/*.template.ts", - "./src/**/*.d.ts", - "**/*.spec.ts", - "test", - "./src/library/forms/**/*", - "./src/library/common/**/*", - "./src/library/platform/http/**/*" - ], - "parserOptions": { - "project": "tsconfig.library.json" - } - }, - { - "files": ["schematics/internal/**/*.ts"], - "excludedFiles": [], - "parserOptions": { - "project": "tsconfig.internal-schematics.json" - } - } - ] -} diff --git a/.github/workflows/alpha.yml b/.github/workflows/alpha.yml deleted file mode 100644 index d592da50..00000000 --- a/.github/workflows/alpha.yml +++ /dev/null @@ -1,52 +0,0 @@ -name: CI - -on: - push: - branches: - - alpha -env: - REPOSITORY_PATH: https://${{secrets.ACCESS_TOKEN}}@github.com/ -jobs: - build: - runs-on: ubuntu-latest - - steps: - - name: init - run: | - git config --global user.name "${GITHUB_ACTOR}" - git config --global user.email "${GITHUB_ACTOR}@gmail.com" - - name: pull-code - uses: actions/checkout@v7 - - name: install-node - uses: actions/setup-node@v7 - with: - node-version: 24.x - cache: "npm" - - name: install-dependencies - run: | - npm ci --legacy-peer-deps - - name: lint - run: | - npm run lint - - name: hook-code - run: | - npm run sync - - name: test - run: | - npm run test:ci - - name: build - run: | - npm run build - - id: publish - name: publish - uses: JS-DevTools/npm-publish@v3 - if: ${{ github.repository_owner == 'wszgrcy' }} - with: - token: ${{ secrets.NPM_PUBLISH_TOKEN }} - package: ./dist/package.json - tag: alpha - - if: ${{ github.repository_owner == 'wszgrcy' && steps.publish.outputs.type }} - run: | - echo "[${{ steps.publish.outputs.type }}]版本已变更: ${{ steps.publish.outputs.old-version }} => ${{ steps.publish.outputs.version }}" - git tag v${{steps.publish.outputs.version}} - git push origin v${{steps.publish.outputs.version}} diff --git a/.github/workflows/default.yml b/.github/workflows/default.yml index 5443d011..90f23fd2 100644 --- a/.github/workflows/default.yml +++ b/.github/workflows/default.yml @@ -4,8 +4,10 @@ on: push: branches: - master -env: - REPOSITORY_PATH: https://${{secrets.ACCESS_TOKEN}}@github.com/ + - alpha +permissions: + id-token: write + contents: write jobs: build: runs-on: ubuntu-latest @@ -21,13 +23,16 @@ jobs: uses: actions/setup-node@v7 with: node-version: 24.x - cache: "npm" + cache: 'npm' - name: install-dependencies run: | npm ci --legacy-peer-deps - name: lint run: | npm run lint + - name: check builder schema + run: | + npm run check:schema - name: hook-code run: | npm run sync @@ -37,15 +42,26 @@ jobs: - name: build run: | npm run build + - name: dist-tag + id: dist-tag + run: | + # master -> latest,其它分支(alpha 等)-> 分支同名 tag + if [ "${GITHUB_REF_NAME}" = "master" ]; then + echo "tag=latest" >> "$GITHUB_OUTPUT" + else + echo "tag=${GITHUB_REF_NAME}" >> "$GITHUB_OUTPUT" + fi - id: publish name: publish - uses: JS-DevTools/npm-publish@v3 - if: ${{ github.repository_owner == 'wszgrcy' }} + uses: JS-DevTools/npm-publish@v4 + if: ${{ github.repository_owner == 'wszgrcy' }} with: - token: ${{ secrets.NPM_PUBLISH_TOKEN }} package: ./dist/package.json - - if: ${{ github.repository_owner == 'wszgrcy' && steps.publish.outputs.type }} + tag: ${{ steps.dist-tag.outputs.tag }} + provenance: true + access: public + - if: ${{ github.repository_owner == 'wszgrcy' && steps.publish.outputs.type }} run: | - echo "[${{ steps.publish.outputs.type }}]Version changed: ${{ steps.publish.outputs.old-version }} => ${{ steps.publish.outputs.version }}" + echo "[${{ steps.publish.outputs.type }}]Version changed: ${{ steps.publish.outputs.old-version }} => ${{ steps.publish.outputs.version }} (dist-tag: ${{ steps.dist-tag.outputs.tag }})" git tag v${{steps.publish.outputs.version}} git push origin v${{steps.publish.outputs.version}} diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 7889edae..977bec73 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -4,46 +4,55 @@ on: push: branches: - master -env: - REPOSITORY_PATH: https://${{secrets.ACCESS_TOKEN}}@github.com/ - # GITHUB_TOKEN: ${{secrets.ACCESS_TOKEN}} + workflow_dispatch: + +permissions: + contents: read + pages: write + id-token: write + +concurrency: + group: 'pages' + cancel-in-progress: false + jobs: build: runs-on: ubuntu-latest steps: - - name: init - run: | - git config --global user.name "${GITHUB_ACTOR}" - git config --global user.email "${GITHUB_ACTOR}@gmail.com" - - name: pull-code + - name: 拉取仓库 uses: actions/checkout@v7 - - name: install-node + + - name: 安装 Node uses: actions/setup-node@v7 with: node-version: 24.x - cache: "npm" - - name: install-dependencies - run: | - npm ci --legacy-peer-deps - - name: hook-code - run: | - npm run sync - - name: build-docs - continue-on-error: true - run: | - npm run deploy - cp -rp docs/ ../dist - git branch gh-pages - git checkout gh-pages - rm -rf * - cp -rp ../dist/* . - cp ./index.html 404.html - env: - INPUT_TOKEN: '' - - name: tag - run: | - git status - git add -A - HUSKY=0 git commit -m 'build: 页面构建' - HUSKY=0 git push --force "${REPOSITORY_PATH}${GITHUB_REPOSITORY}.git" gh-pages - # git pull + cache: 'npm' + + - name: 安装依赖 + run: npm ci --legacy-peer-deps + + - name: 安装文档依赖 + run: npm ci + working-directory: astro-docs + + - name: 同步源码 + run: npm run sync + + - name: 构建文档 + run: npm run deploy + + - name: 上传制品 + uses: actions/upload-pages-artifact@v3 + with: + path: ./docs + + deploy: + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + runs-on: ubuntu-latest + needs: build + steps: + - name: 部署到 GitHub Pages + id: deployment + uses: actions/deploy-pages@v4 diff --git a/.github/workflows/pull_request.yml b/.github/workflows/pull_request.yml index 98f400b0..b954fb9c 100644 --- a/.github/workflows/pull_request.yml +++ b/.github/workflows/pull_request.yml @@ -24,6 +24,9 @@ jobs: - name: lint run: | npm run lint + - name: check builder schema + run: | + npm run check:schema - name: hook-code run: | npm run sync diff --git a/.gitignore b/.gitignore index b7e8f52d..e8d0ac71 100644 --- a/.gitignore +++ b/.gitignore @@ -1,15 +1,19 @@ node_modules* test-project-host-hello-world-app-* +# 进程内构建去重缓存(test/util/shared-build.ts),跑一次清一次 +test/.shared-build dist __test-app coverage-builder -script/startup-jasmine.js .temp-git -.nyc_output docs -deploy/api-doc # 构建 / 探测产物(Vite 输出的小程序包,可随时重新生成) __q __w __decl + +# karma builder 派生出的临时 tsconfig(derived-tsconfig.ts 生成), +# 里面带 typeRoots 的**机器绝对路径**,提交上去既泄本地路径、 +# 又在别的机器上直接不对。模板仓已有同名规则,库仓补齐。 +**/.tsconfig.generated-*.json diff --git a/.nycrc.json b/.nycrc.json deleted file mode 100644 index 0465ab61..00000000 --- a/.nycrc.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "extension": [".ts"], - "exclude": [ - "**/*.d.ts", - "**/*.js", - "**/*.spec.ts", - "startup-jasmine.js", - "test/**/*", - "src/builder/angular-internal/**/*" - ], - "reporter": ["html", "text", "json-summary", "json"], - "all": false, - "report-dir": "./docs/coverage" -} diff --git a/.vscode/launch.json b/.vscode/launch.json index 2388a647..8c0a611c 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -5,39 +5,39 @@ "version": "0.2.0", "configurations": [ { - "name": "测试(jasmine)-library", + "name": "测试(vitest)-library", "type": "node", "request": "launch", "cwd": "${workspaceRoot}", "runtimeExecutable": "npm", - "runtimeArgs": ["run-script", "test", "library"], + "runtimeArgs": ["exec", "vitest run src/builder/library/library.spec.ts"], "port": 7899 }, { - "name": "测试(jasmine)-browser", + "name": "测试(vitest)-browser", "type": "node", "request": "launch", "cwd": "${workspaceRoot}", "runtimeExecutable": "npm", - "runtimeArgs": ["run-script", "test", "builder-dev"], + "runtimeArgs": ["test"], "port": 7899 }, { - "name": "测试(jasmine)-builder-prod", + "name": "测试(vitest)-builder-prod", "type": "node", "request": "launch", "cwd": "${workspaceRoot}", "runtimeExecutable": "npm", - "runtimeArgs": ["run-script", "test", "builder-prod"], + "runtimeArgs": ["test"], "port": 7899 }, { - "name": "测试(jasmine)-browser-watch", + "name": "测试(vitest)-watch", "type": "node", "request": "launch", "cwd": "${workspaceRoot}", "runtimeExecutable": "npm", - "runtimeArgs": ["run-script", "test", "watch"], + "runtimeArgs": ["test:watch"], "port": 7899 } ] diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index b409c6c0..1184676c 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -6,8 +6,8 @@ ```bash npm ci # 安装依赖 -npm run build # 构建 library + builder + karma(会自动补齐同步源码) -npm run test # 运行全部 jasmine 用例 +npm run build # 构建 library + builder(会自动补齐同步源码) +npm run test # 运行全部 vitest 用例 ``` 其它常用命令: @@ -19,23 +19,319 @@ npm run lint # eslint --max-warnings 0 npm run sync # 手动从 angular/angular@17.3.1 同步源码(需要网络) ``` +> **上面都是 Node 侧用例**。还有一层「在真·微信开发者工具里跑」的 +> 小程序运行时用例,见下面 [微信真机测试(vitest)](#微信真机测试vitest)。 +> 那层需要开发者工具 + 真实 AppID + 手动登录,不能纯命令行无人值守。 + +--- + +## 微信真机测试(vitest) + +### 两层测试的分工 + +| | Node vitest(`npm test`) | 小程序运行时(`npm run test:wechat`) | +| -------------- | ------------------------------ | ------------------------------------- | +| 跑在哪 | Node 进程,`wx` 用 Proxy 桩 | 真·微信开发者工具里的小程序运行时 | +| 覆盖 | 编译器、纯函数、可 mock 的逻辑 | 渲染、生命周期、`wx.*` 真实行为 | +| 需要开发者工具 | 否 | **是** | +| 需要真实 AppID | 否 | **是**(游客模式不行) | +| 需要手动登录 | 否 | **是**(见下) | +| 速度 | 全量约 4 分钟 | 13 个 spec 约 17 秒 | +| 连接方向 | —— | 设备 `wx.connectSocket` 主动连出宿主 | + +两层不能互相替代。典型例子:内建控制流 `@if` 的 `nodeList` 填充 bug, +Node 侧合成 lView 测不出来,只有真机跑才暴露。 + +### 前置条件(四条,缺一不可) + +**1. 微信开发者工具已安装,且服务端口已开启** + +IDE → 设置 → 安全设置 → **服务端口:开**。 +没开会直接报: + +``` +工具的服务端口已关闭。要使用命令行调用,请手动打开工具 -> 设置 -> 安全设置,将服务端口开启。 +``` + +**2. 必须手动启动 IDE 并登录 —— CLI 拉不起来登录态** + +这是最容易踩的一条。CLI **能**拉起 IDE 进程(`--port` 会让它启动并监听), +但**那个实例是登出状态**,且等多久都不会恢复: + +``` +# 已登录状态下 cli quit,再让 CLI 从零拉起: +cli --port 40710 islogin → {"login":false} +等待 40s 再查 → {"login":false} + +# 且 profile 是同一个(--debug 实测): +userDirPath C:\Users\\AppData\Local\微信开发者工具\User Data\\Default +``` + +同 profile、同机器,CLI 拉起的实例就是 `login:false`。 + +**所以正确顺序是:你手动打开 IDE → 扫码登录 → 再跑测试。** + +登录没上的表现很坑,**不会报错**: + +``` +cli auto → ✔ auto ← 假成功 +# 然后小程序永远连不上宿主,脚本干等到超时 +``` + +**3. AppID 用游客的就行** + +> ⚠️ **两次纠正**。早先记的两条都是错的: +> +> 1. 「游客模式网络被掐」—— 错,当时把「未登录」归因到了 appid 上 +> 2. 「游客模式 `cli auto` 不可靠」—— 也错,当时把「会话互斥」归因到了 appid 上 +> +> **游客 appid 可以跑测试,实测 13/13 SUCCESS 且可复现。** + +网络硬证据(带唯一标记的服务器,验证**内容真的回来了**,不是只看状态码): + +``` +loopback >> status=200 marker回传=true + body={"marker":"FX-TOURIST-OK-9911","echo":"/hello","host":"127.0.0.1:9901"} +LAN >> status=200 marker回传=true + body={"marker":"FX-TOURIST-OK-9911","echo":"/lan","host":"192.168.31.198:9901"} +``` + +标记串、echo 路径、host 头全部原样返回 —— 真实往返,不是缓存也不是假应答。 + +**之前反复失败的真正原因:会话互斥。** + +DevTools 的自动化会话**同一时刻只能有一个**。上一轮跑完脚本只杀了 +node/vitest,**项目窗口还开在 IDE 里**;新一轮 `cli auto` 去抢会话, +旧连接被强制关掉,设备端刚连上就断: + +``` +Connected on socket +WARN [小程序]: Disconnected (0 times) reconnect failed before timeout of 2000ms (transport close) +Executed 0 of null +``` + +干净 A/B(同代码、同机器、同游客 appid): + +| 前置动作 | 结果 | +| ------------------- | -------------------------------------------- | +| 先 `cli close` 再跑 | ✅ `Executed 13 of null SUCCESS`(连复两次) | +| 不 close 直接跑 | ❌ `transport close` → `Executed 0` | + +**脚本已修**:跑之前自动 `cli close --project <产物>` 并等 8 秒, +不用手动干预。修后连跑两次均全自动 PASS。 + +**唯一真正需要真实 AppID 的场景**:`cli open`。游客 appid 走 `open` +会报 `code: 10 不存在此 AppID`。但测试链路走的是 `auto`,不是 `open`, +所以碰不到这个限制。 + +**4. `urlCheck: false`(就是 IDE 里那个「不校验合法域名」勾选)** + +IDE → 详情 → 本地设置 → +**「不校验合法域名、web-view(业务域名)、TLS 版本以及 HTTPS 证书」** + +这个勾选对应 `project.config.json` 里的 `setting.urlCheck`(**反逻辑**: +勾选 = `false`): + +```json +// test/hello-world-app/src/project.config.json +{ "setting": { "urlCheck": false } } +``` + +**游客模式下这个是必需的,不是可选的。** 原因: + +> 域名白名单是挂在 AppID 上的。**游客模式没有 AppID → 没有任何白名单 +> 上下文 → 所有域名都不合法**。不关掉校验,连 `127.0.0.1` 都过不了。 + +所以「游客 + 本地开发」的正确组合是: + +| 项 | 值 | +| -------------- | ----------------------------------------------------- | +| AppID | `touristappid` | +| 不校验合法域名 | **勾选**(`urlCheck: false`) | +| 登录 | **需要**(未登录时 `wx.request` 直接 `request:fail`) | + +注意最后一行:**游客模式免的是 AppID,不免登录。** 开发者工具本身 +仍然要扫码登录,否则请求根本发不出去。 + +这个设置没开的典型报错: + +``` +request:fail url not in domain list +``` + +而登录没上的报错长得不一样(没有 `url not in domain list`): + +``` +request:fail status: undefined ← 请求根本没发出 +``` + +两个报错能用来快速区分是「域名校验没关」还是「没登录」。 + +### 怎么跑 + +```bash +# 1. 手动打开微信开发者工具并扫码登录(CLI 拉不起登录态) +# 2. 跑(游客 appid 直接可用,不用 --appid) +npm run test:wechat # vitest 链路:编产物 → 起 WS → 开项目 → 收结果 + +``` + +仓库里 `src/project.config.json` 提交的就是 `touristappid`,**直接就能跑 +测试**,不用换真实 AppID(appid 只从 `project.config.json` 读,脚本没有 +`--appid` 参数,想换成自己的就改那个文件、别进版本库)。 + +脚本做的事:**登录态/服务端口预检** → `ng run app:test` → 起 vitest 并等 +WS 监听就绪 → `cli auto --project <产物> --auto-port

` 开项目 → +**等小程序连入(默认 20s)** → 等 vitest 跑完(默认 120s 上限)→ +透传 vitest 退出码(可直接进 CI)。 + +**几个容易退回去的坑**(都是踩过换来的): + +- **开项目必须用 `cli auto`,不是 `cli open`**。`open` 会过 IDE 里的 + `formatProject`,对空/游客 appid 直接 throw `CLI_INVALID_APPID`,就是 + `不存在此 AppID (code 10)`;`auto` 走另一条路径,不校 appid。之前 + vitest 脚本一度写成 `open`,于是同一个环境里 karma 能跑、vitest 跑不了。 +- **CLI 失败得当场断**。`auto`/`open` 失败时只打 `✖ 准备中` + `[error] {...}` + 就返回 0 似的退回去,不检就得干等到超时且看不出原因。现在脚本看到 + `[error]` / `✖` 直接报错退出,并把最能说明问题那行糊在脸上。 +- **连入超时要短**(默认 20s)。项目已经打开了,正常几秒内就连回来;迟迟 + 不连就是根本连不上(urlCheck 没关 / 产物里的 `MP_VITEST_PORT` 和 `--port` + 不一致),多等只是白等。区分「已监听」和「真连上」靠的是 session 打的 + `[mp-vitest] 设备已连接`。 +- **残留窗口**:`cli auto` 自己会先关掉同路径的旧窗口,所以不用像 karma + 那样手动 close + 睡 8 秒;但自动化端口被占时脚本会直接报错, + 而不是开一个抢不到会话的窗口。 +- **端口/超时只有一个来源**:脚本把 `--port` 和 `--connect-timeout` 透成 + `MP_VITEST_PORT` / `MP_VITEST_CONNECT_TIMEOUT`,`vitest.config.mts` 读它们, + 不然脚本日志里的端口和宿主真听的端口能是两回事(指错了还“莫名成功”)。 + 产物里的端口是构建期由 angular.json `test.options.port` define 进去的, + 所以 `--port` 改了就得连 angular.json 一起改并重新编产物。 + +### 设备端运行时的四个硬前提 + +「项目打开了、宿主也在监听、设备就是不连 / 一连就报错」这一整类问题,根因 +都在**小程序运行时不是浏览器**:没有 `window`/`global`,`globalThis` 被 +`buildPlatformDefine` 改写成 `wx.__window`(app.js 自建的普通对象), +ES 新内建也缺。少一样都是「静默不连」或「报错盖报错」,记清楚: + +| 前提 | 少了什么表现 | 在哪补 | +| ------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| spec 里的裸 `describe/it/expect` 要重定向到全局表 | `describe is not defined` / `wx.__window.describe is not a function`,宿主干等 | `src/builder/vitest/vite/globals-define.ts` 的 `miniProgramVitestGlobalDefine`(对齐 karma 的 `jasmineGlobalDefine`);值由 vitest `registerApiGlobally` 挂,开关是 `test.globals`——`miniProgramVitest()` 默认打开,不用在测试工程里再写一遍 | +| `Event` / `EventTarget` | `Event is not defined` / `EventTarget is not defined`,把真错误整个盖掉(tinybench 顶层 `class extends EventTarget`,vite preload helper 失败路径 `new Event`) | `runtime/global-polyfills.ts` + `miniProgramBuiltinDefine` | +| `Date` / 定时器 / `console` 在全局表上 | `The global scope doesn't have a Date object`(fake timers)→ `clearTimeout$1 is not a function` | 同上 `copyRealmGlobals`。**必须模块加载时执行**:`@vitest/runner` 在 `import` 阶段就把定时器抄成常量,等 `startupMiniProgramTest()` 再补就晚了 | +| `AggregateError` | 每条失败用例变成 `Right-hand side of 'instanceof' is not an object`,断言差异全丢(`failTask` 里那句 `instanceof` 没做保护) | 同上,表里放 `MpAggregateError` | + +另外两条容易忽略的: + +- **spec 不能在 app 启动前被 eager `require`**。`spec-modules.plugin.ts` 注进去的 + 是惰性的(`key: () => require(...)`),改成急切 require 的话,spec 在 vitest + 全局装好之前就执行,`main()` 直接炸,设备永远不连。 +- **require 的路径必须是字面量,而且必须是打包后才写进去**。两头都实测过: + 字面量让 rolldown 看见 → `UNRESOLVED_IMPORT`(`specs/...` 是产物路径,源码里 + 没有,走 define 也一样,define 是打包前展开的);用变量绕开静态分析 → 能 + 构建过,但微信的模块系统也是静态扫 `require` 决定哪些文件进包,运行时 + `Error: module 'specs/...' is not defined`。所以只能在 `generateBundle` 里改 + `chunk.code`(karma 时代同一个结论,见 `git log` 里的 `require-context-shim`)。 +- **worker 必须自己调 `onQueued` / `onCollected`**。Node 那边是 vitest 的 + `resolveTestRunner` 包的,自定义 runner 得自己补,少了宿主报 + `AssertionError: Entity must be found for task xxx`。 + +排查这类问题最快的办法:把产物 `dist/vitest/app/test.js` 里 +`worker.reportError(\`处理 worker 请求失败:...\`)`那行临时拼上`error.stack`——宿主端默认只给你两帧,加上栈基本一眼定位。 + +IDE 服务端口与 `.ide` 记录不一致时,本脚本没有 `--ide-port`(karma 时代才有), +直接把真实端口写回 `.ide` 文件最快,见下一节。 + +### 端口机制(`.ide` 文件) + +CLI **不直接问 IDE 端口**,而是读一个状态文件: + +``` +%LOCALAPPDATA%\微信开发者工具\User Data\\Default\ + ├── .ide ← IDE 服务端口 + ├── .ide-status ← 服务端口开关("On" / "Off") + └── .cli ← CLI 自己的端口 +``` + +IDE 每次启动**随机挑端口**,而 `.ide` 只在 IDE 自己的启动流程里写。 +用 `taskkill /F` 强杀、或直接双击 exe 启动,都会让 `.ide` 与实际端口脱节: + +``` +.ide = 40710(陈旧) +实际监听 = 41994 +→ CLI 读 40710 → ECONNREFUSED → 判定「IDE 没启动」→ 去拉新实例 +→ 但已有实例占着 → 40710 永远开不出来 → wait IDE port timeout +``` + +**`--port` 的关键限制**(实测确认): + +> `--port` 只在「CLI 亲自拉起 IDE」那一次生效。 +> 要连**已经在跑**的 IDE,CLI 仍然只认 `.ide` 文件。 + +所以 IDE 已在跑但端口对不上时,直接把真实端口写回去最快: + +```bash +echo 41994 > "$LOCALAPPDATA/微信开发者工具/User Data//Default/.ide" +``` + +### 故障速查表 + +| 现象 | 原因 | 解法 | +| ----------------------------------------------------- | --------------------------------------------- | ----------------------------------------------------- | +| `不存在此 AppID (code 10)` | 用了 `touristappid` 走 `cli open` | 改用 `cli auto`(脚本已改);或换真实 AppID | +| `需要重新登录 (code 10)` | IDE 登录态丢了 | 手动登录 IDE | +| `✔ auto` 但无测试结果,最后超时 | 登录态为 `false`(假成功) | `islogin` 预检,登录后重跑 | +| `wait IDE port timeout` | `.ide` 与实际端口不一致 | 写回真实端口,或干净退出后 `--port` 重拉 | +| `工具的服务端口已关闭` | IDE 安全设置里服务端口没开 | 设置 → 安全设置 → 服务端口 开 | +| `Connected on socket` 后 `no message in 30000 ms` | 上一轮 DevTools 实例还在,把新会话挤掉 | 脚本已自动 `cli close`;手动跑就先 close 旧项目等几秒 | +| `Disconnected ... transport close` → `Executed 0` | 同上,**会话互斥**(不是 appid 问题) | 同上 | +| 连上但零推进 | 产物里的端口和宿主不一致 | 核对 `--port` 与 `test.options.port` 是否同一个值 | +| `describe is not defined`(设备控制台) | `test.globals` 被显式关掉了,或 define 没生效 | 见「设备端运行时的四个硬前提」 | +| `Event is not defined` / `EventTarget is not defined` | 全局表缺这俩,真错误被盖掉 | 同上;先补 polyfill 再看下一条真错误 | +| `Right-hand side of 'instanceof' is not an object` | `AggregateError` 缺失,只在失败路径炸 | 同上 | +| `Entity must be found for task xxx` | runner 没调 `onQueued`/`onCollected` | 同上 | + +### 已验证的网络矩阵 + +``` +目标 真实 AppID 游客 appid +loopback http://127.0.0.1:9901 ✅ ✅(标记串回传验证) +LAN http://192.168.31.198:9901 ✅ ✅(标记串回传验证) +external https://registry.npmjs.org ✅ ✅ +``` + +**局域网可通**:手机连同 WiFi 就能打本机 dev server,真机联调不用改代码 +(把 `test.options.clientHost` 指到本机局域网 IP 即可)。 + +游客与真实 AppID 在网络上**没有区别**,两者都需要:已登录 + `urlCheck:false`。 + +### 为什么 http spec 打本地服务而不是外部 API + +原来打的是 `https://api.realworld.io/api/articles`,该域名已返 +**HTTP 530**(Cloudflare 源站不在,宿主机 `curl` 同样 530),测试会 +长期红且与代码无关。现在由 vitest 宿主(`src/builder/vitest/node/fixture-server.ts`)在同一端口上出 fixture, +请求仍是真的 `wx.request → 127.0.0.1`,**适配层链路一字不变**, +只是响应可控、可重复。见 `src/spec/util/fixture-server.ts`。 + +--- + ## 🔴 发布产物形态:包必须是 `type: commonjs` `src/library/package.json` 里显式写了 `"type": "commonjs"`,**不要删**。 ng-packagr 生成产物时是 `packageJson.type ??= 'module'`——你没写它就给你 `module`。 -而 `builder/**` 和 `karma/**` 是 `script/build.ts` 用 CommonJS 编出来的 +而 `builder/**` 是 `tsc -p ./tsconfig.builder.json` 用 CommonJS 编出来的 (`require` / `exports` + 无扩展名的相对 import)。一旦包顶层是 `type: module`, Node 会把所有 `.js` 当 ESM,于是: ``` -require('angular-miniprogram/karma/plugin') +require('angular-miniprogram/vitest') → exports is not defined in ES module scope → Cannot find module './main' // ESM 解析要求带扩展名 ``` 库自己的产物是 `.mjs`(扩展名优先,永远是 ESM),所以顶层写 `commonjs` -**不影响 ESM 消费方**,只是让 `builder/` 和 `karma/` 的 CJS 能正常加载。 +**不影响 ESM 消费方**,只是让 `builder/` 的 CJS 能正常加载。 线上 1.5.2 没有 `type` 字段(等价 commonjs),就是同一个道理。 发布前自检(`npm run build` 之后): @@ -43,14 +339,42 @@ require('angular-miniprogram/karma/plugin') ```bash cd dist && npm pack && cd /tmp && mkdir s && cd s && npm init -y npm i <绝对路径>/dist/angular-miniprogram-1.5.2.tgz @angular-devkit/architect --legacy-peer-deps -node -e "console.log(Object.keys(require('angular-miniprogram/karma/plugin')))" -# 期望:[ 'framework:@angular-devkit/build-angular', 'launcher:miniprogram' ] +node -e "console.log(Object.keys(require('angular-miniprogram/package.json').exports))" +# 期望含 ./vitest 与 ./vitest/runtime ``` -另:`src/builder/karma/plugin/tsconfig.json` 的 `outDir` 是 `dist/karma` 而不是 -`dist/karma/plugin`——因为 `index.ts` import 了 `../vite/karma-framework`, -TS 把 rootDir 推断到 `src/builder/karma`,outDir 多写一层会让产物变成 -`karma/plugin/plugin/index.js`,与 `exports["./karma/plugin"]` 对不上。 +## 🔴 builder 的运行时依赖必须写在 `src/library/package.json` + +发布产物的根是 `src/library/package.json`(ng-packagr 生成 `dist/package.json`, +`copy:assets` 把 builder 塞进 `dist/builder`)。仓库根的 `package.json` 是 +`private: true` 的开发依赖表,**发布时不会被安装**。 + +所以 `src/builder/**` 里运行时 `import`(`import type` 不算,编译后就没了) +用到的包,必须出现在 `src/library/package.json` 的 `dependencies` 或 +`peerDependencies` 里。只写在根 devDependencies = 装包的人拿不到。 + +真实形状:`ng new` v22 的 devDependencies 只有 `@angular/cli` / +`@angular/compiler-cli` / `prettier` / `typescript`,builder 默认走 +`@angular/build` —— `@angular-devkit/build-angular` 在新工作区里根本不存在, +而 `library/builder.ts`、`util/asset-path.ts` 模块加载时就要 require 它。 + +分工: + +- `dependencies`:我们自己实现要用的(`ws`、`fs-extra`、`glob`…)。新增要同步 + `src/library/ng-package.json` 的 `allowedNonPeerDependencies`,否则 ng-packagr + 直接报错(`Dependency xxx must be explicitly allowed…`) +- `peerDependencies`:宿主工作区提供的工具链(`@angular/*`、`@angular-devkit/*`、 + `typescript`、`ng-packagr`) +- `peerDependenciesMeta.optional`:只有某条链路才需要(`vitest` 只有 + `angular-miniprogram:vitest` 用到),ng-packagr 会原样带进 dist + +改 `src/builder/**` 的 import 时扫一眼新增的包名(动态 `import()` 也算): + +```bash +grep -rn "^import \|await import(" src/builder --include=*.ts | grep -v "import type" +``` + +出现的包名在发布 package.json 里就得有。 ## 🔴 开工前先读这一条 @@ -82,7 +406,7 @@ TS6053: File '.../src/library/common/http/index.ts' not found. 自动补跑一次 `npm run sync`,因此直接 `npm run build` 即可。 > 注意:`ng-packagr` 的 `deleteDestPath: true`,单独跑 `npm run build:library` -> 会清空 `dist`,其中包含 builder/karma 的产物。需要完整产物时请跑 `npm run build`。 +> 会清空 `dist`,其中包含 builder 的产物。需要完整产物时请跑 `npm run build`。 ### 2. Node 22+ 原生 TS 加载会绕过 ts-node @@ -96,25 +420,16 @@ Jasmine 5 默认用 `import()` 加载 spec 文件。Node 22/23/24 自带 `.ts` `require()`,由 ts-node 编译(等价于 Node 21 及以下的行为),因此不再需要 `NODE_OPTIONS=--no-experimental-strip-types`。 -## 关于 karma 用例(2 个 pending) - -`src/builder/karma/index.spec.ts` 里的 `karma 运行` / `karma watch` 用例是作者标记 -为 `xdescribe` / `xit` 的本地用例:它需要真实的小程序运行环境(微信开发者工具) -连上 karma server 才能跑完,CI/容器环境下无法执行,保持 pending 属正常状态。 - -`karma` builder 本身的编译链路(`npm run build:karma`、`dist/karma/client`、 -`dist/karma/plugin`)在 `npm run build` 中已验证可用。 - ## 验证结果(Node v24.21.0) -| 命令 | 结果 | -| ------------------ | ----------------------------------------------- | -| `npm ci` | ✅ 1432 packages | -| `npm run build` | ✅ library + builder + karma 全部产出到 `dist/` | -| `npm run test` | ✅ 26 specs, 0 failures, 2 pending | -| `npm run test:ci` | ✅ | -| `npm run coverage` | ✅ | -| `npm run lint` | ✅ 0 error / 0 warning | +| 命令 | 结果 | +| ------------------ | --------------------------------------- | +| `npm ci` | ✅ 1432 packages | +| `npm run build` | ✅ library + builder 全部产出到 `dist/` | +| `npm run test` | ✅ 26 specs, 0 failures, 2 pending | +| `npm run test:ci` | ✅ | +| `npm run coverage` | ✅ | +| `npm run lint` | ✅ 0 error / 0 warning | ## Angular 版本升级记录(17 → 18 → 19 → 20) @@ -146,14 +461,14 @@ Jasmine 5 默认用 `import()` 加载 spec 文件。Node 22/23/24 自带 `.ts` ### 19 → 20 -| 项目 | 变更 | -| ----------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| 版本 | `@angular/*` 20.3.x、devkit 20.3.x(architect `0.2003.x`)、ng-packagr 20.3.x、TS 5.8、webpack 5.101、rxjs 7.8.2 | -| **ng-packagr 目录结构** | `lib/**` 全部移动到 `src/lib/**`,所有 `ng-packagr/lib/...` 深引用改为 `ng-packagr/src/lib/...` | -| 表达式 AST | 移除 `KeyedWrite` / `PropertyWrite`(赋值改为带赋值运算符的 `Binary`);新增 `visitVoidExpression` / `visitTaggedTemplateLiteral` / `visitParenthesizedExpression` | -| 模板 AST | `Visitor` 新增 `visitComponent` / `visitDirective` | -| webpack | `splitChunks.cacheGroups.test` 参数类型收紧为 `Module`(需向下转型 `NormalModule`),返回值必须是 `boolean` | -| 测试顺序 | `test/hello-world-app/node_modules/test-library` 是上一次 library 构建的拷贝,跨大版本时必须先跑 `npm run test:jasmine library`(即 `npm run test:ci`)刷新,否则会残留旧版本指令(如 v19 的 `ɵɵhostProperty` 在 v20 已删除)导致 app 构建失败 | +| 项目 | 变更 | +| ----------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| 版本 | `@angular/*` 20.3.x、devkit 20.3.x(architect `0.2003.x`)、ng-packagr 20.3.x、TS 5.8、webpack 5.101、rxjs 7.8.2 | +| **ng-packagr 目录结构** | `lib/**` 全部移动到 `src/lib/**`,所有 `ng-packagr/lib/...` 深引用改为 `ng-packagr/src/lib/...` | +| 表达式 AST | 移除 `KeyedWrite` / `PropertyWrite`(赋值改为带赋值运算符的 `Binary`);新增 `visitVoidExpression` / `visitTaggedTemplateLiteral` / `visitParenthesizedExpression` | +| 模板 AST | `Visitor` 新增 `visitComponent` / `visitDirective` | +| webpack | `splitChunks.cacheGroups.test` 参数类型收紧为 `Module`(需向下转型 `NormalModule`),返回值必须是 `boolean` | +| 测试顺序 | `test/hello-world-app/node_modules/test-library` 是上一次 library 构建的拷贝,跨大版本时必须先跑 `npm run test:ci` 刷新,否则会残留旧版本指令(如 v19 的 `ɵɵhostProperty` 在 v20 已删除)导致 app 构建失败 | ### 升级操作清单(可复用) @@ -172,7 +487,7 @@ git clean -xdfq src/library/common src/library/forms npm run sync && npm run build # 5. 先刷新 test-library 再跑全量 -npm run test:jasmine library && npm run test +npm run build:library && npm test npm run lint && npm run coverage ``` @@ -223,7 +538,7 @@ Angular 的模板插值不会自动 unwrap signal。 覆盖返回值、通知次数、抛错时仍然通知、自定义通知来源。 - `src/builder/zoneless.spec.ts`:构建整个 fixture 后扫描产物, 断言没有 `__zone_symbol__` / `zone.js/dist`,且包含 `ChangeDetectionSchedulerImpl`。 -- `test/hello-world-app/src/spec/signal-io-spec/`:小程序内 karma 用例, +- `test/hello-world-app/src/spec/signal-io-spec/`:小程序内运行时用例, 验证 signal input 渲染 + signal output 回传(需微信开发者工具,容器内跑不了)。 ### 已知限制 @@ -309,15 +624,15 @@ bootstrapPage(FooComponent); // 页面 bootstrapPage(FooComponent, { useComponent: true }); // 以 Component 而非 Page 启动 ``` -`pageStartup(module, component)` 标记 `@deprecated` 但保持可用,内部走 -`__ngStartPageWithModule`。 +`pageStartup(module, component)` 与 `AppOptions.__ngStartPageWithModule` 已删除, +页面组件必须 `standalone: true`。 ### 运行时改动 - `AppOptions.__ngStartPage(component, instance)` 改为 standalone 语义, - 内部 `createComponent` + `EnvironmentInjector`,不再产生 `NgModuleRef`。 + 内部 `createComponent` + `EnvironmentInjector`,直接返回 `ComponentRef`。 - `PageService.createPageInjector()` 统一构造带 `PAGE_TOKEN` 的子注入器。 -- `linkNgComponentWithPage` 的 `ngModuleRef` 改为可选,destroy 时用可选链。 +- `linkNgComponentWithPage` 不再接收 `NgModuleRef`,destroy 时只销毁 `componentRef`。 ### 编译器改动(standalone 引入 NgModule 的展开) @@ -342,8 +657,7 @@ standalone 组件的 `imports` 允许直接写 NgModule。这时 | `inputs: string[]`(绑定名) | `ClassPropertyMapping` | 取 `reverseMap` 的 key | | `outputs: string[]` | `ClassPropertyMapping` | 取 `reverseMap` 的 key | -`getComponentPagePattern()` 同时识别两种入口调用:`pageStartup` 取 -`arguments[1]`,`bootstrapPage` 取 `arguments[0]`。 +`getComponentPagePattern()` 只认 `bootstrapPage` 入口,组件取 `arguments[0]`。 ## miniprogram-api-typings 3 → 4 → 5 @@ -399,19 +713,20 @@ Options<{}, {}, {}> -> Options<{}, {}, {}, []> ### 21 → 22 -| 项目 | 说明 | -| ----------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| 版本 | `@angular/*` 22.1.7 / `@angular-devkit/*` 22.1.8 / ng-packagr 22.1.1 / TS 6.0.3 / webpack 5.109.2 | -| **TS 6.0:strict 默认开启** | 空 tsconfig 也会开 `noImplicitAny`。本仓库 `tsconfig.base.json` 已显式 `strict: false`,但 fixture 的没写,直接继承新默认值,冒出成堆 TS7006 / TS7008 / TS2564。显式补 `strict: false`(单独设置的 `strictNullChecks` 不受影响) | -| TS 6.0:废弃项变硬错误 | `baseUrl` / `moduleResolution=node10` / `downlevelIteration` / `target=ES5` 全部报错,加 `"ignoreDeprecations": "6.0"` | -| TS 6.0:根 tsconfig | 根 `tsconfig.json` 是 solution-style(只有 references),但 `code-recycle` 跑 sync 时 ts-node 会拿它直接用。空 `compilerOptions` 让 TS 6 用默认 `target=ES5` 并因缺 `rootDir` 报 TS5107 / TS5011,补上 `target` / `module` / `rootDir` | -| TS 6.0:@types 不再自动全量注入 | karma client 的 tsconfig 显式声明 `typeRoots` 与 `types`(`jasmine` 命名空间、`node` 的 `Console`) | -| `createNgModuleRef` 移除 | 改用 `createNgModule`(签名一致) | -| `ComponentFactoryResolver` 整体移除 | `NgModuleRef.componentFactoryResolver` 也没了。废弃的 `pageStartup(module, component)` 路径改为用模块 injector 当 `environmentInjector` 走 `createComponent` | -| `@content` 新块 | 内容查询块,依赖运行时 content query 观察投影内容并重渲染。小程序 slot / self 模板是静态的,对不上,按 `@defer` 先例显式抛错 | -| **ICU 消息**(`{x, plural/select}`) | 编译成 `ɵɵpipe` + `I18nSelect` 动态切换子模板。**实测该节点会真的出现在 `parseTemplate` 结果里**,而 `visitIcu` 曾是空实现 → 整段内容静默消失 + 后续节点槽位错位且不报错。现显式抛错。注:这**不是「做不到」**——本 fork 已有的 `__templateName`(`