Publish Vulpes Preview 15 sources for desktop, Android and Tundra

Share Gaia assets and adapters, keep private profiles and development documents local, and update both public READMEs.
This commit is contained in:
OverSu 2026-09-30 23:13:03 +02:00
commit b38dea0597
25901 changed files with 9982925 additions and 369 deletions

View file

@ -0,0 +1,17 @@
{
"extends": "../../../../.jshintrc",
"predef": [
"suite",
"test",
"setup",
"teardown",
"suiteSetup",
"suiteTeardown",
"marionette",
"marionetteScriptFinished",
"require",
"define",
"requireApp",
"sinon"
]
}

View file

@ -0,0 +1,44 @@
{
"meta": {},
"objects": [
{
"author": "Fake App Developer",
"content_ratings": {},
"current_version": "1.8",
"device_types": [
"desktop"
],
"icons": {
},
"id": 12,
"is_disabled": false,
"latecustomization_id": 15,
"latecustomization_type": "webapp",
"manifest_url": "http://scone.host/manifest.webapp",
"name": {
"en-US": "Exquisite Scone"
}
},
{
"author": "Fake App Developer",
"current_version": "1.0",
"device_types": [
"firefoxos"
],
"icons": {
"128": "http://bakewell.host/icons/128.png",
"32": "http://bakewell.host/icons/32.png",
"48": "http://bakewell.host/icons/48.png",
"64": "http://bakewell.host/icons/64.png"
},
"id": 80,
"is_disabled": false,
"latecustomization_id": 18,
"latecustomization_type": "webapp",
"manifest_url": "http://bakewell.host/manifest.webapp",
"name": {
"en-US": "Bakewell Tart"
}
}
]
}

View file

@ -0,0 +1,10 @@
{
"meta": {
"limit": 25,
"next": null,
"offset": 0,
"previous": null,
"total_count": 4
},
"objects": []
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.3 KiB

View file

@ -0,0 +1,19 @@
<!DOCTYPE html>
<html>
<head>
<meta name="viewport" content="width=device-width, user-scalable=no, initial-scale=1">
<meta charset="utf-8">
<style>
html, body {
height: 100%; overflow: hidden;
}
body {
background: url(./icons/256.png) no-repeat center center;
background-size: contain;
}
</style>
<script src="/test.js"></script>
</head>
<body>
</body>
</html>

View file

@ -0,0 +1,23 @@
{
"icons": {
"16": "/icons/16.png",
"256": "/icons/256.png",
"32": "/icons/32.png"
},
"locales": {
"en-US": {
"description": "This packaged app has been automatically generated",
"name": "Bakewell Tart"
},
"es-ES": {
"description": "This packaged app has been automatically generated",
"name": "espa\u00f1ol Bakewell Tart"
}
},
"name": "Bakewell Tart",
"short_name": "Bakewell",
"launch_path": "/index.html",
"package_path": "app.zip",
"release_notes": "",
"version": "1.0"
}

View file

@ -0,0 +1,4 @@
'use strict';
window.addEventListener('load', function() {
document.querySelector('body').classList.add('loaded');
});

View file

@ -0,0 +1,45 @@
'use strict';
var Ftu = require('./lib/ftu');
var assert = require('chai').assert;
marionette('First Time Use >', function() {
var ftu;
var client = marionette.client({ profile: Ftu.clientOptions });
setup(function() {
ftu = new Ftu(client);
});
test('FTU comes up on profile generation', function() {
client.apps.switchToApp(Ftu.URL);
});
test('FTU click thru', function() {
client.apps.switchToApp(Ftu.URL);
ftu.clickThruToFinish();
});
test('FTU user timing', function() {
client.apps.switchToApp(Ftu.URL);
ftu.waitForFtuReady();
var markersByName = client.executeScript(function() {
var byName = {};
var markers =
window.wrappedJSObject.performance.getEntriesByType('mark');
markers.forEach(function(mark) {
byName[mark.name] = (byName[mark.name] || 0) + 1;
});
return byName;
});
console.log('user timing markers:', markersByName);
assert.ok(markersByName.navigationLoaded);
assert.ok(markersByName.navigationInteractive);
assert.ok(markersByName.visuallyLoaded);
assert.ok(markersByName.contentInteractive);
assert.ok(markersByName.fullyLoaded);
});
});

View file

@ -0,0 +1,67 @@
'use strict';
var assert = require('assert');
var Ftu = require('./lib/ftu');
marionette('First Time Use >', function() {
var ftu, system;
var client = marionette.client({
profile: {
prefs: {
'focusmanager.testmode': true
},
settings: {
'ftu.manifestURL': 'app://ftu.gaiamobile.org/manifest.webapp',
'deviceinfo.os': '1.3.1-foo',
'deviceinfo.previous_os': '3.0.0-bar'
}
}
});
setup(function() {
client.executeAsyncScript(function() {
var req = navigator.mozSettings.createLock().set({
'deviceinfo.os': '1.3.1-foo',
'deviceinfo.previous_os': '3.0.0-bar'
});
req.onsuccess = function(evt) {
window.addEventListener('starting', function onevent(evt) {
window.wrappedJSObject.asyncStorage.setItem('ftu.enabled', false);
marionetteScriptFinished();
});
};
req.onerror = marionetteScriptFinished;
});
system = client.loader.getAppClass('system');
ftu = new Ftu(client);
system.waitForFullyLoaded();
});
test('FTU launches for upgrade when disabled', function() {
client.switchToFrame();
var isEnabled = client.executeAsyncScript(function() {
var asyncStorage = window.wrappedJSObject.asyncStorage;
asyncStorage.getItem('ftu.enabled', function(result) {
return marionetteScriptFinished(result);
});
});
assert.equal(false, isEnabled);
client.apps.switchToApp(Ftu.URL);
client.helper.waitForElement('#update-screen');
});
test('Sets appropriate theme color', function() {
client.apps.switchToApp(Ftu.URL);
client.helper.waitForElement('#update-screen');
var expectedColor = client.executeScript(function() {
return window.wrappedJSObject.UIManager.DARK_THEME;
});
client.waitFor(function() {
var themeColor = client.executeScript(function() {
var meta = document.querySelector('meta[name="theme-color"]');
return meta.getAttribute('content');
});
return (themeColor == expectedColor);
});
});
});

View file

@ -0,0 +1,37 @@
'use strict';
var assert = require('assert');
var Ftu = require('./lib/ftu');
marionette('First Time Use > Pseudo Localization (Disabled)', function() {
var ftu;
var clientOptions = {
prefs: Ftu.clientOptions.prefs,
settings: Object.assign({}, Ftu.clientOptions.settings, {
'devtools.pseudolocalization.enabled': false
})
};
var client = marionette.client({
profile: clientOptions,
desiredCapabilities: { raisesAccessibilityExceptions: false }
});
setup(function() {
ftu = new Ftu(client);
});
suite('pseudolocalization disabled', function() {
test('FTU Languages without pseudo localization', function() {
ftu.waitForFtuReady();
var enOption = ftu.findElement('gaia-radio[value="en-US"]');
assert.ok(enOption && enOption.displayed(), 'en-US element is displayed');
// confirm accented pseudolocale isnt present
var pseudoOption = ftu.findElement('gaia-radio[value="fr-x-psaccent"]');
assert.ok(!pseudoOption, 'pseudo language should not be present');
});
});
});

View file

@ -0,0 +1,51 @@
'use strict';
var assert = require('assert');
var Ftu = require('./lib/ftu');
marionette('First Time Use > Pseudo Localization', function() {
var ftu;
var clientOptions = {
prefs: Ftu.clientOptions.prefs,
settings: Object.assign({}, Ftu.clientOptions.settings, {
'devtools.pseudolocalization.enabled': true
})
};
var client = marionette.client({
profile: clientOptions,
desiredCapabilities: { raisesAccessibilityExceptions: false }
});
setup(function() {
ftu = new Ftu(client);
});
suite('pseudolocalization enabled', function() {
test('FTU Languages with pseudo localization', function() {
ftu.waitForFtuReady();
client.helper.waitForElement('gaia-radio[value="en-US"]');
client.helper.waitForElement('gaia-radio[value="fr-x-psaccent"]');
});
test('Can select accented-english', function(done) {
ftu.waitForFtuReady();
var header = client.helper.waitForElement(Ftu.Selectors.header);
ftu.selectLanguage('fr-x-psaccent');
client.executeAsyncScript(function() {
var pseudo = window.wrappedJSObject.document.l10n.pseudo;
pseudo['fr-x-psaccent'].processString('Language').then(
function(string) {
marionetteScriptFinished(string);
}
);
}, function(err, value) {
assert.equal(value, header.text());
done(err);
});
});
});
});

View file

@ -0,0 +1,183 @@
'use strict';
var assert = require('chai').assert;
var Ftu = require('./lib/ftu');
var LateCustomization = require('./lib/latecustomization');
var AppInstall =
require('../../../system/test/marionette/lib/app_install');
marionette('First Time Use >', function() {
var ftu, lateCustom, home, system, appInstall;
var settings = {
'ftu.manifestURL': 'app://ftu.gaiamobile.org/manifest.webapp',
'latecustomization.url': 'PLACEHOLDER/late-customization/',
'latecustomization.operatorInfo': LateCustomization.Settings.operatorInfo
};
var client = marionette.client({
profile: {
prefs: Ftu.clientOptions.prefs,
settings: settings
},
desiredCapabilities: { raisesAccessibilityExceptions: true }
});
suite('perform late customization', () => {
setup(() => {
ftu = new Ftu(client);
home = client.loader.getAppClass('homescreen');
system = client.loader.getAppClass('system');
appInstall = new AppInstall(client);
lateCustom = new LateCustomization(client, ftu);
});
function createServerSetup(responseFilename, appNames) {
return function(done) {
console.log('server setup: responseFilename: ',
responseFilename);
lateCustom.manifestResponseFilename = responseFilename;
lateCustom.startServers(appNames, (err) => {
if (!err) {
var apiServer = lateCustom.server.api;
console.log('server setup: updating latecustomization.url ' +
'setting value:',
apiServer.url + '/' + responseFilename);
client.settings.set('latecustomization.url',
apiServer.url + '/' + responseFilename);
console.log('server setup: /updating ' +
'latecustomization.url');
}
done(err);
});
};
}
teardown((done) => {
lateCustom.stopServers(done);
});
suite('with apps', function() {
setup(createServerSetup(
'late-customization-operator.json',
['bakewell']
));
setup(() => {
client.apps.switchToApp(Ftu.URL);
ftu.waitForCurtainUp();
var url = client.settings.get('latecustomization.url');
lateCustom.waitForServerRequest(url);
});
test('free apps panel is shown', () => {
// click through to the free apps panel
var finishScreen = client.findElement(Ftu.Selectors.finishScreen);
var seenLCPanel = false;
var hash;
while (!seenLCPanel && !finishScreen.displayed()) {
hash = ftu.getLocationHash();
seenLCPanel = hash.indexOf(lateCustom.hash) === 0;
if (seenLCPanel) {
// check the expected apps are in the list
var items = lateCustom.appItems;
assert.equal(items.length, 1);
assert.equal(items[0].findElement('p').text(), 'Bakewell Tart');
}
ftu.goNext();
}
assert.ok(seenLCPanel);
});
test('> apps are installed', () => {
var appManifestUrl = lateCustom.translateAliasedUrl(
'http://bakewell.host/manifest.webapp');
ftu.clickThruToFinish();
// verify server got request for app manifest
lateCustom.waitForServerRequest(appManifestUrl);
client.switchToFrame();
assert.ok(!appInstall.installDialog.displayed(),
'installDialog not displayed');
client.apps.switchToApp(Ftu.URL);
client.waitFor(() => {
return lateCustom.isInstalled(appManifestUrl);
});
ftu.skipTourButton.tap();
client.switchToFrame();
home.waitForLaunch();
// verify the app actually got installed
// and can be launched from homescreen
var newIcon;
client.waitFor(() => {
return (newIcon = home.getIcon(appManifestUrl));
});
assert.ok(newIcon, 'found new icon on homescreen');
home.scrollIconToCenter(newIcon);
home.launchIcon(newIcon);
});
});
suite('no apps', () => {
setup(createServerSetup(
'late-customization.json',
[]
));
setup(() => {
client.apps.switchToApp(Ftu.URL);
ftu.waitForCurtainUp();
var url = client.settings.get('latecustomization.url');
lateCustom.waitForServerRequest(url);
});
test('hide panel with no apps', () => {
// click through to finish, ensure free apps panel is not displayed
var finishScreen = client.findElement(Ftu.Selectors.finishScreen);
var seenLCPanel = false;
var hash;
while (!seenLCPanel && !finishScreen.displayed()) {
hash = ftu.getLocationHash();
seenLCPanel = hash.indexOf(lateCustom.hash) === 0;
ftu.goNext();
}
assert.ok(!seenLCPanel);
});
});
suite('server error', function() {
setup(createServerSetup(
'late-customization-operator.json',
['bakewell']
));
setup(() => {
var apiServer = lateCustom.server.api;
apiServer.serverError('/late-customization-operator.json');
client.apps.switchToApp(Ftu.URL);
ftu.waitForCurtainUp();
var url = client.settings.get('latecustomization.url');
lateCustom.waitForServerRequest(url);
});
test('FTU operates normally without valid server response', () => {
// click through to finish, ensure free apps panel is not displayed
var finishScreen = client.findElement(Ftu.Selectors.finishScreen);
var seenLCPanel = false;
var hash;
while (!seenLCPanel && !finishScreen.displayed()) {
hash = ftu.getLocationHash();
seenLCPanel = hash.indexOf(lateCustom.hash) === 0;
ftu.goNext();
}
assert.ok(!seenLCPanel);
});
});
});
});

View file

@ -0,0 +1,199 @@
'use strict';
/* global module */
var Marionette = require('marionette-client');
function Ftu(client) {
this.client = client;
this.actions = new Marionette.Actions(this.client);
}
Ftu.clientOptions = {
prefs: {
'focusmanager.testmode': true
},
settings: {
'ftu.manifestURL': 'app://ftu.gaiamobile.org/manifest.webapp'
}
};
/**
* @type String Origin of Ftu app
*/
Ftu.URL = 'app://ftu.gaiamobile.org';
Ftu.Selectors = {
'languagePanel': '#languages',
'wifiPanel': '#wifi',
'header': '#activation-screen gaia-header h1',
'languageItems': '#languages ul > li[data-value]',
'finishScreen': '#finish-screen',
'splashScreen': '#splash-screen',
// Tutorial Section
'startTourButton': '#lets-go-button',
'skipTourButton': '#skip-tutorial-button',
'completeTourButton': '#tutorialFinished',
'tourPanel': '#tutorial',
'nextButton': '#forward',
'tourNextButton': '#forward-tutorial',
'tourFinishedPanels': '.tutorial-finish-base'
};
Ftu.prototype = {
URL: Ftu.URL,
get startTourButton() {
return this.client.findElement(Ftu.Selectors.startTourButton);
},
get completeTourButton() {
return this.client.findElement(Ftu.Selectors.completeTourButton);
},
get skipTourButton() {
return this.client.findElement(Ftu.Selectors.skipTourButton);
},
getPanel: function(panel) {
return this.client.helper.waitForElement(
Ftu.Selectors[panel + 'Panel']);
},
clickThruPanel: function(panel_id, button_id) {
if (panel_id == '#wifi') {
// The wifi panel will bring up a screen to show it is scanning for
// networks. Not waiting for this to clear will blow test timing and cause
// things to fail.
this.client.helper.waitForElementToDisappear('#loading-overlay');
}
// waitForElement is used to make sure animations and page changes have
// finished, and that the panel is displayed.
this.client.helper.waitForElement(panel_id);
if (button_id) {
var button = this.client.helper.waitForElement(button_id);
button.click();
}
},
findElement: function(selector) {
// client.findElement wants to wait for the given selector to match?
// so check first to allow us to return negative results
var exists = this.client.executeScript(function(selector) {
var doc = window.wrappedJSObject.document;
return !!doc.querySelector(selector);
}, [selector]);
return exists ? this.client.findElement(selector) : null;
},
clickThruToFinish: function() {
this.waitForFtuReady();
var finishScreen = this.client.findElement(Ftu.Selectors.finishScreen);
while (!finishScreen.displayed()) {
this.goNext();
}
},
waitForCurtainUp: function() {
this.switchToFtu();
this.client.helper.waitForElementToDisappear(Ftu.Selectors.splashScreen);
},
waitForLanguagesToLoad: function() {
this.switchToFtu();
var panel = this.client.helper.waitForElement('#languages');
this.client.waitFor(function() {
return !!panel.getAttribute('data-languages-ready');
});
this.client.helper.waitForElement('#languages ul > li');
},
switchToFtu: function() {
this.client.switchToFrame();
this.client.apps.switchToApp(Ftu.URL);
},
waitForFtuReady: function() {
console.log('waitForFtuReady');
this.waitForCurtainUp();
this.waitForLanguagesToLoad();
this.switchToFtu();
console.log('/waitForFtuReady');
},
selectLanguage: function(language) {
this.waitForLanguagesToLoad();
var item = this.client.findElement(
'#languages li[data-value="' + language + '"]');
if (item) {
// scroll to it..
item.scriptWith(function(el){
el.scrollIntoView(false);
});
item.tap();
this.client.waitFor(function() {
var docLang = this.client.executeScript(function() {
return document.documentElement.lang;
});
return docLang === language;
}.bind(this));
} else {
throw new Error('Option '+ language +
' could not be found in select wrapper');
}
},
getLocationHash: function() {
var hash = this.client.executeScript(function() {
return window.wrappedJSObject.location.hash;
});
return hash;
},
goNext: function() {
var finishScreen = this.client.findElement(Ftu.Selectors.finishScreen);
this.client.helper.waitForElementToDisappear('#loading-overlay');
var button = this.client.helper.waitForElement(Ftu.Selectors.nextButton);
this.client.waitFor(function() {
return button.enabled;
});
var currentHash = this.getLocationHash();
button.tap();
this.client.waitFor(function() {
return (
finishScreen.displayed() ||
currentHash !== this.getLocationHash()
);
}.bind(this));
},
tapTakeTour: function() {
var button =
this.client.helper.waitForElement(Ftu.Selectors.startTourButton);
button.tap();
this.client.helper.waitForElement(Ftu.Selectors.tourPanel);
},
tapTourNext: function() {
var panel =
this.client.helper.waitForElement(Ftu.Selectors.tourPanel);
var currentStep = panel.getAttribute('data-step');
var button =
this.client.helper.waitForElement(Ftu.Selectors.tourNextButton);
button.tap();
this.client.waitFor(function() {
return (
(panel.getAttribute('data-step') != currentStep) ||
this.isTourFinished()
);
}.bind(this));
},
isTourFinished: function() {
var finishedPanels =
this.client.findElements(Ftu.Selectors.tourFinishedPanels);
return finishedPanels.some(function(panel) {
return panel.displayed();
});
}
};
module.exports = Ftu;

View file

@ -0,0 +1,206 @@
/* global module, __dirname */
(function(module) {
'use strict';
var createServer = require('./server-parent.js');
var fs = require('fs');
var Path = require('path');
var Url = require('url');
var LateCustomization = function(client, ftu) {
this.client = client;
this.ftu = ftu;
this.server = {};
};
LateCustomization.Settings = {
operatorInfo: {
'carrier': 'telenor', 'operator': 'telenor', 'region': 'ca',
'mcc': '238', 'mnc': '02'
}
};
LateCustomization.Selectors = {
element: '#late_customization',
appList: '#late_customization-applist',
appItems: '#late_customization-applist > li'
};
LateCustomization.prototype = {
hash: '#late_customization',
get displayed() {
return this.client.findElement(
LateCustomization.Selectors.element
).displayed();
},
get appList() {
return this.client.findElement(
LateCustomization.Selectors.appList
);
},
get appItems() {
return this.client.findElements(
LateCustomization.Selectors.appItems
);
},
manifestResponseFilename: '',
startServers: function(appNames, callback) {
appNames.push('api');
var startedPromises = appNames.map((appName) => {
return this.startServer(appName);
});
Promise.all(startedPromises).then((servers) => {
servers.forEach((server) => {
this.setupServerRoutes(server.appName, server);
});
callback(null, servers);
}).catch((ex) => {
callback(ex);
});
},
startServer: function(name) {
var rootDir = Path.join(__dirname, '..', '/fixtures/' + name);
return new Promise((resolve, reject) => {
createServer(rootDir, this.client, (err, server) => {
if (err) {
console.log('createServer err:' + err);
return reject(err);
}
server.appName = name;
// we'll map requests for http://{name}.host/ to this server
this.server[name] = server;
resolve(server);
});
});
},
stopServers: function(callback) {
var stopped = [];
for (var name in this.server) {
stopped.push(this._stopServer(this.server[name]));
}
Promise.all(stopped).then(() => {
callback();
}).catch((ex) => {
callback(ex);
});
},
_stopServer: function(server) {
return new Promise((resolve, reject) => {
server.close((err, ok) => {
if (err) {
return reject(err);
}
resolve();
});
});
},
setupServerRoutes: function(name, server) {
console.log('setupServerRoutes for: ' + name);
if (name === 'api') {
this._setupAPIServerRoutes(server);
}
},
_setupAPIServerRoutes: function(server) {
// configure server for late customization manifest request
var responsePathname = server.root +'/'+ this.manifestResponseFilename;
var filestr = fs.readFileSync(responsePathname).toString();
// we don't know our mock server's hostname until the server is created
// so preprocess all response strings, replacing faux 'name.host'
// hostnames with the actual localhost origins
var appHostRe = /(http|https):\/\/([a-z_\-]+)\.host/g;
var responseStr = filestr.replace(appHostRe, (m, proto, name) => {
if (name in this.server) {
return this.server[name].url;
} else {
console.log('_setupAPIServerRoutes, no name/host: ', name, m);
console.log('_setupAPIServerRoutes, server names: ',
Object.keys(this.server));
return m;
}
});
console.log('_setupAPIServerRoutes, prepared canned response to: ',
responsePathname);
server.setResponse(
'/' + this.manifestResponseFilename,
200, null, responseStr
);
},
translateAliasedUrl: function(_url) {
var url = Url.parse(_url);
if (url.hostname === 'localhost') {
return _url;
}
var name = url.hostname.replace('.host', '');
var server = this.server[name];
if (server) {
var serverUrl = Url.parse(server.url);
url.host = serverUrl.host;
url.protocol = serverUrl.protocol;
return Url.format(url);
} else {
console.log('translateAliasedUrl, no match for: ', _url);
return _url;
}
},
getServerForUrl: function(_url) {
var url = Url.parse(_url);
var server;
if (url.hostname !== 'localhost') {
url = this.translateAliasedUrl(Url.parse(_url));
}
for(var name in this.server) {
if (this.server[name].url === 'http://' + url.host) {
server = this.server[name];
break;
}
}
return server;
},
waitForServerRequest: function(_url, options) {
var url = Url.parse(this.translateAliasedUrl(_url));
var server = this.getServerForUrl(Url.format(url));
if (!server) {
throw new Error('waitForServerRequest, nothing serving url: ' + _url);
}
this.client.waitFor(() => {
var metaUrl = '/meta/request_count?url=' +
encodeURIComponent(url.pathname);
var response = server && JSON.parse(server.get(metaUrl));
return response && response.count;
}, options);
},
isInstalled: function(_manifestUrl) {
var manifestURL = this.translateAliasedUrl(_manifestUrl);
this.client.switchToFrame();
var result = this.client.executeAsyncScript(function(url) {
var mozApps = window.wrappedJSObject.navigator.mozApps;
var request = mozApps.mgmt.getAll();
request.onsuccess = function(evt) {
var app = evt.target.result.find(app => app.manifestURL === url);
marionetteScriptFinished(!!app);
};
request.onerror = function(evt) {
marionetteScriptFinished(false);
};
}, [manifestURL]);
this.client.apps.switchToApp(this.ftu.URL);
console.log('isInstalled, result for url: ', manifestURL, result);
return result;
}
};
module.exports = LateCustomization;
})(module);

View file

@ -0,0 +1,337 @@
/* global Buffer, process */
'use strict';
/**
App server for fixtures. Serves up packaged apps.
*/
var debug = require('debug')('appserver:child');
var http = require('http');
var fs = require('fs');
var fsPath = require('path');
var mime = require('mime');
var archiver = require('archiver');
var Url = require('url');
var EventEmitter = require('events').EventEmitter;
function readdirRecursive(dir) {
dir = fsPath.join(dir, '/');
var results = [];
var files = fs.readdirSync(dir);
var file;
while((file = files.shift())) {
file = fsPath.join(dir, file);
var stat = fs.statSync(file);
if (stat.isDirectory()) {
results = results.concat(readdirRecursive(file));
} else {
results.push(file);
}
}
return results;
}
function writeJSON(res, object) {
var json = JSON.stringify(object);
res.writeHead(200, {
'Content-Length': Buffer.byteLength(json),
'Content-Type': 'application/json'
});
res.end(json);
}
function decorateHandlerForJSON(method) {
return function(req, res) {
// if it is not json just skip it
var type = req.headers['content-type'];
if (!type || type.indexOf('json') === -1) {
return method.call(this, req, res);
}
var buffer = '';
req.on('data', function(data) {
buffer += data;
});
req.once('end', function() {
req.body = JSON.parse(buffer);
method.call(this, req, res);
}.bind(this));
};
}
function defaultSettings() {
return {
corked: false,
fail: false
};
}
/**
* @param {String} root directory where content is.@
* @param {Object} routes for the app server.
* @constructor
*/
function AppServer(root, routes) {
EventEmitter.call(this);
// ensure we always have the slash suffix
if (root[root.length - 1] !== '/') {
root += '/';
}
this.root = root;
this.routes = routes;
this._settings = {};
this._requestsByPathname = {};
}
AppServer.prototype = {
__proto__: EventEmitter.prototype,
/**
* @return {Object} default settings for a given url.
*/
settings: function(url) {
return (this._settings[url] = this._settings[url] || defaultSettings());
},
requestHandler: function() {
var routes = this.routes;
return function(req, res) {
var pathname = Url.parse(req.url).pathname;
var route = routes[pathname] ? pathname : '*';
var handler = routes[route] || routes[route];
var settings = this.settings(pathname);
if (!this._requestsByPathname[pathname]) {
this._requestsByPathname[pathname] = 0;
}
this._requestsByPathname[pathname]++;
debug('requestHandler', req.method, req.url, 'route:'+route,
'pathname/count: ', pathname, this._requestsByPathname[pathname] || 0);
// If this url has a http error on it just return without calling the
// handler.
if (settings.response) {
res.writeHead(settings.response.status, settings.response.headers);
res.end(settings.response.body);
return;
}
// routes are always invoked with the context of the app server.
return handler.call(this, req, res);
}.bind(this);
},
};
var routes = {
/**
Change the root serving directory for this server.
*/
'/settings/set_root': decorateHandlerForJSON(function(req, res) {
this.root = req.body;
writeJSON(res, req.body);
}),
/**
Override or set the response of a particular url.
*/
'/settings/set_response': decorateHandlerForJSON(function(req, res) {
var options = req.body;
var path = options.path.split('?')[0];
var settings = this.settings(path);
settings.response = options;
writeJSON(res, options.path);
}),
/**
Clear the response override.
*/
'/settings/clear_response': decorateHandlerForJSON(function(req, res) {
var url = req.body;
var settings = this.settings(url);
delete settings.response;
writeJSON(res, url);
}),
/**
A 'corked' request will send headers but no body until 'uncork' is used.
*/
'/settings/cork': decorateHandlerForJSON(function(req, res) {
var url = req.body;
var settings = this.settings(url);
settings.corked = true;
// issue a cork to the particular endpoint
var event = 'corked ' + url;
debug('cork event', event);
this.emit('corked ' + url);
writeJSON(res, url);
}),
/**
The inverse of 'cork' will uncork a uri and send the body through.
*/
'/settings/uncork': decorateHandlerForJSON(function(req, res) {
var url = req.body;
var settings = this.settings(url);
settings.corked = false;
var event = 'uncorked ' + url;
debug('uncork event', event);
this.emit(event);
writeJSON(res, url);
}),
/**
Fail is a combination of 'cork' and a flag which will cause the incoming http
request to be closed after a short delay.
*/
'/settings/fail': decorateHandlerForJSON(function(req, res) {
var url = req.body;
var settings = this.settings(url);
// must be corked to ensure we don't send the body
settings.corked = true;
settings.fail = true;
writeJSON(res, url);
}),
/**
The inverse to fail but unlike uncork this cannot be called in the middle of
a request (for obvious reasons).
*/
'/settings/unfail': decorateHandlerForJSON(function(req, res) {
var url = req.body;
var settings = this.settings(url);
settings.fail = settings.corked = false;
writeJSON(res, url);
}),
'/package.manifest': function(req, res) {
var json = JSON.parse(fs.readFileSync(this.root + '/manifest.webapp'));
json.package_path = 'http://' + req.headers.host + '/app.zip';
// TODO: Remove this once we add the ability to override requests
delete json.role;
var body = JSON.stringify(json, null, 2);
res.writeHead(200, {
'Content-Length': Buffer.byteLength(body),
'Content-Type': 'application/x-web-app-manifest+json'
});
res.end(body);
},
'/app.zip': function(req, res) {
var url = req.url;
var settings = this.settings(url);
// always write the head we need it in all cases
res.writeHead(200, {
'Content-Type': 'application/zip'
});
var root = this.root;
function writeZip() {
// read the entire app directory
var files = readdirRecursive(root);
var zip = archiver.create('zip');
files.forEach(function(file) {
debug('packaged app zip file', file);
var zipPath = file.replace(root, '');
zip.append(fs.createReadStream(file), { name: zipPath });
});
zip.finalize();
zip.pipe(res);
}
if (settings.fail) {
process.nextTick(function() {
req.socket.destroy();
});
return;
}
// if we are not corked just write the zip and we are done
if (!settings.corked) {
return writeZip();
} else {
this.once('uncorked ' + url, writeZip);
}
},
'/meta/request_count': function(req, res) {
var query = Url.parse(req.url, true).query;
var path = query.url && Url.parse(query.url).pathname;
var count = this._requestsByPathname[path || '*'];
debug('request_count handler, path/count: ', path, count);
writeJSON(res, {
path: path || '*',
count: count,
all: this._requestsByPathname
});
},
/**
Catch all handler which serves up static assets.
*/
'*': function(req, res) {
var url = req.url;
var settings = this.settings(url);
var filePath = fsPath.join(this.root, url);
// XXX: This is not a production quality server don't use sync methods in
// in servers in your user facing node code.
if (!fs.existsSync(filePath)) {
res.writeHead(404);
res.end();
return;
}
var contentType = mime.lookup(filePath);
var contentLength = fs.statSync(filePath).size;
debug('static asset', filePath, contentType, contentLength);
// write out the head
res.writeHead(200, {
'Content-Type': contentType,
'Content-Length': contentLength
});
function writeContent() {
fs.createReadStream(filePath).pipe(res);
}
if (settings.fail) {
process.nextTick(function() {
req.socket.destroy();
});
return;
}
if (!settings.corked) {
// if we are not corked just return the stream
return writeContent();
} else {
// otherwise wait for the uncork
this.once('uncorked ' + url, writeContent);
}
}
};
var port = parseInt((process.argv[3] || 0), 10);
var appServer = new AppServer(process.argv[2], routes);
var server = http.createServer(appServer.requestHandler());
server.listen(port, function() {
if (process.send) {
process.send({ type: 'started', port: server.address().port });
}
});

View file

@ -0,0 +1,196 @@
'use strict';
/* global module, __dirname */
var fork = require('child_process').fork;
/**
* Issue a POST request via marionette.
*/
function post(client, url, json) {
// must run in chrome so we can do cross domain xhr
client = client.scope({ context: 'chrome' });
console.log('post: Attempting to post to url: ' + url);
return client.executeAsyncScript(function(url, json) {
window.fetch(url, {
method: 'POST',
headers: new window.Headers({
'Content-Type': 'application/json'
}),
body: json
}).then((response) => {
marionetteScriptFinished(response);
}).catch((ex) => {
console.log('executeAsyncScript for window.fetch raised exepction:', ex);
});
}, [url, JSON.stringify(json)]);
}
function AppServer(root, marionette, port, proc) {
this.root = root;
this.marionette = marionette;
this.url = 'http://localhost:' + port;
this.process = proc;
}
AppServer.prototype = {
/**
issues a GET request to the server and returns the parsed body.
*/
get: function(uri) {
// must run in chrome so we can do cross domain xhr
var client = this.marionette.scope({ context: 'chrome' });
console.log('AppServer.get: ' + uri);
return client.executeAsyncScript(function(url) {
var xhr = new XMLHttpRequest();
xhr.addEventListener('load', function() {
marionetteScriptFinished(xhr.response);
});
xhr.addEventListener('error', function(evt) {
console.log('AppServer got error');
marionetteScriptFinished('Ack: ', evt.type);
});
xhr.open('GET', url, true);
xhr.send();
}, [this.url + uri]);
},
/**
Changes the root of the server.
@param {String} path relative to the file system.
*/
setRoot: function(path) {
return post(this.marionette, this.url + '/settings/set_root', path);
},
/**
Set the http response of a particular url.
@param {String} path to set the response of.
@param {Number} status code to set.
@param {Object} headers to override.
@param {String} body of the request.
*/
setResponse: function(path, status, headers, body) {
var options = {
path: path,
status: status,
headers: headers || {},
body: body
};
return post(this.marionette, this.url + '/settings/set_response', options);
},
/**
Clear the response override set by `setResponse`
*/
clearResponse: function(url) {
return post(
this.marionette, this.url + '/settings/clear_response', url
);
},
/**
Trigger a 500 error for a particular url and return an empty body.
@param {String} url to fail (/index.html).
*/
serverError: function(url) {
return this.setResponse(url, 500, {}, '');
},
/**
Clears the server error for a particular url.
@param {String} url to fail (/index.html).
*/
clearServerError: function(url) {
return this.clearResponse();
},
/**
Indicate to the server that all requests to the given url should be
given a response with headers but then the socket should be closed shortly
after that time.
@param {String} url to ban (/index.html).
*/
fail: function(url) {
return post(this.marionette, this.url + '/settings/fail', url);
},
/**
Allow requests to the given url to proceed without failure after calling
`.fail`.
@param {String} url to unban.
*/
unfail: function(url) {
return post(this.marionette, this.url + '/settings/unfail', url);
},
/**
Cork the response body of the given url while allowing headers.
@param {String} url to cork.
*/
cork: function(url) {
return post(this.marionette, this.url + '/settings/cork', url);
},
/**
Allow the body to be sent after calling `.cork`.
@param {String} url to uncork.
*/
uncork: function(url) {
return post(this.marionette, this.url + '/settings/uncork', url);
},
close: function(callback) {
this.process.kill();
this.process.once('exit', callback.bind(this, null));
},
get manifest() {
return JSON.parse(this.get('/manifest.webapp'));
},
/**
URI where the application zip lives this defined in server-child.js
*/
get applicationZipUri() {
return '/app.zip';
},
get manifestURL() {
return this.url + '/manifest.webapp';
},
get packageManifestURL() {
return this.url + '/package.manifest';
}
};
/**
* Create a app server for use in marionette tests.
*
* @param {String} appRoot path to the root of the app.
* @param {Marionette.Client} client for marionette.
* @param {Function} callback [Error]
*/
module.exports = function create(appRoot, client, callback) {
var proc = fork(__dirname + '/server-child.js', [appRoot]);
proc.once('error', callback);
proc.on('message', function(msg) {
if (msg.type !== 'started') {
return;
}
proc.removeListener('error', callback);
callback(null, new AppServer(appRoot, client, msg.port, proc));
});
};
module.exports.AppServer;

View file

@ -0,0 +1,60 @@
'use strict';
var Ftu = require('./lib/ftu');
var assert = require('assert');
marionette('First Time Use >', function() {
var ftu;
var clientOptions = {
prefs: Ftu.clientOptions.prefs,
settings: Object.assign({}, Ftu.clientOptions.settings, {
'devtools.pseudolocalization.enabled': true
})
};
var client = marionette.client({
profile: clientOptions,
desiredCapabilities: { raisesAccessibilityExceptions: false }
});
setup(function() {
ftu = new Ftu(client);
});
test('Change language to bidi english', function(done) {
ftu.waitForFtuReady();
var header = client.helper.waitForElement(Ftu.Selectors.header);
ftu.selectLanguage('ar-x-psbidi');
var languageSettingValue = client.settings.get('language.current');
assert.equal(languageSettingValue, 'ar-x-psbidi');
// sanity check expected translation
var body = client.helper.waitForElement('body');
var direction = body.scriptWith(function(el) {
return window.wrappedJSObject.getComputedStyle(el).direction;
});
assert.equal(direction, 'rtl');
client.executeAsyncScript(function() {
var pseudo = window.wrappedJSObject.document.l10n.pseudo;
pseudo['ar-x-psbidi'].processString('Language').then(
function(string) {
marionetteScriptFinished(string);
}
);
}, function(err, value) {
assert.equal(value, header.text());
done(err);
});
});
test('Correct nav button order under RTL', function() {
ftu.waitForFtuReady();
ftu.selectLanguage('ar-x-psbidi');
var direction = client.helper.waitForElement('#nav-bar')
.scriptWith(function(el) {
return window.wrappedJSObject.getComputedStyle(el).direction;
});
assert.equal(direction, 'rtl', 'Expect direction to be rtl');
});
});

View file

@ -0,0 +1,75 @@
/* global require */
'use strict';
var Ftu = require('./lib/ftu');
var assert = require('assert');
marionette('First Time Use >', function() {
var client = marionette.client({
profile: Ftu.clientOptions,
desiredCapabilities: { raisesAccessibilityExceptions: false }
});
var ftu, system;
setup(function() {
system = client.loader.getAppClass('system');
ftu = new Ftu(client);
});
test('statusbar', function() {
client.waitFor(function() {
return system.statusbar.displayed();
});
client.apps.switchToApp(Ftu.URL);
ftu.waitForFtuReady();
var finishScreen = client.findElement(Ftu.Selectors.finishScreen);
while (!finishScreen.displayed()) {
client.switchToFrame();
assert.ok(system.statusbar.displayed());
client.apps.switchToApp(Ftu.URL);
ftu.goNext();
}
ftu.tapTakeTour();
while (!ftu.isTourFinished()) {
client.switchToFrame();
assert.ok(system.statusbar.displayed());
client.apps.switchToApp(Ftu.URL);
ftu.tapTourNext();
}
client.switchToFrame();
assert.ok(system.statusbar.displayed());
});
test('statusbar icons should be dark', function() {
client.apps.switchToApp(Ftu.URL);
ftu.waitForCurtainUp();
client.switchToFrame();
client.waitFor(function() {
return system.statusbar.displayed();
});
client.waitFor(function() {
var className = system.statusbar.scriptWith(function(element) {
return element.className;
});
var index = className.indexOf('light');
return index > -1;
});
});
test('statusbar icons should change', function() {
client.apps.switchToApp(Ftu.URL);
ftu.clickThruToFinish();
client.switchToFrame();
client.waitFor(function() {
var className = system.statusbar.scriptWith(function(element) {
return element.className;
});
var index = className.indexOf('light');
return index <= -1;
});
});
});

View file

@ -0,0 +1,60 @@
'use strict';
var assert = require('assert');
var Ftu = require('./lib/ftu');
marionette('First Time Use > Wifi Hidden Network Test', function() {
var ftu, system;
var client = marionette.client({
profile: Ftu.clientOptions,
desiredCapabilities: { raisesAccessibilityExceptions: false }
});
var wifiPassword64 =
'e93FSJpMGMxRnWHs2vJYyMud5h6u7yEhSC445cz7RdHVxXrj2LCTZPAphzaYuyy2';
setup(function() {
system = client.loader.getAppClass('system');
system.waitForFullyLoaded();
ftu = new Ftu(client);
});
test('Wi-Fi hidden network password 64 characters', function() {
ftu.client.apps.switchToApp(Ftu.URL);
ftu.waitForFtuReady();
ftu.clickThruPanel('#languages', '#forward');
ftu.clickThruPanel('#wifi', '#join-hidden-button');
var input = client.findElement('#hidden-wifi-password');
var password = input.getAttribute('value');
assert.equal(password.length, 0);
input.sendKeys(wifiPassword64);
password = input.getAttribute('value');
assert.equal(password.length, 63);
});
test('Wi-Fi hidden network show password', function() {
ftu.client.apps.switchToApp(Ftu.URL);
ftu.waitForFtuReady();
ftu.clickThruPanel('#languages', '#forward');
ftu.clickThruPanel('#wifi', '#join-hidden-button');
var hiddenWifiPanel = ftu.client.helper
.waitForElement('#hidden-wifi-authentication');
var showPassword = ftu.client.findElement('#hidden-wifi-show-password');
var passwordInput = ftu.client.findElement('#hidden-wifi-password');
assert(hiddenWifiPanel.displayed() &&
showPassword.displayed() &&
passwordInput.displayed()
);
showPassword.click();
ftu.client.helper.waitFor(function() {
return passwordInput.getAttribute('type') === 'text';
});
// Make sure input wont get auto-corrected
assert.equal(passwordInput.getAttribute('x-inputmode'), 'verbatim');
});
});

View file

@ -0,0 +1,21 @@
'use strict';
var Ftu = require('./lib/ftu');
marionette('First Time Use > Wifi Scanning Test', function() {
var ftu;
var client = marionette.client({ profile: Ftu.clientOptions });
setup(function() {
ftu = new Ftu(client);
});
test('FTU Wifi Scanning Tests', function() {
client.apps.switchToApp(Ftu.URL);
ftu.waitForFtuReady();
ftu.clickThruPanel('#languages', '#forward');
ftu.clickThruPanel('#wifi', '#forward');
ftu.clickThruPanel('#date_and_time', '#back');
ftu.clickThruPanel('#wifi', undefined);
});
});