chore: move container integration back to playwright-core (#17487)
This commit is contained in:
parent
0abb1c773b
commit
d431958603
|
|
@ -24,6 +24,7 @@
|
||||||
"./lib/outofprocess": "./lib/outofprocess.js",
|
"./lib/outofprocess": "./lib/outofprocess.js",
|
||||||
"./lib/utils": "./lib/utils/index.js",
|
"./lib/utils": "./lib/utils/index.js",
|
||||||
"./lib/common/userAgent": "./lib/common/userAgent.js",
|
"./lib/common/userAgent": "./lib/common/userAgent.js",
|
||||||
|
"./lib/containers/docker": "./lib/containers/docker.js",
|
||||||
"./lib/utils/comparators": "./lib/utils/comparators.js",
|
"./lib/utils/comparators": "./lib/utils/comparators.js",
|
||||||
"./lib/utils/eventsHelper": "./lib/utils/eventsHelper.js",
|
"./lib/utils/eventsHelper": "./lib/utils/eventsHelper.js",
|
||||||
"./lib/utils/fileUtils": "./lib/utils/fileUtils.js",
|
"./lib/utils/fileUtils": "./lib/utils/fileUtils.js",
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@
|
||||||
[cli.ts]
|
[cli.ts]
|
||||||
../server/trace/viewer/traceViewer.ts
|
../server/trace/viewer/traceViewer.ts
|
||||||
../server/
|
../server/
|
||||||
|
../containers/
|
||||||
|
|
||||||
[driver.ts]
|
[driver.ts]
|
||||||
../**
|
../**
|
||||||
|
|
|
||||||
|
|
@ -38,6 +38,7 @@ import type { GridFactory } from '../grid/gridServer';
|
||||||
import { GridServer } from '../grid/gridServer';
|
import { GridServer } from '../grid/gridServer';
|
||||||
import type { Executable } from '../server';
|
import type { Executable } from '../server';
|
||||||
import { registry, writeDockerVersion } from '../server';
|
import { registry, writeDockerVersion } from '../server';
|
||||||
|
import { addDockerCLI } from '../containers/docker';
|
||||||
|
|
||||||
const packageJSON = require('../../package.json');
|
const packageJSON = require('../../package.json');
|
||||||
|
|
||||||
|
|
@ -114,6 +115,7 @@ function checkBrowsersToInstall(args: string[]): Executable[] {
|
||||||
return executables;
|
return executables;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
program
|
program
|
||||||
.command('install [browser...]')
|
.command('install [browser...]')
|
||||||
.description('ensure browsers necessary for this version of Playwright are installed')
|
.description('ensure browsers necessary for this version of Playwright are installed')
|
||||||
|
|
@ -305,6 +307,8 @@ Examples:
|
||||||
|
|
||||||
$ show-trace https://example.com/trace.zip`);
|
$ show-trace https://example.com/trace.zip`);
|
||||||
|
|
||||||
|
addDockerCLI(program);
|
||||||
|
|
||||||
if (!process.env.PW_LANG_NAME) {
|
if (!process.env.PW_LANG_NAME) {
|
||||||
let playwrightTestPackagePath = null;
|
let playwrightTestPackagePath = null;
|
||||||
const resolvePwTestPaths = [__dirname, process.cwd()];
|
const resolvePwTestPaths = [__dirname, process.cwd()];
|
||||||
|
|
|
||||||
4
packages/playwright-core/src/containers/DEPS.list
Normal file
4
packages/playwright-core/src/containers/DEPS.list
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
[*]
|
||||||
|
../utils/
|
||||||
|
../utilsBundle.ts
|
||||||
|
../common/
|
||||||
|
|
@ -17,13 +17,11 @@
|
||||||
|
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
import { colors } from 'playwright-core/lib/utilsBundle';
|
import { spawnAsync } from '../utils/spawnAsync';
|
||||||
import { spawnAsync } from 'playwright-core/lib/utils/spawnAsync';
|
import * as utils from '../utils';
|
||||||
import * as utils from 'playwright-core/lib/utils';
|
import { getPlaywrightVersion } from '../common/userAgent';
|
||||||
import { getPlaywrightVersion } from 'playwright-core/lib/common/userAgent';
|
|
||||||
import * as dockerApi from './dockerApi';
|
import * as dockerApi from './dockerApi';
|
||||||
import type { TestRunnerPlugin } from '../plugins';
|
import type { Command } from '../utilsBundle';
|
||||||
import type { FullConfig, Reporter, Suite } from '../../types/testReporter';
|
|
||||||
|
|
||||||
const VRT_IMAGE_DISTRO = 'focal';
|
const VRT_IMAGE_DISTRO = 'focal';
|
||||||
const VRT_IMAGE_NAME = `playwright:local-${getPlaywrightVersion()}-${VRT_IMAGE_DISTRO}`;
|
const VRT_IMAGE_NAME = `playwright:local-${getPlaywrightVersion()}-${VRT_IMAGE_DISTRO}`;
|
||||||
|
|
@ -31,7 +29,7 @@ const VRT_CONTAINER_NAME = `playwright-${getPlaywrightVersion()}-${VRT_IMAGE_DIS
|
||||||
const VRT_CONTAINER_LABEL_NAME = 'dev.playwright.vrt-service.version';
|
const VRT_CONTAINER_LABEL_NAME = 'dev.playwright.vrt-service.version';
|
||||||
const VRT_CONTAINER_LABEL_VALUE = '1';
|
const VRT_CONTAINER_LABEL_VALUE = '1';
|
||||||
|
|
||||||
export async function startPlaywrightContainer() {
|
async function startPlaywrightContainer() {
|
||||||
await checkDockerEngineIsRunningOrDie();
|
await checkDockerEngineIsRunningOrDie();
|
||||||
|
|
||||||
let info = await containerInfo();
|
let info = await containerInfo();
|
||||||
|
|
@ -52,7 +50,7 @@ export async function startPlaywrightContainer() {
|
||||||
].join('\n'));
|
].join('\n'));
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function stopAllPlaywrightContainers() {
|
async function stopAllPlaywrightContainers() {
|
||||||
await checkDockerEngineIsRunningOrDie();
|
await checkDockerEngineIsRunningOrDie();
|
||||||
|
|
||||||
const allContainers = await dockerApi.listContainers();
|
const allContainers = await dockerApi.listContainers();
|
||||||
|
|
@ -63,7 +61,7 @@ export async function stopAllPlaywrightContainers() {
|
||||||
})));
|
})));
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function deletePlaywrightImage() {
|
async function deletePlaywrightImage() {
|
||||||
await checkDockerEngineIsRunningOrDie();
|
await checkDockerEngineIsRunningOrDie();
|
||||||
|
|
||||||
const dockerImage = await findDockerImage(VRT_IMAGE_NAME);
|
const dockerImage = await findDockerImage(VRT_IMAGE_NAME);
|
||||||
|
|
@ -75,7 +73,7 @@ export async function deletePlaywrightImage() {
|
||||||
await dockerApi.removeImage(dockerImage.imageId);
|
await dockerApi.removeImage(dockerImage.imageId);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function buildPlaywrightImage() {
|
async function buildPlaywrightImage() {
|
||||||
await checkDockerEngineIsRunningOrDie();
|
await checkDockerEngineIsRunningOrDie();
|
||||||
|
|
||||||
const isDevelopmentMode = getPlaywrightVersion().includes('next');
|
const isDevelopmentMode = getPlaywrightVersion().includes('next');
|
||||||
|
|
@ -130,44 +128,12 @@ export async function buildPlaywrightImage() {
|
||||||
console.log(`Done!`);
|
console.log(`Done!`);
|
||||||
}
|
}
|
||||||
|
|
||||||
export const dockerPlugin: TestRunnerPlugin = {
|
|
||||||
name: 'playwright:docker',
|
|
||||||
|
|
||||||
async setup(config: FullConfig, configDir: string, rootSuite: Suite, reporter: Reporter) {
|
|
||||||
if (!process.env.PLAYWRIGHT_DOCKER)
|
|
||||||
return;
|
|
||||||
|
|
||||||
const print = (text: string) => reporter.onStdOut?.(text);
|
|
||||||
const println = (text: string) => reporter.onStdOut?.(text + '\n');
|
|
||||||
|
|
||||||
println(colors.dim('Using docker container to run browsers.'));
|
|
||||||
await checkDockerEngineIsRunningOrDie();
|
|
||||||
let info = await containerInfo();
|
|
||||||
if (!info) {
|
|
||||||
print(colors.dim(`Starting docker container... `));
|
|
||||||
const time = Date.now();
|
|
||||||
info = await ensurePlaywrightContainerOrDie();
|
|
||||||
const deltaMs = (Date.now() - time);
|
|
||||||
println(colors.dim('Done in ' + (deltaMs / 1000).toFixed(1) + 's'));
|
|
||||||
println(colors.dim('The Docker container will keep running after tests finished.'));
|
|
||||||
println(colors.dim('Stop manually using:'));
|
|
||||||
println(colors.dim(' npx playwright docker stop'));
|
|
||||||
}
|
|
||||||
println(colors.dim(`View screen: ${info.vncSession}`));
|
|
||||||
println('');
|
|
||||||
process.env.PW_TEST_CONNECT_WS_ENDPOINT = info.wsEndpoint;
|
|
||||||
process.env.PW_TEST_CONNECT_HEADERS = JSON.stringify({
|
|
||||||
'x-playwright-proxy': '*',
|
|
||||||
});
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
interface ContainerInfo {
|
interface ContainerInfo {
|
||||||
wsEndpoint: string;
|
wsEndpoint: string;
|
||||||
vncSession: string;
|
vncSession: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function printDockerStatus() {
|
async function printDockerStatus() {
|
||||||
const isDockerEngine = await dockerApi.checkEngineRunning();
|
const isDockerEngine = await dockerApi.checkEngineRunning();
|
||||||
const imageIsPulled = isDockerEngine && !!(await findDockerImage(VRT_IMAGE_NAME));
|
const imageIsPulled = isDockerEngine && !!(await findDockerImage(VRT_IMAGE_NAME));
|
||||||
const info = isDockerEngine ? await containerInfo() : undefined;
|
const info = isDockerEngine ? await containerInfo() : undefined;
|
||||||
|
|
@ -180,7 +146,7 @@ export async function printDockerStatus() {
|
||||||
}, null, 2));
|
}, null, 2));
|
||||||
}
|
}
|
||||||
|
|
||||||
async function containerInfo(): Promise<ContainerInfo|undefined> {
|
export async function containerInfo(): Promise<ContainerInfo|undefined> {
|
||||||
const allContainers = await dockerApi.listContainers();
|
const allContainers = await dockerApi.listContainers();
|
||||||
const pwDockerImage = await findDockerImage(VRT_IMAGE_NAME);
|
const pwDockerImage = await findDockerImage(VRT_IMAGE_NAME);
|
||||||
const container = allContainers.find(container => container.imageId === pwDockerImage?.imageId && container.state === 'running');
|
const container = allContainers.find(container => container.imageId === pwDockerImage?.imageId && container.state === 'running');
|
||||||
|
|
@ -210,7 +176,7 @@ async function containerInfo(): Promise<ContainerInfo|undefined> {
|
||||||
return wsEndpoint && vncSession ? { wsEndpoint, vncSession } : undefined;
|
return wsEndpoint && vncSession ? { wsEndpoint, vncSession } : undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function ensurePlaywrightContainerOrDie(): Promise<ContainerInfo> {
|
export async function ensurePlaywrightContainerOrDie(): Promise<ContainerInfo> {
|
||||||
const pwImage = await findDockerImage(VRT_IMAGE_NAME);
|
const pwImage = await findDockerImage(VRT_IMAGE_NAME);
|
||||||
if (!pwImage) {
|
if (!pwImage) {
|
||||||
throw createStacklessError('\n' + utils.wrapInASCIIBox([
|
throw createStacklessError('\n' + utils.wrapInASCIIBox([
|
||||||
|
|
@ -284,7 +250,7 @@ async function ensurePlaywrightContainerOrDie(): Promise<ContainerInfo> {
|
||||||
return info;
|
return info;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function checkDockerEngineIsRunningOrDie() {
|
export async function checkDockerEngineIsRunningOrDie() {
|
||||||
if (await dockerApi.checkEngineRunning())
|
if (await dockerApi.checkEngineRunning())
|
||||||
return;
|
return;
|
||||||
throw createStacklessError(utils.wrapInASCIIBox([
|
throw createStacklessError(utils.wrapInASCIIBox([
|
||||||
|
|
@ -306,3 +272,54 @@ function createStacklessError(message: string) {
|
||||||
error.stack = '';
|
error.stack = '';
|
||||||
return error;
|
return error;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function addDockerCLI(program: Command) {
|
||||||
|
const dockerCommand = program.command('docker')
|
||||||
|
.description(`Manage Docker integration (EXPERIMENTAL)`);
|
||||||
|
|
||||||
|
dockerCommand.command('build')
|
||||||
|
.description('build local docker image')
|
||||||
|
.action(async function(options) {
|
||||||
|
try {
|
||||||
|
await buildPlaywrightImage();
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e.stack ? e : e.message);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
dockerCommand.command('start')
|
||||||
|
.description('start docker container')
|
||||||
|
.action(async function(options) {
|
||||||
|
try {
|
||||||
|
await startPlaywrightContainer();
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e.stack ? e : e.message);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
dockerCommand.command('stop')
|
||||||
|
.description('stop docker container')
|
||||||
|
.action(async function(options) {
|
||||||
|
try {
|
||||||
|
await stopAllPlaywrightContainers();
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e.stack ? e : e.message);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
dockerCommand.command('delete-image', { hidden: true })
|
||||||
|
.description('delete docker image, if any')
|
||||||
|
.action(async function(options) {
|
||||||
|
try {
|
||||||
|
await deletePlaywrightImage();
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e.stack ? e : e.message);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
dockerCommand.command('print-status-json', { hidden: true })
|
||||||
|
.description('print docker status')
|
||||||
|
.action(async function(options) {
|
||||||
|
await printDockerStatus();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -1,6 +1,5 @@
|
||||||
[*]
|
[*]
|
||||||
./utilsBundle.ts
|
./utilsBundle.ts
|
||||||
docker/
|
|
||||||
matchers/
|
matchers/
|
||||||
reporters/
|
reporters/
|
||||||
third_party/
|
third_party/
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,6 @@
|
||||||
/* eslint-disable no-console */
|
/* eslint-disable no-console */
|
||||||
|
|
||||||
import type { Command } from 'playwright-core/lib/utilsBundle';
|
import type { Command } from 'playwright-core/lib/utilsBundle';
|
||||||
import * as docker from './docker/docker';
|
|
||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
import url from 'url';
|
import url from 'url';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
|
|
@ -33,58 +32,6 @@ export function addTestCommands(program: Command) {
|
||||||
addTestCommand(program);
|
addTestCommand(program);
|
||||||
addShowReportCommand(program);
|
addShowReportCommand(program);
|
||||||
addListFilesCommand(program);
|
addListFilesCommand(program);
|
||||||
addDockerCommand(program);
|
|
||||||
}
|
|
||||||
|
|
||||||
function addDockerCommand(program: Command) {
|
|
||||||
const dockerCommand = program.command('docker')
|
|
||||||
.description(`Manage Docker integration (EXPERIMENTAL)`);
|
|
||||||
|
|
||||||
dockerCommand.command('build')
|
|
||||||
.description('build local docker image')
|
|
||||||
.action(async function(options) {
|
|
||||||
try {
|
|
||||||
await docker.buildPlaywrightImage();
|
|
||||||
} catch (e) {
|
|
||||||
console.error(e.stack ? e : e.message);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
dockerCommand.command('start')
|
|
||||||
.description('start docker container')
|
|
||||||
.action(async function(options) {
|
|
||||||
try {
|
|
||||||
await docker.startPlaywrightContainer();
|
|
||||||
} catch (e) {
|
|
||||||
console.error(e.stack ? e : e.message);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
dockerCommand.command('stop')
|
|
||||||
.description('stop docker container')
|
|
||||||
.action(async function(options) {
|
|
||||||
try {
|
|
||||||
await docker.stopAllPlaywrightContainers();
|
|
||||||
} catch (e) {
|
|
||||||
console.error(e.stack ? e : e.message);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
dockerCommand.command('delete-image', { hidden: true })
|
|
||||||
.description('delete docker image, if any')
|
|
||||||
.action(async function(options) {
|
|
||||||
try {
|
|
||||||
await docker.deletePlaywrightImage();
|
|
||||||
} catch (e) {
|
|
||||||
console.error(e.stack ? e : e.message);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
dockerCommand.command('print-status-json', { hidden: true })
|
|
||||||
.description('print docker status')
|
|
||||||
.action(async function(options) {
|
|
||||||
await docker.printDockerStatus();
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function addTestCommand(program: Command) {
|
function addTestCommand(program: Command) {
|
||||||
|
|
|
||||||
54
packages/playwright-test/src/plugins/dockerPlugin.ts
Normal file
54
packages/playwright-test/src/plugins/dockerPlugin.ts
Normal file
|
|
@ -0,0 +1,54 @@
|
||||||
|
/**
|
||||||
|
* Copyright (c) Microsoft Corporation.
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { TestRunnerPlugin } from '.';
|
||||||
|
import type { FullConfig, Reporter, Suite } from '../../types/testReporter';
|
||||||
|
import { colors } from 'playwright-core/lib/utilsBundle';
|
||||||
|
import { checkDockerEngineIsRunningOrDie, containerInfo, ensurePlaywrightContainerOrDie } from 'playwright-core/lib/containers/docker';
|
||||||
|
|
||||||
|
export const dockerPlugin: TestRunnerPlugin = {
|
||||||
|
name: 'playwright:docker',
|
||||||
|
|
||||||
|
async setup(config: FullConfig, configDir: string, rootSuite: Suite, reporter: Reporter) {
|
||||||
|
if (!process.env.PLAYWRIGHT_DOCKER)
|
||||||
|
return;
|
||||||
|
|
||||||
|
const print = (text: string) => reporter.onStdOut?.(text);
|
||||||
|
const println = (text: string) => reporter.onStdOut?.(text + '\n');
|
||||||
|
|
||||||
|
println(colors.dim('Using docker container to run browsers.'));
|
||||||
|
await checkDockerEngineIsRunningOrDie();
|
||||||
|
let info = await containerInfo();
|
||||||
|
if (!info) {
|
||||||
|
print(colors.dim(`Starting docker container... `));
|
||||||
|
const time = Date.now();
|
||||||
|
info = await ensurePlaywrightContainerOrDie();
|
||||||
|
const deltaMs = (Date.now() - time);
|
||||||
|
println(colors.dim('Done in ' + (deltaMs / 1000).toFixed(1) + 's'));
|
||||||
|
println(colors.dim('The Docker container will keep running after tests finished.'));
|
||||||
|
println(colors.dim('Stop manually using:'));
|
||||||
|
println(colors.dim(' npx playwright docker stop'));
|
||||||
|
}
|
||||||
|
println(colors.dim(`View screen: ${info.vncSession}`));
|
||||||
|
println('');
|
||||||
|
process.env.PW_TEST_CONNECT_WS_ENDPOINT = info.wsEndpoint;
|
||||||
|
process.env.PW_TEST_CONNECT_HEADERS = JSON.stringify({
|
||||||
|
'x-playwright-proxy': '*',
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -45,7 +45,7 @@ import { SigIntWatcher } from './sigIntWatcher';
|
||||||
import type { TestRunnerPlugin } from './plugins';
|
import type { TestRunnerPlugin } from './plugins';
|
||||||
import { setRunnerToAddPluginsTo } from './plugins';
|
import { setRunnerToAddPluginsTo } from './plugins';
|
||||||
import { webServerPluginsForConfig } from './plugins/webServerPlugin';
|
import { webServerPluginsForConfig } from './plugins/webServerPlugin';
|
||||||
import { dockerPlugin } from './docker/docker';
|
import { dockerPlugin } from './plugins/dockerPlugin';
|
||||||
import { MultiMap } from 'playwright-core/lib/utils/multimap';
|
import { MultiMap } from 'playwright-core/lib/utils/multimap';
|
||||||
|
|
||||||
const removeFolderAsync = promisify(rimraf);
|
const removeFolderAsync = promisify(rimraf);
|
||||||
|
|
|
||||||
|
|
@ -301,14 +301,14 @@ copyFiles.push({
|
||||||
// Babel doesn't touch JS files, so copy them manually.
|
// Babel doesn't touch JS files, so copy them manually.
|
||||||
// For example: diff_match_patch.js
|
// For example: diff_match_patch.js
|
||||||
copyFiles.push({
|
copyFiles.push({
|
||||||
files: 'packages/playwright-core/src/**/*.js',
|
files: 'packages/playwright-core/src/**/*.(js|sh)',
|
||||||
from: 'packages/playwright-core/src',
|
from: 'packages/playwright-core/src',
|
||||||
to: 'packages/playwright-core/lib',
|
to: 'packages/playwright-core/lib',
|
||||||
ignored: ['**/.eslintrc.js', '**/webpack*.config.js', '**/injected/**/*']
|
ignored: ['**/.eslintrc.js', '**/webpack*.config.js', '**/injected/**/*']
|
||||||
});
|
});
|
||||||
|
|
||||||
copyFiles.push({
|
copyFiles.push({
|
||||||
files: 'packages/playwright-test/src/**/*.(js|sh)',
|
files: 'packages/playwright-test/src/**/*.sh',
|
||||||
from: 'packages/playwright-test/src',
|
from: 'packages/playwright-test/src',
|
||||||
to: 'packages/playwright-test/lib',
|
to: 'packages/playwright-test/lib',
|
||||||
ignored: ['**/.eslintrc.js']
|
ignored: ['**/.eslintrc.js']
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue