@applitools/eyes-storybook 3.31.4 → 3.32.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -3,6 +3,20 @@
3
3
  ## Unreleased
4
4
 
5
5
 
6
+ ## 3.32.0 - 2023/3/30
7
+
8
+ ### Features
9
+ - Add support for configuration per subsets of stories
10
+ ### Bug fixes
11
+ - Improve performance in DOM snapshot
12
+
13
+ ## 3.31.5 - 2023/3/28
14
+
15
+ ### Features
16
+ ### Bug fixes
17
+ - Optimized number of requests during polling
18
+ - Write result files without throwing error
19
+
6
20
  ## 3.31.4 - 2023/3/17
7
21
 
8
22
  ### Features
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@applitools/eyes-storybook",
3
- "version": "3.31.4",
3
+ "version": "3.32.0",
4
4
  "description": "",
5
5
  "keywords": [
6
6
  "applitools",
@@ -73,14 +73,14 @@
73
73
  }
74
74
  },
75
75
  "dependencies": {
76
- "@applitools/core": "2.4.2",
76
+ "@applitools/core": "2.4.4",
77
77
  "@applitools/driver": "1.11.37",
78
78
  "@applitools/functional-commons": "1.6.0",
79
79
  "@applitools/logger": "1.1.48",
80
80
  "@applitools/monitoring-commons": "1.0.19",
81
81
  "@applitools/spec-driver-puppeteer": "1.1.49",
82
82
  "@applitools/test-server": "1.1.28",
83
- "@applitools/ufg-client": "1.2.3",
83
+ "@applitools/ufg-client": "1.2.4",
84
84
  "@applitools/utils": "1.3.32",
85
85
  "boxen": "4.2.0",
86
86
  "chalk": "3.0.0",
@@ -94,7 +94,7 @@
94
94
  "yargs": "15.4.1"
95
95
  },
96
96
  "devDependencies": {
97
- "@applitools/bongo": "^3.0.1",
97
+ "@applitools/bongo": "^3.0.3",
98
98
  "@applitools/sdk-shared": "0.9.15",
99
99
  "@applitools/snaptdout": "^1.0.1",
100
100
  "@storybook/addon-interactions": "^6.4.18",
@@ -0,0 +1,29 @@
1
+ 'use strict';
2
+
3
+ const checkSettingsParams = [
4
+ 'browser',
5
+ 'ignoreCaret',
6
+ 'matchLevel',
7
+ 'waitBeforeCapture',
8
+ 'ignoreDisplacements',
9
+ 'ignoreRegions',
10
+ 'floatingRegions',
11
+ 'layoutRegions',
12
+ 'strictRegions',
13
+ 'contentRegions',
14
+ 'accessibilityRegions',
15
+ 'accessibilityValidation',
16
+ 'layoutBreakpoints',
17
+ 'sendDom',
18
+ 'useDom',
19
+ 'enablePatterns',
20
+ 'visualGridOptions',
21
+ 'scriptHooks',
22
+ 'target',
23
+ 'region',
24
+ 'selector',
25
+ 'fully',
26
+ 'fakeIE',
27
+ ];
28
+
29
+ module.exports = {checkSettingsParams};
package/src/cli.js CHANGED
@@ -7,7 +7,7 @@ const eyesStorybook = require('./eyesStorybook');
7
7
  const processResults = require('./processResults');
8
8
  const validateAndPopulateConfig = require('./validateAndPopulateConfig');
9
9
  const yargsOptions = require('./yargsOptions');
10
- const generateConfig = require('./generateConfig');
10
+ const {generateConfig} = require('./generateConfig');
11
11
  const defaultConfig = require('./defaultConfig');
12
12
  const configDigest = require('./configDigest');
13
13
  const {makeTiming} = require('@applitools/monitoring-commons');
@@ -42,7 +42,7 @@ const {performance, timeItAsync} = makeTiming();
42
42
  process.exit(config.exitcode ? config.exitcode : 0);
43
43
  } else {
44
44
  const totalTime = performance['eyesStorybook'];
45
- const {exitCode, formatter, outputStr} = processResults({
45
+ const {exitCode, summary, outputStr} = processResults({
46
46
  results,
47
47
  totalTime,
48
48
  testConcurrency: config.testConcurrency,
@@ -50,13 +50,13 @@ const {performance, timeItAsync} = makeTiming();
50
50
  });
51
51
  console.log(outputStr);
52
52
  if (config.jsonFilePath) {
53
- handleJsonFile(config.jsonFilePath, formatter);
53
+ handleJsonFile(config.jsonFilePath, summary);
54
54
  }
55
55
  if (config.tapFilePath) {
56
- handleTapFile(config.tapFilePath, formatter);
56
+ handleTapFile(config.tapFilePath, summary);
57
57
  }
58
58
  if (config.xmlFilePath) {
59
- handleXmlFile(config.xmlFilePath, formatter, {totalTime});
59
+ handleXmlFile(config.xmlFilePath, summary, {totalTime});
60
60
  }
61
61
  process.exit(config.exitcode ? exitCode : 0);
62
62
  }
@@ -1,30 +1,32 @@
1
- const {shouldRenderIE} = require('./shouldRenderIE');
2
-
3
1
  async function executeRenders({
4
2
  timeItAsync,
5
3
  setTransitioningIntoIE,
6
4
  renderStories,
7
- configs,
8
5
  pagePool,
9
- stories,
6
+ storiesByBrowserWithConfig,
10
7
  logger,
11
8
  setRenderIE,
12
9
  }) {
13
10
  const results = [];
14
- for (const config of configs) {
15
- logger.verbose(`executing render story with ${JSON.stringify(config)}`);
16
- if (shouldRenderIE(config)) {
17
- setRenderIE(true);
18
- setTransitioningIntoIE(true);
19
- await pagePool.drain();
20
- setTransitioningIntoIE(false);
21
- }
22
-
23
- const result = await timeItAsync('renderStories', () => renderStories(stories, config));
24
-
11
+ if (storiesByBrowserWithConfig.stories.length) {
12
+ logger.verbose(`executing render stories for non fakeIE browsers`);
13
+ const result = await timeItAsync('renderStories', () =>
14
+ renderStories(storiesByBrowserWithConfig.stories, false),
15
+ );
25
16
  results.push(...result);
26
17
  }
18
+ if (storiesByBrowserWithConfig.storiesWithIE.length) {
19
+ logger.verbose(`executing render stories for fakeIE`);
20
+ setRenderIE(true);
21
+ setTransitioningIntoIE(true);
22
+ await pagePool.drain();
23
+ setTransitioningIntoIE(false);
27
24
 
25
+ const result = await timeItAsync('renderStories', () =>
26
+ renderStories(storiesByBrowserWithConfig.storiesWithIE, true),
27
+ );
28
+ results.push(...result);
29
+ }
28
30
  return results;
29
31
  }
30
32
 
@@ -19,10 +19,10 @@ const {takeDomSnapshots} = require('@applitools/core');
19
19
  const {Driver} = require('@applitools/driver');
20
20
  const spec = require('@applitools/spec-driver-puppeteer');
21
21
  const {refineErrorMessage} = require('./errMessages');
22
- const {splitConfigsByBrowser} = require('./shouldRenderIE');
23
22
  const executeRenders = require('./executeRenders');
24
23
  const {makeCore} = require('@applitools/core');
25
24
  const {makeUFGClient} = require('@applitools/ufg-client');
25
+ const makeGetStoriesWithConfig = require('./getStoriesWithConfig');
26
26
 
27
27
  const CONCURRENT_PAGES = 3;
28
28
  const MAX_RETRIES = 10;
@@ -65,19 +65,13 @@ async function eyesStorybook({
65
65
  if (config.puppeteerExtraHTTPHeaders) {
66
66
  await page.setExtraHTTPHeaders(config.puppeteerExtraHTTPHeaders);
67
67
  }
68
- const core = await makeCore({
69
- spec,
70
- concurrency: testConcurrency,
71
- logger,
72
- agentId,
73
- });
68
+ const core = await makeCore({spec, agentId, logger});
74
69
  const manager = await core.makeManager({
75
- spec,
76
- concurrency: testConcurrency,
77
- agentId,
78
- logger,
79
- core,
80
70
  type: 'ufg',
71
+ settings: {
72
+ concurrency: testConcurrency,
73
+ },
74
+ logger,
81
75
  });
82
76
 
83
77
  const settings = {
@@ -88,6 +82,8 @@ async function eyesStorybook({
88
82
  };
89
83
  const [error, account] = await presult(core.getAccountInfo({settings, logger}));
90
84
 
85
+ const getStoriesWithConfig = makeGetStoriesWithConfig({config});
86
+
91
87
  if (error && error.message && error.message.includes('Unauthorized(401)')) {
92
88
  const failMsg = 'Incorrect API Key';
93
89
  logger.log(failMsg);
@@ -161,7 +157,18 @@ async function eyesStorybook({
161
157
  config,
162
158
  });
163
159
 
164
- logger.log(`starting to run ${storiesIncludingVariations.length} stories`);
160
+ logger.log(
161
+ `there are ${storiesIncludingVariations.length} stories after filtering and adding variations `,
162
+ );
163
+
164
+ const storiesByBrowserWithConfig = getStoriesWithConfig({
165
+ stories: storiesIncludingVariations,
166
+ logger,
167
+ });
168
+
169
+ logger.log(
170
+ `starting to run ${storiesByBrowserWithConfig.stories.length} normal stories ("non fake IE") and ${storiesByBrowserWithConfig.storiesWithIE.length} "fake IE stories"`,
171
+ );
165
172
 
166
173
  const getStoryData = makeGetStoryData({
167
174
  logger,
@@ -197,14 +204,13 @@ async function eyesStorybook({
197
204
  });
198
205
 
199
206
  logger.log('finished creating functions');
200
- const configs = config.fakeIE ? splitConfigsByBrowser(config) : [config];
207
+
201
208
  const [error, results] = await presult(
202
209
  executeRenders({
203
210
  renderStories,
204
211
  setRenderIE,
205
212
  setTransitioningIntoIE,
206
- configs,
207
- stories: storiesIncludingVariations,
213
+ storiesByBrowserWithConfig,
208
214
  pagePool,
209
215
  logger,
210
216
  timeItAsync,
@@ -40,7 +40,7 @@ function generateConfig({argv = {}, defaultConfig = {}, externalConfigParams = [
40
40
  result.waitBeforeCapture = Number(result.waitBeforeCapture);
41
41
  }
42
42
 
43
- if (result.showLogs === '1') {
43
+ if (result.showLogs === '1' || process.env.APPLITOOLS_SHOW_LOGS === 'true') {
44
44
  result.showLogs = true;
45
45
  }
46
46
 
@@ -77,18 +77,31 @@ function generateConfig({argv = {}, defaultConfig = {}, externalConfigParams = [
77
77
  );
78
78
  }
79
79
 
80
- if (!result.browser) {
80
+ transformBrowser(result);
81
+ if (!result.renderers) {
81
82
  result.renderers = [{name: 'chrome', width: 1024, height: 768}];
82
- } else {
83
+ }
84
+
85
+ return result;
86
+ }
87
+
88
+ function transformBrowser(result) {
89
+ if (result.browser) {
83
90
  result.renderers = [];
84
91
  if (!Array.isArray(result.browser)) {
85
92
  result.browser = [result.browser];
86
93
  }
87
94
  result.renderers = result.browser.map(browser => {
88
- return browser.deviceName ? {chromeEmulationInfo: browser} : browser;
95
+ if (browser.deviceName) {
96
+ return {chromeEmulationInfo: browser};
97
+ } else if (!browser.name) {
98
+ return {...browser, name: 'chrome'};
99
+ } else {
100
+ return browser;
101
+ }
89
102
  });
90
103
  }
91
104
  delete result.browser;
92
105
  return result;
93
106
  }
94
- module.exports = generateConfig;
107
+ module.exports = {generateConfig, transformBrowser};
@@ -0,0 +1,102 @@
1
+ const {splitConfigsByBrowser, shouldRenderIE} = require('./shouldRenderIE');
2
+ const getStoryTitle = require('./getStoryTitle');
3
+ const {transformBrowser} = require('./generateConfig');
4
+ const {checkSettingsParams} = require('./checkSettingsParams');
5
+ const getStoryBaselineName = require('./getStoryBaselineName');
6
+
7
+ function makeGetStoriesWithConfig({config}) {
8
+ const storiesWithConfig = new Map();
9
+ const storiesWithConfigIE = new Map();
10
+ const basicConfig = {...config};
11
+ delete basicConfig.storyConfiguration;
12
+
13
+ return function getStoriesWithConfig({stories, logger = console}) {
14
+ const storiesWithTitle = addStoryTitleAndBaselineName(stories);
15
+ if (!config.storyConfiguration) {
16
+ addConfigToStories({config, stories: storiesWithTitle});
17
+ } else {
18
+ const storyConfigurations = Array.isArray(config.storyConfiguration)
19
+ ? config.storyConfiguration
20
+ : [config.storyConfiguration];
21
+ let remainingStories = [...storiesWithTitle];
22
+ for (const storyConfig of storyConfigurations) {
23
+ const filterStories = storyConfig.stories;
24
+ delete storyConfig.stories;
25
+
26
+ if (filterStories) {
27
+ const storiesSubset = storiesWithTitle
28
+ .filter(story => {
29
+ try {
30
+ return filterStories(story);
31
+ } catch (err) {
32
+ logger.log(`An error was thrown from substory function: ${err}`);
33
+ return;
34
+ }
35
+ })
36
+ .filter(Boolean);
37
+ addConfigToStories({
38
+ config: transformBrowser(allowedProps(storyConfig)),
39
+ stories: storiesSubset,
40
+ });
41
+
42
+ remainingStories = remainingStories.filter(story => !storiesSubset.includes(story));
43
+ }
44
+ }
45
+
46
+ if (remainingStories.length) {
47
+ addConfigToStories({config: basicConfig, stories: remainingStories});
48
+ }
49
+ }
50
+ return {
51
+ stories: Array.from(storiesWithConfig.values()),
52
+ storiesWithIE: Array.from(storiesWithConfigIE.values()),
53
+ };
54
+ };
55
+
56
+ function addConfigToStories({config, stories}) {
57
+ const configs = config.fakeIE ? splitConfigsByBrowser(config) : [config];
58
+ for (const config of configs) {
59
+ for (const story of stories) {
60
+ addConfigToStoy({
61
+ story,
62
+ config,
63
+ isIE: shouldRenderIE(config),
64
+ });
65
+ }
66
+ }
67
+ }
68
+
69
+ function addConfigToStoy({story, config, isIE}) {
70
+ const storiesToUpdate = isIE ? storiesWithConfigIE : storiesWithConfig;
71
+ storiesToUpdate.set(story.baselineName, {
72
+ ...story,
73
+ config: {
74
+ ...basicConfig,
75
+ ...storiesToUpdate.get(story.baselineName)?.config,
76
+ ...config,
77
+ },
78
+ });
79
+ }
80
+
81
+ function addStoryTitleAndBaselineName(stories) {
82
+ return stories.map(story => {
83
+ return {
84
+ ...story,
85
+ storyTitle: getStoryTitle(story),
86
+ baselineName: getStoryBaselineName(story),
87
+ };
88
+ });
89
+ }
90
+
91
+ function allowedProps(config) {
92
+ const configKeys = Object.keys(config);
93
+ for (const key of configKeys) {
94
+ if (!checkSettingsParams.includes(key)) {
95
+ delete config[key];
96
+ }
97
+ }
98
+ return config;
99
+ }
100
+ }
101
+
102
+ module.exports = makeGetStoriesWithConfig;
@@ -2,10 +2,11 @@
2
2
 
3
3
  const fs = require('fs');
4
4
  const {resolve} = require('path');
5
+ const {formatters} = require('@applitools/core');
5
6
 
6
- function handleJsonFile(jsonFilePath, formatter) {
7
+ function handleJsonFile(jsonFilePath, summary) {
7
8
  const path = resolve(jsonFilePath, 'eyes.json');
8
- fs.writeFileSync(path, formatter.toJsonOutput());
9
+ fs.writeFileSync(path, formatters.toJsonOutput(summary));
9
10
  return path;
10
11
  }
11
12
 
@@ -2,10 +2,17 @@
2
2
 
3
3
  const fs = require('fs');
4
4
  const {resolve} = require('path');
5
+ const {formatters} = require('@applitools/core');
5
6
 
6
- function handleTapFile(tapFilePath, formatter) {
7
+ function handleTapFile(tapFilePath, summary) {
7
8
  const path = resolve(tapFilePath, 'eyes.tap');
8
- fs.writeFileSync(path, formatter.asHierarchicTAPString(false, true));
9
+ fs.writeFileSync(
10
+ path,
11
+ formatters.toHierarchicTAPString(summary.results, {
12
+ includeSubTests: false,
13
+ markNewAsPassed: true,
14
+ }),
15
+ );
9
16
  return path;
10
17
  }
11
18
 
@@ -2,10 +2,11 @@
2
2
 
3
3
  const fs = require('fs');
4
4
  const {resolve} = require('path');
5
+ const {formatters} = require('@applitools/core');
5
6
 
6
- function handleXmlFile(xmlFilePath, formatter, {suiteName = 'Eyes Storybook', totalTime} = {}) {
7
+ function handleXmlFile(xmlFilePath, summary, {suiteName = 'Eyes Storybook', totalTime} = {}) {
7
8
  const path = resolve(xmlFilePath, 'eyes.xml');
8
- fs.writeFileSync(path, formatter.toXmlOutput({suiteName, totalTime}));
9
+ fs.writeFileSync(path, formatters.toXmlOutput(summary.results, {suiteName, totalTime}));
9
10
  return path;
10
11
  }
11
12
 
@@ -4,9 +4,8 @@ const chalk = require('chalk');
4
4
  const utils = require('@applitools/utils');
5
5
  const uniq = require('./uniq');
6
6
  const concurrencyMsg = require('./concurrencyMsg');
7
- const {formatters} = require('@applitools/core');
8
7
 
9
- function processResults({results = [], totalTime, testConcurrency, saveNewTests = true}) {
8
+ function processResults({results, totalTime, testConcurrency, saveNewTests = true}) {
10
9
  let outputStr = '\n';
11
10
  const pluralize = utils.general.pluralize;
12
11
  let testResults = flatten(results.summary.results);
@@ -102,12 +101,11 @@ function processResults({results = [], totalTime, testConcurrency, saveNewTests
102
101
  // TODO require from core
103
102
  outputStr += `\n${concurrencyMsg}\n`;
104
103
  }
105
- const formatter = formatters.toJsonOutput(results.summary);
106
104
  const exitCode =
107
105
  !warnForUnsavedNewTests && passedOrNew.length && !errors.length && !unresolved.length ? 0 : 1;
108
106
  return {
109
107
  outputStr,
110
- formatter,
108
+ summary: results.summary,
111
109
  exitCode,
112
110
  };
113
111
  }
@@ -3,7 +3,6 @@ const getStoryUrl = require('./getStoryUrl');
3
3
  const getStoryBaselineName = require('./getStoryBaselineName');
4
4
  const ora = require('ora');
5
5
  const {presult} = require('@applitools/functional-commons');
6
- const {shouldRenderIE} = require('./shouldRenderIE');
7
6
 
8
7
  function makeRenderStories({
9
8
  getStoryData,
@@ -17,7 +16,7 @@ function makeRenderStories({
17
16
  }) {
18
17
  let newPageIdToAdd;
19
18
 
20
- return async function renderStories(stories, config) {
19
+ return async function renderStories(stories, isIE) {
21
20
  let doneStories = 0;
22
21
  const allTestResults = [];
23
22
  let allStoriesPromise = Promise.resolve();
@@ -66,7 +65,7 @@ function makeRenderStories({
66
65
  getStoryData({
67
66
  story,
68
67
  storyUrl,
69
- renderers: config.renderers,
68
+ renderers: story.config.renderers,
70
69
  page,
71
70
  waitBeforeStory: waitBeforeCapture,
72
71
  }),
@@ -92,7 +91,7 @@ function makeRenderStories({
92
91
  getStoryData({
93
92
  story,
94
93
  storyUrl,
95
- renderers: config.renderers,
94
+ renderers: story.config.renderers,
96
95
  page: newPageObj.page,
97
96
  waitBeforeStory: waitBeforeCapture,
98
97
  }),
@@ -113,7 +112,6 @@ function makeRenderStories({
113
112
  snapshots: storyData,
114
113
  url: storyUrl,
115
114
  story,
116
- config,
117
115
  });
118
116
  return onDoneStory(testResults, story);
119
117
  } catch (ex) {
@@ -134,11 +132,11 @@ function makeRenderStories({
134
132
  }
135
133
 
136
134
  function updateSpinnerText(number, length) {
137
- return `Done ${number} stories out of ${length} ${shouldRenderIE(config) ? '(IE)' : ''}`;
135
+ return `Done ${number} stories out of ${length} ${isIE ? '(IE)' : ''}`;
138
136
  }
139
137
 
140
138
  function onDoneStory(resultsOrErr, story) {
141
- spinner.text = updateSpinnerText(++doneStories, stories.length);
139
+ spinner.text = updateSpinnerText(++doneStories, stories.length, story.config);
142
140
  const title = getStoryBaselineName(story);
143
141
  allTestResults.push({title, resultsOrErr});
144
142
  return {title, resultsOrErr};
@@ -1,6 +1,4 @@
1
1
  'use strict';
2
- const getStoryTitle = require('./getStoryTitle');
3
- const getStoryBaselineName = require('./getStoryBaselineName');
4
2
  const {deprecationWarning} = require('./errMessages');
5
3
  const throat = require('throat');
6
4
 
@@ -15,10 +13,11 @@ function makeRenderStory({
15
13
  serverSettings,
16
14
  }) {
17
15
  const throttle = throat(storyDataGap);
18
- return function renderStory({config, story, snapshots, url}) {
16
+ return function renderStory({story, snapshots, url}) {
17
+ const config = story.config;
19
18
  const {name, kind, parameters, hasPlayFunction} = story;
20
- const baselineName = getStoryBaselineName({name, kind, parameters});
21
- const title = getStoryTitle({name, kind, parameters});
19
+ const baselineName = story.baselineName;
20
+ const title = story.storyTitle;
22
21
  const eyesParameters = (parameters && parameters.eyes) || {};
23
22
  const eyesOptions = {
24
23
  ...config,
@@ -38,7 +37,6 @@ function makeRenderStory({
38
37
  target,
39
38
  fully,
40
39
  selector,
41
- region,
42
40
  tag,
43
41
  properties,
44
42
  ignore,
@@ -111,10 +109,8 @@ function makeRenderStory({
111
109
  renderers,
112
110
  hooks: scriptHooks,
113
111
  sizeMode,
114
- target,
112
+ region: target === 'region' ? selector : undefined,
115
113
  fully,
116
- selector,
117
- region,
118
114
  tag,
119
115
  sendDom,
120
116
  ufgOptions: visualGridOptions,
@@ -134,6 +130,23 @@ function makeRenderStory({
134
130
  : undefined,
135
131
  };
136
132
 
133
+ return timeItAsync(baselineName, async () => {
134
+ const {checkAndClose} = await openEyes({settings: openParams});
135
+ return new Promise((resolve, reject) => {
136
+ throttle(async () => {
137
+ try {
138
+ const checkResults = await checkAndClose({
139
+ settings: {...checkParams, ...closeSettings, throwEx: false},
140
+ target: snapshots,
141
+ });
142
+ resolve(checkResults);
143
+ } catch (ex) {
144
+ reject(ex);
145
+ }
146
+ });
147
+ });
148
+ }).then(onDoneStory);
149
+
137
150
  function mapAccessibilityRegions(accessabilityRegions) {
138
151
  if (!accessabilityRegions) return;
139
152
  if (!Array.isArray(accessabilityRegions)) accessabilityRegions = [accessabilityRegions];
@@ -196,23 +209,6 @@ function makeRenderStory({
196
209
  }
197
210
  }
198
211
 
199
- return timeItAsync(baselineName, async () => {
200
- const {checkAndClose} = await openEyes({settings: openParams});
201
- return new Promise((resolve, reject) => {
202
- throttle(async () => {
203
- try {
204
- const checkResults = await checkAndClose({
205
- settings: {...checkParams, ...closeSettings, throwEx: false},
206
- target: snapshots,
207
- });
208
- resolve(checkResults);
209
- } catch (ex) {
210
- reject(ex);
211
- }
212
- });
213
- });
214
- }).then(onDoneStory);
215
-
216
212
  function onDoneStory(results) {
217
213
  logger.log('finished story', baselineName, 'in', performance[baselineName]);
218
214
  return results;