Compare commits

...

6 Commits

Author SHA1 Message Date
Rich Harris
e1926e1bcb -> v0.2.8 2017-12-23 13:16:58 -05:00
Rich Harris
db1c1f332a Add goto function (#46)
* WIP

* programmatic navigation

* get tests working

* wait longer
2017-12-23 13:15:40 -05:00
Rich Harris
e8d510b261 dont use /tmp, it fails when now server wakes after a sleep 2017-12-23 12:27:51 -05:00
Rich Harris
f8e237b265 make invalidation message look less like an error 2017-12-23 10:55:55 -05:00
Rich Harris
68c2f2e388 linting 2017-12-22 09:19:32 -05:00
Rich Harris
0bcb61650b use nightmare for testing 2017-12-21 16:48:53 -05:00
31 changed files with 2630 additions and 4584 deletions

View File

@@ -1,7 +1,6 @@
{
"root": true,
"rules": {
"indent": [ 2, "tab", { "SwitchCase": 1 } ],
"semi": [ 2, "always" ],
"space-before-blocks": [ 2, "always" ],
"no-mixed-spaces-and-tabs": [ 2, "smart-tabs" ],
@@ -33,7 +32,7 @@
"plugin:import/warnings"
],
"parserOptions": {
"ecmaVersion": 6,
"ecmaVersion": 8,
"sourceType": "module"
}
}

View File

@@ -1,10 +1,21 @@
sudo: false
language: node_js
node_js:
- "stable"
env:
global:
- BUILD_TIMEOUT=10000
addons:
apt:
packages:
- xvfb
install:
- export DISPLAY=':99.0'
- Xvfb :99 -screen 0 1024x768x24 > /dev/null 2>&1 &
- npm install
- (cd test/app && npm install)

View File

@@ -1,5 +1,10 @@
# sapper changelog
## 0.2.8
* Add `goto` function ([#29](https://github.com/sveltejs/sapper/issues/29))
* Don't use `/tmp` as destination in Now environments
## 0.2.7
* Fix streaming bug

View File

@@ -1,6 +1,5 @@
const fs = require('fs');
const path = require('path');
const glob = require('glob');
const mkdirp = require('mkdirp');
const rimraf = require('rimraf');
const { client, server } = require('./utils/compilers.js');
@@ -9,7 +8,7 @@ const generate_asset_cache = require('./utils/generate_asset_cache.js');
const { dest } = require('./config.js');
module.exports = () => {
mkdirp(dest);
mkdirp.sync(dest);
rimraf.sync(path.join(dest, '**/*'));
// create main.js and server-routes.js

View File

@@ -8,13 +8,10 @@ exports.templates = path.resolve(process.env.SAPPER_TEMPLATES || 'templates');
exports.src = path.resolve(process.env.SAPPER_ROUTES || 'routes');
exports.dest = path.resolve(
process.env.NOW ? '/tmp' :
process.env.SAPPER_DEST || '.sapper'
);
exports.dest = path.resolve(process.env.SAPPER_DEST || '.sapper');
if (exports.dev) {
mkdirp(exports.dest);
mkdirp.sync(exports.dest);
rimraf.sync(path.join(exports.dest, '**/*'));
}

View File

@@ -1,9 +1,5 @@
const fs = require('fs');
const path = require('path');
const glob = require('glob');
const rimraf = require('rimraf');
const mkdirp = require('mkdirp');
const webpack = require('webpack');
const route_manager = require('./route_manager.js');
const templates = require('./templates.js');
const create_app = require('./utils/create_app.js');
@@ -11,7 +7,7 @@ const create_watcher = require('./utils/create_watcher.js');
const compilers = require('./utils/compilers.js');
const generate_asset_cache = require('./utils/generate_asset_cache.js');
const escape_html = require('escape-html');
const { src, dest, dev } = require('./config.js');
const { dest, dev } = require('./config.js');
function connect_dev() {
create_app();
@@ -20,7 +16,7 @@ function connect_dev() {
let asset_cache;
return compose_handlers([
const middleware = compose_handlers([
require('webpack-hot-middleware')(compilers.client, {
reload: true,
path: '/__webpack_hmr',
@@ -59,6 +55,13 @@ function connect_dev() {
get_not_found_handler(() => asset_cache)
]);
middleware.close = () => {
watcher.close();
// TODO shut down chokidar
};
return middleware;
}
function connect_prod() {
@@ -67,7 +70,7 @@ function connect_prod() {
read_json(path.join(dest, 'stats.server.json'))
);
return compose_handlers([
const middleware = compose_handlers([
set_req_pathname,
get_asset_handler({
@@ -95,6 +98,12 @@ function connect_prod() {
get_not_found_handler(() => asset_cache)
]);
// here for API consistency between dev, and prod, but
// doesn't actually need to do anything
middleware.close = () => {};
return middleware;
}
module.exports = dev ? connect_dev : connect_prod;
@@ -139,7 +148,7 @@ function get_route_handler(fn) {
// TODO detect other stuff we can preload? images, CSS, fonts?
res.set('Link', `<${client.main_file}>;rel="preload";as="script", <${client.routes[route.id]}>;rel="preload";as="script"`);
let data = { params: req.params, query: req.query };
const data = { params: req.params, query: req.query };
if (mod.preload) {
const promise = Promise.resolve(mod.preload(data)).then(preloaded => {
@@ -147,7 +156,7 @@ function get_route_handler(fn) {
return mod.render(data);
});
templates.stream(res, 200, {
await templates.stream(res, 200, {
main: client.main_file,
html: promise.then(rendered => rendered.html),
head: promise.then(({ head }) => `<noscript id='sapper-head-start'></noscript>${head}<noscript id='sapper-head-end'></noscript>`),
@@ -178,28 +187,30 @@ function get_route_handler(fn) {
next();
} catch(err) {
res.status(500).end(templates.render(500, {
res.status(500);
res.end(templates.render(500, {
title: (err && err.name) || 'Internal server error',
url,
error: escape_html(err && (err.details || err.message || err) || 'Unknown error'),
stack: err && err.stack.split('\n').slice(1).join('\n')
}));
}
}
};
}
function get_not_found_handler(fn) {
return function handle_not_found(req, res) {
const asset_cache = fn();
res.status(404).end(templates.render(404, {
res.status(404);
res.end(templates.render(404, {
title: 'Not found',
status: 404,
method: req.method,
main: asset_cache.client.main_file,
url: req.url
}));
}
};
}
function compose_handlers(handlers) {
@@ -219,7 +230,7 @@ function compose_handlers(handlers) {
}
go();
}
};
}
function read_json(file) {

View File

@@ -21,7 +21,8 @@ update();
if (dev) {
const watcher = chokidar.watch(`${src}/**/*.+(html|js|mjs)`, {
ignoreInitial: true
ignoreInitial: true,
persistent: false
});
watcher.on('add', update);

View File

@@ -57,7 +57,7 @@ function create_templates() {
i = end + 1;
} while (i < template.length);
}
}
};
})
.sort((a, b) => b.specificity - a.specificity);
}
@@ -66,7 +66,8 @@ create_templates();
if (dev) {
const watcher = chokidar.watch('templates/**.html', {
ignoreInitial: true
ignoreInitial: true,
persistent: false
});
watcher.on('add', create_templates);

View File

@@ -2,10 +2,10 @@ const fs = require('fs');
const path = require('path');
const chokidar = require('chokidar');
const route_manager = require('../route_manager.js');
const { src, dest, entry, dev } = require('../config.js');
const { src, entry, dev } = require('../config.js');
function posixify(file) {
return file.replace(/[\/\\]/g, '/');
return file.replace(/[/\\]/g, '/');
}
function create_app() {
@@ -71,7 +71,8 @@ if (dev) {
route_manager.onchange(create_app);
const watcher = chokidar.watch(`templates/main.js`, {
ignoreInitial: true
ignoreInitial: true,
persistent: false
});
watcher.on('add', create_app);

View File

@@ -74,4 +74,4 @@ module.exports = function create_matchers(files) {
});
return routes;
}
};

View File

@@ -38,15 +38,11 @@ module.exports = function create_watcher() {
);
});
watcher = {
ready: invalidate()
};
function watch_compiler(type) {
const compiler = compilers[type];
compiler.plugin('invalid', filename => {
console.log(chalk.red(`${type} bundle invalidated, file changed: ${chalk.bold(filename)}`));
console.log(chalk.cyan(`${type} bundle invalidated, file changed: ${chalk.bold(filename)}`));
deferreds[type] = deferred();
watcher.ready = invalidate();
});
@@ -55,7 +51,7 @@ module.exports = function create_watcher() {
deferreds[type].reject(err);
});
compiler.watch({}, (err, stats) => {
return compiler.watch({}, (err, stats) => {
if (stats.hasErrors()) {
deferreds[type].reject(stats.toJson().errors[0]);
} else {
@@ -64,8 +60,16 @@ module.exports = function create_watcher() {
});
}
watch_compiler('client');
watch_compiler('server');
const watcher = {
ready: invalidate(),
client: watch_compiler('client'),
server: watch_compiler('server'),
close: () => {
watcher.client.close();
watcher.server.close();
}
};
return watcher;
};

View File

@@ -1,2 +1,3 @@
--recursive
test/unit/**/*.js
test/unit/**/*.js
test/common/test.js

3239
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
{
"name": "sapper",
"version": "0.2.7",
"version": "0.2.8",
"description": "Military-grade apps, engineered by Svelte",
"main": "lib/index.js",
"bin": {
@@ -21,22 +21,23 @@
"webpack-hot-middleware": "^2.21.0"
},
"devDependencies": {
"cypress": "^1.3.0",
"css-loader": "^0.28.7",
"eslint": "^4.13.1",
"eslint-plugin-import": "^2.8.0",
"express": "^4.16.2",
"get-port": "^3.2.0",
"mocha": "^4.0.1",
"nightmare": "^2.10.0",
"node-fetch": "^1.7.3",
"npm-run-all": "^4.1.2",
"style-loader": "^0.19.1",
"svelte": "^1.49.1",
"svelte-loader": "^2.3.2",
"wait-on": "^2.0.2"
},
"scripts": {
"cy:open": "cypress open",
"test": "run-s test:unit test:dev test:prod",
"test:unit": "mocha --opts mocha.opts",
"test:dev": "run-p --race test:launch:dev cy:run:dev",
"test:launch:dev": "node test/launch.js --dev",
"cy:run:dev": "wait-on http://localhost:3000 && cypress run -s test/cypress/integration/dev.js",
"test:prod": "run-p --race test:launch:prod cy:run:prod",
"test:launch:prod": "node test/launch.js --prod",
"cy:run:prod": "wait-on http://localhost:3000 && cypress run -s test/cypress/integration/prod.js"
"test": "mocha --opts mocha.opts"
},
"repository": "https://github.com/sveltejs/sapper",
"keywords": [

View File

@@ -6,12 +6,17 @@ export let component;
let target;
let routes;
const history = typeof window !== 'undefined' ? window.history : {
pushState: () => {},
replaceState: () => {},
};
const scroll_history = {};
let uid = 1;
let cid;
if ('scrollRestoration' in history) {
history.scrollRestoration = 'manual'
history.scrollRestoration = 'manual';
}
function select_route(url) {
@@ -39,7 +44,7 @@ function render(Component, data, scroll) {
} else {
// first load — remove SSR'd <head> contents
const start = document.querySelector('#sapper-head-start');
let end = document.querySelector('#sapper-head-end');
const end = document.querySelector('#sapper-head-end');
if (start && end) {
while (start.nextSibling !== end) detach(start.nextSibling);
@@ -75,8 +80,6 @@ function navigate(url, id) {
id = cid = ++uid;
scroll_history[cid] = { x: 0, y: 0 };
history.pushState({ id }, '', url.href);
}
selected.route.load().then(mod => {
@@ -123,6 +126,7 @@ function handle_click(event) {
if (navigate(url, null)) {
event.preventDefault();
history.pushState({ id: cid }, '', url.href);
}
}
@@ -173,7 +177,7 @@ export function init(_target, _routes) {
inited = true;
}
const scroll = scroll_history[uid] = scroll_state();
scroll_history[uid] = scroll_state();
history.replaceState({ id: uid }, '', window.location.href);
navigate(new URL(window.location), uid);
@@ -189,4 +193,12 @@ function scroll_state() {
x: window.scrollX,
y: window.scrollY
};
}
export function goto(href, opts = {}) {
if (navigate(new URL(href, window.location.href))) {
if (history) history[opts.replaceState ? 'replaceState' : 'pushState']({ id: cid }, '', href);
} else {
window.location.href = href;
}
}

1
test/app/.gitignore vendored
View File

@@ -3,3 +3,4 @@ node_modules
.sapper
yarn.lock
cypress/screenshots
templates/.*

View File

@@ -6,14 +6,21 @@
<h1>About this site</h1>
<p>This is the 'about' page. There's not much here.</p>
<button on:click='goto("/blog/what-is-sapper")'>What is Sapper?</button>
</Layout>
<script>
import Layout from './_components/Layout.html';
import { goto } from '../../../runtime/app.js';
export default {
components: {
Layout
},
methods: {
goto
}
};
</script>

View File

@@ -10,9 +10,9 @@
<link rel='icon' type='image/png' href='/favicon.png'>
<script>
if ('serviceWorker' in navigator) {
navigator.serviceWorker.register('/service-worker.js');
}
// if ('serviceWorker' in navigator) {
// navigator.serviceWorker.register('/service-worker.js');
// }
</script>
<!-- Sapper generates a <style> tag containing critical CSS

View File

@@ -1,10 +1,6 @@
import { init } from '__app__';
import { init } from '../../../runtime/app.js';
// `routes` is an array of route objects injected by Sapper
init(document.querySelector('#sapper'), __routes__);
// if (__dev__) {
// // Enable hot-module reloading
// import('sapper/webpack/hmr');
// if (module.hot) module.hot.accept();
// }
window.READY = true;

View File

@@ -1,7 +1,5 @@
const config = require('../../webpack/config.js');
const webpack = require('webpack');
const ExtractTextPlugin = require("extract-text-webpack-plugin");
const UglifyJSPlugin = require('uglifyjs-webpack-plugin');
module.exports = {
entry: config.client.entry(),
@@ -24,27 +22,18 @@ module.exports = {
}
}
},
config.dev && {
{
test: /\.css$/,
use: [
{ loader: "style-loader" },
{ loader: "css-loader" }
]
},
!config.dev && {
test: /\.css$/,
use: ExtractTextPlugin.extract({
fallback: 'style-loader',
use: [{ loader: 'css-loader', options: { sourceMap: config.dev } }]
})
}
].filter(Boolean)
},
plugins: [
config.dev && new webpack.HotModuleReplacementPlugin(),
!config.dev && new ExtractTextPlugin('main.css'),
!config.dev && new webpack.optimize.ModuleConcatenationPlugin(),
!config.dev && new UglifyJSPlugin()
!config.dev && new webpack.optimize.ModuleConcatenationPlugin()
].filter(Boolean),
devtool: config.dev ? 'inline-source-map' : false
};

View File

@@ -1,7 +1,4 @@
const config = require('../../webpack/config.js');
const webpack = require('webpack');
const ExtractTextPlugin = require("extract-text-webpack-plugin");
const UglifyJSPlugin = require('uglifyjs-webpack-plugin');
module.exports = {
entry: config.server.entry(),

234
test/common/test.js Normal file
View File

@@ -0,0 +1,234 @@
const path = require('path');
const assert = require('assert');
const express = require('express');
const serve = require('serve-static');
const Nightmare = require('nightmare');
const getPort = require('get-port');
const fetch = require('node-fetch');
run('production');
run('development');
function run(env) {
describe(`env=${env}`, function () {
this.timeout(5000);
let PORT;
let server;
let nightmare;
let middleware;
let capture;
let base;
function get(url) {
return new Promise(fulfil => {
const req = {
url,
method: 'GET'
};
const result = {
headers: {},
body: ''
};
const res = {
set: (headers, value) => {
if (typeof headers === 'string') {
return res.set({ [headers]: value });
}
Object.assign(result.headers, headers);
},
status: code => {
result.status = code;
},
write: data => {
result.body += data;
},
end: data => {
result.body += data;
fulfil(result);
}
};
middleware(req, res, () => {
fulfil(result);
});
});
}
before(async () => {
process.chdir(path.resolve(__dirname, '../app'));
process.env.NODE_ENV = env;
if (env === 'production') {
const cli = path.resolve(__dirname, '../../cli/index.js');
await exec(`${cli} build`);
}
const resolved = require.resolve('../..');
delete require.cache[resolved];
const sapper = require(resolved);
PORT = await getPort();
base = `http://localhost:${PORT}`;
global.fetch = (url, opts) => {
if (url[0] === '/') url = `${base}${url}`;
return fetch(url, opts);
};
let captured;
capture = async fn => {
const result = captured = [];
await fn();
captured = null;
return result;
};
const app = express();
app.use(serve('assets'));
app.use((req, res, next) => {
if (captured) captured.push(req);
next();
});
middleware = sapper();
app.use(middleware);
return new Promise((fulfil, reject) => {
server = app.listen(PORT, err => {
if (err) reject(err);
else fulfil();
});
});
});
after(() => {
server.close();
middleware.close();
// give a chance to clean up
return new Promise(fulfil => setTimeout(fulfil, 500));
});
describe('basic functionality', () => {
beforeEach(() => {
nightmare = new Nightmare();
nightmare.on('console', (type, ...args) => {
console[type](...args);
});
nightmare.on('page', (type, ...args) => {
if (type === 'error') {
console.error(args[1]);
} else {
console.warn(type, args);
}
});
});
afterEach(async () => {
await nightmare.end();
});
it('serves /', async () => {
const title = await nightmare
.goto(base)
.evaluate(() => document.querySelector('h1').textContent);
assert.equal(title, 'Great success!');
});
it('serves static route', async () => {
const title = await nightmare
.goto(`${base}/about`)
.evaluate(() => document.querySelector('h1').textContent);
assert.equal(title, 'About this site');
});
it('serves dynamic route', async () => {
const title = await nightmare
.goto(`${base}/blog/what-is-sapper`)
.evaluate(() => document.querySelector('h1').textContent);
assert.equal(title, 'What is Sapper?');
});
it('navigates to a new page without reloading', async () => {
await nightmare.goto(base);
const requests = await capture(async () => {
await nightmare.click('a[href="/about"]');
});
assert.equal(
await nightmare.path(),
'/about'
);
assert.equal(
await nightmare.evaluate(() => document.title),
'About'
);
assert.deepEqual(requests.map(r => r.url), []);
});
it('navigates programmatically', async () => {
await nightmare
.goto(`${base}/about`)
.wait(() => window.READY)
.click('button')
.wait(() => window.location.pathname === '/blog/what-is-sapper')
.wait(100);
assert.equal(
await nightmare.evaluate(() => document.title),
'What is Sapper?'
);
});
});
describe('headers', () => {
it('sets Content-Type and Link...preload headers', async () => {
const { headers } = await get('/');
assert.equal(
headers['Content-Type'],
'text/html'
);
assert.ok(
/<\/client\/main.\w+\.js>;rel="preload";as="script", <\/client\/_.\d+.\w+.js>;rel="preload";as="script"/.test(headers['Link']),
headers['Link']
);
});
});
});
}
function exec(cmd) {
return new Promise((fulfil, reject) => {
require('child_process').exec(cmd, (err, stdout, stderr) => {
if (err) {
process.stdout.write(stdout);
process.stderr.write(stderr);
return reject(err);
}
fulfil();
});
});
}

View File

@@ -1,5 +0,0 @@
{
"name": "Using fixtures to represent data",
"email": "hello@cypress.io",
"body": "Fixtures are a great way to mock data for responses to routes"
}

View File

@@ -1,19 +0,0 @@
describe('dev mode', () => {
beforeEach(() => {
cy.visit('/')
});
it('has the correct <h1>', () => {
cy.contains('h1', 'Great success!')
});
it('navigates to /about', () => {
cy.get('nav a').contains('about').click();
cy.url().should('include', '/about');
});
it('navigates to /blog', () => {
cy.get('nav a').contains('blog').click();
cy.url().should('include', '/blog');
});
});

View File

@@ -1,19 +0,0 @@
describe('prod mode', () => {
beforeEach(() => {
cy.visit('/')
});
it('has the correct <h1>', () => {
cy.contains('h1', 'Great success!')
});
it('navigates to /about', () => {
cy.get('nav a').contains('about').click();
cy.url().should('include', '/about');
});
it('navigates to /blog', () => {
cy.get('nav a').contains('blog').click();
cy.url().should('include', '/blog');
});
});

View File

@@ -1,17 +0,0 @@
// ***********************************************************
// This example plugins/index.js can be used to load plugins
//
// You can change the location of this file or turn off loading
// the plugins file with the 'pluginsFile' configuration option.
//
// You can read more here:
// https://on.cypress.io/plugins-guide
// ***********************************************************
// This function is called when a project is opened or re-opened (e.g. due to
// the project's config changing)
module.exports = (on, config) => {
// `on` is used to hook into various events Cypress emits
// `config` is the resolved Cypress config
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 331 KiB

View File

@@ -1,25 +0,0 @@
// ***********************************************
// This example commands.js shows you how to
// create various custom commands and overwrite
// existing commands.
//
// For more comprehensive examples of custom
// commands please read more here:
// https://on.cypress.io/custom-commands
// ***********************************************
//
//
// -- This is a parent command --
// Cypress.Commands.add("login", (email, password) => { ... })
//
//
// -- This is a child command --
// Cypress.Commands.add("drag", { prevSubject: 'element'}, (subject, options) => { ... })
//
//
// -- This is a dual command --
// Cypress.Commands.add("dismiss", { prevSubject: 'optional'}, (subject, options) => { ... })
//
//
// -- This is will overwrite an existing command --
// Cypress.Commands.overwrite("visit", (originalFn, url, options) => { ... })

View File

@@ -1,20 +0,0 @@
// ***********************************************************
// This example support/index.js is processed and
// loaded automatically before your test files.
//
// This is a great place to put global configuration and
// behavior that modifies Cypress.
//
// You can change the location of this file or turn off
// automatically serving support files with the
// 'supportFile' configuration option.
//
// You can read more here:
// https://on.cypress.io/configuration
// ***********************************************************
// Import commands.js using ES2015 syntax:
import './commands'
// Alternatively you can use CommonJS syntax:
// require('./commands')

View File

@@ -1,75 +0,0 @@
const fs = require('fs');
const path = require('path');
const rimraf = require('rimraf');
const child_process = require('child_process');
// ensure sapper doesn't exist in app/node_modules
rimraf.sync(
path.join(__dirname, 'app/node_modules/sapper')
);
rimraf.sync(
path.join(__dirname, 'app/node_modules/.bin/sapper')
);
// create symlinks
fs.symlinkSync(
path.join(__dirname, '..'),
path.join(__dirname, 'app/node_modules/sapper')
);
fs.symlinkSync(
path.join(__dirname, '../cli/index.js'),
path.join(__dirname, 'app/node_modules/.bin/sapper')
);
const app_dir = path.join(__dirname, 'app');
function start_server() {
const server = child_process.spawn(process.execPath, ['server.js'], {
cwd: app_dir,
env: {
NODE_ENV: 'development'
},
stdio: 'pipe'
});
server.stdout.on('data', (data) => {
process.stdout.write(data);
});
server.stderr.on('data', (data) => {
process.stderr.write(data);
});
}
function launch() {
if (process.argv[2] === '--dev') {
start_server();
} else {
child_process.exec(`npm run build`, {
cwd: app_dir
}, (err, stdout, stderr) => {
if (err) throw err;
process.stdout.write(stdout);
process.stderr.write(stderr);
start_server();
});
}
}
// this is a terrible hack
if (process.env.APPVEYOR) {
child_process.exec(`npm install`, {
cwd: app_dir
}, (err, stdout, stderr) => {
if (err) throw err;
process.stdout.write(stdout);
process.stderr.write(stderr);
launch();
});
} else {
launch();
}

3370
yarn.lock

File diff suppressed because it is too large Load Diff