Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -465,6 +465,13 @@ def getRunTestsBuildArguments = { taskName ->
if (onlyX86) {
arguments.add("-PonlyX86")
}
// App file modes, see test-app/tools/appfiles-test-mode.init.gradle
if (project.hasProperty("loadCodeFromApk")) {
arguments.add("-PloadCodeFromApk")
}
if (project.hasProperty("storeCodeUncompressed")) {
arguments.add("-PstoreCodeUncompressed")
}
if (project.hasProperty("abis")) {
arguments.add("-Pabis=${project.property('abis')}")
}
Expand Down
22 changes: 22 additions & 0 deletions test-app/app/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -216,9 +216,31 @@ def computeNamespace = { ->
return "com.tns.testapplication"
}

/*
* With "android": { "storeCodeUncompressed": true } in app/package.json, code
* is stored in the APK uncompressed, so loadCodeFromApk reads it without
* inflating. noCompress matches by file-name suffix across the whole APK, not
* only under assets/app, so the metadata streams are named in full.
*/
project.ext.nsUncompressedCodeSuffixes = [".js", ".mjs", ".cjs", ".map",
"treeNodeStream.dat", "treeStringsStream.dat", "treeValueStream.dat"]

def computeStoreCodeUncompressed = { ->
def appPackageJsonFile = file("${APP_DIR}/$PACKAGE_JSON")
if (!appPackageJsonFile.exists()) {
return false
}
def packageJsonMap = new JsonSlurper().parseText(appPackageJsonFile.getText("UTF-8"))
return packageJsonMap?.android?.storeCodeUncompressed == true
}

android {
namespace = computeNamespace()

if (computeStoreCodeUncompressed()) {
androidResources.noCompress.addAll(project.ext.nsUncompressedCodeSuffixes)
}

applyBeforePluginGradleConfiguration()

kotlinOptions {
Expand Down
3 changes: 3 additions & 0 deletions test-app/app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,9 @@
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<meta-data
android:name="com.tns.tests.TestAppFileLayersCustomizer"
android:value="org.nativescript.AppFileLayersCustomizer" />
<activity
android:name="com.tns.ErrorReportActivity"
android:label="ErrorActivity">
Expand Down
3 changes: 3 additions & 0 deletions test-app/app/src/main/assets/app/mainpage.js
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,9 @@ require('./tests/testCreateRequire');
require('./tests/testNodeUrlModule');
require('./tests/testImportMetaResolution');
require('./tests/testWorkerEsmEntry');
require('./tests/testCodeCache');
require('./tests/testAppFileLayers');
require('./tests/testLoadCodeFromApk');
// Worker wrapper reachability across GC (strong while running, collectable after)
require('./tests/testWorkerLifetime');
// Java->JS calls route to the runtime that created the object
Expand Down
4 changes: 3 additions & 1 deletion test-app/app/src/main/assets/app/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,9 @@
"forceLog": false,
"suppressCallJSMethodExceptions": false,
"enableLineBreakpoints": false,
"enableMultithreadedJavascript": true
"enableMultithreadedJavascript": true,
"codeCache": true,
"alwaysExtract": ["tests/alwaysExtracted/**"]
},
"discardUncaughtJsExceptions": false,
"security": {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
module.exports.value = 'kept';
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
module.exports.value = 'target';
module.exports.filename = __filename;
16 changes: 16 additions & 0 deletions test-app/app/src/main/assets/app/tests/codeCacheLoadWorker.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
onmessage = function (msg) {
var data = msg.data;
var loaded;
try {
loaded = data.isModule
? import(data.path).then(function (ns) { return ns.value; })
: Promise.resolve(require(data.path).value);
} catch (e) {
loaded = Promise.reject(e);
}
loaded.then(function (value) {
postMessage({ value: value });
}, function (e) {
postMessage({ error: String(e) });
});
};
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export const value = 'alias';
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
module.exports.value = 'apk';
42 changes: 42 additions & 0 deletions test-app/app/src/main/assets/app/tests/testAppFileLayers.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
// Layers come from com.tns.tests.TestAppFileLayersCustomizer, registered in the
// test app's manifest.
describe("App file layers", function () {
var dir = __dirname + "/appFileLayers/";

it("serves a map entry from an APK asset under the requested path", function () {
var remapped = require("./appFileLayers/remapped.js");
expect(remapped.value).toBe("target");
expect(remapped.filename).toBe(dir + "remapped.js");
expect(com.tns.AppFiles.isFile(dir + "remapped.js")).toBe(true);
});

it("serves files from a customizer's directory layer", function () {
expect(require("./appFileLayers/fromDirectory.js").value).toBe("directory");
});

it("serves ES modules from a customizer's directory layer", function (done) {
import(dir + "fromDirectory.mjs").then(function (ns) {
expect(ns.value).toBe("directory-esm");
done();
}, function (e) {
expect(String(e)).toBe("");
done();
});
});

it("serves files from a customizer's APK asset layer", function () {
expect(require("./appFileLayers/fromApkBase.js").value).toBe("apk-assets");
});

it("hides a whited-out path that a lower layer has", function () {
expect(com.tns.AppFiles.isFile(dir + "whitedOut.js")).toBe(false);
expect(function () {
require("./appFileLayers/whitedOut.js");
}).toThrow();
});

it("never layers the runtime's internal files", function () {
expect(com.tns.AppFiles.isFile(__approot + "/internal/ts_helpers.js")).toBe(true);
expect(com.tns.AppFiles.isFile(__approot + "/internal/tests/appFileLayers/remapped.js")).toBe(false);
});
});
185 changes: 185 additions & 0 deletions test-app/app/src/main/assets/app/tests/testCodeCache.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,185 @@
// Needs "codeCache": true in the test app's package.json. Every load runs in a
// fresh worker, which has its own module registry, so the probe compiles again
// and consults the cache entry the previous load left behind.
describe("V8 code cache", function () {
var File = java.io.File;
var HEADER_SIZE = 24;
var UNTOUCHED_MTIME = 1000000;

var filesDir = new File(__approot);
var cacheDir = new File(filesDir.getParentFile(), "code_cache/v8");
var probeDir = new File(filesDir, "app/codeCacheProbe");

// Mirrors the runtime's entry name: FNV-1a 64 of the logical path.
function cacheFileFor(path, isModule) {
var hash = 0xcbf29ce484222325n;
var mask = (1n << 64n) - 1n;
var bytes = new TextEncoder().encode(path);
for (var i = 0; i < bytes.length; i++) {
hash ^= BigInt(bytes[i]);
hash = (hash * 0x100000001b3n) & mask;
}
var name = hash.toString(16).padStart(16, "0") + (isModule ? ".mcache" : ".cache");
return new File(cacheDir, name);
}

function writeText(file, text) {
var writer = new java.io.FileWriter(file);
writer.write(text);
writer.close();
}

function readBytes(file) {
var raf = new java.io.RandomAccessFile(file, "r");
var bytes = Array.create("byte", raf.length());
raf.readFully(bytes);
raf.close();
return bytes;
}

function writeByte(file, offset, value) {
var raf = new java.io.RandomAccessFile(file, "rw");
raf.seek(offset);
raf.write(value);
raf.close();
}

function readUint32(bytes, offset) {
return ((bytes[offset] & 255) |
((bytes[offset + 1] & 255) << 8) |
((bytes[offset + 2] & 255) << 16) |
((bytes[offset + 3] & 255) << 24)) >>> 0;
}

function writeUint32(file, offset, value) {
for (var i = 0; i < 4; i++) {
writeByte(file, offset + i, (value >>> (8 * i)) & 255);
}
}

function payloadCrc(bytes) {
var crc = new java.util.zip.CRC32();
crc.update(bytes, HEADER_SIZE, bytes.length - HEADER_SIZE);
return Number(crc.getValue());
}

function expectValidEntry(file) {
expect(file.isFile()).toBe(true);
var bytes = readBytes(file);
expect(bytes.length).toBeGreaterThan(HEADER_SIZE);
var magic = String.fromCharCode(bytes[0], bytes[1], bytes[2], bytes[3]);
expect(magic).toBe("NSCC");
expect(readUint32(bytes, 16)).toBe(bytes.length - HEADER_SIZE);
expect(readUint32(bytes, 20)).toBe(payloadCrc(bytes));
return bytes;
}

function deleteTree(file) {
var children = file.listFiles();
if (children) {
for (var i = 0; i < children.length; i++) {
deleteTree(children[i]);
}
}
file.delete();
}

function loadInWorker(probe, callback) {
var worker = new Worker("./codeCacheLoadWorker.js");
worker.onmessage = function (msg) {
worker.terminate();
callback(msg.data);
};
worker.postMessage({ path: probe.path, isModule: probe.isModule });
}

[false, true].forEach(function (isModule) {
describe(isModule ? "for ES modules" : "for CommonJS modules", function () {
// Created by the first spec and removed by the last: the specs build
// on each other's cache entry, in order.
var probe;

function createProbe() {
probeDir.mkdirs();
var token = "probe-" + Date.now() + "-" + Math.floor(Math.random() * 1e9);
var path = probeDir.getAbsolutePath() + "/" + token + (isModule ? ".mjs" : ".js");
writeText(new File(path), isModule
? "export const value = '" + token + "';\n"
: "module.exports.value = '" + token + "';\n");
probe = {
path: path,
isModule: isModule,
token: token,
cacheFile: cacheFileFor(path, isModule)
};
}

function removeProbe() {
new File(probe.path).delete();
probe.cacheFile.delete();
probeDir.delete();
}

it("writes an entry on the first compile", function (done) {
createProbe();
loadInWorker(probe, function (result) {
expect(result.error).toBeUndefined();
expect(result.value).toBe(probe.token);
expectValidEntry(probe.cacheFile);
done();
});
});

it("consumes a valid entry without rewriting it", function (done) {
expect(probe.cacheFile.setLastModified(UNTOUCHED_MTIME)).toBe(true);
loadInWorker(probe, function (result) {
expect(result.value).toBe(probe.token);
expect(Number(probe.cacheFile.lastModified())).toBe(UNTOUCHED_MTIME);
done();
});
});

it("rejects an entry whose payload checksum fails and rewrites it", function (done) {
var bytes = readBytes(probe.cacheFile);
var offset = HEADER_SIZE + Math.floor((bytes.length - HEADER_SIZE) / 2);
writeByte(probe.cacheFile, offset, (bytes[offset] & 255) ^ 0xff);
expect(probe.cacheFile.setLastModified(UNTOUCHED_MTIME)).toBe(true);
loadInWorker(probe, function (result) {
expect(result.value).toBe(probe.token);
expect(Number(probe.cacheFile.lastModified())).not.toBe(UNTOUCHED_MTIME);
expectValidEntry(probe.cacheFile);
done();
});
});

it("rewrites an entry V8 rejects", function (done) {
// Corrupt V8's own header (its magic number) but keep our
// checksum valid, so the entry reaches V8 and V8 rejects it.
var bytes = readBytes(probe.cacheFile);
var original = bytes[HEADER_SIZE] & 255;
bytes[HEADER_SIZE] = ((original ^ 0xff) << 24) >> 24;
writeByte(probe.cacheFile, HEADER_SIZE, original ^ 0xff);
writeUint32(probe.cacheFile, 20, payloadCrc(bytes));
expect(probe.cacheFile.setLastModified(UNTOUCHED_MTIME)).toBe(true);
loadInWorker(probe, function (result) {
expect(result.value).toBe(probe.token);
expect(Number(probe.cacheFile.lastModified())).not.toBe(UNTOUCHED_MTIME);
var rewritten = expectValidEntry(probe.cacheFile);
expect(rewritten[HEADER_SIZE] & 255).toBe(original);
done();
});
});

it("tolerates a missing cache directory", function (done) {
deleteTree(cacheDir);
expect(cacheDir.exists()).toBe(false);
loadInWorker(probe, function (result) {
expect(result.value).toBe(probe.token);
expectValidEntry(probe.cacheFile);
removeProbe();
done();
});
});
});
});
});
Loading
Loading