sounding 0.0.0 → 0.0.2

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.
@@ -0,0 +1,128 @@
1
+ const fs = require('node:fs')
2
+ const path = require('node:path')
3
+ const { pathToFileURL } = require('node:url')
4
+
5
+ const {
6
+ defineFactory,
7
+ defineScenario,
8
+ isFactoryDefinition,
9
+ isScenarioDefinition,
10
+ } = require('./define-world')
11
+
12
+ const WORLD_EXTENSIONS = new Set(['.js', '.cjs', '.mjs'])
13
+
14
+ function listDefinitionFiles(directory) {
15
+ const output = []
16
+
17
+ for (const entry of fs.readdirSync(directory, { withFileTypes: true })) {
18
+ const nextPath = path.join(directory, entry.name)
19
+
20
+ if (entry.isDirectory()) {
21
+ output.push(...listDefinitionFiles(nextPath))
22
+ continue
23
+ }
24
+
25
+ if (WORLD_EXTENSIONS.has(path.extname(entry.name))) {
26
+ output.push(nextPath)
27
+ }
28
+ }
29
+
30
+ return output.sort()
31
+ }
32
+
33
+ async function loadModule(filePath) {
34
+ try {
35
+ delete require.cache[require.resolve(filePath)]
36
+ return require(filePath)
37
+ } catch (error) {
38
+ if (error.code !== 'ERR_REQUIRE_ESM') {
39
+ throw error
40
+ }
41
+
42
+ return import(`${pathToFileURL(filePath).href}?t=${Date.now()}`)
43
+ }
44
+ }
45
+
46
+ async function registerExport(value, api, source) {
47
+ const entry = value?.default ?? value
48
+
49
+ if (entry === undefined || entry === null) {
50
+ return
51
+ }
52
+
53
+ if (Array.isArray(entry)) {
54
+ for (const item of entry) {
55
+ await registerExport(item, api, source)
56
+ }
57
+ return
58
+ }
59
+
60
+ if (isFactoryDefinition(entry) || isScenarioDefinition(entry)) {
61
+ api.world.register(entry)
62
+ return
63
+ }
64
+
65
+ if (typeof entry === 'function') {
66
+ const returned = await entry(api)
67
+
68
+ if (returned !== undefined) {
69
+ await registerExport(returned, api, source)
70
+ }
71
+
72
+ return
73
+ }
74
+
75
+ if (typeof entry === 'object' && (entry.factories || entry.scenarios)) {
76
+ for (const factory of entry.factories || []) {
77
+ await registerExport(factory, api, source)
78
+ }
79
+
80
+ for (const scenario of entry.scenarios || []) {
81
+ await registerExport(scenario, api, source)
82
+ }
83
+
84
+ return
85
+ }
86
+
87
+ throw new Error(
88
+ `Sounding could not understand the world definition exported from ${source}.`
89
+ )
90
+ }
91
+
92
+ async function loadWorldFiles({ world, appPath, config, sails }) {
93
+ const directories = [config.world?.factories, config.world?.scenarios]
94
+ .filter(Boolean)
95
+ .map((relativePath) => path.resolve(appPath, relativePath))
96
+
97
+ const loadedFiles = []
98
+ const api = {
99
+ sails,
100
+ world,
101
+ defineFactory,
102
+ defineScenario,
103
+ factory: defineFactory,
104
+ scenario: defineScenario,
105
+ registerFactory: world.defineFactory.bind(world),
106
+ registerScenario: world.defineScenario.bind(world),
107
+ }
108
+
109
+ for (const directory of directories) {
110
+ if (!fs.existsSync(directory)) {
111
+ continue
112
+ }
113
+
114
+ for (const filePath of listDefinitionFiles(directory)) {
115
+ const loaded = await loadModule(filePath)
116
+ await registerExport(loaded, api, filePath)
117
+ loadedFiles.push(filePath)
118
+ }
119
+ }
120
+
121
+ return loadedFiles
122
+ }
123
+
124
+ module.exports = {
125
+ loadWorldFiles,
126
+ listDefinitionFiles,
127
+ loadModule,
128
+ }
@@ -0,0 +1,76 @@
1
+ const DEFAULT_CONFIG = Object.freeze({
2
+ enableInProduction: false,
3
+ app: {
4
+ path: '.',
5
+ environment: 'test',
6
+ quiet: true,
7
+ liftOptions: {},
8
+ },
9
+ world: {
10
+ factories: 'tests/factories',
11
+ scenarios: 'tests/scenarios',
12
+ },
13
+ datastore: {
14
+ mode: 'managed',
15
+ identity: 'default',
16
+ adapter: 'sails-sqlite',
17
+ root: '.tmp/db',
18
+ isolation: 'worker',
19
+ },
20
+ browser: {
21
+ enabled: true,
22
+ type: 'chromium',
23
+ projects: ['desktop'],
24
+ defaultProject: 'desktop',
25
+ launchOptions: {
26
+ headless: true,
27
+ },
28
+ },
29
+ mail: {
30
+ capture: true,
31
+ },
32
+ request: {
33
+ transport: 'virtual',
34
+ },
35
+ auth: {
36
+ defaultActor: 'guest',
37
+ modelIdentity: null,
38
+ sessionKey: null,
39
+ worldCollection: null,
40
+ password: {
41
+ loginPath: '/login',
42
+ pagePath: '/login',
43
+ pageQuery: {},
44
+ form: {
45
+ email: 'email',
46
+ password: 'password',
47
+ rememberMe: 'rememberMe',
48
+ returnUrl: 'returnUrl',
49
+ },
50
+ selectors: {},
51
+ },
52
+ },
53
+ })
54
+
55
+ function cloneValue(value) {
56
+ if (Array.isArray(value)) {
57
+ return value.map(cloneValue)
58
+ }
59
+
60
+ if (value && typeof value === 'object') {
61
+ return Object.fromEntries(
62
+ Object.entries(value).map(([key, nested]) => [key, cloneValue(nested)])
63
+ )
64
+ }
65
+
66
+ return value
67
+ }
68
+
69
+ function getDefaultConfig() {
70
+ return cloneValue(DEFAULT_CONFIG)
71
+ }
72
+
73
+ module.exports = {
74
+ DEFAULT_CONFIG,
75
+ getDefaultConfig,
76
+ }
@@ -0,0 +1,37 @@
1
+ function defineFactory(name, definition) {
2
+ const entry = {
3
+ __soundingType: 'factory',
4
+ name,
5
+ definition,
6
+ traits: [],
7
+ trait(traitName, patch) {
8
+ entry.traits.push([traitName, patch])
9
+ return entry
10
+ },
11
+ }
12
+
13
+ return entry
14
+ }
15
+
16
+ function defineScenario(name, definition) {
17
+ return {
18
+ __soundingType: 'scenario',
19
+ name,
20
+ definition,
21
+ }
22
+ }
23
+
24
+ function isFactoryDefinition(value) {
25
+ return value?.__soundingType === 'factory'
26
+ }
27
+
28
+ function isScenarioDefinition(value) {
29
+ return value?.__soundingType === 'scenario'
30
+ }
31
+
32
+ module.exports = {
33
+ defineFactory,
34
+ defineScenario,
35
+ isFactoryDefinition,
36
+ isScenarioDefinition,
37
+ }
@@ -0,0 +1,25 @@
1
+ function isPlainObject(value) {
2
+ return Boolean(value) && typeof value === 'object' && !Array.isArray(value)
3
+ }
4
+
5
+ function mergeConfig(base, override) {
6
+ const output = { ...base }
7
+
8
+ for (const [key, value] of Object.entries(override || {})) {
9
+ if (Array.isArray(value)) {
10
+ output[key] = [...value]
11
+ continue
12
+ }
13
+
14
+ if (isPlainObject(value) && isPlainObject(base[key])) {
15
+ output[key] = mergeConfig(base[key], value)
16
+ continue
17
+ }
18
+
19
+ output[key] = value
20
+ }
21
+
22
+ return output
23
+ }
24
+
25
+ module.exports = { mergeConfig }
@@ -0,0 +1,54 @@
1
+ function isPlainObject(value) {
2
+ return Boolean(value) && typeof value === 'object' && !Array.isArray(value)
3
+ }
4
+
5
+ function normalizeDatastore(datastore) {
6
+ if (typeof datastore === 'string') {
7
+ return {
8
+ mode: datastore,
9
+ }
10
+ }
11
+
12
+ if (!isPlainObject(datastore)) {
13
+ return datastore
14
+ }
15
+
16
+ const normalized = { ...datastore }
17
+ const legacyManaged = isPlainObject(normalized.managed) ? normalized.managed : {}
18
+
19
+ if (normalized.adapter == null && legacyManaged.adapter != null) {
20
+ normalized.adapter = legacyManaged.adapter
21
+ }
22
+
23
+ if (normalized.root == null) {
24
+ normalized.root = legacyManaged.root ?? legacyManaged.directory
25
+ }
26
+
27
+ if (normalized.isolation == null && legacyManaged.isolation != null) {
28
+ normalized.isolation = legacyManaged.isolation
29
+ }
30
+
31
+ delete normalized.managed
32
+ delete normalized.directory
33
+
34
+ return normalized
35
+ }
36
+
37
+ function normalizeUserConfig(config = {}) {
38
+ if (!isPlainObject(config)) {
39
+ return {}
40
+ }
41
+
42
+ const normalized = { ...config }
43
+
44
+ if ('datastore' in normalized) {
45
+ normalized.datastore = normalizeDatastore(normalized.datastore)
46
+ }
47
+
48
+ return normalized
49
+ }
50
+
51
+ module.exports = {
52
+ normalizeDatastore,
53
+ normalizeUserConfig,
54
+ }
@@ -0,0 +1,93 @@
1
+ function isPlainObject(value) {
2
+ return Boolean(value) && typeof value === 'object' && !Array.isArray(value)
3
+ }
4
+
5
+ function normalizeIdentity(value) {
6
+ if (typeof value !== 'string') {
7
+ return null
8
+ }
9
+
10
+ const normalized = value.trim().toLowerCase()
11
+ return normalized || null
12
+ }
13
+
14
+ function titleCase(value) {
15
+ if (!value) {
16
+ return value
17
+ }
18
+
19
+ return `${value[0].toUpperCase()}${value.slice(1)}`
20
+ }
21
+
22
+ function resolveSoundingConfig({ sails, getConfig } = {}) {
23
+ return (typeof getConfig === 'function' ? getConfig() : null) || sails?.config?.sounding || {}
24
+ }
25
+
26
+ function getModelByIdentity(sails, identity) {
27
+ const normalizedIdentity = normalizeIdentity(identity)
28
+
29
+ if (!normalizedIdentity) {
30
+ return null
31
+ }
32
+
33
+ return sails?.models?.[normalizedIdentity] || sails?.models?.[titleCase(normalizedIdentity)] || null
34
+ }
35
+
36
+ function detectModelIdentity(sails) {
37
+ for (const candidate of ['user', 'creator']) {
38
+ if (getModelByIdentity(sails, candidate)) {
39
+ return candidate
40
+ }
41
+ }
42
+
43
+ return 'user'
44
+ }
45
+
46
+ function resolvePasswordConfig(authConfig = {}) {
47
+ const passwordConfig = isPlainObject(authConfig.password) ? authConfig.password : {}
48
+ const formConfig = isPlainObject(passwordConfig.form) ? passwordConfig.form : {}
49
+ const selectorConfig = isPlainObject(passwordConfig.selectors) ? passwordConfig.selectors : {}
50
+ const form = {
51
+ email: formConfig.email || 'email',
52
+ password: formConfig.password || 'password',
53
+ rememberMe: formConfig.rememberMe || 'rememberMe',
54
+ returnUrl: formConfig.returnUrl || 'returnUrl',
55
+ }
56
+
57
+ return {
58
+ loginPath: passwordConfig.loginPath || authConfig.loginPath || '/login',
59
+ pagePath: passwordConfig.pagePath || passwordConfig.loginPath || authConfig.loginPath || '/login',
60
+ pageQuery: isPlainObject(passwordConfig.pageQuery) ? passwordConfig.pageQuery : {},
61
+ form,
62
+ selectors: {
63
+ email: selectorConfig.email || `input[name="${form.email}"], input[type="email"]`,
64
+ password:
65
+ selectorConfig.password || `input[name="${form.password}"], input[type="password"]`,
66
+ rememberMe:
67
+ selectorConfig.rememberMe ||
68
+ `input[name="${form.rememberMe}"], input[id="${form.rememberMe}"], input[type="checkbox"]`,
69
+ submit: selectorConfig.submit || 'button[type="submit"], input[type="submit"]',
70
+ },
71
+ }
72
+ }
73
+
74
+ function resolveAuthConfig({ sails, getConfig } = {}) {
75
+ const soundingConfig = resolveSoundingConfig({ sails, getConfig })
76
+ const authConfig = isPlainObject(soundingConfig.auth) ? soundingConfig.auth : {}
77
+ const modelIdentity = normalizeIdentity(authConfig.modelIdentity) || detectModelIdentity(sails)
78
+
79
+ return {
80
+ modelIdentity,
81
+ model: getModelByIdentity(sails, modelIdentity),
82
+ sessionKey: authConfig.sessionKey || `${modelIdentity}Id`,
83
+ worldCollection: authConfig.worldCollection || `${modelIdentity}s`,
84
+ password: resolvePasswordConfig(authConfig),
85
+ }
86
+ }
87
+
88
+ module.exports = {
89
+ detectModelIdentity,
90
+ getModelByIdentity,
91
+ resolveAuthConfig,
92
+ resolveSoundingConfig,
93
+ }
@@ -0,0 +1,97 @@
1
+ const path = require('node:path')
2
+
3
+ function getWorkerToken(env = process.env) {
4
+ return (
5
+ env.SOUNDING_WORKER_INDEX ||
6
+ env.PLAYWRIGHT_WORKER_INDEX ||
7
+ env.TEST_WORKER_INDEX ||
8
+ String(process.pid)
9
+ )
10
+ }
11
+
12
+ function buildManagedSqlitePath({
13
+ root = path.join(process.cwd(), '.tmp', 'db'),
14
+ identity = 'default',
15
+ isolation = 'worker',
16
+ env = process.env,
17
+ }) {
18
+ const workerToken = isolation === 'run' ? 'run' : `worker-${getWorkerToken(env)}`
19
+ return path.join(root, identity, `${workerToken}.db`)
20
+ }
21
+
22
+ function resolveManagedRoot({ sails, datastore = {}, appPath } = {}) {
23
+ const configuredRoot = datastore.root || path.join('.tmp', 'db')
24
+
25
+ if (path.isAbsolute(configuredRoot)) {
26
+ return configuredRoot
27
+ }
28
+
29
+ return path.resolve(appPath || sails?.config?.appPath || process.cwd(), configuredRoot)
30
+ }
31
+
32
+ function resolveDatastore({ sails, soundingConfig }) {
33
+ const datastoreConfig = soundingConfig.datastore || {}
34
+ const mode = datastoreConfig.mode || 'managed'
35
+ const identity = datastoreConfig.identity || 'default'
36
+ const datastores = (sails.config.datastores ||= {})
37
+
38
+ if (mode === 'inherit' || mode === 'external') {
39
+ const configuredDatastore = datastores[identity]
40
+
41
+ if (!configuredDatastore) {
42
+ throw new Error(
43
+ `Sounding could not find datastore \`${identity}\` in sails.config.datastores for mode \`${mode}\`.`
44
+ )
45
+ }
46
+
47
+ return {
48
+ mode,
49
+ identity,
50
+ config: { ...configuredDatastore },
51
+ managed: false,
52
+ }
53
+ }
54
+
55
+ if (mode === 'managed') {
56
+ const adapter = datastoreConfig.adapter || 'sails-sqlite'
57
+
58
+ if (adapter !== 'sails-sqlite') {
59
+ throw new Error(
60
+ `Sounding only supports managed adapter \`sails-sqlite\` in v0.0.1. Received \`${adapter}\`.`
61
+ )
62
+ }
63
+
64
+ const filePath = buildManagedSqlitePath({
65
+ root: resolveManagedRoot({
66
+ sails,
67
+ datastore: datastoreConfig,
68
+ }),
69
+ identity,
70
+ isolation: datastoreConfig.isolation || 'worker',
71
+ })
72
+
73
+ const nextDatastore = {
74
+ ...(datastores[identity] || {}),
75
+ adapter,
76
+ url: filePath,
77
+ }
78
+
79
+ datastores[identity] = nextDatastore
80
+
81
+ return {
82
+ mode,
83
+ identity,
84
+ config: { ...nextDatastore },
85
+ managed: true,
86
+ filePath,
87
+ }
88
+ }
89
+
90
+ throw new Error(`Unknown Sounding datastore mode: ${mode}`)
91
+ }
92
+
93
+ module.exports = {
94
+ buildManagedSqlitePath,
95
+ resolveManagedRoot,
96
+ resolveDatastore,
97
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sounding",
3
- "version": "0.0.0",
3
+ "version": "0.0.2",
4
4
  "description": "Testing framework for Sails applications and The Boring JavaScript Stack.",
5
5
  "main": "index.js",
6
6
  "license": "MIT",
@@ -13,10 +13,26 @@
13
13
  ],
14
14
  "files": [
15
15
  "index.js",
16
+ "lib",
16
17
  "README.md",
17
18
  "RESEARCH.md",
18
19
  "LICENSE"
19
20
  ],
21
+ "scripts": {
22
+ "test": "node --test"
23
+ },
24
+ "sails": {
25
+ "isHook": true,
26
+ "hookName": "sounding"
27
+ },
28
+ "repository": {
29
+ "type": "git",
30
+ "url": "git+https://github.com/sailscastshq/sounding.git"
31
+ },
32
+ "homepage": "https://github.com/sailscastshq/sounding#readme",
33
+ "bugs": {
34
+ "url": "https://github.com/sailscastshq/sounding/issues"
35
+ },
20
36
  "publishConfig": {
21
37
  "access": "public"
22
38
  }