Renames the link-preview engineId from "wllamapreview" to "link-preview" in EngineProcess.sys.mjs and metrics.yaml, with the generated lib.gecko.glean.d.ts updated accordingly. Bumps the ModelHub IndexedDB schema from version 6 to 7 to perform a non-destructive in-place rewrite of the engineIds field in the engines store. This preserves any already-downloaded model file in OPFS for users who used link-preview before the rename, so the new uninstall path (which queries by "link-preview") can still find and delete the row. Adds two regression tests in browser_ml_cache.js. Differential Revision: https://phabricator.services.mozilla.com/D299369
2138 lines
51 KiB
JavaScript
2138 lines
51 KiB
JavaScript
/* Any copyright is dedicated to the Public Domain.
|
|
http://creativecommons.org/publicdomain/zero/1.0/ */
|
|
"use strict";
|
|
|
|
const { sinon } = ChromeUtils.importESModule(
|
|
"resource://testing-common/Sinon.sys.mjs"
|
|
);
|
|
|
|
const { ProgressStatusText, ProgressType } = ChromeUtils.importESModule(
|
|
"chrome://global/content/ml/Utils.sys.mjs"
|
|
);
|
|
const { OPFS } = ChromeUtils.importESModule(
|
|
"chrome://global/content/ml/OPFS.sys.mjs"
|
|
);
|
|
|
|
const { URLChecker } = ChromeUtils.importESModule(
|
|
"chrome://global/content/ml/Utils.sys.mjs"
|
|
);
|
|
|
|
const { Progress, MLUtils } = ChromeUtils.importESModule(
|
|
"chrome://global/content/ml/Utils.sys.mjs"
|
|
);
|
|
|
|
// Root URL of the fake hub, see the `data` dir in the tests.
|
|
const FAKE_HUB =
|
|
"chrome://mochitests/content/browser/toolkit/components/ml/tests/browser/data";
|
|
const FAKE_URL_TEMPLATE = "{model}/resolve/{revision}";
|
|
|
|
const FAKE_MODEL_ARGS = {
|
|
model: "acme/bert",
|
|
revision: "main",
|
|
file: "config.json",
|
|
taskName: "task_model",
|
|
};
|
|
|
|
const FAKE_RELEASED_MODEL_ARGS = {
|
|
model: "acme/bert",
|
|
revision: "v0.1",
|
|
file: "config.json",
|
|
taskName: "task_released",
|
|
};
|
|
|
|
const FAKE_ONNX_MODEL_ARGS = {
|
|
model: "acme/bert",
|
|
revision: "main",
|
|
file: "onnx/config.json",
|
|
taskName: "task_onnx",
|
|
};
|
|
|
|
/**
|
|
* Test the MOZ_ALLOW_EXTERNAL_ML_HUB environment variable
|
|
*/
|
|
add_task(async function test_allow_external_ml_hub() {
|
|
Services.env.set("MOZ_ALLOW_EXTERNAL_ML_HUB", "1");
|
|
new ModelHub({ rootUrl: "https://huggingface.co" });
|
|
Services.env.set("MOZ_ALLOW_EXTERNAL_ML_HUB", "");
|
|
});
|
|
|
|
const badInputs = [
|
|
[
|
|
{
|
|
model: "ac me/bert",
|
|
revision: "main",
|
|
file: "config.json",
|
|
},
|
|
"Org can only contain letters, numbers, and hyphens",
|
|
],
|
|
[
|
|
{
|
|
model: "1111/bert",
|
|
revision: "main",
|
|
file: "config.json",
|
|
},
|
|
"Org cannot contain only numbers",
|
|
],
|
|
[
|
|
{
|
|
model: "-acme/bert",
|
|
revision: "main",
|
|
file: "config.json",
|
|
},
|
|
"Org start or end with a hyphen, or use consecutive hyphens",
|
|
],
|
|
[
|
|
{
|
|
model: "a-c-m-e/#bert",
|
|
revision: "main",
|
|
file: "config.json",
|
|
},
|
|
"Models can only contain letters, numbers, and hyphens, underscord, periods",
|
|
],
|
|
[
|
|
{
|
|
model: "a-c-m-e/b$ert",
|
|
revision: "main",
|
|
file: "config.json",
|
|
},
|
|
"Models cannot contain spaces or control characters",
|
|
],
|
|
[
|
|
{
|
|
model: "a-c-m-e/b$ert",
|
|
revision: "main",
|
|
file: ".filename",
|
|
},
|
|
"File",
|
|
],
|
|
];
|
|
|
|
/**
|
|
* Make sure we reject bad inputs.
|
|
*/
|
|
add_task(async function test_bad_inputs() {
|
|
const hub = new ModelHub({
|
|
rootUrl: FAKE_HUB,
|
|
urlTemplate: FAKE_URL_TEMPLATE,
|
|
});
|
|
|
|
for (const badInput of badInputs) {
|
|
const params = badInput[0];
|
|
const errorMsg = badInput[1];
|
|
try {
|
|
await hub.getModelFileAsArrayBuffer(params);
|
|
} catch (error) {
|
|
continue;
|
|
}
|
|
throw new Error(errorMsg);
|
|
}
|
|
});
|
|
|
|
/**
|
|
* Test that we can retrieve a file as an ArrayBuffer.
|
|
*/
|
|
add_task(async function test_getting_file() {
|
|
const hub = new ModelHub({
|
|
rootUrl: FAKE_HUB,
|
|
urlTemplate: FAKE_URL_TEMPLATE,
|
|
});
|
|
|
|
let [array, headers] = await hub.getModelFileAsArrayBuffer(FAKE_MODEL_ARGS);
|
|
|
|
Assert.equal(headers["Content-Type"], "application/json");
|
|
|
|
// check the content of the file.
|
|
let jsonData = JSON.parse(
|
|
String.fromCharCode.apply(null, new Uint8Array(array))
|
|
);
|
|
|
|
Assert.equal(jsonData.hidden_size, 32);
|
|
});
|
|
|
|
/**
|
|
* Test that we can retrieve a file as an ArrayBuffer even if we don't have headers
|
|
*/
|
|
add_task(async function test_getting_file_no_headers() {
|
|
await SpecialPowers.pushPrefEnv({
|
|
set: [
|
|
// Enabled by default.
|
|
["browser.ml.logLevel", "All"],
|
|
],
|
|
});
|
|
|
|
const hub = new ModelHub({
|
|
rootUrl: FAKE_HUB,
|
|
urlTemplate: FAKE_URL_TEMPLATE,
|
|
reset: true,
|
|
});
|
|
|
|
// Return empty headers
|
|
sinon.stub(hub, "extractHeaders").callsFake(function () {
|
|
return {};
|
|
});
|
|
|
|
let [array, headers] = await hub.getModelFileAsArrayBuffer(FAKE_MODEL_ARGS);
|
|
|
|
Assert.equal(headers["Content-Type"], "application/octet-stream"); // default content type
|
|
|
|
// check the content of the file.
|
|
let jsonData = JSON.parse(
|
|
String.fromCharCode.apply(null, new Uint8Array(array))
|
|
);
|
|
|
|
Assert.equal(jsonData.hidden_size, 32);
|
|
|
|
hub.extractHeaders.restore();
|
|
await deleteCache(hub.cache);
|
|
});
|
|
|
|
/**
|
|
* Test that we can retrieve a file from a released model and skip head calls
|
|
*/
|
|
add_task(async function test_getting_released_file() {
|
|
const hub = new ModelHub({
|
|
rootUrl: FAKE_HUB,
|
|
urlTemplate: FAKE_URL_TEMPLATE,
|
|
});
|
|
let spy = sinon.spy(hub, "getETag");
|
|
let [array, headers] = await hub.getModelFileAsArrayBuffer(
|
|
FAKE_RELEASED_MODEL_ARGS
|
|
);
|
|
|
|
Assert.equal(headers["Content-Type"], "application/json");
|
|
|
|
// check the content of the file.
|
|
let jsonData = JSON.parse(
|
|
String.fromCharCode.apply(null, new Uint8Array(array))
|
|
);
|
|
|
|
Assert.equal(jsonData.hidden_size, 768);
|
|
|
|
// check that head calls were not made
|
|
Assert.ok(!spy.called, "getETag should have never been called.");
|
|
spy.restore();
|
|
});
|
|
|
|
/**
|
|
* Make sure files can be located in sub directories
|
|
*/
|
|
add_task(async function test_getting_file_in_subdir() {
|
|
const hub = new ModelHub({
|
|
rootUrl: FAKE_HUB,
|
|
urlTemplate: FAKE_URL_TEMPLATE,
|
|
});
|
|
|
|
let [array, metadata] =
|
|
await hub.getModelFileAsArrayBuffer(FAKE_ONNX_MODEL_ARGS);
|
|
|
|
Assert.equal(metadata["Content-Type"], "application/json");
|
|
|
|
// check the content of the file.
|
|
let jsonData = JSON.parse(
|
|
String.fromCharCode.apply(null, new Uint8Array(array))
|
|
);
|
|
|
|
Assert.equal(jsonData.hidden_size, 768);
|
|
});
|
|
|
|
/**
|
|
* Test that we can use a custom URL template.
|
|
*/
|
|
add_task(async function test_getting_file_custom_path() {
|
|
const hub = new ModelHub({
|
|
rootUrl: FAKE_HUB,
|
|
urlTemplate: "{model}/resolve/{revision}",
|
|
});
|
|
|
|
let res = await hub.getModelFileAsArrayBuffer(FAKE_MODEL_ARGS);
|
|
|
|
Assert.equal(res[1]["Content-Type"], "application/json");
|
|
});
|
|
|
|
/**
|
|
* Test that we can't use an URL with a query for the template
|
|
*/
|
|
add_task(async function test_getting_file_custom_path_rogue() {
|
|
const urlTemplate = "{model}/resolve/{revision}/?some_id=bedqwdw";
|
|
Assert.throws(
|
|
() => new ModelHub({ rootUrl: FAKE_HUB, urlTemplate }),
|
|
/Invalid URL template/,
|
|
`Should throw with ${urlTemplate}`
|
|
);
|
|
});
|
|
|
|
/**
|
|
* Test that the file can be returned as a response and its content correct.
|
|
*/
|
|
add_task(async function test_getting_file_as_response() {
|
|
const hub = new ModelHub({
|
|
rootUrl: FAKE_HUB,
|
|
urlTemplate: FAKE_URL_TEMPLATE,
|
|
});
|
|
|
|
let response = await hub.getModelFileAsResponse(FAKE_MODEL_ARGS);
|
|
|
|
// check the content of the file.
|
|
let jsonData = await response.json();
|
|
Assert.equal(jsonData.hidden_size, 32);
|
|
});
|
|
|
|
/**
|
|
* Test that the cache is used when the data is retrieved from the server
|
|
* and that the cache is updated with the new data.
|
|
*/
|
|
add_task(async function test_getting_file_from_cache() {
|
|
const hub = new ModelHub({
|
|
rootUrl: FAKE_HUB,
|
|
urlTemplate: FAKE_URL_TEMPLATE,
|
|
});
|
|
let array = await hub.getModelFileAsArrayBuffer(FAKE_MODEL_ARGS);
|
|
|
|
var lastUsed = array[1].lastUsed;
|
|
|
|
// stub to verify that the data was retrieved from IndexDB
|
|
let matchMethod = hub.cache._testGetData;
|
|
|
|
sinon.stub(hub.cache, "_testGetData").callsFake(function () {
|
|
return matchMethod.apply(this, arguments).then(result => {
|
|
Assert.notEqual(result, null);
|
|
return result;
|
|
});
|
|
});
|
|
|
|
// exercises the cache
|
|
let array2 = await hub.getModelFileAsArrayBuffer(FAKE_MODEL_ARGS);
|
|
hub.cache._testGetData.restore();
|
|
|
|
let newLastUsed = array2[1].lastUsed;
|
|
|
|
// make sure the last used field was updated
|
|
Assert.greater(newLastUsed, lastUsed);
|
|
|
|
// we don't compare the lastUsed fiel because it changes for each read
|
|
Assert.deepEqual(stripLastUsed(array), stripLastUsed(array2));
|
|
});
|
|
|
|
/**
|
|
* Test that the callback is appropriately called when the data is retrieved from the server
|
|
* or from the cache.
|
|
*/
|
|
add_task(async function test_getting_file_from_url_cache_with_callback() {
|
|
const hub = new ModelHub({
|
|
rootUrl: FAKE_HUB,
|
|
urlTemplate: FAKE_URL_TEMPLATE,
|
|
});
|
|
|
|
hub.cache = await initializeCache();
|
|
|
|
let numCalls = 0;
|
|
let currentData = null;
|
|
let array = await hub.getModelFileAsArrayBuffer({
|
|
...FAKE_MODEL_ARGS,
|
|
progressCallback: data => {
|
|
// expecting initiate status and download
|
|
currentData = data;
|
|
if (numCalls == 0) {
|
|
Assert.deepEqual(
|
|
{
|
|
type: data.type,
|
|
statusText: data.statusText,
|
|
ok: data.ok,
|
|
model: currentData?.metadata?.model,
|
|
file: currentData?.metadata?.file,
|
|
revision: currentData?.metadata?.revision,
|
|
taskName: currentData?.metadata?.taskName,
|
|
},
|
|
{
|
|
type: ProgressType.DOWNLOAD,
|
|
statusText: ProgressStatusText.INITIATE,
|
|
ok: true,
|
|
...FAKE_MODEL_ARGS,
|
|
},
|
|
"Initiate Data from server should be correct"
|
|
);
|
|
}
|
|
|
|
if (numCalls == 1) {
|
|
Assert.deepEqual(
|
|
{
|
|
type: data.type,
|
|
statusText: data.statusText,
|
|
ok: data.ok,
|
|
model: currentData?.metadata?.model,
|
|
file: currentData?.metadata?.file,
|
|
revision: currentData?.metadata?.revision,
|
|
taskName: currentData?.metadata?.taskName,
|
|
},
|
|
{
|
|
type: ProgressType.DOWNLOAD,
|
|
statusText: ProgressStatusText.SIZE_ESTIMATE,
|
|
ok: true,
|
|
...FAKE_MODEL_ARGS,
|
|
},
|
|
"size estimate Data from server should be correct"
|
|
);
|
|
}
|
|
|
|
numCalls += 1;
|
|
},
|
|
});
|
|
|
|
var lastUsed = array[1].lastUsed;
|
|
|
|
Assert.greaterOrEqual(numCalls, 3);
|
|
|
|
// last received message is DONE
|
|
Assert.deepEqual(
|
|
{
|
|
type: currentData?.type,
|
|
statusText: currentData?.statusText,
|
|
ok: currentData?.ok,
|
|
model: currentData?.metadata?.model,
|
|
file: currentData?.metadata?.file,
|
|
revision: currentData?.metadata?.revision,
|
|
taskName: currentData?.metadata?.taskName,
|
|
},
|
|
{
|
|
type: ProgressType.DOWNLOAD,
|
|
statusText: ProgressStatusText.DONE,
|
|
ok: true,
|
|
...FAKE_MODEL_ARGS,
|
|
},
|
|
"Done Data from server should be correct"
|
|
);
|
|
|
|
// stub to verify that the data was retrieved from IndexDB
|
|
let matchMethod = hub.cache._testGetData;
|
|
|
|
sinon.stub(hub.cache, "_testGetData").callsFake(function () {
|
|
return matchMethod.apply(this, arguments).then(result => {
|
|
Assert.notEqual(result, null);
|
|
return result;
|
|
});
|
|
});
|
|
|
|
numCalls = 0;
|
|
currentData = null;
|
|
|
|
// Now we expect the callback to indicate cache usage.
|
|
let array2 = await hub.getModelFileAsArrayBuffer({
|
|
...FAKE_MODEL_ARGS,
|
|
progressCallback: data => {
|
|
// expecting initiate status and download
|
|
|
|
currentData = data;
|
|
|
|
if (numCalls == 0) {
|
|
Assert.deepEqual(
|
|
{
|
|
type: data.type,
|
|
statusText: data.statusText,
|
|
ok: data.ok,
|
|
model: currentData?.metadata?.model,
|
|
file: currentData?.metadata?.file,
|
|
revision: currentData?.metadata?.revision,
|
|
taskName: currentData?.metadata?.taskName,
|
|
},
|
|
{
|
|
type: ProgressType.LOAD_FROM_CACHE,
|
|
statusText: ProgressStatusText.INITIATE,
|
|
ok: true,
|
|
...FAKE_MODEL_ARGS,
|
|
},
|
|
"Initiate Data from cache should be correct"
|
|
);
|
|
}
|
|
|
|
numCalls += 1;
|
|
},
|
|
});
|
|
hub.cache._testGetData.restore();
|
|
|
|
let newLastUsed = array2[1].lastUsed;
|
|
|
|
// make sure the last used field was updated
|
|
Assert.greater(newLastUsed, lastUsed);
|
|
|
|
Assert.deepEqual(stripLastUsed(array), stripLastUsed(array2));
|
|
|
|
// last received message is DONE
|
|
Assert.deepEqual(
|
|
{
|
|
type: currentData?.type,
|
|
statusText: currentData?.statusText,
|
|
ok: currentData?.ok,
|
|
model: currentData?.metadata?.model,
|
|
file: currentData?.metadata?.file,
|
|
revision: currentData?.metadata?.revision,
|
|
taskName: currentData?.metadata?.taskName,
|
|
},
|
|
{
|
|
type: ProgressType.LOAD_FROM_CACHE,
|
|
statusText: ProgressStatusText.DONE,
|
|
ok: true,
|
|
...FAKE_MODEL_ARGS,
|
|
},
|
|
"Done Data from cache should be correct"
|
|
);
|
|
|
|
await deleteCache(hub.cache);
|
|
});
|
|
|
|
/**
|
|
* Test parsing of a well-formed full URL, including protocol and path.
|
|
*/
|
|
add_task(async function testWellFormedFullUrl() {
|
|
const hub = new ModelHub({
|
|
rootUrl: FAKE_HUB,
|
|
urlTemplate: "{model}/{revision}",
|
|
});
|
|
const url = `${FAKE_HUB}/org1/model1/v1/file/path`;
|
|
const result = hub.parseUrl(url);
|
|
|
|
Assert.equal(
|
|
result.model,
|
|
"org1/model1",
|
|
"Model should be parsed correctly."
|
|
);
|
|
Assert.equal(result.revision, "v1", "Revision should be parsed correctly.");
|
|
Assert.equal(
|
|
result.file,
|
|
"file/path",
|
|
"File path should be parsed correctly."
|
|
);
|
|
});
|
|
|
|
/**
|
|
* Test parsing of well-formed URLs, starting with a slash.
|
|
*/
|
|
const URLS_AND_RESULT = [
|
|
{
|
|
url: "/Xenova/bert-base-NER/resolve/main/onnx/model.onnx",
|
|
model: "Xenova/bert-base-NER",
|
|
revision: "main",
|
|
file: "onnx/model.onnx",
|
|
urlTemplate: "{model}/resolve/{revision}",
|
|
},
|
|
{
|
|
url: "/org1/model1/v1/file/path",
|
|
model: "org1/model1",
|
|
revision: "v1",
|
|
file: "file/path",
|
|
urlTemplate: "{model}/{revision}",
|
|
},
|
|
];
|
|
|
|
add_task(async function testWellFormedRelativeUrl() {
|
|
for (const example of URLS_AND_RESULT) {
|
|
const hub = new ModelHub({
|
|
rootUrl: FAKE_HUB,
|
|
urlTemplate: example.urlTemplate,
|
|
});
|
|
const result = hub.parseUrl(example.url);
|
|
|
|
Assert.equal(
|
|
result.model,
|
|
example.model,
|
|
"Model should be parsed correctly."
|
|
);
|
|
Assert.equal(
|
|
result.revision,
|
|
example.revision,
|
|
"Revision should be parsed correctly."
|
|
);
|
|
Assert.equal(
|
|
result.file,
|
|
example.file,
|
|
"File path should be parsed correctly."
|
|
);
|
|
}
|
|
});
|
|
|
|
/**
|
|
* Ensures an error is thrown when the URL does not start with the expected root URL or a slash.
|
|
*/
|
|
add_task(async function testInvalidDomain() {
|
|
const hub = new ModelHub({
|
|
rootUrl: FAKE_HUB,
|
|
urlTemplate: FAKE_URL_TEMPLATE,
|
|
});
|
|
const url = "https://example.com/org1/model1/resolve/v1/file/path";
|
|
Assert.throws(
|
|
() => hub.parseUrl(url),
|
|
new RegExp(`Error: Invalid domain for model URL: ${url}`),
|
|
`Should throw with ${url}`
|
|
);
|
|
});
|
|
|
|
/**
|
|
* Tests the method's error handling when the URL format does not include the required segments.
|
|
*/
|
|
add_task(async function testTooFewParts() {
|
|
const hub = new ModelHub({
|
|
rootUrl: FAKE_HUB,
|
|
urlTemplate: FAKE_URL_TEMPLATE,
|
|
});
|
|
const url = "/org1/model1/resolve";
|
|
Assert.throws(
|
|
() => hub.parseUrl(url),
|
|
new RegExp(`Error: Invalid model URL format: ${url}`),
|
|
`Should throw with ${url}`
|
|
);
|
|
});
|
|
|
|
// IndexedDB tests
|
|
|
|
/**
|
|
* Test the initialization and creation of the IndexedDBCache instance.
|
|
*/
|
|
add_task(async function test_Init() {
|
|
const cache = await initializeCache();
|
|
Assert.ok(
|
|
cache instanceof IndexedDBCache,
|
|
"The cache instance should be created successfully."
|
|
);
|
|
Assert.ok(
|
|
IDBDatabase.isInstance(cache.db),
|
|
`The cache should have an IDBDatabase instance. Found ${cache.db}`
|
|
);
|
|
await deleteCache(cache);
|
|
});
|
|
|
|
/**
|
|
* Test checking existence of data in the cache.
|
|
*/
|
|
add_task(async function test_PutAndCheckExists() {
|
|
const cache = await initializeCache();
|
|
const testData = createBlob();
|
|
const key = "file.txt";
|
|
await cache.put({
|
|
taskName: "task",
|
|
model: "org/model",
|
|
revision: "v1",
|
|
file: "file.txt",
|
|
data: testData,
|
|
headers: {
|
|
ETag: "ETAG123",
|
|
},
|
|
});
|
|
|
|
// Checking if the file exists
|
|
let exists = await cache.fileExists({
|
|
model: "org/model",
|
|
revision: "v1",
|
|
file: key,
|
|
});
|
|
Assert.ok(exists, "The file should exist in the cache.");
|
|
|
|
// Removing all files from the model
|
|
await cache.deleteModels({ model: "org/model", revision: "v1" });
|
|
|
|
exists = await cache.fileExists({
|
|
taskName: "task",
|
|
model: "org/model",
|
|
revision: "v1",
|
|
file: key,
|
|
});
|
|
Assert.ok(!exists, "The file should be gone from the cache.");
|
|
|
|
await deleteCache(cache);
|
|
});
|
|
|
|
/**
|
|
* Test adding data to the cache and retrieving it.
|
|
*/
|
|
add_task(async function test_PutAndGet() {
|
|
const cache = await initializeCache();
|
|
const testData = createBlob();
|
|
await cache.put({
|
|
taskName: "task",
|
|
model: "org/model",
|
|
revision: "v1",
|
|
file: "file.txt",
|
|
data: testData,
|
|
headers: {
|
|
ETag: "ETAG123",
|
|
},
|
|
});
|
|
|
|
const [retrievedData, headers] = await cache.getFile({
|
|
model: "org/model",
|
|
revision: "v1",
|
|
file: "file.txt",
|
|
});
|
|
Assert.deepEqual(
|
|
retrievedData,
|
|
testData,
|
|
"The retrieved data should match the stored data."
|
|
);
|
|
Assert.equal(
|
|
headers.ETag,
|
|
"ETAG123",
|
|
"The retrieved ETag should match the stored ETag."
|
|
);
|
|
|
|
await deleteCache(cache);
|
|
});
|
|
|
|
/**
|
|
* Test retrieving the headers for a cache entry.
|
|
*/
|
|
add_task(async function test_GetHeaders() {
|
|
const cache = await initializeCache();
|
|
const testData = createBlob();
|
|
const headers = {
|
|
ETag: "ETAG123",
|
|
status: 200,
|
|
extra: "extra",
|
|
};
|
|
|
|
const when = await cache.put({
|
|
taskName: "task",
|
|
model: "org/model",
|
|
revision: "v1",
|
|
file: "file.txt",
|
|
data: testData,
|
|
headers,
|
|
});
|
|
|
|
const storedHeaders = await cache.getHeaders({
|
|
model: "org/model",
|
|
revision: "v1",
|
|
file: "file.txt",
|
|
});
|
|
|
|
// The `extra` field should be removed from the stored headers because
|
|
// it's not part of the allowed keys.
|
|
// The content-type one is added when not present
|
|
Assert.deepEqual(
|
|
{
|
|
ETag: "ETAG123",
|
|
status: 200,
|
|
"Content-Type": "application/octet-stream",
|
|
fileSize: 8,
|
|
lastUsed: when,
|
|
lastUpdated: when,
|
|
},
|
|
storedHeaders,
|
|
"The retrieved headers should match the stored headers."
|
|
);
|
|
await deleteCache(cache);
|
|
});
|
|
|
|
/**
|
|
* Test listing all models stored in the cache.
|
|
*/
|
|
add_task(async function test_ListModels() {
|
|
const cache = await initializeCache();
|
|
|
|
await Promise.all([
|
|
cache.put({
|
|
taskName: "task1",
|
|
model: "org1/modelA",
|
|
revision: "v1",
|
|
file: "file1.txt",
|
|
data: createBlob(),
|
|
headers: null,
|
|
}),
|
|
cache.put({
|
|
taskName: "task2",
|
|
model: "org2/modelB",
|
|
revision: "v2",
|
|
file: "file2.txt",
|
|
data: createBlob(),
|
|
headers: null,
|
|
}),
|
|
]);
|
|
|
|
const models = await cache.listModels();
|
|
const expected = [
|
|
{ name: "org1/modelA", revision: "v1", taskName: "task1" },
|
|
{ name: "org2/modelB", revision: "v2", taskName: "task2" },
|
|
];
|
|
Assert.deepEqual(models, expected, "All models should be listed");
|
|
await deleteCache(cache);
|
|
});
|
|
|
|
/**
|
|
* Test deleting a model and its data from the cache.
|
|
*/
|
|
add_task(async function test_DeleteModels() {
|
|
const cache = await initializeCache();
|
|
await cache.put({
|
|
taskName: "task",
|
|
model: "org/model",
|
|
revision: "v1",
|
|
file: "file.txt",
|
|
data: createBlob(),
|
|
headers: null,
|
|
});
|
|
await cache.deleteModels({ model: "org/model", revision: "v1" });
|
|
|
|
const dataAfterDelete = await cache.getFile({
|
|
model: "org/model",
|
|
revision: "v1",
|
|
file: "file.txt",
|
|
});
|
|
Assert.equal(
|
|
dataAfterDelete,
|
|
null,
|
|
"The data for the deleted model should not exist."
|
|
);
|
|
await deleteCache(cache);
|
|
});
|
|
|
|
/**
|
|
* Test that after deleting a model from the cache, the remaing models are still there.
|
|
*/
|
|
add_task(async function test_nonDeletedModels() {
|
|
const cache = await initializeCache();
|
|
|
|
const testData = createRandomBlob();
|
|
|
|
await Promise.all([
|
|
cache.put({
|
|
taskName: "task1",
|
|
model: "org/model",
|
|
revision: "v1",
|
|
file: "file.txt",
|
|
data: testData,
|
|
headers: {
|
|
ETag: "ETAG123",
|
|
},
|
|
}),
|
|
cache.put({
|
|
taskName: "task2",
|
|
model: "org/model2",
|
|
revision: "v1",
|
|
file: "file.txt",
|
|
data: createRandomBlob(),
|
|
headers: {
|
|
ETag: "ETAG1234",
|
|
},
|
|
}),
|
|
|
|
cache.put({
|
|
taskName: "task3",
|
|
model: "org/model2",
|
|
revision: "v1",
|
|
file: "file2.txt",
|
|
data: createRandomBlob(),
|
|
headers: {
|
|
ETag: "ETAG1234",
|
|
},
|
|
}),
|
|
]);
|
|
|
|
await cache.deleteModels({ model: "org/model2", revision: "v1" });
|
|
|
|
const [retrievedData, headers] = await cache.getFile({
|
|
model: "org/model",
|
|
revision: "v1",
|
|
file: "file.txt",
|
|
});
|
|
Assert.deepEqual(
|
|
retrievedData,
|
|
testData,
|
|
"The retrieved data should match the stored data."
|
|
);
|
|
|
|
Assert.equal(
|
|
headers.ETag,
|
|
"ETAG123",
|
|
"The retrieved ETag should match the stored ETag."
|
|
);
|
|
|
|
const dataAfterDelete = await cache.getFile({
|
|
model: "org/model2",
|
|
revision: "v1",
|
|
file: "file.txt",
|
|
});
|
|
Assert.equal(
|
|
dataAfterDelete,
|
|
null,
|
|
"The data for the deleted model should not exist."
|
|
);
|
|
|
|
const dataAfterDelete2 = await cache.getFile({
|
|
model: "org/model2",
|
|
revision: "v1",
|
|
file: "file2.txt",
|
|
});
|
|
Assert.equal(
|
|
dataAfterDelete2,
|
|
null,
|
|
"The data for the deleted model should not exist."
|
|
);
|
|
|
|
await deleteCache(cache);
|
|
});
|
|
|
|
/**
|
|
* Test deleting a model and its data from the cache using a task name.
|
|
*/
|
|
add_task(async function test_DeleteModelsUsingTaskName() {
|
|
const cache = await initializeCache();
|
|
const model = "mozilla/distilvit";
|
|
const revision = "main";
|
|
const taskName = "echo";
|
|
|
|
await cache.put({
|
|
taskName,
|
|
model,
|
|
revision,
|
|
file: "file.txt",
|
|
data: createBlob(),
|
|
headers: null,
|
|
});
|
|
|
|
await cache.deleteModels({ taskName });
|
|
|
|
// Model should be gone.
|
|
const models = await cache.listModels();
|
|
const expected = [];
|
|
Assert.deepEqual(models, expected, "All models should be deleted.");
|
|
|
|
const dataAfterDelete = await cache.getFile({
|
|
model,
|
|
revision,
|
|
file: "file.txt",
|
|
});
|
|
Assert.equal(
|
|
dataAfterDelete,
|
|
null,
|
|
"The data for the deleted model should not exist."
|
|
);
|
|
await deleteCache(cache);
|
|
});
|
|
|
|
/**
|
|
* Test deleting a model and its data from the cache using a non-existing task name.
|
|
*/
|
|
add_task(async function test_DeleteModelsUsingNonExistingTaskName() {
|
|
const cache = await initializeCache();
|
|
const model = "mozilla/distilvit";
|
|
const revision = "main";
|
|
const taskName = "echo";
|
|
|
|
await cache.put({
|
|
taskName,
|
|
model,
|
|
revision,
|
|
file: "file.txt",
|
|
data: createBlob(),
|
|
headers: null,
|
|
});
|
|
|
|
await cache.deleteModels({ taskName: "non-existing-task" });
|
|
|
|
// Model should still be there.
|
|
const models = await cache.listModels();
|
|
const expected = [{ name: model, revision, taskName }];
|
|
Assert.deepEqual(models, expected, "All models should be listed");
|
|
|
|
await deleteCache(cache);
|
|
});
|
|
|
|
/**
|
|
* Test that after deleting a model from the cache, the remaing models are still there.
|
|
*/
|
|
add_task(async function test_deleteNonMatchingModelRevisions() {
|
|
const hub = new ModelHub({
|
|
rootUrl: FAKE_HUB,
|
|
urlTemplate: FAKE_URL_TEMPLATE,
|
|
});
|
|
|
|
const cache = await initializeCache();
|
|
|
|
hub.cache = cache;
|
|
|
|
const testData = createRandomBlob();
|
|
|
|
const testData2 = createRandomBlob();
|
|
|
|
const taskName = "task";
|
|
|
|
const file = "file.txt";
|
|
const hostname = new URL(FAKE_HUB).hostname;
|
|
|
|
await Promise.all([
|
|
cache.put({
|
|
taskName,
|
|
model: `${hostname}/org/model`,
|
|
revision: "v1",
|
|
file,
|
|
data: testData,
|
|
headers: {
|
|
ETag: "ETAG123",
|
|
},
|
|
}),
|
|
cache.put({
|
|
taskName,
|
|
model: `${hostname}/org/model2`,
|
|
revision: "v1",
|
|
file,
|
|
data: createRandomBlob(),
|
|
headers: {
|
|
ETag: "ETAG1234",
|
|
},
|
|
}),
|
|
|
|
cache.put({
|
|
taskName,
|
|
model: `${hostname}/org/model2`,
|
|
revision: "v2",
|
|
file,
|
|
data: createRandomBlob(),
|
|
headers: {
|
|
ETag: "ETAG1234",
|
|
},
|
|
}),
|
|
|
|
cache.put({
|
|
taskName,
|
|
model: `${hostname}/org/model2`,
|
|
revision: "v3",
|
|
file,
|
|
data: testData2,
|
|
headers: {
|
|
ETag: "ETAG1234",
|
|
},
|
|
}),
|
|
]);
|
|
|
|
await hub.deleteNonMatchingModelRevisions(
|
|
taskName,
|
|
`${hostname}/org/model2`,
|
|
"v3"
|
|
);
|
|
|
|
const [retrievedData, headers] = await cache.getFile({
|
|
model: `${hostname}/org/model`,
|
|
revision: "v1",
|
|
file,
|
|
});
|
|
Assert.deepEqual(
|
|
retrievedData,
|
|
testData,
|
|
"The retrieved data should match the stored data."
|
|
);
|
|
Assert.equal(
|
|
headers.ETag,
|
|
"ETAG123",
|
|
"The retrieved ETag should match the stored ETag."
|
|
);
|
|
|
|
const dataAfterDelete = await cache.getFile({
|
|
model: `${hostname}/org/model2`,
|
|
revision: "v1",
|
|
file,
|
|
});
|
|
Assert.equal(dataAfterDelete, null, "The data for v1 should not exist.");
|
|
|
|
const dataAfterDelete2 = await cache.getFile({
|
|
model: `${hostname}/org/model2`,
|
|
revision: "v2",
|
|
file,
|
|
});
|
|
Assert.equal(dataAfterDelete2, null, "The data for v2 should not exist.");
|
|
|
|
const [retrievedData2, headers2] = await cache.getFile({
|
|
model: `${hostname}/org/model2`,
|
|
revision: "v3",
|
|
file,
|
|
});
|
|
Assert.deepEqual(
|
|
retrievedData2,
|
|
testData2,
|
|
"The retrieved data for v3 should match the stored data."
|
|
);
|
|
|
|
Assert.equal(
|
|
headers2.ETag,
|
|
"ETAG1234",
|
|
"The retrieved ETag for v3 should match the stored ETag."
|
|
);
|
|
|
|
await deleteCache(cache);
|
|
});
|
|
|
|
/**
|
|
* Test listing files
|
|
*/
|
|
add_task(async function test_listFiles() {
|
|
const cache = await initializeCache();
|
|
const headers = { "Content-Length": "12345", ETag: "XYZ" };
|
|
const blob = createBlob();
|
|
|
|
const when1 = await cache.put({
|
|
taskName: "task1",
|
|
model: "org/model",
|
|
revision: "v1",
|
|
file: "file.txt",
|
|
data: blob,
|
|
headers: null,
|
|
});
|
|
|
|
const when2 = await cache.put({
|
|
taskName: "task1",
|
|
model: "org/model",
|
|
revision: "v1",
|
|
file: "file2.txt",
|
|
data: blob,
|
|
headers: null,
|
|
});
|
|
|
|
const when3 = await cache.put({
|
|
taskName: "task2",
|
|
model: "org/model",
|
|
revision: "v1",
|
|
file: "sub/file3.txt",
|
|
data: createBlob(32),
|
|
headers,
|
|
});
|
|
|
|
const { files } = await cache.listFiles({
|
|
model: "org/model",
|
|
revision: "v1",
|
|
});
|
|
const expected = [
|
|
{
|
|
path: "file.txt",
|
|
headers: {
|
|
"Content-Type": "application/octet-stream",
|
|
fileSize: 8,
|
|
ETag: "NO_ETAG",
|
|
lastUsed: when1,
|
|
lastUpdated: when1,
|
|
},
|
|
engineIds: [],
|
|
},
|
|
{
|
|
path: "file2.txt",
|
|
headers: {
|
|
"Content-Type": "application/octet-stream",
|
|
fileSize: 8,
|
|
ETag: "NO_ETAG",
|
|
lastUsed: when2,
|
|
lastUpdated: when2,
|
|
},
|
|
engineIds: [],
|
|
},
|
|
{
|
|
path: "sub/file3.txt",
|
|
headers: {
|
|
"Content-Length": "12345",
|
|
"Content-Type": "application/octet-stream",
|
|
fileSize: 32,
|
|
ETag: "XYZ",
|
|
lastUsed: when3,
|
|
lastUpdated: when3,
|
|
},
|
|
engineIds: [],
|
|
},
|
|
];
|
|
|
|
Assert.deepEqual(files, expected);
|
|
await deleteCache(cache);
|
|
});
|
|
|
|
/**
|
|
* Test listing files using a task name
|
|
*/
|
|
add_task(async function test_listFilesUsingTaskName() {
|
|
const cache = await initializeCache();
|
|
|
|
const model = "mozilla/distilvit";
|
|
const revision = "main";
|
|
const taskName = "echo";
|
|
|
|
const headers = { "Content-Length": "12345", ETag: "XYZ" };
|
|
const blob = createBlob();
|
|
|
|
const when1 = await cache.put({
|
|
taskName,
|
|
model,
|
|
revision,
|
|
file: "file.txt",
|
|
data: blob,
|
|
headers: null,
|
|
});
|
|
|
|
const when2 = await cache.put({
|
|
taskName,
|
|
model,
|
|
revision,
|
|
file: "file2.txt",
|
|
data: blob,
|
|
headers: null,
|
|
});
|
|
|
|
const when3 = await cache.put({
|
|
taskName,
|
|
model,
|
|
revision,
|
|
file: "sub/file3.txt",
|
|
data: createBlob(32),
|
|
headers,
|
|
});
|
|
|
|
const { files } = await cache.listFiles({ taskName, model, revision });
|
|
const expected = [
|
|
{
|
|
path: "file.txt",
|
|
headers: {
|
|
"Content-Type": "application/octet-stream",
|
|
fileSize: 8,
|
|
ETag: "NO_ETAG",
|
|
lastUsed: when1,
|
|
lastUpdated: when1,
|
|
},
|
|
engineIds: [],
|
|
},
|
|
{
|
|
path: "file2.txt",
|
|
headers: {
|
|
"Content-Type": "application/octet-stream",
|
|
fileSize: 8,
|
|
ETag: "NO_ETAG",
|
|
lastUsed: when2,
|
|
lastUpdated: when2,
|
|
},
|
|
engineIds: [],
|
|
},
|
|
{
|
|
path: "sub/file3.txt",
|
|
headers: {
|
|
"Content-Length": "12345",
|
|
"Content-Type": "application/octet-stream",
|
|
fileSize: 32,
|
|
ETag: "XYZ",
|
|
lastUsed: when3,
|
|
lastUpdated: when3,
|
|
},
|
|
engineIds: [],
|
|
},
|
|
];
|
|
|
|
Assert.deepEqual(files, expected);
|
|
|
|
await deleteCache(cache);
|
|
});
|
|
|
|
/**
|
|
* Test listing files using a non existing task name
|
|
*/
|
|
add_task(async function test_listFilesUsingNonExistingTaskName() {
|
|
const cache = await initializeCache();
|
|
|
|
const model = "mozilla/distilvit";
|
|
const revision = "main";
|
|
const taskName = "echo";
|
|
|
|
const headers = { "Content-Length": "12345", ETag: "XYZ" };
|
|
const blob = createBlob();
|
|
|
|
await Promise.all([
|
|
cache.put({
|
|
taskName,
|
|
model,
|
|
revision,
|
|
file: "file.txt",
|
|
data: blob,
|
|
headers: null,
|
|
}),
|
|
cache.put({
|
|
taskName,
|
|
model,
|
|
revision,
|
|
file: "file2.txt",
|
|
data: blob,
|
|
headers: null,
|
|
}),
|
|
cache.put({
|
|
taskName,
|
|
model,
|
|
revision,
|
|
file: "sub/file3.txt",
|
|
data: createBlob(32),
|
|
headers,
|
|
}),
|
|
]);
|
|
|
|
const { files } = await cache.listFiles({ taskName: "non-existing-task" });
|
|
|
|
Assert.deepEqual(files, []);
|
|
|
|
await deleteCache(cache);
|
|
});
|
|
|
|
/**
|
|
* Test the ability to add a database from a non-existing database.
|
|
*/
|
|
add_task(async function test_initDbFromNonExisting() {
|
|
const cache = await initializeCache();
|
|
|
|
Assert.notEqual(cache, null);
|
|
|
|
await deleteCache(cache);
|
|
});
|
|
|
|
/**
|
|
* Test that we can upgrade even if the existing database is missing some stores or indices.
|
|
*/
|
|
add_task(async function test_initDbFromExistingEmpty() {
|
|
const dbName = `modelFiles-${crypto.randomUUID()}`;
|
|
await TestIndexedDBCache.deleteDatabaseAndWait(dbName).catch(() => {});
|
|
|
|
const dbVersion = 1;
|
|
|
|
const newVersion = dbVersion + 1;
|
|
|
|
async function openDB() {
|
|
return new Promise((resolve, reject) => {
|
|
const request = indexedDB.open(dbName, dbVersion);
|
|
request.onerror = event => reject(event.target.error);
|
|
request.onsuccess = event => resolve(event.target.result);
|
|
});
|
|
}
|
|
|
|
const db = await openDB();
|
|
db.close();
|
|
|
|
const cache = await IndexedDBCache.init({ dbName, version: newVersion });
|
|
|
|
Assert.notEqual(cache, null);
|
|
Assert.equal(cache.db.version, newVersion);
|
|
|
|
const model = "mozilla/distilvit";
|
|
const revision = "main";
|
|
const taskName = "echo";
|
|
|
|
const blob = createBlob();
|
|
|
|
const when = await cache.put({
|
|
taskName,
|
|
model,
|
|
revision,
|
|
file: "file.txt",
|
|
data: blob,
|
|
headers: null,
|
|
});
|
|
|
|
const expected = [
|
|
{
|
|
path: "file.txt",
|
|
headers: {
|
|
"Content-Type": "application/octet-stream",
|
|
fileSize: 8,
|
|
ETag: "NO_ETAG",
|
|
lastUsed: when,
|
|
lastUpdated: when,
|
|
},
|
|
engineIds: [],
|
|
},
|
|
];
|
|
|
|
// Ensure every table & indices is on so that we can list files
|
|
const { files } = await cache.listFiles({ taskName, model, revision });
|
|
Assert.deepEqual(files, expected);
|
|
|
|
await deleteCache(cache);
|
|
});
|
|
|
|
/**
|
|
* Test that upgrading from version 1 to version 2 results in existing data being deleted.
|
|
*/
|
|
add_task(async function test_initDbFromExistingNoChange() {
|
|
const dbName = `modelFiles-${crypto.randomUUID()}`;
|
|
|
|
await TestIndexedDBCache.deleteDatabaseAndWait(dbName).catch(() => {});
|
|
|
|
// Create version 1
|
|
let cache = await IndexedDBCache.init({ dbName, version: 1 });
|
|
|
|
Assert.notEqual(cache, null);
|
|
Assert.equal(cache.db.version, 1);
|
|
|
|
const model = "mozilla/distilvit";
|
|
const revision = "main";
|
|
const taskName = "echo";
|
|
|
|
const blob = createBlob();
|
|
|
|
await cache.put({
|
|
taskName,
|
|
model,
|
|
revision,
|
|
file: "file.txt",
|
|
data: blob,
|
|
headers: null,
|
|
});
|
|
|
|
cache.db.close();
|
|
|
|
// Create version 2
|
|
cache = await IndexedDBCache.init({ dbName, version: 2 });
|
|
|
|
Assert.notEqual(cache, null);
|
|
Assert.equal(cache.db.version, 2);
|
|
|
|
// Ensure tables are all empty.
|
|
const { files } = await cache.listFiles({ taskName });
|
|
|
|
Assert.deepEqual(files, []);
|
|
|
|
await deleteCache(cache);
|
|
});
|
|
|
|
/**
|
|
* Test that upgrading an existing cache from another source is possible.
|
|
*/
|
|
add_task(async function test_initDbFromExistingElseWhereStoreChanges() {
|
|
const dbName = `modelFiles-${crypto.randomUUID()}`;
|
|
await TestIndexedDBCache.deleteDatabaseAndWait(dbName).catch(() => {});
|
|
|
|
const dbVersion = 2;
|
|
const model = "mozilla/distilvit";
|
|
const revision = "main";
|
|
const taskName = "echo";
|
|
|
|
const blob = createBlob();
|
|
// Create version 2
|
|
const cache1 = await IndexedDBCache.init({ dbName, version: dbVersion });
|
|
|
|
Assert.notEqual(cache1, null);
|
|
Assert.equal(cache1.db.version, 2);
|
|
|
|
// Cache1 is not closed by design of this test
|
|
|
|
// Create version 3
|
|
const cache2 = await IndexedDBCache.init({ dbName, version: dbVersion + 1 });
|
|
|
|
Assert.notEqual(cache2, null);
|
|
Assert.equal(cache2.db.version, 3);
|
|
|
|
const when = await cache2.put({
|
|
taskName,
|
|
model,
|
|
revision,
|
|
file: "file.txt",
|
|
data: blob,
|
|
headers: null,
|
|
});
|
|
|
|
const expected = [
|
|
{
|
|
path: "file.txt",
|
|
headers: {
|
|
"Content-Type": "application/octet-stream",
|
|
fileSize: 8,
|
|
ETag: "NO_ETAG",
|
|
lastUpdated: when,
|
|
lastUsed: when,
|
|
},
|
|
engineIds: [],
|
|
},
|
|
];
|
|
|
|
// Ensure every table & indices is on so that we can list files
|
|
const { files } = await cache2.listFiles({ taskName, model, revision });
|
|
Assert.deepEqual(files, expected);
|
|
|
|
await deleteCache(cache2);
|
|
});
|
|
|
|
/**
|
|
* Test that we can use a custom hub on every API call to get files.
|
|
*/
|
|
add_task(async function test_getting_file_custom_hub() {
|
|
// The hub is configured to use localhost
|
|
const hub = new ModelHub({
|
|
rootUrl: "https://localhost",
|
|
urlTemplate: "{model}/boo/revision",
|
|
});
|
|
|
|
// but we can use APIs against another hub
|
|
const args = {
|
|
model: "acme/bert",
|
|
revision: "main",
|
|
file: "config.json",
|
|
taskName: "task_model",
|
|
modelHubRootUrl: FAKE_HUB,
|
|
modelHubUrlTemplate: "{model}/resolve/{revision}",
|
|
};
|
|
|
|
let [array, headers] = await hub.getModelFileAsArrayBuffer(args);
|
|
|
|
Assert.equal(headers["Content-Type"], "application/json");
|
|
|
|
// check the content of the file.
|
|
let jsonData = JSON.parse(
|
|
String.fromCharCode.apply(null, new Uint8Array(array))
|
|
);
|
|
|
|
Assert.equal(jsonData.hidden_size, 32);
|
|
|
|
let res = await hub.getModelFileAsBlob(args);
|
|
Assert.equal(res[0].size, 548);
|
|
|
|
let response = await hub.getModelFileAsResponse(args);
|
|
Assert.equal((await response.blob()).size, 548);
|
|
});
|
|
|
|
/**
|
|
* Make sure that we can't pass a rootUrl that is not allowed when using the API calls
|
|
*/
|
|
add_task(async function test_getting_file_disallowed_custom_hub() {
|
|
// The hub is configured to use localhost
|
|
const hub = new ModelHub({
|
|
rootUrl: "https://localhost",
|
|
urlTemplate: "{model}/boo/revision",
|
|
allowDenyList: [{ filter: "ALLOW", urlPrefix: "https://example.com" }],
|
|
});
|
|
|
|
// and we can't use APIs against another hub if it's not allowed
|
|
const args = {
|
|
model: "acme/bert",
|
|
revision: "main",
|
|
file: "config.json",
|
|
taskName: "task_model",
|
|
modelHubRootUrl: "https://forbidden.com",
|
|
modelHubUrlTemplate: "{model}/{revision}",
|
|
};
|
|
|
|
// This catch the error returned by getEtag when checking if file is in cache
|
|
try {
|
|
await hub.getModelFileAsArrayBuffer(args);
|
|
throw new Error("Expected method to reject.");
|
|
} catch (error) {
|
|
Assert.throws(
|
|
() => {
|
|
throw error;
|
|
},
|
|
new RegExp(`ForbiddenURLError`),
|
|
`Should throw with https://forbidden.com`
|
|
);
|
|
}
|
|
|
|
// This catch the error returned when useCached is false
|
|
try {
|
|
await hub.getModelFileAsArrayBuffer({ ...args, revision: "v1" });
|
|
throw new Error("Expected method to reject.");
|
|
} catch (error) {
|
|
Assert.throws(
|
|
() => {
|
|
throw error;
|
|
},
|
|
new RegExp(`ForbiddenURLError`),
|
|
`Should throw with https://forbidden.com`
|
|
);
|
|
}
|
|
|
|
try {
|
|
await hub.getModelFileAsBlob(args);
|
|
throw new Error("Expected method to reject.");
|
|
} catch (error) {
|
|
Assert.throws(
|
|
() => {
|
|
throw error;
|
|
},
|
|
new RegExp(`ForbiddenURLError`),
|
|
`Should throw with https://forbidden.com`
|
|
);
|
|
}
|
|
|
|
try {
|
|
await hub.getModelFileAsResponse(args);
|
|
throw new Error("Expected method to reject.");
|
|
} catch (error) {
|
|
Assert.throws(
|
|
() => {
|
|
throw error;
|
|
},
|
|
new RegExp(`ForbiddenURLError`),
|
|
`Should throw with https://forbidden.com`
|
|
);
|
|
}
|
|
|
|
// This catch the error when http error codes are returned, useCached is false
|
|
try {
|
|
await hub.getModelFileAsArrayBuffer({
|
|
...args,
|
|
revision: "v1",
|
|
modelHubRootUrl: "https://example.com",
|
|
});
|
|
throw new Error("Expected method to reject.");
|
|
} catch (error) {
|
|
Assert.throws(
|
|
() => {
|
|
throw error;
|
|
},
|
|
new RegExp(`HTTP error! Status: 404 Not Found`),
|
|
`Should throw with 404`
|
|
);
|
|
}
|
|
|
|
// This catch the error returned when useCached is true with no checks for etags
|
|
try {
|
|
// store a file in the hub
|
|
await hub.cache.put({
|
|
...args,
|
|
model: "forbidden.com/acme/bert",
|
|
engineId: "engineOne",
|
|
revision: "v1",
|
|
data: createBlob(),
|
|
headers: null,
|
|
});
|
|
|
|
await hub.getModelFileAsArrayBuffer({ ...args, revision: "v1" });
|
|
throw new Error("Expected method to reject.");
|
|
} catch (error) {
|
|
Assert.throws(
|
|
() => {
|
|
throw error;
|
|
},
|
|
new RegExp(`ForbiddenURLError`),
|
|
`Should throw with https://forbidden.com`
|
|
);
|
|
}
|
|
});
|
|
|
|
/**
|
|
* Test deleting files used by several engines
|
|
*/
|
|
add_task(async function test_DeleteFileByEngines() {
|
|
const cache = await initializeCache();
|
|
const testData = createBlob();
|
|
const engineOne = "engine-1";
|
|
const engineTwo = "engine-2";
|
|
const model = "org/model";
|
|
const revision = "v1";
|
|
|
|
// a file is stored by engineOne
|
|
await cache.put({
|
|
engineId: engineOne,
|
|
taskName: "task",
|
|
model: "org/model",
|
|
revision: "v1",
|
|
file: "file.txt",
|
|
data: createBlob(),
|
|
headers: null,
|
|
});
|
|
|
|
// The file is read by engineTwo
|
|
let retrievedData = await cache.getFile({
|
|
engineId: engineTwo,
|
|
model: "org/model",
|
|
revision: "v1",
|
|
file: "file.txt",
|
|
});
|
|
|
|
Assert.deepEqual(
|
|
retrievedData[0],
|
|
testData,
|
|
"The retrieved data should match the stored data."
|
|
);
|
|
|
|
// We should have two engines associated with the model file
|
|
let retrievedFiles = await cache.listFiles({ model, revision });
|
|
Assert.equal(retrievedFiles.metadata.engineIds.length, 2);
|
|
|
|
// if we delete the model by engineOne, it will still be around for engineTwo
|
|
await cache.deleteFilesByEngine({ engineId: engineOne });
|
|
|
|
retrievedData = await cache.getFile({
|
|
engineId: engineTwo,
|
|
model: "org/model",
|
|
revision: "v1",
|
|
file: "file.txt",
|
|
});
|
|
Assert.deepEqual(
|
|
retrievedData[0],
|
|
testData,
|
|
"The retrieved data should match the stored data."
|
|
);
|
|
|
|
// We should now have one engine associated with the model file
|
|
retrievedFiles = await cache.listFiles({ model, revision });
|
|
Assert.equal(retrievedFiles.metadata.engineIds.length, 1);
|
|
Assert.equal(retrievedFiles.metadata.engineIds[0], engineTwo);
|
|
|
|
// now deleting via engineTwo
|
|
await cache.deleteFilesByEngine({ engineId: engineTwo });
|
|
|
|
// at this point we should not have anymore files
|
|
const dataAfterDelete = await cache.getFile({
|
|
engineId: engineOne,
|
|
model: "org/model",
|
|
revision: "v1",
|
|
file: "file.txt",
|
|
});
|
|
Assert.equal(
|
|
dataAfterDelete,
|
|
null,
|
|
"The data for the deleted model should not exist."
|
|
);
|
|
|
|
// Now we should have no more engine
|
|
Assert.equal(await cache._testGetData(cache.enginesStoreName), null);
|
|
|
|
await deleteCache(cache);
|
|
});
|
|
|
|
/**
|
|
* Test deleting files used by an engine via the model hub. This is similar to
|
|
* `test_DeleteFileByEngines`, except we are calling the model hub method.
|
|
*/
|
|
add_task(async function test_ModelHub_DeleteFileByEngines() {
|
|
const cache = await initializeCache();
|
|
const engineOne = "engine-1";
|
|
const hub = new ModelHub({
|
|
rootUrl: FAKE_HUB,
|
|
urlTemplate: FAKE_URL_TEMPLATE,
|
|
allowDenyList: [],
|
|
});
|
|
hub.cache = cache;
|
|
|
|
// a file is stored by engineOne
|
|
await cache.put({
|
|
engineId: engineOne,
|
|
taskName: "task",
|
|
model: "org/model",
|
|
revision: "v1",
|
|
file: "file.txt",
|
|
data: createBlob(),
|
|
headers: null,
|
|
});
|
|
|
|
// We should have at least one file
|
|
const dataBeforeDelete = await cache.getFile({
|
|
engineId: engineOne,
|
|
model: "org/model",
|
|
revision: "v1",
|
|
file: "file.txt",
|
|
});
|
|
Assert.notEqual(dataBeforeDelete, null, "The data should exist");
|
|
|
|
// We should have at least one engine
|
|
Assert.notEqual(await cache._testGetData(cache.enginesStoreName), null);
|
|
|
|
await hub.deleteFilesByEngine({ engineId: engineOne });
|
|
|
|
// at this point we should not have anymore files
|
|
const dataAfterDelete = await cache.getFile({
|
|
engineId: engineOne,
|
|
model: "org/model",
|
|
revision: "v1",
|
|
file: "file.txt",
|
|
});
|
|
Assert.equal(
|
|
dataAfterDelete,
|
|
null,
|
|
"The data for the deleted model should not exist."
|
|
);
|
|
|
|
// The engine should be removed from the model/revision/file engine list.
|
|
// In our case, this means we should have no entry in the engine list since it is
|
|
// the last
|
|
Assert.equal(await cache._testGetData(cache.enginesStoreName), null);
|
|
|
|
await deleteCache(cache);
|
|
});
|
|
|
|
// tests allow deny list updating after model is cached
|
|
add_task(async function test_update_allow_deny_after_model_cache() {
|
|
const cache = await initializeCache();
|
|
const file = "config.json";
|
|
const taskName = FAKE_MODEL_ARGS.taskName;
|
|
const model = FAKE_MODEL_ARGS.model;
|
|
const revision = "v0.1";
|
|
await cache.put({
|
|
taskName,
|
|
model,
|
|
revision,
|
|
file,
|
|
data: createBlob(),
|
|
headers: null,
|
|
});
|
|
|
|
let exists = await cache.fileExists({
|
|
model,
|
|
revision,
|
|
file,
|
|
});
|
|
Assert.ok(exists, "The file should exist in the cache.");
|
|
|
|
let list = [
|
|
{
|
|
filter: "ALLOW",
|
|
urlPrefix:
|
|
"chrome://mochitests/content/browser/toolkit/components/ml/tests/browser/data/acme",
|
|
},
|
|
];
|
|
|
|
let hub = new ModelHub({
|
|
rootUrl: FAKE_HUB,
|
|
urlTemplate: FAKE_URL_TEMPLATE,
|
|
allowDenyList: list,
|
|
});
|
|
hub.cache = cache;
|
|
// should go through since model is allowed
|
|
await hub.getModelFileAsArrayBuffer({ ...FAKE_MODEL_ARGS, file, revision });
|
|
|
|
// put model in deny list
|
|
list = [
|
|
{
|
|
filter: "DENY",
|
|
urlPrefix:
|
|
"chrome://mochitests/content/browser/toolkit/components/ml/tests/browser/data/acme",
|
|
},
|
|
];
|
|
|
|
hub = new ModelHub({
|
|
rootUrl: FAKE_HUB,
|
|
urlTemplate: FAKE_URL_TEMPLATE,
|
|
allowDenyList: list,
|
|
});
|
|
hub.cache = cache;
|
|
|
|
// now ensure the model cannot be called after being put in the deny list
|
|
try {
|
|
await hub.getModelFileAsArrayBuffer({ ...FAKE_MODEL_ARGS, file, revision });
|
|
} catch (e) {
|
|
Assert.strictEqual(e.name, "ForbiddenURLError");
|
|
}
|
|
// make sure that the model is deleted after
|
|
const dataAfterForbidden = await cache.getFile({
|
|
model,
|
|
revision,
|
|
file,
|
|
});
|
|
Assert.equal(
|
|
dataAfterForbidden,
|
|
null,
|
|
"The data for the deleted model should not exist."
|
|
);
|
|
});
|
|
|
|
/**
|
|
* Test that data from OPFS is wiped
|
|
*/
|
|
add_task(async function test_migrateStore_modelsDeleted() {
|
|
const dbName = `modelFiles-${crypto.randomUUID()}`;
|
|
await TestIndexedDBCache.deleteDatabaseAndWait(dbName).catch(() => {});
|
|
|
|
// Initialize version 4 of the database
|
|
let cache = await IndexedDBCache.init({ dbName, version: 4 });
|
|
|
|
// Add some test data for unknown models
|
|
await Promise.all([
|
|
cache.put({
|
|
taskName: "task",
|
|
model: "random/model",
|
|
revision: "v1",
|
|
file: "random.txt",
|
|
data: createBlob(),
|
|
headers: null,
|
|
}),
|
|
cache.put({
|
|
taskName: "task",
|
|
model: "unknown/model",
|
|
revision: "v2",
|
|
file: "unknown.txt",
|
|
data: createBlob(),
|
|
headers: null,
|
|
}),
|
|
]);
|
|
|
|
// Close version 4 and upgrade to version 5
|
|
cache.db.close();
|
|
cache = await IndexedDBCache.init({ dbName, version: 5 });
|
|
|
|
// Verify all unknown model data is deleted
|
|
const { files: random } = await cache.listFiles({
|
|
model: "random/model",
|
|
revision: "v1",
|
|
});
|
|
Assert.deepEqual(random, [], "All unknown model files should be deleted.");
|
|
|
|
const { files: unknown } = await cache.listFiles({
|
|
model: "unknown/model",
|
|
revision: "v2",
|
|
});
|
|
Assert.deepEqual(unknown, [], "All unknown model files should be deleted.");
|
|
|
|
await deleteCache(cache);
|
|
});
|
|
|
|
/**
|
|
* Test migration when database starts empty.
|
|
*/
|
|
add_task(async function test_migrateStore_emptyDatabase() {
|
|
const dbName = `modelFiles-${crypto.randomUUID()}`;
|
|
await TestIndexedDBCache.deleteDatabaseAndWait(dbName).catch(() => {});
|
|
|
|
// Initialize an empty version 4 database
|
|
let cache = await IndexedDBCache.init({ dbName, version: 4 });
|
|
cache.db.close();
|
|
|
|
// Upgrade to version 5
|
|
cache = await IndexedDBCache.init({ dbName, version: 5 });
|
|
|
|
// Verify database is still empty
|
|
const models = await cache.listModels();
|
|
Assert.deepEqual(
|
|
models,
|
|
[],
|
|
"The database should remain empty after migration."
|
|
);
|
|
|
|
await deleteCache(cache);
|
|
});
|
|
|
|
add_task(async function test_getOwnerIcon_cache() {
|
|
await SpecialPowers.pushPrefEnv({
|
|
set: [["browser.ml.logLevel", "All"]],
|
|
});
|
|
|
|
const originalOPFSFile = OPFS.File;
|
|
|
|
const localPaths = new Set();
|
|
const stub = sinon.stub(OPFS, "File").callsFake(function (args) {
|
|
localPaths.add(args.localPath);
|
|
return new originalOPFSFile(args); // preserve original behavior
|
|
});
|
|
|
|
const hub = new ModelHub({
|
|
rootUrl: FAKE_HUB,
|
|
urlTemplate: FAKE_URL_TEMPLATE,
|
|
});
|
|
|
|
const fullyQualifiedModelName = `mochitests/mozilla/distilvit-${crypto.randomUUID()}`;
|
|
|
|
// first call will get the icon from the web
|
|
const icon = await hub.getOwnerIcon(fullyQualifiedModelName);
|
|
Assert.notEqual(icon, null);
|
|
|
|
// second call will get it from the cache
|
|
let spy = sinon.spy(Progress, "fetchUrl");
|
|
|
|
const icon2 = await hub.getOwnerIcon(fullyQualifiedModelName);
|
|
Assert.notEqual(icon2, null);
|
|
|
|
// check that it cames from OPFS
|
|
Assert.equal(spy.called, false);
|
|
|
|
spy.restore();
|
|
stub.restore();
|
|
for (const path of localPaths) {
|
|
await OPFS.remove(path, { recursive: true });
|
|
}
|
|
});
|
|
|
|
add_task(async function test_getOwnerIcon_download() {
|
|
await SpecialPowers.pushPrefEnv({
|
|
set: [["browser.ml.logLevel", "All"]],
|
|
});
|
|
|
|
const originalOPFSFile = OPFS.File;
|
|
|
|
const localPaths = new Set();
|
|
const stub = sinon.stub(OPFS, "File").callsFake(function (args) {
|
|
localPaths.add(args.localPath);
|
|
return new originalOPFSFile(args); // preserve original behavior
|
|
});
|
|
|
|
const hub = new ModelHub({
|
|
rootUrl: FAKE_HUB,
|
|
urlTemplate: FAKE_URL_TEMPLATE,
|
|
});
|
|
|
|
const fullyQualifiedModelName = `mochitests/mozilla/distilvit-${crypto.randomUUID()}`;
|
|
|
|
let spy = sinon.spy(Progress, "fetchUrl");
|
|
// first call will get the icon from the web
|
|
const icon = await hub.getOwnerIcon(fullyQualifiedModelName);
|
|
Assert.notEqual(icon, null);
|
|
// check that it didn't come from OPFS
|
|
Assert.equal(spy.called, true);
|
|
Assert.notEqual(await spy.lastCall?.returnValue, null);
|
|
|
|
spy.restore();
|
|
stub.restore();
|
|
for (const path of localPaths) {
|
|
await OPFS.remove(path, { recursive: true });
|
|
}
|
|
});
|
|
|
|
/**
|
|
* Test that downloads can be cancelled
|
|
*/
|
|
add_task(async function test_download_cancellation() {
|
|
const cache = await initializeCache();
|
|
const hub = new ModelHub({
|
|
rootUrl: FAKE_HUB,
|
|
urlTemplate: FAKE_URL_TEMPLATE,
|
|
});
|
|
hub.cache = cache;
|
|
|
|
const controller = new AbortController();
|
|
const { signal } = controller;
|
|
|
|
try {
|
|
controller.abort();
|
|
|
|
await Assert.rejects(
|
|
hub.getModelDataAsFile({ ...FAKE_MODEL_ARGS, abortSignal: signal }),
|
|
err => err?.name === "AbortError",
|
|
"The call should be cancelled"
|
|
);
|
|
} catch (err) {
|
|
Assert.ok(false, `Expected AbortError. Got ${err}`);
|
|
} finally {
|
|
await deleteCache(cache);
|
|
}
|
|
});
|
|
|
|
/**
|
|
* Test that downloads can be cancelled after fetch is successfull
|
|
*/
|
|
add_task(async function test_download_cancellation_after_fetch() {
|
|
const cache = await initializeCache();
|
|
const hub = new ModelHub({
|
|
rootUrl: FAKE_HUB,
|
|
urlTemplate: FAKE_URL_TEMPLATE,
|
|
});
|
|
hub.cache = cache;
|
|
|
|
const controller = new AbortController();
|
|
const { signal } = controller;
|
|
|
|
const fetchUrlStub = sinon
|
|
.stub(MLUtils, "fetchUrl")
|
|
.callsFake((url, { signal: _, ...rest } = {}) => {
|
|
const p = fetch(url, rest);
|
|
controller.abort();
|
|
return p;
|
|
});
|
|
|
|
try {
|
|
await Assert.rejects(
|
|
hub.getModelDataAsFile({ ...FAKE_MODEL_ARGS, abortSignal: signal }),
|
|
err => err?.name === "AbortError",
|
|
"The call should be cancelled"
|
|
);
|
|
} catch (err) {
|
|
Assert.ok(false, `Expected AbortError. Got ${err}`);
|
|
} finally {
|
|
await deleteCache(cache);
|
|
fetchUrlStub.restore();
|
|
}
|
|
});
|
|
|
|
/**
|
|
* Bug 1967279: Verify the 6 -> 7 schema migration rewrites a row tagged with
|
|
* the old "wllamapreview" engineId to "link-preview" without dropping the
|
|
* underlying OPFS model file, so the renamed feature can still uninstall it.
|
|
*/
|
|
add_task(async function test_migrateStore_renamesWllamapreviewEngineId() {
|
|
const dbName = `modelFiles-${crypto.randomUUID()}`;
|
|
await TestIndexedDBCache.deleteDatabaseAndWait(dbName).catch(() => {});
|
|
|
|
const model = "org/link-preview";
|
|
const revision = "v1";
|
|
const file = "weights.bin";
|
|
|
|
// Seed a row under the pre-migration schema with the obsolete engineId.
|
|
let cache = await IndexedDBCache.init({ dbName, version: 6 });
|
|
await cache.put({
|
|
engineId: "wllamapreview",
|
|
taskName: "wllama-text-generation",
|
|
model,
|
|
revision,
|
|
file,
|
|
data: createBlob(),
|
|
headers: null,
|
|
});
|
|
|
|
let listed = await cache.listFiles({ model, revision });
|
|
Assert.deepEqual(
|
|
listed.metadata.engineIds,
|
|
["wllamapreview"],
|
|
"Row should be seeded with the legacy engineId."
|
|
);
|
|
cache.db.close();
|
|
|
|
// Reopen at the current schema version and confirm the rename happened.
|
|
cache = await IndexedDBCache.init({ dbName });
|
|
|
|
listed = await cache.listFiles({ model, revision });
|
|
Assert.deepEqual(
|
|
listed.metadata.engineIds,
|
|
["link-preview"],
|
|
"Migration should rewrite the legacy engineId to its replacement."
|
|
);
|
|
Assert.equal(
|
|
listed.files.length,
|
|
1,
|
|
"Migration must not drop the OPFS model file."
|
|
);
|
|
|
|
// The uninstall path used by LinkPreview should now find and remove the row.
|
|
await cache.deleteFilesByEngine({ engineId: "link-preview" });
|
|
listed = await cache.listFiles({ model, revision });
|
|
Assert.deepEqual(
|
|
listed.files,
|
|
[],
|
|
"Uninstall via the new engineId should physically delete the file."
|
|
);
|
|
|
|
await deleteCache(cache);
|
|
});
|
|
|
|
/**
|
|
* Bug 1967279: If a row already carries both the legacy and the new engineId
|
|
* (e.g. a re-download happened before the migration), the rewrite must
|
|
* de-duplicate rather than leaving the stale id behind.
|
|
*/
|
|
add_task(async function test_migrateStore_dedupsLegacyAndNewEngineIds() {
|
|
const dbName = `modelFiles-${crypto.randomUUID()}`;
|
|
await TestIndexedDBCache.deleteDatabaseAndWait(dbName).catch(() => {});
|
|
|
|
const model = "org/link-preview";
|
|
const revision = "v1";
|
|
const file = "weights.bin";
|
|
|
|
let cache = await IndexedDBCache.init({ dbName, version: 6 });
|
|
await cache.put({
|
|
engineId: "wllamapreview",
|
|
taskName: "wllama-text-generation",
|
|
model,
|
|
revision,
|
|
file,
|
|
data: createBlob(),
|
|
headers: null,
|
|
});
|
|
await cache.put({
|
|
engineId: "link-preview",
|
|
taskName: "wllama-text-generation",
|
|
model,
|
|
revision,
|
|
file,
|
|
data: createBlob(),
|
|
headers: null,
|
|
});
|
|
cache.db.close();
|
|
|
|
cache = await IndexedDBCache.init({ dbName });
|
|
const listed = await cache.listFiles({ model, revision });
|
|
Assert.deepEqual(
|
|
listed.metadata.engineIds,
|
|
["link-preview"],
|
|
"Migration should collapse duplicate ids to the new engineId only."
|
|
);
|
|
|
|
await deleteCache(cache);
|
|
});
|