Refactoring Schritt 1: Browser-Tests für den Reader, in der CI; Plan in docs/refactoring.md
- e2e/reader.spec.js: ein Test-EPUB (e2e/make_epub.py) hochladen, öffnen ohne zu senden, lesen und Lesestand nach Neuladen, Fußnote, Markierung und Notizleiste, Hell/Dunkel samt Reader, alle Tabs ohne Seitenfehler. - e2e/env.js: E2E_PORT und E2E_PYTHON; nie einen laufenden Server wiederverwenden (dahinter läge eine echte Datenbank). - CI: eigener Job e2e (Playwright + Chromium); blockiert den Docker-Build nicht. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
fd8f20ba3a
commit
ffcb6fba4f
7 changed files with 294 additions and 8 deletions
|
|
@ -38,3 +38,31 @@ jobs:
|
||||||
|
|
||||||
- name: Run tests
|
- name: Run tests
|
||||||
run: /tmp/venv/bin/python manage.py test
|
run: /tmp/venv/bin/python manage.py test
|
||||||
|
|
||||||
|
e2e:
|
||||||
|
# The browser tests (e2e/, docs/refactoring.md): a real runserver on its own
|
||||||
|
# throwaway database, a book uploaded, read, highlighted, the theme switched.
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
container:
|
||||||
|
image: catthehacker/ubuntu:act-24.04
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Install Python dependencies
|
||||||
|
run: |
|
||||||
|
python -m venv /tmp/venv
|
||||||
|
/tmp/venv/bin/pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
|
- name: Install Playwright and Chromium
|
||||||
|
run: |
|
||||||
|
npm ci
|
||||||
|
npx playwright install --with-deps chromium
|
||||||
|
|
||||||
|
- name: Browser tests
|
||||||
|
env:
|
||||||
|
CI: '1'
|
||||||
|
E2E_PYTHON: /tmp/venv/bin/python
|
||||||
|
run: |
|
||||||
|
mkdir -p data
|
||||||
|
npx playwright test
|
||||||
|
|
|
||||||
52
docs/refactoring.md
Normal file
52
docs/refactoring.md
Normal file
|
|
@ -0,0 +1,52 @@
|
||||||
|
# Refactoring (started 2026-10-14)
|
||||||
|
|
||||||
|
diora grew fast across four code bases that share one web front end:
|
||||||
|
diora-web (server + app.js and friends), diora-android (Kotlin + web-native/*.js),
|
||||||
|
diora-desktop (Electron + desktop.js) and the vendored copies between them.
|
||||||
|
This is the plan for tidying it up without breaking anything.
|
||||||
|
|
||||||
|
## Rules
|
||||||
|
|
||||||
|
* **No behaviour changes in a refactoring step.** Features and fixes go in their own commits.
|
||||||
|
* **Tests before moving code.** A step only starts when what it touches is covered.
|
||||||
|
* **Small steps, each one releasable**, each one checked on web, desktop and Android.
|
||||||
|
* Whoever owns a file does its refactoring (web-native/* → the Android session;
|
||||||
|
app.js, home.js, templates, desktop → the desktop session); shared contracts live in docs/.
|
||||||
|
|
||||||
|
## The numbers (2026-10-14)
|
||||||
|
|
||||||
|
| file | lines |
|
||||||
|
|---|---|
|
||||||
|
| static/js/app.js | 6 864, about 275 functions, about 120 globals, 45 sections |
|
||||||
|
| static/podcasts/native-podcast.js (copy from diora-android) | 4 554 |
|
||||||
|
| static/css/app.css | 2 010 |
|
||||||
|
| static/js/podcasts-web.js | 1 899 |
|
||||||
|
| podcasts/views.py | 2 505 |
|
||||||
|
| web-native/native.css | 2 150 |
|
||||||
|
|
||||||
|
Web JavaScript has no unit tests and four Playwright smoke tests that run nowhere.
|
||||||
|
The reading-position rules exist four times (books/views.py, app.js, desktop ops.js,
|
||||||
|
Android Ops.kt), each tested separately or not at all.
|
||||||
|
|
||||||
|
## Steps
|
||||||
|
|
||||||
|
1. **Safety net** (in progress)
|
||||||
|
* Playwright tests that run locally and in CI against a throwaway database: log in, open a
|
||||||
|
book (an EPUB fixture), read, the position is saved and restored, a highlight, a note
|
||||||
|
in the margin, the theme switch, radio and podcasts tabs load.
|
||||||
|
* Shared test vectors for the reading-position rules (`docs/progress-cases.json`), run by
|
||||||
|
the Django tests, desktop's node tests and Android's OpsTest — one contract, checked
|
||||||
|
in all four places.
|
||||||
|
2. **Split app.js** by its sections into plain scripts loaded in order (radio, focus,
|
||||||
|
ambient, crypto, epub, books, reading position, reader, tts, highlights, margin, …). They
|
||||||
|
stay classic scripts sharing globals, so the native layers that wrap `window.*` keep
|
||||||
|
working unchanged; template, sw.js SHELL and Android's build_web list them. No code
|
||||||
|
changes inside the moved functions.
|
||||||
|
3. **Dead code**: features and helpers nothing calls any more (found by the split and by
|
||||||
|
coverage of the tests), removed one at a time.
|
||||||
|
4. **Server**: podcasts/views.py into modules by area (feeds, episodes, queue, player sync,
|
||||||
|
import); shared helpers (`_require_auth`, JSON body parsing) in one place.
|
||||||
|
5. **CSS**: the remaining fixed colours to the theme tokens (docs/theme.md); app.css split
|
||||||
|
like app.js.
|
||||||
|
6. **Copies between repositories**: one sync script per direction with a check in CI that
|
||||||
|
the copy matches its source revision (today: sync_native.sh, sync_podcast_ui.py).
|
||||||
|
|
@ -1,6 +1,9 @@
|
||||||
// Shared constants between playwright.config.js and e2e/global-setup.js.
|
// Shared constants between playwright.config.js and e2e/global-setup.js.
|
||||||
const PORT = 8000;
|
// E2E_PORT / E2E_PYTHON: a port that no dev server holds (the suite must never
|
||||||
|
// run against a real database), and the Python with the requirements installed.
|
||||||
|
const PORT = Number(process.env.E2E_PORT || 8000);
|
||||||
const BASE_URL = `http://127.0.0.1:${PORT}`;
|
const BASE_URL = `http://127.0.0.1:${PORT}`;
|
||||||
|
const PYTHON = process.env.E2E_PYTHON || 'python3';
|
||||||
|
|
||||||
const SERVER_ENV = {
|
const SERVER_ENV = {
|
||||||
...process.env,
|
...process.env,
|
||||||
|
|
@ -15,4 +18,4 @@ const TEST_USER = {
|
||||||
password: 'e2e-test-pw-12345',
|
password: 'e2e-test-pw-12345',
|
||||||
};
|
};
|
||||||
|
|
||||||
module.exports = { PORT, BASE_URL, SERVER_ENV, TEST_USER };
|
module.exports = { PORT, BASE_URL, PYTHON, SERVER_ENV, TEST_USER };
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,7 @@ const fs = require('fs');
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
const { execFileSync } = require('child_process');
|
const { execFileSync } = require('child_process');
|
||||||
const { chromium } = require('@playwright/test');
|
const { chromium } = require('@playwright/test');
|
||||||
const { BASE_URL, SERVER_ENV, TEST_USER } = require('./env');
|
const { BASE_URL, PYTHON, SERVER_ENV, TEST_USER } = require('./env');
|
||||||
|
|
||||||
const ROOT = path.join(__dirname, '..');
|
const ROOT = path.join(__dirname, '..');
|
||||||
const DB_PATH = path.join(ROOT, 'data', SERVER_ENV.DIORA_DB_NAME);
|
const DB_PATH = path.join(ROOT, 'data', SERVER_ENV.DIORA_DB_NAME);
|
||||||
|
|
@ -22,14 +22,14 @@ module.exports = async function globalSetup() {
|
||||||
// Fresh DB every run.
|
// Fresh DB every run.
|
||||||
fs.rmSync(DB_PATH, { force: true });
|
fs.rmSync(DB_PATH, { force: true });
|
||||||
|
|
||||||
execFileSync('python3', ['manage.py', 'migrate', '--noinput'], {
|
execFileSync(PYTHON, ['manage.py', 'migrate', '--noinput'], {
|
||||||
cwd: ROOT,
|
cwd: ROOT,
|
||||||
env: SERVER_ENV,
|
env: SERVER_ENV,
|
||||||
stdio: 'inherit',
|
stdio: 'inherit',
|
||||||
});
|
});
|
||||||
|
|
||||||
execFileSync(
|
execFileSync(
|
||||||
'python3',
|
PYTHON,
|
||||||
[
|
[
|
||||||
'manage.py',
|
'manage.py',
|
||||||
'shell',
|
'shell',
|
||||||
|
|
@ -45,6 +45,8 @@ User.objects.create_user(${JSON.stringify(TEST_USER.username)}, "", ${JSON.strin
|
||||||
);
|
);
|
||||||
|
|
||||||
fs.mkdirSync(AUTH_DIR, { recursive: true });
|
fs.mkdirSync(AUTH_DIR, { recursive: true });
|
||||||
|
// The book the reader tests read (e2e/make_epub.py).
|
||||||
|
execFileSync(PYTHON, [path.join(__dirname, 'make_epub.py'), path.join(AUTH_DIR, 'book.epub')], { stdio: 'inherit' });
|
||||||
|
|
||||||
const browser = await chromium.launch();
|
const browser = await chromium.launch();
|
||||||
const page = await browser.newPage({ baseURL: BASE_URL });
|
const page = await browser.newPage({ baseURL: BASE_URL });
|
||||||
|
|
|
||||||
58
e2e/make_epub.py
Normal file
58
e2e/make_epub.py
Normal file
|
|
@ -0,0 +1,58 @@
|
||||||
|
"""A small EPUB for the e2e tests: 12 chapters of numbered paragraphs (long enough
|
||||||
|
to scroll and to have a reading position), one footnote. Written to the path in
|
||||||
|
argv[1]; e2e/global-setup.js calls it for every run."""
|
||||||
|
import sys
|
||||||
|
import zipfile
|
||||||
|
|
||||||
|
CONTAINER = """<?xml version="1.0"?>
|
||||||
|
<container version="1.0" xmlns="urn:oasis:names:tc:opendocument:xmlns:container">
|
||||||
|
<rootfiles><rootfile full-path="OEBPS/content.opf" media-type="application/oebps-package+xml"/></rootfiles>
|
||||||
|
</container>"""
|
||||||
|
|
||||||
|
|
||||||
|
def chapter(n):
|
||||||
|
paras = []
|
||||||
|
for p in range(1, 41):
|
||||||
|
text = f'Kapitel {n}, Absatz {p}. ' + ' '.join(
|
||||||
|
f'Wort{w} im Absatz {p} des Kapitels {n} steht hier, damit der Absatz einige Zeilen lang wird.'
|
||||||
|
for w in range(1, 6))
|
||||||
|
if n == 1 and p == 3:
|
||||||
|
text += ' Mit Fußnote<a href="ch12.xhtml#fn1" epub:type="noteref"><sup>1</sup></a>.'
|
||||||
|
paras.append(f'<p id="c{n}p{p}">{text}</p>')
|
||||||
|
notes = ''
|
||||||
|
if n == 12:
|
||||||
|
notes = '<aside epub:type="footnote" id="fn1"><p>1 Der Text der Fußnote.</p></aside>'
|
||||||
|
return f"""<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:epub="http://www.idpf.org/2007/ops">
|
||||||
|
<head><title>Kapitel {n}</title></head>
|
||||||
|
<body><h1>Kapitel {n}</h1>{''.join(paras)}{notes}</body></html>"""
|
||||||
|
|
||||||
|
|
||||||
|
def main(path):
|
||||||
|
chapters = range(1, 13)
|
||||||
|
manifest = ''.join(f'<item id="c{n}" href="ch{n}.xhtml" media-type="application/xhtml+xml"/>' for n in chapters)
|
||||||
|
spine = ''.join(f'<itemref idref="c{n}"/>' for n in chapters)
|
||||||
|
nav_items = ''.join(f'<li><a href="ch{n}.xhtml">Kapitel {n}</a></li>' for n in chapters)
|
||||||
|
opf = f"""<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<package xmlns="http://www.idpf.org/2007/opf" version="3.0" unique-identifier="id">
|
||||||
|
<metadata xmlns:dc="http://purl.org/dc/elements/1.1/">
|
||||||
|
<dc:identifier id="id">diora-e2e-book</dc:identifier>
|
||||||
|
<dc:title>E2E Testbuch</dc:title><dc:creator>diora</dc:creator><dc:language>de</dc:language>
|
||||||
|
</metadata>
|
||||||
|
<manifest><item id="nav" href="nav.xhtml" media-type="application/xhtml+xml" properties="nav"/>{manifest}</manifest>
|
||||||
|
<spine>{spine}</spine>
|
||||||
|
</package>"""
|
||||||
|
nav = f"""<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:epub="http://www.idpf.org/2007/ops">
|
||||||
|
<head><title>Inhalt</title></head><body><nav epub:type="toc"><ol>{nav_items}</ol></nav></body></html>"""
|
||||||
|
with zipfile.ZipFile(path, 'w') as z:
|
||||||
|
z.writestr(zipfile.ZipInfo('mimetype'), 'application/epub+zip', compress_type=zipfile.ZIP_STORED)
|
||||||
|
z.writestr('META-INF/container.xml', CONTAINER, compress_type=zipfile.ZIP_DEFLATED)
|
||||||
|
z.writestr('OEBPS/content.opf', opf, compress_type=zipfile.ZIP_DEFLATED)
|
||||||
|
z.writestr('OEBPS/nav.xhtml', nav, compress_type=zipfile.ZIP_DEFLATED)
|
||||||
|
for n in chapters:
|
||||||
|
z.writestr(f'OEBPS/ch{n}.xhtml', chapter(n), compress_type=zipfile.ZIP_DEFLATED)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
main(sys.argv[1])
|
||||||
142
e2e/reader.spec.js
Normal file
142
e2e/reader.spec.js
Normal file
|
|
@ -0,0 +1,142 @@
|
||||||
|
// The reader end to end: a book goes in (encrypted in the browser), opens, is
|
||||||
|
// read, and its place, a highlight and the theme survive a reload. The safety
|
||||||
|
// net for refactoring app.js (docs/refactoring.md). One book for the whole file:
|
||||||
|
// the tests build on each other and run in order.
|
||||||
|
const path = require('path');
|
||||||
|
const { test, expect } = require('@playwright/test');
|
||||||
|
|
||||||
|
test.use({ storageState: path.join(__dirname, '.auth', 'user.json') });
|
||||||
|
test.describe.configure({ mode: 'serial' });
|
||||||
|
|
||||||
|
const BOOK = path.join(__dirname, '.auth', 'book.epub');
|
||||||
|
let bookId = null;
|
||||||
|
|
||||||
|
// Errors in the page fail the test that caused them.
|
||||||
|
let pageErrors = [];
|
||||||
|
test.beforeEach(({ page }) => {
|
||||||
|
pageErrors = [];
|
||||||
|
page.on('pageerror', e => pageErrors.push(String(e)));
|
||||||
|
});
|
||||||
|
test.afterEach(() => {
|
||||||
|
expect(pageErrors, 'errors in the page').toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
async function openTestBook(page) {
|
||||||
|
await page.goto('/#books');
|
||||||
|
// A new service worker taking over reloads the page once: let that settle first.
|
||||||
|
await page.waitForLoadState('networkidle');
|
||||||
|
await page.waitForFunction(() => typeof openBook === 'function' && typeof _lastBookListData !== 'undefined' && Array.isArray(_lastBookListData));
|
||||||
|
await page.evaluate(id => openBook(id), bookId);
|
||||||
|
await page.waitForFunction(() => document.querySelectorAll('#reader-content [data-epub-src]').length === 12
|
||||||
|
&& typeof _readPos !== 'undefined' && _readPos && _readPos.ready);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function serverBook(page) {
|
||||||
|
const books = await (await page.request.get('/books/')).json();
|
||||||
|
return books.find(b => b.id === bookId);
|
||||||
|
}
|
||||||
|
|
||||||
|
test('a book goes in and shows in the list', async ({ page }) => {
|
||||||
|
await page.goto('/#books');
|
||||||
|
await page.setInputFiles('#book-file-input', BOOK);
|
||||||
|
const found = await page.waitForFunction(() => {
|
||||||
|
const list = typeof _lastBookListData !== 'undefined' ? (_lastBookListData || []) : [];
|
||||||
|
const b = list.find(x => x.title === 'E2E Testbuch');
|
||||||
|
return b ? b.id : null;
|
||||||
|
}, null, { timeout: 20000 });
|
||||||
|
bookId = await found.jsonValue();
|
||||||
|
expect(bookId).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('opening without reading sends no position', async ({ page }) => {
|
||||||
|
const posts = [];
|
||||||
|
page.on('request', r => { if (r.method() === 'POST' && /\/progress\/$/.test(r.url())) posts.push(r.url()); });
|
||||||
|
await openTestBook(page);
|
||||||
|
await page.waitForTimeout(3000);
|
||||||
|
await page.evaluate(() => saveReaderProgress());
|
||||||
|
await page.evaluate(() => closeReader());
|
||||||
|
await page.waitForTimeout(500);
|
||||||
|
expect(posts).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('reading moves the position, and it comes back after a reload', async ({ page }) => {
|
||||||
|
await openTestBook(page);
|
||||||
|
const content = page.locator('#reader-content');
|
||||||
|
await content.hover();
|
||||||
|
for (let i = 0; i < 12; i++) await page.mouse.wheel(0, 600);
|
||||||
|
const saved = page.waitForRequest(r => r.method() === 'POST' && /\/progress\/$/.test(r.url()));
|
||||||
|
const body = JSON.parse((await saved).postData());
|
||||||
|
expect(body.device).toBe('Browser');
|
||||||
|
expect(body.read_at).toBeGreaterThan(1e12);
|
||||||
|
const anchor = await page.evaluate(() => getPositionAnchor(document.getElementById('reader-content')));
|
||||||
|
await page.waitForTimeout(500);
|
||||||
|
const b = await serverBook(page);
|
||||||
|
expect(b.position_anchor).not.toBe('');
|
||||||
|
expect(b.device).toBe('Browser');
|
||||||
|
|
||||||
|
await page.reload();
|
||||||
|
await openTestBook(page);
|
||||||
|
const restored = await page.evaluate(() => getPositionAnchor(document.getElementById('reader-content')));
|
||||||
|
const block = a => Number(a.split(':')[0]);
|
||||||
|
expect(Math.abs(block(restored) - block(anchor))).toBeLessThanOrEqual(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a footnote shows its text', async ({ page }) => {
|
||||||
|
await openTestBook(page);
|
||||||
|
const ref = page.locator('#reader-content [data-footnote-ref]').first();
|
||||||
|
await ref.scrollIntoViewIfNeeded();
|
||||||
|
await ref.click();
|
||||||
|
await expect(page.locator('.footnote-popover')).toContainText('Der Text der Fußnote');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a highlight is kept, and shows in the margin', async ({ page }) => {
|
||||||
|
await openTestBook(page);
|
||||||
|
// Marker mode on (the ✎ button), select the start of a paragraph on screen
|
||||||
|
// the way a mouse would, then let go.
|
||||||
|
await page.evaluate(() => {
|
||||||
|
if (!markerModeActive) toggleMarkerMode();
|
||||||
|
const p = document.querySelector('#reader-content #c1p2') || document.querySelector('#reader-content p');
|
||||||
|
p.scrollIntoView({ block: 'center' });
|
||||||
|
const text = p.firstChild;
|
||||||
|
const range = document.createRange();
|
||||||
|
range.setStart(text, 0);
|
||||||
|
range.setEnd(text, 20);
|
||||||
|
const sel = window.getSelection();
|
||||||
|
sel.removeAllRanges();
|
||||||
|
sel.addRange(range);
|
||||||
|
const r = range.getBoundingClientRect();
|
||||||
|
p.dispatchEvent(new MouseEvent('mouseup', { bubbles: true, clientX: r.right, clientY: r.bottom }));
|
||||||
|
});
|
||||||
|
const saved = page.waitForRequest(r => r.method() === 'POST' && /\/highlights\/$/.test(r.url()), { timeout: 15000 });
|
||||||
|
await page.locator('.hl-color-btn[data-hl-color="yellow"]').click();
|
||||||
|
await page.evaluate(() => saveHighlights && saveHighlights());
|
||||||
|
await saved;
|
||||||
|
|
||||||
|
await page.reload();
|
||||||
|
await openTestBook(page);
|
||||||
|
await page.waitForFunction(() => document.querySelectorAll('#reader-content mark.epub-highlight, #reader-content .epub-highlight').length > 0);
|
||||||
|
// The margin remembers being open; open it if it isn't.
|
||||||
|
await page.evaluate(() => { if (!readerAnnotationsMarginOpen) toggleAnnotationsMargin(); });
|
||||||
|
await expect(page.locator('#reader-margin-markers .margin-note')).toHaveCount(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the theme switch turns the page and the reader light, and stays', async ({ page }) => {
|
||||||
|
await page.goto('/');
|
||||||
|
await expect(page.locator('html')).toHaveAttribute('data-theme', 'dark');
|
||||||
|
await page.locator('#theme-toggle').click();
|
||||||
|
await expect(page.locator('html')).toHaveAttribute('data-theme', 'light');
|
||||||
|
await page.reload();
|
||||||
|
await expect(page.locator('html')).toHaveAttribute('data-theme', 'light');
|
||||||
|
await openTestBook(page);
|
||||||
|
await expect(page.locator('#reader-overlay')).toHaveClass(/reader-theme-bright/);
|
||||||
|
await page.evaluate(() => { closeReader(); __dioraSetTheme('dark'); });
|
||||||
|
await expect(page.locator('html')).toHaveAttribute('data-theme', 'dark');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('every tab opens without errors', async ({ page }) => {
|
||||||
|
await page.goto('/');
|
||||||
|
for (const tab of ['home', 'radio', 'podcasts', 'books', 'feeds', 'focus', 'ambient']) {
|
||||||
|
await page.evaluate(t => showTab(t), tab);
|
||||||
|
await expect(page.locator(`#tab-${tab}`)).toBeVisible();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
@ -6,7 +6,7 @@
|
||||||
// migrated and seeded with a test user.
|
// migrated and seeded with a test user.
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
const { defineConfig, devices } = require('@playwright/test');
|
const { defineConfig, devices } = require('@playwright/test');
|
||||||
const { BASE_URL, SERVER_ENV } = require('./e2e/env');
|
const { BASE_URL, PORT, PYTHON, SERVER_ENV } = require('./e2e/env');
|
||||||
|
|
||||||
module.exports = defineConfig({
|
module.exports = defineConfig({
|
||||||
testDir: './e2e',
|
testDir: './e2e',
|
||||||
|
|
@ -27,9 +27,10 @@ module.exports = defineConfig({
|
||||||
],
|
],
|
||||||
globalSetup: require.resolve('./e2e/global-setup.js'),
|
globalSetup: require.resolve('./e2e/global-setup.js'),
|
||||||
webServer: {
|
webServer: {
|
||||||
command: 'python3 manage.py runserver 127.0.0.1:8000 --noreload',
|
command: `${PYTHON} manage.py runserver 127.0.0.1:${PORT} --noreload`,
|
||||||
url: BASE_URL + '/accounts/login/',
|
url: BASE_URL + '/accounts/login/',
|
||||||
reuseExistingServer: !process.env.CI,
|
// Never reuse: a server already on the port has someone's real database behind it.
|
||||||
|
reuseExistingServer: false,
|
||||||
timeout: 30_000,
|
timeout: 30_000,
|
||||||
cwd: path.join(__dirname),
|
cwd: path.join(__dirname),
|
||||||
env: SERVER_ENV,
|
env: SERVER_ENV,
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue