apphx 1.1.0 → 2.0.1
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/README.md +50 -182
- package/benchmarks/peer-context-worker.mjs +186 -0
- package/benchmarks/peer-context.mjs +150 -0
- package/dist/apple-types.js.map +1 -1
- package/dist/cli.js +370 -118
- package/dist/cli.js.map +1 -1
- package/dist/config.js +42 -4
- package/dist/config.js.map +1 -1
- package/dist/dependencies.js +455 -16
- package/dist/dependencies.js.map +1 -1
- package/dist/export-options.js +44 -2
- package/dist/export-options.js.map +1 -1
- package/dist/flutter.js +13 -25
- package/dist/flutter.js.map +1 -1
- package/dist/go-dependencies.js +389 -0
- package/dist/go-dependencies.js.map +1 -0
- package/dist/inbox.js +7 -8
- package/dist/inbox.js.map +1 -1
- package/dist/integrations.js +30 -3
- package/dist/integrations.js.map +1 -1
- package/dist/isolation.js +190 -0
- package/dist/isolation.js.map +1 -0
- package/dist/macos.js +57 -0
- package/dist/macos.js.map +1 -0
- package/dist/mygo-build.js +55 -0
- package/dist/mygo-build.js.map +1 -0
- package/dist/policy.js +22 -0
- package/dist/policy.js.map +1 -0
- package/dist/preflight.js +1 -1
- package/dist/process.js +3 -3
- package/dist/project-profiles.js +163 -0
- package/dist/project-profiles.js.map +1 -0
- package/dist/proof.js +33 -0
- package/dist/proof.js.map +1 -0
- package/dist/remote.js +1 -1
- package/dist/runner.js +269 -287
- package/dist/runner.js.map +1 -1
- package/dist/scheduler.js +0 -3
- package/dist/scheduler.js.map +1 -1
- package/dist/schemas.js +156 -44
- package/dist/schemas.js.map +1 -1
- package/dist/store.js +166 -44
- package/dist/store.js.map +1 -1
- package/dist/vendor/obfuscator/dist/cli.js +72 -0
- package/dist/vendor/obfuscator/dist/cli.js.map +1 -0
- package/dist/vendor/obfuscator/dist/code.js +585 -0
- package/dist/vendor/obfuscator/dist/code.js.map +1 -0
- package/dist/vendor/obfuscator/dist/common.js +262 -0
- package/dist/vendor/obfuscator/dist/common.js.map +1 -0
- package/dist/vendor/obfuscator/dist/config.js +123 -0
- package/dist/vendor/obfuscator/dist/config.js.map +1 -0
- package/dist/vendor/obfuscator/dist/content.js +89 -0
- package/dist/vendor/obfuscator/dist/content.js.map +1 -0
- package/dist/vendor/obfuscator/dist/obfuscate.js +93 -0
- package/dist/vendor/obfuscator/dist/obfuscate.js.map +1 -0
- package/dist/vendor/obfuscator/dist/png.js +283 -0
- package/dist/vendor/obfuscator/dist/png.js.map +1 -0
- package/dist/vendor/obfuscator/dist/project.js +159 -0
- package/dist/vendor/obfuscator/dist/project.js.map +1 -0
- package/dist/vendor/obfuscator/dist/resources.js +335 -0
- package/dist/vendor/obfuscator/dist/resources.js.map +1 -0
- package/dist/vendor/obfuscator/package.json +22 -0
- package/dist/workspace.js +113 -0
- package/dist/workspace.js.map +1 -0
- package/evals/control.mjs +73 -0
- package/evals/expectations.mjs +37 -0
- package/evals/host.mjs +51 -0
- package/evals/oracle.mjs +80 -0
- package/evals/policy.mjs +268 -0
- package/examples/android-java.factory.json +18 -0
- package/examples/android-java.json +16 -0
- package/examples/existing-project.factory.json +18 -0
- package/examples/existing-project.json +17 -0
- package/examples/expo.eas.json +17 -0
- package/examples/expo.factory.json +18 -0
- package/examples/expo.json +16 -0
- package/examples/factory.json +0 -2
- package/examples/flutter.factory.json +13 -0
- package/examples/flutter.json +16 -0
- package/examples/luggage.json +5 -3
- package/examples/mygo-web.factory.json +18 -0
- package/examples/mygo-web.json +16 -0
- package/examples/mygo.factory.json +18 -0
- package/examples/mygo.json +16 -0
- package/examples/react-native.factory.json +18 -0
- package/examples/react-native.json +16 -0
- package/examples/runtime/android-java/app/build.gradle +7 -0
- package/examples/runtime/android-java/app/src/main/AndroidManifest.xml +8 -0
- package/examples/runtime/android-java/app/src/main/java/dev/apphx/runtime/MainActivity.java +44 -0
- package/examples/runtime/android-java/sdk-build.mjs +29 -0
- package/examples/runtime/android-java/settings.gradle +2 -0
- package/examples/runtime/android-ui.mjs +70 -0
- package/examples/runtime/mygo-native/go.mod +13 -0
- package/examples/runtime/mygo-native/go.sum +10 -0
- package/examples/runtime/mygo-native/main.go +173 -0
- package/examples/runtime/mygo-native/main_test.go +14 -0
- package/examples/runtime/mygo-native/mygo.json +1 -0
- package/examples/runtime/mygo-web/bun.lock +15 -0
- package/examples/runtime/mygo-web/go.mod +9 -0
- package/examples/runtime/mygo-web/go.sum +8 -0
- package/examples/runtime/mygo-web/index.html +1 -0
- package/examples/runtime/mygo-web/main.go +60 -0
- package/examples/runtime/mygo-web/mygo.config.ts +10 -0
- package/examples/runtime/mygo-web/package.json +1 -0
- package/examples/runtime/mygo-web/src/main.ts +23 -0
- package/examples/runtime/mygo-web/src/mygo.ts +14 -0
- package/examples/swift.factory.json +18 -0
- package/examples/swift.json +16 -0
- package/examples/uni-app.factory.json +18 -0
- package/examples/uni-app.json +16 -0
- package/package.json +12 -5
- package/scripts/bundle-obfuscator.mjs +19 -0
- package/scripts/runtime-smoke.mjs +209 -0
- package/skills/apphx/SKILL.md +94 -17
- package/skills/apphx/resources/google-play-research-result.json +6 -0
- package/skills/apphx/resources/local-research-result.json +6 -0
- package/skills/apphx/resources/requirement.json +5 -8
- package/skills/apphx/resources/research-result.json +5 -14
- package/skills/apphx/resources/review-result.json +1 -1
- package/dist/apple.js +0 -900
- package/dist/apple.js.map +0 -1
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { Counter } from './mygo';
|
|
2
|
+
const count=document.querySelector<HTMLOutputElement>('#count')!;
|
|
3
|
+
const status=document.querySelector<HTMLParagraphElement>('#status')!;
|
|
4
|
+
async function change(delta:number){try{count.textContent=String(await Counter.change(delta));status.textContent='Saved';}catch(error){status.textContent=String(error);}}
|
|
5
|
+
document.querySelector<HTMLButtonElement>('#add')!.addEventListener('click',()=>void change(3));
|
|
6
|
+
document.querySelector<HTMLButtonElement>('#subtract')!.addEventListener('click',()=>void change(-10));
|
|
7
|
+
Object.assign(window,{smokeReady:false,runAcceptance:async(relaunch=false)=>{
|
|
8
|
+
const wait=async(check:()=>boolean)=>{const until=Date.now()+10000;while(!check()){if(Date.now()>until)throw new Error('Visible UI transition timed out');const pending=Promise.withResolvers<void>();setTimeout(pending.resolve,20);await pending.promise;}};
|
|
9
|
+
if(relaunch){if(count.textContent!=='3')throw new Error('Relaunch lost persisted total');}
|
|
10
|
+
else{if(count.textContent!=='0')throw new Error('Fresh state must start at zero');document.querySelector<HTMLButtonElement>('#add')!.click();await wait(()=>count.textContent==='3'&&status.textContent==='Saved');}
|
|
11
|
+
document.querySelector<HTMLButtonElement>('#subtract')!.click();await wait(()=>status.textContent!.includes('negative'));
|
|
12
|
+
if(count.textContent!=='3')throw new Error('Rejected subtraction changed visible total');
|
|
13
|
+
return {visibleTotal:Number(count.textContent),negativeRejected:true,error:status.textContent,goValue:await Counter.value()};
|
|
14
|
+
}});
|
|
15
|
+
const controls=[...document.querySelectorAll<HTMLButtonElement>('button')];
|
|
16
|
+
controls.forEach(control=>{control.disabled=true;});
|
|
17
|
+
count.textContent='Loading';
|
|
18
|
+
void Counter.value().then(value=>{
|
|
19
|
+
count.textContent=String(value);
|
|
20
|
+
status.textContent='Ready';
|
|
21
|
+
controls.forEach(control=>{control.disabled=false;});
|
|
22
|
+
Object.assign(window,{smokeReady:true});
|
|
23
|
+
},error=>{status.textContent=`Could not load saved counter: ${String(error)}`;});
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
// Code generated by `mygo generate`. DO NOT EDIT.
|
|
2
|
+
|
|
3
|
+
import { call } from "mygo-runtime";
|
|
4
|
+
|
|
5
|
+
// ---- Services ----
|
|
6
|
+
|
|
7
|
+
export const Counter = {
|
|
8
|
+
change(delta: number): Promise<number> {
|
|
9
|
+
return call("Counter.Change", delta);
|
|
10
|
+
},
|
|
11
|
+
value(): Promise<number> {
|
|
12
|
+
return call("Counter.Value");
|
|
13
|
+
},
|
|
14
|
+
} as const;
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
{
|
|
2
|
+
"stateDir": ".factory",
|
|
3
|
+
"workspaceRoot": "workspaces",
|
|
4
|
+
"allowLocalExecution": false,
|
|
5
|
+
"allowCloudBuild": false,
|
|
6
|
+
"packageTarget": "simulator",
|
|
7
|
+
"executionEnv": ["IOS_DEVICE_ID"],
|
|
8
|
+
"verification": {
|
|
9
|
+
"prerequisites": [["xcodebuild", "-version"]],
|
|
10
|
+
"checks": [["xcodebuild", "-project", "App.xcodeproj", "-scheme", "App", "build-for-testing", "-destination", "generic/platform=iOS Simulator", "-derivedDataPath", "build/DerivedData"]],
|
|
11
|
+
"runtime": [["xcodebuild", "-project", "App.xcodeproj", "-scheme", "App", "test", "-destination", "id=${IOS_DEVICE_ID}", "-derivedDataPath", "build/DerivedData"]]
|
|
12
|
+
},
|
|
13
|
+
"packaging": {
|
|
14
|
+
"provider": "local",
|
|
15
|
+
"commands": [["xcodebuild", "-project", "App.xcodeproj", "-scheme", "App", "build", "-configuration", "Debug", "-destination", "generic/platform=iOS Simulator", "-derivedDataPath", "build/DerivedData"]],
|
|
16
|
+
"artifactPath": "build/DerivedData/Build/Products/Debug-iphonesimulator/App.app"
|
|
17
|
+
}
|
|
18
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
{
|
|
2
|
+
"id": "swift-field-notes",
|
|
3
|
+
"name": "Offline field notes",
|
|
4
|
+
"framework": "swift",
|
|
5
|
+
"platform": "ios",
|
|
6
|
+
"channel": "local",
|
|
7
|
+
"version": "1.0.0",
|
|
8
|
+
"screenshotsDir": "build/screenshots",
|
|
9
|
+
"purpose": "Capture field notes offline and restore an owner-exported backup",
|
|
10
|
+
"audience": "Field researchers",
|
|
11
|
+
"differentiators": ["Timestamped notes with a reversible local backup workflow"],
|
|
12
|
+
"acceptance": ["Create and edit a note offline, restart the app and recover the saved text", "Export notes, delete local data and restore the same notes from the export", "Exercise both workflows in XCTest UI acceptance on the selected simulator and save actual screenshots"],
|
|
13
|
+
"constraints": ["Use App.xcodeproj with shared scheme App, app product App.app and real XCTest UI tests; adapt names to the owner's project", "No accounts, tracking, hidden features or network dependency"],
|
|
14
|
+
"hiddenFeatures": false,
|
|
15
|
+
"reviewBypass": false
|
|
16
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
{
|
|
2
|
+
"stateDir": ".factory",
|
|
3
|
+
"workspaceRoot": "workspaces",
|
|
4
|
+
"allowLocalExecution": false,
|
|
5
|
+
"allowCloudBuild": false,
|
|
6
|
+
"packageTarget": "aab",
|
|
7
|
+
"executionEnv": ["ANDROID_HOME", "ANDROID_DEVICE_ID"],
|
|
8
|
+
"verification": {
|
|
9
|
+
"prerequisites": [["node", "--version"], ["java", "-version"], ["adb", "version"]],
|
|
10
|
+
"checks": [["npm", "run", "lint"], ["npm", "test", "--", "--runInBand"]],
|
|
11
|
+
"runtime": [["npm", "run", "test:e2e", "--", "--platform", "android", "--device", "${ANDROID_DEVICE_ID}"]]
|
|
12
|
+
},
|
|
13
|
+
"packaging": {
|
|
14
|
+
"provider": "local",
|
|
15
|
+
"commands": [["./native/android/gradlew", "-p", "native/android", ":app:bundleRelease"]],
|
|
16
|
+
"artifactPath": "native/android/app/build/outputs/bundle/release/app-release.aab"
|
|
17
|
+
}
|
|
18
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
{
|
|
2
|
+
"id": "uni-field-notes",
|
|
3
|
+
"name": "Offline field notes",
|
|
4
|
+
"framework": "uni-app",
|
|
5
|
+
"platform": "android",
|
|
6
|
+
"channel": "local",
|
|
7
|
+
"version": "1.0.0",
|
|
8
|
+
"screenshotsDir": "build/screenshots",
|
|
9
|
+
"purpose": "Capture field notes offline and restore an owner-exported backup",
|
|
10
|
+
"audience": "Field researchers",
|
|
11
|
+
"differentiators": ["Timestamped notes with a reversible local backup workflow"],
|
|
12
|
+
"acceptance": ["Create and edit a note offline, restart the app and recover the saved text", "Export notes, delete local data and restore the same notes from the export", "Run real device acceptance for both workflows using the project's test:e2e script and save actual screenshots"],
|
|
13
|
+
"constraints": ["Use a CLI uni-app project with an owner-maintained native/android offline SDK host and app Gradle module", "During the host build turn, run the existing real build:app script and integrate its generated App resources into the native host before verification; packaging only compiles that verified host", "Adapt lint, test and test:e2e to the project's existing scripts; do not replace native acceptance with H5-only tests", "Owner supplies the DCloud offline SDK, app identifiers and release signing; do not convert or rewrite an existing project", "No accounts, tracking, hidden features or network dependency"],
|
|
14
|
+
"hiddenFeatures": false,
|
|
15
|
+
"reviewBypass": false
|
|
16
|
+
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "apphx",
|
|
3
|
-
"version": "
|
|
4
|
-
"description": "Host-native harness for verified Flutter app delivery",
|
|
3
|
+
"version": "2.0.1",
|
|
4
|
+
"description": "Host-native harness for verified Flutter, Java Android, Swift, React Native, Expo, uni-app and MyGo macOS delivery",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"preferUnplugged": true,
|
|
@@ -9,12 +9,16 @@
|
|
|
9
9
|
"node": ">=22.18"
|
|
10
10
|
},
|
|
11
11
|
"bin": {
|
|
12
|
-
"apphx": "./dist/cli.js"
|
|
12
|
+
"apphx": "./dist/cli.js",
|
|
13
|
+
"obfuscator": "./dist/vendor/obfuscator/dist/cli.js"
|
|
13
14
|
},
|
|
14
15
|
"files": [
|
|
15
16
|
"dist",
|
|
16
17
|
"skills",
|
|
17
|
-
"examples"
|
|
18
|
+
"examples",
|
|
19
|
+
"evals",
|
|
20
|
+
"scripts",
|
|
21
|
+
"benchmarks"
|
|
18
22
|
],
|
|
19
23
|
"repository": {
|
|
20
24
|
"type": "git",
|
|
@@ -25,8 +29,11 @@
|
|
|
25
29
|
"yaml": "^2.9.1",
|
|
26
30
|
"zod": "^4.6.7"
|
|
27
31
|
},
|
|
32
|
+
"devDependencies": {
|
|
33
|
+
"obfuscator": "0.1.0"
|
|
34
|
+
},
|
|
28
35
|
"scripts": {
|
|
29
|
-
"build": "tsdown",
|
|
36
|
+
"build": "tsdown && node scripts/bundle-obfuscator.mjs",
|
|
30
37
|
"typecheck": "tsc --noEmit",
|
|
31
38
|
"test": "node --test"
|
|
32
39
|
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { chmodSync, cpSync, existsSync, mkdirSync } from 'node:fs';
|
|
2
|
+
import { dirname, join } from 'node:path';
|
|
3
|
+
import { fileURLToPath } from 'node:url';
|
|
4
|
+
|
|
5
|
+
// Bundle the private obfuscator into this package so installing apphx also provides the
|
|
6
|
+
// `obfuscator` executable and the harness can resolve its default obfuscation command.
|
|
7
|
+
const packageRoot = fileURLToPath(new URL('..', import.meta.url));
|
|
8
|
+
const source = join(packageRoot, '..', 'obfuscator');
|
|
9
|
+
const target = join(packageRoot, 'dist', 'vendor', 'obfuscator');
|
|
10
|
+
|
|
11
|
+
if (!existsSync(join(source, 'dist', 'cli.js'))) {
|
|
12
|
+
console.error('obfuscator dist is missing; build packages/obfuscator first (turbo orders this via the workspace dependency)');
|
|
13
|
+
process.exit(1);
|
|
14
|
+
}
|
|
15
|
+
mkdirSync(dirname(target), { recursive: true });
|
|
16
|
+
cpSync(join(source, 'package.json'), join(target, 'package.json'));
|
|
17
|
+
cpSync(join(source, 'dist'), join(target, 'dist'), { recursive: true });
|
|
18
|
+
chmodSync(join(target, 'dist', 'cli.js'), 0o755);
|
|
19
|
+
console.log(`bundled obfuscator into ${target}`);
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { createHash, randomBytes } from 'node:crypto';
|
|
4
|
+
import { spawn, spawnSync } from 'node:child_process';
|
|
5
|
+
import { cpSync, existsSync, mkdirSync, openSync, closeSync, readFileSync, readdirSync, writeFileSync } from 'node:fs';
|
|
6
|
+
import { createServer } from 'node:net';
|
|
7
|
+
import { dirname, join, resolve } from 'node:path';
|
|
8
|
+
import { fileURLToPath } from 'node:url';
|
|
9
|
+
|
|
10
|
+
const help = `Actual apphx runtime workflows (no product build or installation of tools).
|
|
11
|
+
node scripts/runtime-smoke.mjs --out FRESH_DIRECTORY --stack android-java|mygo|all [options]
|
|
12
|
+
Android: --sdk SDK --java-home JDK [--gradle COMPATIBLE_CACHED_GRADLE] [--system-image system-images;android-35;google_apis;arm64-v8a]
|
|
13
|
+
MyGo: --go GO_BINARY --gopath CACHED_GOPATH --bun BUN_BINARY --frontend-modules EXISTING_NODE_MODULES [--go-cache CACHE]
|
|
14
|
+
Optional: --cli CURRENT_DIST_CLI --retained-root ORIGINAL_MYGO_FIXTURE_ROOT
|
|
15
|
+
All paths are owner supplied. Android uses a fresh AVD/home and private ADB server,
|
|
16
|
+
never user devices. Optional cached Gradle uses an empty user home and offline
|
|
17
|
+
built-in Exec project. Without --gradle the same owner-defined SDK pipeline runs
|
|
18
|
+
directly: aapt -> javac -> d8 -> ZIP -> zipalign -> ephemeral debug apksigner key.
|
|
19
|
+
MyGo dependencies must already be cached (GOPROXY=off); frontend node_modules are
|
|
20
|
+
copied from an owner-supplied installation. No package install runs. MyGo native
|
|
21
|
+
and WKWebView windows run serially on a logged-in macOS
|
|
22
|
+
desktop. Native uses actual AppKit accessibility button actions; Web invokes actual
|
|
23
|
+
DOM button handlers and Go bridge. Both relaunch and verify durable state. This is
|
|
24
|
+
not OS-pointer automation, Apple device testing, publication or store-policy proof.
|
|
25
|
+
Each CLI workflow abandons and reacquires an interrupted build turn under the same
|
|
26
|
+
job ID, then verifies, reviews and packages. No state/database editing is used.
|
|
27
|
+
Outputs include raw command logs, CLI transitions, source snapshots, runtime PNGs,
|
|
28
|
+
artifact validation and evidence.json. Original fixtures remain untouched.
|
|
29
|
+
`;
|
|
30
|
+
const args = process.argv.slice(2), options = {};
|
|
31
|
+
if (!args.length || args.includes('--help')) { console.log(help); process.exit(0); }
|
|
32
|
+
for (let i = 0; i < args.length; i += 2) {
|
|
33
|
+
assert.match(args[i], /^--[a-z-]+$/); assert.ok(args[i + 1] && !args[i + 1].startsWith('--'), `Missing ${args[i]}`);
|
|
34
|
+
options[args[i].slice(2)] = args[i + 1];
|
|
35
|
+
}
|
|
36
|
+
for (const key of Object.keys(options)) assert.ok(['out','stack','sdk','java-home','gradle','system-image','go','gopath','bun','frontend-modules','go-cache','cli','retained-root'].includes(key), `Unknown --${key}`);
|
|
37
|
+
assert.ok(options.out, '--out must be a fresh directory');
|
|
38
|
+
const root = resolve(options.out);
|
|
39
|
+
assert.ok(!existsSync(root), 'Refusing an existing output directory');
|
|
40
|
+
mkdirSync(root, { recursive: true });
|
|
41
|
+
const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
|
42
|
+
const fixtures = join(packageRoot, 'examples/runtime');
|
|
43
|
+
const cli = resolve(options.cli ?? join(packageRoot, 'dist/cli.js'));
|
|
44
|
+
const stack = options.stack ?? 'all';
|
|
45
|
+
assert.ok(['all', 'android-java', 'mygo'].includes(stack));
|
|
46
|
+
const evidence = { root, cli, startedAt: new Date().toISOString(), runs: [], commands: [], limitations: ['Local build-only; no Apple devices, store approval or publishing', 'Acceptance drivers exercise native accessibility/DOM controls, not external physical input'], cleanup: [] };
|
|
47
|
+
let sequence = 0;
|
|
48
|
+
function command(argv, { cwd = root, env = process.env, input, timeout = 300000 } = {}) {
|
|
49
|
+
const result = spawnSync(argv[0], argv.slice(1), { cwd, env, input, encoding: 'utf8', timeout, maxBuffer: 32 * 1024 * 1024 });
|
|
50
|
+
const log = join(root, `command-${String(++sequence).padStart(3, '0')}.json`);
|
|
51
|
+
writeFileSync(log, JSON.stringify({ argv, cwd, status: result.status, signal: result.signal, error: result.error?.message, stdout: result.stdout, stderr: result.stderr }, null, 2));
|
|
52
|
+
evidence.commands.push(log);
|
|
53
|
+
if (result.error || result.status !== 0) throw result.error ?? new Error(`${argv.join(' ')} exited ${result.status}; see ${log}\n${result.stderr}`);
|
|
54
|
+
return result.stdout;
|
|
55
|
+
}
|
|
56
|
+
function snapshot(directory) {
|
|
57
|
+
const files = {};
|
|
58
|
+
function walk(relative = '') {
|
|
59
|
+
for (const entry of readdirSync(join(directory, relative), { withFileTypes: true }).sort((a,b) => a.name.localeCompare(b.name))) {
|
|
60
|
+
const name = join(relative, entry.name);
|
|
61
|
+
assert.ok(!entry.isSymbolicLink(), `Source symlink refused: ${name}`);
|
|
62
|
+
if (entry.isDirectory()) walk(name);
|
|
63
|
+
else files[name] = createHash('sha256').update(readFileSync(join(directory, name))).digest('hex');
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
walk(); return files;
|
|
67
|
+
}
|
|
68
|
+
function png(path) {
|
|
69
|
+
const bytes = readFileSync(path);
|
|
70
|
+
assert.equal(bytes.subarray(0,8).toString('hex'), '89504e470d0a1a0a');
|
|
71
|
+
assert.ok(bytes.length > 1000); assert.ok(bytes.readUInt32BE(16) > 100 && bytes.readUInt32BE(20) > 100);
|
|
72
|
+
return { path, bytes: bytes.length, width: bytes.readUInt32BE(16), height: bytes.readUInt32BE(20), sha256: createHash('sha256').update(bytes).digest('hex') };
|
|
73
|
+
}
|
|
74
|
+
async function workflow(name, profile, environment, prepare = () => {}) {
|
|
75
|
+
const control = join(root, name); mkdirSync(control);
|
|
76
|
+
const source = join(control, 'source');
|
|
77
|
+
cpSync(join(fixtures, name), source, { recursive: true, filter: path => !['build', 'dist', 'node_modules', '.gradle'].includes(path.split('/').at(-1)) });
|
|
78
|
+
const before = snapshot(source);
|
|
79
|
+
const spec = { id: `runtime-${name}`, name: profile.name, framework: profile.framework, platform: profile.platform, channel: 'local', version: '0.1.0', purpose: 'Exercise a real visible application and durable local records', audience: 'Application maintainers', differentiators: ['Real compiled application and persisted UI state'], acceptance: [profile.acceptance], screenshotsDir: 'build/screenshots', project: { sourceDir: source } };
|
|
80
|
+
const config = { stateDir: join(control, 'state'), workspaceRoot: join(control, 'workspaces'), allowLocalExecution: true, allowSubmit: false, allowRelease: false, allowCloudBuild: false, timeoutMs: 300000, retryDelayMs: 1, packageTarget: profile.target, executionEnv: profile.executionEnv, verification: { prerequisites: profile.prerequisites, checks: [profile.build], runtime: profile.runtime }, packaging: { provider: 'local', commands: [profile.build], artifactPath: profile.artifact } };
|
|
81
|
+
const configPath = join(control, 'factory.json'), specPath = join(control, 'requirement.json');
|
|
82
|
+
writeFileSync(configPath, JSON.stringify(config, null, 2)); writeFileSync(specPath, JSON.stringify(spec, null, 2));
|
|
83
|
+
const transitions = [];
|
|
84
|
+
function invoke(argv) { const value = JSON.parse(command([process.execPath, cli, ...argv, '--config', configPath, '--build-only'], { cwd: control, env: environment })); transitions.push({ argv, value }); writeFileSync(join(control, 'transitions.json'), JSON.stringify(transitions, null, 2)); return value; }
|
|
85
|
+
const job = invoke(['enqueue', specPath]);
|
|
86
|
+
let turn = invoke(['next', job.id]);
|
|
87
|
+
const research = join(control, 'research.json');
|
|
88
|
+
writeFileSync(research, JSON.stringify({ viable: true, policySources: [], distinctness: 'Local runtime validation only, no store differentiation or approval claim.', plan: ['Compile real application, interact with visible controls, relaunch durable state, validate signed artifact'] }));
|
|
89
|
+
invoke(['complete', job.id, turn.turn.id, research]);
|
|
90
|
+
turn = invoke(['next', job.id]); assert.equal(turn.turn.stage, 'build');
|
|
91
|
+
const interrupted = turn.turn.id;
|
|
92
|
+
invoke(['abandon', job.id, interrupted]);
|
|
93
|
+
turn = invoke(['next', job.id]); assert.equal(turn.turn.stage, 'build'); assert.notEqual(turn.turn.id, interrupted); assert.equal(turn.job.id, job.id);
|
|
94
|
+
const resumed = turn.turn.id;
|
|
95
|
+
const workspace = turn.turn.cwd;
|
|
96
|
+
prepare(workspace);
|
|
97
|
+
invoke(['complete', job.id, turn.turn.id]);
|
|
98
|
+
const verified = invoke(['step', job.id]); assert.equal(verified.stage, 'review', JSON.stringify(verified));
|
|
99
|
+
const screenshots = profile.screenshots.map(path => png(join(workspace, path)));
|
|
100
|
+
profile.assertRuntime(workspace);
|
|
101
|
+
turn = invoke(['next', job.id]);
|
|
102
|
+
const review = join(control, 'review.json');
|
|
103
|
+
writeFileSync(review, JSON.stringify({ approved: true, findings: [], acceptanceEvidence: [{ requirement: spec.acceptance[0], evidence: `Actual compiled UI interaction and persisted relaunch succeeded in runtime logs; validated PNGs: ${screenshots.map(item => item.sha256).join(', ')}. This driver records observable acceptance, not an independent policy review.` }] }));
|
|
104
|
+
invoke(['complete', job.id, turn.turn.id, review]);
|
|
105
|
+
const built = invoke(['step', job.id]); assert.equal(built.status, 'built', JSON.stringify(built)); assert.equal(built.artifact.kind, profile.kind);
|
|
106
|
+
profile.validateArtifact(join(workspace, profile.artifact), environment);
|
|
107
|
+
const after = snapshot(source); assert.deepEqual(after, before);
|
|
108
|
+
for (const [path, digest] of Object.entries(before)) assert.equal(createHash('sha256').update(readFileSync(join(workspace, path))).digest('hex'), digest, `Imported source changed: ${path}`);
|
|
109
|
+
writeFileSync(join(control, 'source-snapshots.json'), JSON.stringify({ before, after }, null, 2));
|
|
110
|
+
const events = invoke(['events', job.id]);
|
|
111
|
+
evidence.runs.push({ name, jobId: job.id, control, workspace, artifact: built.artifact, screenshots, interruptedTurn: interrupted, resumedTurn: resumed, interruptionRecovery: 'CLI abandon + next retained job ID and issued a fresh build turn', sourceUnchanged: true, events });
|
|
112
|
+
}
|
|
113
|
+
async function freePort(preferred) {
|
|
114
|
+
const server = createServer();
|
|
115
|
+
await new Promise((ok, fail) => { server.once('error', fail); server.listen(preferred ?? 0, '127.0.0.1', ok); });
|
|
116
|
+
const port = server.address().port;
|
|
117
|
+
await new Promise(ok => server.close(ok)); return port;
|
|
118
|
+
}
|
|
119
|
+
const retainedBefore = options['retained-root'] ? Object.fromEntries(['native-source','web-source'].map(name => [name, snapshot(join(options['retained-root'], name))])) : null;
|
|
120
|
+
async function android() {
|
|
121
|
+
for (const key of ['sdk','java-home']) assert.ok(options[key], `Android requires --${key}`);
|
|
122
|
+
const sdk = resolve(options.sdk), java = resolve(options['java-home']);
|
|
123
|
+
const owned = join(root, 'android-device'); mkdirSync(owned);
|
|
124
|
+
const avdHome = join(owned, 'avd'), home = join(owned, 'home'); mkdirSync(avdHome); mkdirSync(home);
|
|
125
|
+
const env = { ...process.env, ANDROID_SDK_ROOT: sdk, ANDROID_HOME: sdk, JAVA_HOME: java, ANDROID_AVD_HOME: avdHome, ANDROID_USER_HOME: home, ANDROID_EMULATOR_HOME: home, GRADLE_USER_HOME: join(owned, 'gradle'), PATH: `${join(java,'bin')}:${process.env.PATH}` };
|
|
126
|
+
const avd = `apphx-smoke-${randomBytes(8).toString('hex')}`, port = await freePort();
|
|
127
|
+
let emulatorPort;
|
|
128
|
+
for (let candidate = 5580; candidate < 5680; candidate += 2) { try { await freePort(candidate); await freePort(candidate + 1); emulatorPort = candidate; break; } catch {} }
|
|
129
|
+
assert.ok(emulatorPort, 'No unused emulator console/ADB port pair');
|
|
130
|
+
const serial = `emulator-${emulatorPort}`;
|
|
131
|
+
env.ANDROID_ADB_SERVER_PORT = String(port); env.ADB_SERVER_SOCKET = `tcp:127.0.0.1:${port}`;
|
|
132
|
+
const session = join(owned, 'session.json'); writeFileSync(session, JSON.stringify({ sdk, avd, serial, port }));
|
|
133
|
+
const adb = (...args) => command([join(sdk, 'platform-tools/adb'), '-P', String(port), ...args], { env, timeout: 45000 });
|
|
134
|
+
let emulator;
|
|
135
|
+
const interrupted = signal => {
|
|
136
|
+
if (emulator?.pid) emulator.kill('SIGKILL');
|
|
137
|
+
try { adb('kill-server'); } catch {}
|
|
138
|
+
evidence.status = 'interrupted'; evidence.error = signal;
|
|
139
|
+
evidence.cleanup.push({ emulatorPid: emulator?.pid, adbPort: port, signalCleanupRequested: true });
|
|
140
|
+
writeFileSync(join(root, 'evidence.json'), JSON.stringify(evidence, null, 2));
|
|
141
|
+
process.exit(130);
|
|
142
|
+
};
|
|
143
|
+
const onInterrupt = () => interrupted('SIGINT'), onTerminate = () => interrupted('SIGTERM');
|
|
144
|
+
process.once('SIGINT', onInterrupt); process.once('SIGTERM', onTerminate);
|
|
145
|
+
try {
|
|
146
|
+
command([join(sdk, 'cmdline-tools/22.0/bin/avdmanager'), 'create', 'avd', '-n', avd, '-k', options['system-image'] ?? 'system-images;android-35;google_apis;arm64-v8a', '-p', join(avdHome, avd + '.avd')], { env, input: 'no\n' });
|
|
147
|
+
adb('start-server');
|
|
148
|
+
const fd = openSync(join(owned, 'emulator.log'), 'w');
|
|
149
|
+
emulator = spawn(join(sdk, 'emulator/emulator'), ['-avd', avd, '-port', String(emulatorPort), '-no-snapshot', '-no-audio', '-no-boot-anim', '-gpu', 'swiftshader_indirect', '-camera-back', 'none', '-camera-front', 'none'], { env, stdio: ['ignore', fd, fd] }); closeSync(fd);
|
|
150
|
+
let launchError; emulator.on('error', error => { launchError = error; });
|
|
151
|
+
const deadline = Date.now() + 180000;
|
|
152
|
+
let booted = false;
|
|
153
|
+
while (Date.now() < deadline) {
|
|
154
|
+
await new Promise(ok => setTimeout(ok, 1500));
|
|
155
|
+
if (launchError) throw launchError;
|
|
156
|
+
if (emulator.exitCode !== null) throw new Error(`Owned emulator exited ${emulator.exitCode}; see emulator.log`);
|
|
157
|
+
const result = spawnSync(join(sdk, 'platform-tools/adb'), ['-P', String(port), '-s', serial, 'shell', 'getprop', 'sys.boot_completed'], { env, encoding: 'utf8', timeout: 5000 });
|
|
158
|
+
if (result.status === 0 && result.stdout.trim() === '1') { booted = true; break; }
|
|
159
|
+
}
|
|
160
|
+
assert.ok(booted, 'Owned emulator did not boot within 180s');
|
|
161
|
+
adb('-s', serial, 'shell', 'input', 'keyevent', '82');
|
|
162
|
+
const build = options.gradle ? [resolve(options.gradle), '--offline', '--no-daemon', '--console=plain', `-Pnode=${process.execPath}`, ':app:assembleDebug'] : [process.execPath, 'sdk-build.mjs'];
|
|
163
|
+
await workflow('android-java', { name: 'apphx Local Records', framework: 'android-java', platform: 'android', target: 'apk', kind: 'apk', executionEnv: ['ANDROID_SDK_ROOT','ANDROID_HOME','JAVA_HOME','GRADLE_USER_HOME'], prerequisites: [[join(java,'bin/java'), '-version']], build, runtime: [[process.execPath, join(fixtures,'android-ui.mjs'), session]], artifact: 'build/records.apk', acceptance: 'Enter a record with native Android controls; save and force-stop/relaunch; visible text and SharedPreferences retain the exact record', screenshots: ['build/screenshots/saved.png','build/screenshots/relaunch.png'], assertRuntime: workspace => { const value = JSON.parse(readFileSync(join(workspace,'build/runtime-evidence.json'))); assert.equal(value.relaunched, true); assert.equal(value.avd,avd); }, validateArtifact: artifact => { assert.equal(readFileSync(artifact).subarray(0,2).toString(),'PK'); command([join(sdk,'build-tools/35.0.0/apksigner'),'verify','--verbose','--print-certs',artifact],{env}); command([join(sdk,'build-tools/35.0.0/aapt'),'dump','badging',artifact],{env}); } }, env);
|
|
164
|
+
} finally {
|
|
165
|
+
if (emulator && emulator.exitCode === null && emulator.signalCode === null) {
|
|
166
|
+
emulator.kill('SIGTERM');
|
|
167
|
+
await Promise.race([new Promise(ok => emulator.once('exit', ok)), new Promise(ok => setTimeout(ok,5000))]);
|
|
168
|
+
if (emulator.exitCode === null && emulator.signalCode === null) {
|
|
169
|
+
emulator.kill('SIGKILL');
|
|
170
|
+
await new Promise(ok => emulator.once('exit', ok));
|
|
171
|
+
}
|
|
172
|
+
evidence.cleanup.push({ emulatorPid: emulator.pid, exitCode: emulator.exitCode, signal: emulator.signalCode, exited: true });
|
|
173
|
+
}
|
|
174
|
+
try { adb('kill-server'); evidence.cleanup.push({ adbPort:port, stopped:true }); } catch(error) { evidence.cleanup.push({ adbPort:port, error:error.message }); }
|
|
175
|
+
process.removeListener('SIGINT', onInterrupt); process.removeListener('SIGTERM', onTerminate);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
async function mygo() {
|
|
179
|
+
for (const key of ['go','gopath','bun','frontend-modules']) assert.ok(options[key], `MyGo requires --${key}`);
|
|
180
|
+
const go = resolve(options.go), gopath = resolve(options.gopath);
|
|
181
|
+
const env = { ...process.env, PATH: `${dirname(go)}:${dirname(resolve(options.bun))}:${process.env.PATH}`, GOPATH:gopath, GOMODCACHE:join(gopath,'pkg/mod'), GOCACHE:resolve(options['go-cache'] ?? join(root,'gocache')), GOPROXY:'off', GOSUMDB:'off', GOTOOLCHAIN:'local', GOWORK:'off', CGO_ENABLED:'0' };
|
|
182
|
+
const build = [go,'tool','mygo','build','-platform',`darwin/${process.arch}`,'-sign','-','-skip-notarize','-skip-dmg','-o','build'];
|
|
183
|
+
for (const mode of ['native','web']) {
|
|
184
|
+
const name = mode === 'native' ? 'MyGo App' : 'MyGo Web App';
|
|
185
|
+
const artifact = `build/darwin-${process.arch}/${name}.app`, executable = `${artifact}/Contents/MacOS/${name}`;
|
|
186
|
+
const state = 'build/counter-state';
|
|
187
|
+
const runtime = mode === 'native' ? [[executable,'-acceptance','first','-state-file',state,'-screenshot','build/screenshots/first.png'],[executable,'-acceptance','relaunch','-state-file',state,'-screenshot','build/screenshots/relaunch.png']] : [[executable,'-acceptance','-state-file',state,'-screenshot','build/screenshots/first.png'],[executable,'-acceptance','-relaunch','-state-file',state,'-screenshot','build/screenshots/relaunch.png']];
|
|
188
|
+
await workflow(`mygo-${mode}`, { name, framework:'mygo', platform:'macos', target:'app', kind:'macos-app', executionEnv:['GOPATH','GOMODCACHE','GOCACHE','GOPROXY','GOSUMDB','CGO_ENABLED'], prerequisites:[[go,'version'],[go,'tool','mygo','version']], build, runtime, artifact, acceptance:mode === 'native' ? 'Visible AppKit buttons transition 1,2,1; relaunch restores 1 and Reset persists 0' : 'Visible WKWebView Add saves 3 through Go bridge; negative subtraction is rejected; fresh process restores visible 3', screenshots:['build/screenshots/first.png','build/screenshots/relaunch.png'], assertRuntime: workspace => assert.equal(readFileSync(join(workspace,state),'utf8'),mode === 'native' ? '0':'3'), validateArtifact: artifact => { command(['/usr/bin/codesign','--verify','--deep','--strict',artifact],{env}); const result = command(['/usr/bin/file',join(artifact,'Contents/MacOS',name)],{env}); assert.ok(result.includes('Mach-O')); } }, env, workspace => {
|
|
189
|
+
if (mode === 'web') {
|
|
190
|
+
const modules = resolve(options['frontend-modules']);
|
|
191
|
+
assert.equal(JSON.parse(readFileSync(join(modules, 'mygo-runtime/package.json'))).version, '0.3.0');
|
|
192
|
+
cpSync(modules, join(workspace, 'node_modules'), { recursive: true, dereference: true });
|
|
193
|
+
command(build, { cwd: workspace, env });
|
|
194
|
+
}
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
try {
|
|
199
|
+
if (stack === 'all' || stack === 'android-java') await android();
|
|
200
|
+
if (stack === 'all' || stack === 'mygo') await mygo();
|
|
201
|
+
evidence.status = 'passed';
|
|
202
|
+
} catch (error) {
|
|
203
|
+
evidence.status = 'failed'; evidence.error = error.stack; process.exitCode = 1;
|
|
204
|
+
} finally {
|
|
205
|
+
if (retainedBefore) { const retainedAfter = Object.fromEntries(['native-source','web-source'].map(name => [name,snapshot(join(options['retained-root'],name))])); evidence.retainedOriginalsUnchanged = JSON.stringify(retainedBefore) === JSON.stringify(retainedAfter); if (!evidence.retainedOriginalsUnchanged) { evidence.status='failed'; process.exitCode=1; } }
|
|
206
|
+
evidence.finishedAt = new Date().toISOString();
|
|
207
|
+
writeFileSync(join(root,'evidence.json'),JSON.stringify(evidence,null,2));
|
|
208
|
+
console.log(JSON.stringify(evidence,null,2));
|
|
209
|
+
}
|